mirror of
https://github.com/unclebob/crapper.git
synced 2026-11-06 18:13:50 +00:00
Fix the coverage, git, and Clojure bugs, and bring CRAP under 10.
A path in a built-in coverage command is one argument, a failed custom coverage command does not read a stale report, and git failures exit with git's status. The Clojure reader honors discards, metadata, and character literals. Long functions are split so every score is under 10 except the option switch.
This commit is contained in:
@@ -25,12 +25,21 @@ def _coverage(bundle: CoverageBundle | None, function: Function) -> float | None
|
|||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _source_path(file: Path, root: Path) -> str:
|
||||||
|
try:
|
||||||
|
return file.relative_to(root).as_posix()
|
||||||
|
except ValueError:
|
||||||
|
return file.as_posix()
|
||||||
|
|
||||||
|
|
||||||
def analyze_files(
|
def analyze_files(
|
||||||
files: list[Path],
|
files: list[Path],
|
||||||
project_root: Path,
|
project_root: Path,
|
||||||
bundle: CoverageBundle | None,
|
bundle: CoverageBundle | None,
|
||||||
) -> list[Entry]:
|
) -> list[Entry]:
|
||||||
root = project_root.resolve()
|
root = project_root.resolve()
|
||||||
|
if bundle is not None:
|
||||||
|
bundle.bind_sources([_source_path(file.resolve(), root) for file in files])
|
||||||
entries: list[Entry] = []
|
entries: list[Entry] = []
|
||||||
for file in files:
|
for file in files:
|
||||||
file = file.resolve()
|
file = file.resolve()
|
||||||
|
|||||||
+109
-39
@@ -8,13 +8,34 @@ from dataclasses import dataclass, field
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from crapper.analyze import analyze_files
|
from crapper.analyze import analyze_files
|
||||||
from crapper.coverage import load_bundle
|
from crapper.coverage import CoverageBundle, load_bundle
|
||||||
from crapper.discover import is_test_file, iter_source_files, language_of
|
from crapper.discover import (
|
||||||
|
CLOJURE_TEST_SUFFIXES,
|
||||||
|
GO_TEST_SUFFIX,
|
||||||
|
JS_TEST_SUFFIXES,
|
||||||
|
SKIP_DIRS,
|
||||||
|
TEST_DIRS,
|
||||||
|
is_test_file,
|
||||||
|
iter_source_files,
|
||||||
|
language_of,
|
||||||
|
)
|
||||||
from crapper.metrics import write_metrics
|
from crapper.metrics import write_metrics
|
||||||
from crapper.report import format_report
|
from crapper.report import format_report
|
||||||
from crapper.runners import run_coverage
|
from crapper.runners import run_coverage
|
||||||
|
|
||||||
HELP = """\
|
_SKIPPED_DIRS = ", ".join(sorted(SKIP_DIRS | TEST_DIRS))
|
||||||
|
_TEST_PATTERNS = ", ".join(
|
||||||
|
[
|
||||||
|
f"*{GO_TEST_SUFFIX}",
|
||||||
|
*[f"*{suffix}" for suffix in CLOJURE_TEST_SUFFIXES],
|
||||||
|
*[f"*{suffix}" for suffix in JS_TEST_SUFFIXES],
|
||||||
|
"test_*.py",
|
||||||
|
"*_test.py",
|
||||||
|
"conftest.py",
|
||||||
|
]
|
||||||
|
)
|
||||||
|
|
||||||
|
HELP = f"""\
|
||||||
Usage: crapper [options] [path-or-filter ...]
|
Usage: crapper [options] [path-or-filter ...]
|
||||||
|
|
||||||
Detect the language of each source file and score it with the CRAP metric
|
Detect the language of each source file and score it with the CRAP metric
|
||||||
@@ -46,8 +67,7 @@ Arguments:
|
|||||||
path contains this text are analyzed.
|
path contains this text are analyzed.
|
||||||
|
|
||||||
With no paths, source files under the project root are analyzed. Directories
|
With no paths, source files under the project root are analyzed. Directories
|
||||||
named test, tests, spec, specs, vendor, node_modules, and target are skipped,
|
named {_SKIPPED_DIRS} are skipped, as are {_TEST_PATTERNS}.
|
||||||
as are *_test.go, *.spec.ts, test_*.py, and *_test.py files.
|
|
||||||
|
|
||||||
Coverage, when it is produced:
|
Coverage, when it is produced:
|
||||||
Clojure clj -M:cov --lcov, then Cloverage form counts or LCOV
|
Clojure clj -M:cov --lcov, then Cloverage form counts or LCOV
|
||||||
@@ -144,26 +164,58 @@ def parse_args(argv: list[str] | None = None) -> Options:
|
|||||||
return options
|
return options
|
||||||
|
|
||||||
|
|
||||||
def _changed_files(root: Path) -> list[Path]:
|
class GitError(Exception):
|
||||||
result = subprocess.run(
|
"""git could not describe the working tree. `code` is git's status."""
|
||||||
["git", "status", "--porcelain"],
|
|
||||||
cwd=root,
|
def __init__(self, code: int, message: str) -> None:
|
||||||
|
super().__init__(message)
|
||||||
|
self.code = code
|
||||||
|
|
||||||
|
|
||||||
|
def _git(root: Path, args: list[str]) -> subprocess.CompletedProcess[str]:
|
||||||
|
return subprocess.run(
|
||||||
|
["git", "-C", str(root), *args],
|
||||||
check=False,
|
check=False,
|
||||||
capture_output=True,
|
capture_output=True,
|
||||||
text=True,
|
text=True,
|
||||||
|
encoding="utf-8",
|
||||||
)
|
)
|
||||||
if result.returncode != 0:
|
|
||||||
print(result.stderr.strip() or "git status failed", file=sys.stderr)
|
|
||||||
return []
|
def _changed_files(root: Path) -> list[Path]:
|
||||||
|
"""Added and modified files from git, including files in new directories.
|
||||||
|
|
||||||
|
Paths come from `git status -z`, which does not quote non-ASCII names.
|
||||||
|
They are resolved from the repository root, then limited to `root`, so
|
||||||
|
`--root` can be a subdirectory. Deleted files are left out.
|
||||||
|
"""
|
||||||
|
|
||||||
|
top_result = _git(root, ["rev-parse", "--show-toplevel"])
|
||||||
|
if top_result.returncode != 0:
|
||||||
|
raise GitError(top_result.returncode, top_result.stderr.strip() or "git status failed")
|
||||||
|
status = _git(
|
||||||
|
root,
|
||||||
|
[
|
||||||
|
"status",
|
||||||
|
"--porcelain",
|
||||||
|
"-z",
|
||||||
|
"--no-renames",
|
||||||
|
"--untracked-files=all",
|
||||||
|
"--",
|
||||||
|
".",
|
||||||
|
],
|
||||||
|
)
|
||||||
|
if status.returncode != 0:
|
||||||
|
raise GitError(status.returncode, status.stderr.strip() or "git status failed")
|
||||||
|
top = Path(top_result.stdout.strip())
|
||||||
|
limit = root.resolve()
|
||||||
found: list[Path] = []
|
found: list[Path] = []
|
||||||
for line in result.stdout.splitlines():
|
for entry in status.stdout.split("\0"):
|
||||||
if len(line) < 4:
|
if len(entry) < 4:
|
||||||
continue
|
continue
|
||||||
path_text = line[3:].strip()
|
path = (top / entry[3:]).resolve()
|
||||||
if " -> " in path_text:
|
if path.is_file() and path.is_relative_to(limit):
|
||||||
path_text = path_text.split(" -> ", 1)[1]
|
found.append(path)
|
||||||
path_text = path_text.strip('"')
|
|
||||||
found.append((root / path_text).resolve())
|
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
@@ -222,38 +274,56 @@ def select_files(options: Options) -> list[Path]:
|
|||||||
return sorted({path.resolve() for path in files}, key=lambda path: path.as_posix())
|
return sorted({path.resolve() for path in files}, key=lambda path: path.as_posix())
|
||||||
|
|
||||||
|
|
||||||
|
def _show_help(options: Options) -> int:
|
||||||
|
stream = sys.stdout if options.exit_code == 0 else sys.stderr
|
||||||
|
print(options.message, file=stream, end="" if options.message.endswith("\n") else "\n")
|
||||||
|
return options.exit_code
|
||||||
|
|
||||||
|
|
||||||
|
def _coverage_bundle(options: Options, root: Path, files: list[Path]):
|
||||||
|
if options.no_coverage:
|
||||||
|
return None
|
||||||
|
if options.use_existing_coverage:
|
||||||
|
return load_bundle(root)
|
||||||
|
status = run_coverage(root, files, options.coverage_command)
|
||||||
|
if options.coverage_command and status not in (0, None):
|
||||||
|
return CoverageBundle()
|
||||||
|
return load_bundle(root)
|
||||||
|
|
||||||
|
|
||||||
|
def _threshold_status(options: Options, entries) -> int:
|
||||||
|
if options.threshold is None:
|
||||||
|
return 0
|
||||||
|
scored = [entry.crap for entry in entries if entry.crap is not None]
|
||||||
|
if scored and max(scored) > options.threshold:
|
||||||
|
print(
|
||||||
|
f"CRAP threshold exceeded: {max(scored):.1f} > {options.threshold:.1f}",
|
||||||
|
file=sys.stderr,
|
||||||
|
)
|
||||||
|
return 2
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
def run(argv: list[str] | None = None) -> int:
|
def run(argv: list[str] | None = None) -> int:
|
||||||
options = parse_args(argv)
|
options = parse_args(argv)
|
||||||
if options.action == "help":
|
if options.action == "help":
|
||||||
stream = sys.stdout if options.exit_code == 0 else sys.stderr
|
return _show_help(options)
|
||||||
print(options.message, file=stream, end="" if options.message.endswith("\n") else "\n")
|
|
||||||
return options.exit_code
|
|
||||||
|
|
||||||
root = options.project_root.resolve()
|
root = options.project_root.resolve()
|
||||||
files = select_files(options)
|
try:
|
||||||
|
files = select_files(options)
|
||||||
|
except GitError as exc:
|
||||||
|
print(str(exc), file=sys.stderr)
|
||||||
|
return exc.code
|
||||||
if not files:
|
if not files:
|
||||||
print("No source files to analyze.")
|
print("No source files to analyze.")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
bundle = None
|
entries = analyze_files(files, root, _coverage_bundle(options, root, files))
|
||||||
if not options.no_coverage:
|
|
||||||
if not options.use_existing_coverage:
|
|
||||||
run_coverage(root, files, options.coverage_command)
|
|
||||||
bundle = load_bundle(root)
|
|
||||||
|
|
||||||
entries = analyze_files(files, root, bundle)
|
|
||||||
metrics = write_metrics(entries, root)
|
metrics = write_metrics(entries, root)
|
||||||
print(format_report(entries), end="", flush=True)
|
print(format_report(entries), end="", flush=True)
|
||||||
print(f"Wrote {metrics}", file=sys.stderr)
|
print(f"Wrote {metrics}", file=sys.stderr)
|
||||||
if options.threshold is not None:
|
return _threshold_status(options, entries)
|
||||||
scored = [entry.crap for entry in entries if entry.crap is not None]
|
|
||||||
if scored and max(scored) > options.threshold:
|
|
||||||
print(
|
|
||||||
f"CRAP threshold exceeded: {max(scored):.1f} > {options.threshold:.1f}",
|
|
||||||
file=sys.stderr,
|
|
||||||
)
|
|
||||||
return 2
|
|
||||||
return 0
|
|
||||||
|
|
||||||
|
|
||||||
def main(argv: list[str] | None = None) -> None:
|
def main(argv: list[str] | None = None) -> None:
|
||||||
|
|||||||
+209
-71
@@ -60,6 +60,129 @@ def suffix_match(path: str, suffix: str) -> bool:
|
|||||||
return path_parts[-len(suffix_parts) :] == suffix_parts
|
return path_parts[-len(suffix_parts) :] == suffix_parts
|
||||||
|
|
||||||
|
|
||||||
|
def _key_items(keys: list[str]) -> list[tuple[str, str]]:
|
||||||
|
return [(key, normalize_path(key)) for key in keys]
|
||||||
|
|
||||||
|
|
||||||
|
def _exact_key(items: list[tuple[str, str]], candidates: list[str]) -> str | None:
|
||||||
|
for candidate in candidates:
|
||||||
|
for original, norm in items:
|
||||||
|
if norm == candidate:
|
||||||
|
return original
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _nonempty_parts(candidates: list[str]) -> list[tuple[str, list[str]]]:
|
||||||
|
found = []
|
||||||
|
for candidate in candidates:
|
||||||
|
parts = _segments(candidate)
|
||||||
|
if parts:
|
||||||
|
found.append((candidate, parts))
|
||||||
|
return found
|
||||||
|
|
||||||
|
|
||||||
|
def _key_has_suffix(key_parts: list[str], parts: list[str]) -> bool:
|
||||||
|
if len(key_parts) < len(parts):
|
||||||
|
return False
|
||||||
|
return key_parts[-len(parts) :] == parts
|
||||||
|
|
||||||
|
|
||||||
|
def _forward_matches(
|
||||||
|
items: list[tuple[str, str]],
|
||||||
|
parts_of: list[tuple[str, list[str]]],
|
||||||
|
source_parts: list[tuple[str, ...]],
|
||||||
|
) -> list[str]:
|
||||||
|
forward: list[tuple[int, str]] = []
|
||||||
|
for _candidate, parts in parts_of:
|
||||||
|
for original, norm in items:
|
||||||
|
key_parts = _segments(norm)
|
||||||
|
if not _key_has_suffix(key_parts, parts):
|
||||||
|
continue
|
||||||
|
if _longer_source_owns(key_parts, len(parts), source_parts):
|
||||||
|
continue
|
||||||
|
forward.append((len(key_parts) - len(parts), original))
|
||||||
|
if not forward:
|
||||||
|
return []
|
||||||
|
smallest = min(extra for extra, _key in forward)
|
||||||
|
return list(dict.fromkeys(key for extra, key in forward if extra == smallest))
|
||||||
|
|
||||||
|
|
||||||
|
def _key_is_proper_suffix(parts: list[str], key_parts: list[str]) -> bool:
|
||||||
|
if not key_parts or len(key_parts) >= len(parts):
|
||||||
|
return False
|
||||||
|
return parts[-len(key_parts) :] == key_parts
|
||||||
|
|
||||||
|
|
||||||
|
def _reverse_key(
|
||||||
|
items: list[tuple[str, str]],
|
||||||
|
parts_of: list[tuple[str, list[str]]],
|
||||||
|
source_parts: list[tuple[str, ...]],
|
||||||
|
) -> str | None:
|
||||||
|
own = {tuple(parts) for _candidate, parts in parts_of}
|
||||||
|
reverse: list[str] = []
|
||||||
|
for _candidate, parts in parts_of:
|
||||||
|
for original, norm in items:
|
||||||
|
key_parts = _segments(norm)
|
||||||
|
if not _key_is_proper_suffix(parts, key_parts):
|
||||||
|
continue
|
||||||
|
if _another_source_ends_with(key_parts, source_parts, own):
|
||||||
|
continue
|
||||||
|
reverse.append(original)
|
||||||
|
unique = list(dict.fromkeys(reverse))
|
||||||
|
if len(unique) == 1:
|
||||||
|
return unique[0]
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _select_key(
|
||||||
|
keys: list[str], source_path: str, source_parts: list[tuple[str, ...]]
|
||||||
|
) -> str | None:
|
||||||
|
"""Report key for one source file.
|
||||||
|
|
||||||
|
An exact path wins. A report path may be the source path plus a prefix
|
||||||
|
(`proj/src/a.go` for `src/a.go`). It is not a match when another source
|
||||||
|
file is a longer suffix of that key, so `b/main.go` does not take
|
||||||
|
`a/b/main.go`.
|
||||||
|
"""
|
||||||
|
|
||||||
|
items = _key_items(keys)
|
||||||
|
candidates = _candidates(source_path)
|
||||||
|
exact = _exact_key(items, candidates)
|
||||||
|
if exact is not None:
|
||||||
|
return exact
|
||||||
|
parts_of = _nonempty_parts(candidates)
|
||||||
|
forward = _forward_matches(items, parts_of, source_parts)
|
||||||
|
if len(forward) == 1:
|
||||||
|
return forward[0]
|
||||||
|
if forward:
|
||||||
|
return None
|
||||||
|
return _reverse_key(items, parts_of, source_parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _longer_source_owns(
|
||||||
|
key_parts: list[str], source_len: int, source_parts: list[tuple[str, ...]]
|
||||||
|
) -> bool:
|
||||||
|
for other in source_parts:
|
||||||
|
if len(other) <= source_len:
|
||||||
|
continue
|
||||||
|
if len(other) <= len(key_parts) and key_parts[-len(other) :] == list(other):
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
def _another_source_ends_with(
|
||||||
|
key_parts: list[str],
|
||||||
|
source_parts: list[tuple[str, ...]],
|
||||||
|
own: set[tuple[str, ...]],
|
||||||
|
) -> bool:
|
||||||
|
for other in source_parts:
|
||||||
|
if other in own or len(other) < len(key_parts):
|
||||||
|
continue
|
||||||
|
if list(other[-len(key_parts) :]) == key_parts:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
def _candidates(source_path: str) -> list[str]:
|
def _candidates(source_path: str) -> list[str]:
|
||||||
relative = normalize_path(source_path)
|
relative = normalize_path(source_path)
|
||||||
absolute = normalize_path(str(Path(source_path).resolve())) if source_path else relative
|
absolute = normalize_path(str(Path(source_path).resolve())) if source_path else relative
|
||||||
@@ -119,6 +242,22 @@ def _add_branch(record: FileCoverage, line: int, taken: str) -> None:
|
|||||||
record.branches[line] = (covered + (1 if _branch_hit(taken) else 0), total + 1)
|
record.branches[line] = (covered + (1 if _branch_hit(taken) else 0), total + 1)
|
||||||
|
|
||||||
|
|
||||||
|
def _store_lcov(out: dict[str, FileCoverage], current_file: str | None, current: FileCoverage) -> None:
|
||||||
|
if current_file is not None:
|
||||||
|
out[current_file] = current
|
||||||
|
|
||||||
|
|
||||||
|
def _read_lcov_line(current: FileCoverage, line: str) -> None:
|
||||||
|
match = _LCOV_DA.match(line)
|
||||||
|
if match:
|
||||||
|
hits = int(match.group(2))
|
||||||
|
current[int(match.group(1))] = (1 if hits > 0 else 0, 1)
|
||||||
|
return
|
||||||
|
branch = _LCOV_BRDA.match(line)
|
||||||
|
if branch:
|
||||||
|
_add_branch(current, int(branch.group(1)), branch.group(4))
|
||||||
|
|
||||||
|
|
||||||
def parse_lcov(text: str) -> dict[str, FileCoverage]:
|
def parse_lcov(text: str) -> dict[str, FileCoverage]:
|
||||||
out: dict[str, FileCoverage] = {}
|
out: dict[str, FileCoverage] = {}
|
||||||
current_file = None
|
current_file = None
|
||||||
@@ -126,26 +265,16 @@ def parse_lcov(text: str) -> dict[str, FileCoverage]:
|
|||||||
for raw in text.splitlines():
|
for raw in text.splitlines():
|
||||||
line = raw.strip()
|
line = raw.strip()
|
||||||
if line.startswith("SF:"):
|
if line.startswith("SF:"):
|
||||||
if current_file is not None:
|
_store_lcov(out, current_file, current)
|
||||||
out[current_file] = current
|
|
||||||
current_file = line[3:]
|
current_file = line[3:]
|
||||||
current = FileCoverage()
|
current = FileCoverage()
|
||||||
elif line == "end_of_record":
|
elif line == "end_of_record":
|
||||||
if current_file is not None:
|
_store_lcov(out, current_file, current)
|
||||||
out[current_file] = current
|
|
||||||
current_file = None
|
current_file = None
|
||||||
current = FileCoverage()
|
current = FileCoverage()
|
||||||
elif current_file is not None:
|
elif current_file is not None:
|
||||||
match = _LCOV_DA.match(line)
|
_read_lcov_line(current, line)
|
||||||
if match:
|
_store_lcov(out, current_file, current)
|
||||||
hits = int(match.group(2))
|
|
||||||
current[int(match.group(1))] = (1 if hits > 0 else 0, 1)
|
|
||||||
continue
|
|
||||||
branch = _LCOV_BRDA.match(line)
|
|
||||||
if branch:
|
|
||||||
_add_branch(current, int(branch.group(1)), branch.group(4))
|
|
||||||
if current_file is not None:
|
|
||||||
out[current_file] = current
|
|
||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
@@ -189,18 +318,13 @@ def parse_go_profile(text: str) -> dict[str, list[tuple[int, int, int, int]]]:
|
|||||||
return out
|
return out
|
||||||
|
|
||||||
|
|
||||||
def _profile_segments(profile, path: str):
|
def _profile_segments(profile, path: str, source_parts: list[tuple[str, ...]] | None = None):
|
||||||
if profile is None:
|
if profile is None:
|
||||||
return None
|
return None
|
||||||
for candidate in _candidates(path):
|
key = _select_key(list(profile), path, source_parts or [])
|
||||||
for key, value in profile.items():
|
if key is None:
|
||||||
if (
|
return None
|
||||||
normalize_path(key) == candidate
|
return profile[key]
|
||||||
or suffix_match(key, candidate)
|
|
||||||
or suffix_match(candidate, key)
|
|
||||||
):
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def go_percent(
|
def go_percent(
|
||||||
@@ -208,8 +332,9 @@ def go_percent(
|
|||||||
path: str,
|
path: str,
|
||||||
start: int,
|
start: int,
|
||||||
end: int,
|
end: int,
|
||||||
|
source_parts: list[tuple[str, ...]] | None = None,
|
||||||
) -> float | None:
|
) -> float | None:
|
||||||
segments = _profile_segments(profile, path)
|
segments = _profile_segments(profile, path, source_parts)
|
||||||
if segments is None:
|
if segments is None:
|
||||||
return None
|
return None
|
||||||
total = 0
|
total = 0
|
||||||
@@ -250,6 +375,36 @@ def _jacoco_from_index(classes, class_names, method_name, line) -> float | None:
|
|||||||
return nearest.percent
|
return nearest.percent
|
||||||
|
|
||||||
|
|
||||||
|
def _instruction_counter(method):
|
||||||
|
for child in list(method):
|
||||||
|
if child.tag == "counter" and child.get("type") == "INSTRUCTION":
|
||||||
|
return child
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _method_line(method) -> int:
|
||||||
|
try:
|
||||||
|
return int(method.get("line") or "0")
|
||||||
|
except ValueError:
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
def _record_jacoco_method(found: dict[str, list[JacocoMethod]], class_name: str, method) -> None:
|
||||||
|
if method.tag != "method":
|
||||||
|
return
|
||||||
|
counter = _instruction_counter(method)
|
||||||
|
if counter is None:
|
||||||
|
return
|
||||||
|
key = f"{class_name}#{method.get('name', '')}"
|
||||||
|
found.setdefault(key, []).append(
|
||||||
|
JacocoMethod(
|
||||||
|
missed=int(counter.get("missed") or "0"),
|
||||||
|
covered=int(counter.get("covered") or "0"),
|
||||||
|
line=_method_line(method),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def parse_jacoco_index(text: str) -> dict[str, list[JacocoMethod]]:
|
def parse_jacoco_index(text: str) -> dict[str, list[JacocoMethod]]:
|
||||||
"""Key methods as `binary.class.name#method`."""
|
"""Key methods as `binary.class.name#method`."""
|
||||||
|
|
||||||
@@ -259,30 +414,7 @@ def parse_jacoco_index(text: str) -> dict[str, list[JacocoMethod]]:
|
|||||||
for class_node in root.iter("class"):
|
for class_node in root.iter("class"):
|
||||||
class_name = class_node.get("name", "").replace("/", ".")
|
class_name = class_node.get("name", "").replace("/", ".")
|
||||||
for method in list(class_node):
|
for method in list(class_node):
|
||||||
if method.tag != "method":
|
_record_jacoco_method(found, class_name, method)
|
||||||
continue
|
|
||||||
counter = next(
|
|
||||||
(
|
|
||||||
child
|
|
||||||
for child in list(method)
|
|
||||||
if child.tag == "counter" and child.get("type") == "INSTRUCTION"
|
|
||||||
),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if counter is None:
|
|
||||||
continue
|
|
||||||
try:
|
|
||||||
line = int(method.get("line") or "0")
|
|
||||||
except ValueError:
|
|
||||||
line = 0
|
|
||||||
key = f"{class_name}#{method.get('name', '')}"
|
|
||||||
found.setdefault(key, []).append(
|
|
||||||
JacocoMethod(
|
|
||||||
missed=int(counter.get("missed") or "0"),
|
|
||||||
covered=int(counter.get("covered") or "0"),
|
|
||||||
line=line,
|
|
||||||
)
|
|
||||||
)
|
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
@@ -292,6 +424,20 @@ class CoverageBundle:
|
|||||||
go_profile: dict[str, list[tuple[int, int, int, int]]] | None = None
|
go_profile: dict[str, list[tuple[int, int, int, int]]] | None = None
|
||||||
jacoco: dict[str, list[JacocoMethod]] | None = None
|
jacoco: dict[str, list[JacocoMethod]] | None = None
|
||||||
form_html: dict[str, dict[int, tuple[int, int]]] = field(default_factory=dict)
|
form_html: dict[str, dict[int, tuple[int, int]]] = field(default_factory=dict)
|
||||||
|
source_parts: list[tuple[str, ...]] = field(default_factory=list)
|
||||||
|
|
||||||
|
def bind_sources(self, paths: list[str]) -> None:
|
||||||
|
"""Remember project files so a short path cannot take a longer file's report."""
|
||||||
|
|
||||||
|
found: list[tuple[str, ...]] = []
|
||||||
|
seen: set[tuple[str, ...]] = set()
|
||||||
|
for source in paths:
|
||||||
|
for candidate in _candidates(source):
|
||||||
|
parts = tuple(_segments(candidate))
|
||||||
|
if parts and parts not in seen:
|
||||||
|
seen.add(parts)
|
||||||
|
found.append(parts)
|
||||||
|
self.source_parts = found
|
||||||
|
|
||||||
def percent_for(self, function: Function) -> float | None:
|
def percent_for(self, function: Function) -> float | None:
|
||||||
if function.language == "java":
|
if function.language == "java":
|
||||||
@@ -305,7 +451,11 @@ class CoverageBundle:
|
|||||||
)
|
)
|
||||||
if function.language == "go":
|
if function.language == "go":
|
||||||
return go_percent(
|
return go_percent(
|
||||||
self.go_profile, function.path, function.start_line, function.end_line
|
self.go_profile,
|
||||||
|
function.path,
|
||||||
|
function.start_line,
|
||||||
|
function.end_line,
|
||||||
|
self.source_parts,
|
||||||
)
|
)
|
||||||
html = self._html_lines(function.path)
|
html = self._html_lines(function.path)
|
||||||
if html is not None:
|
if html is not None:
|
||||||
@@ -319,23 +469,19 @@ class CoverageBundle:
|
|||||||
return percent_for_range(record, function.start_line, function.end_line)
|
return percent_for_range(record, function.start_line, function.end_line)
|
||||||
|
|
||||||
def _html_lines(self, path: str) -> dict[int, tuple[int, int]] | None:
|
def _html_lines(self, path: str) -> dict[int, tuple[int, int]] | None:
|
||||||
return _lookup(self.form_html, path)
|
return _lookup(self.form_html, path, self.source_parts)
|
||||||
|
|
||||||
def _lcov_lines(self, path: str) -> dict[int, tuple[int, int]] | None:
|
def _lcov_lines(self, path: str) -> dict[int, tuple[int, int]] | None:
|
||||||
return _lookup(self.lcov, path)
|
return _lookup(self.lcov, path, self.source_parts)
|
||||||
|
|
||||||
|
|
||||||
def _lookup(index: dict[str, dict], source_path: str):
|
def _lookup(index: dict[str, dict], source_path: str, source_parts: list[tuple[str, ...]]):
|
||||||
if not index:
|
if not index:
|
||||||
return None
|
return None
|
||||||
normalized = {normalize_path(key): value for key, value in index.items()}
|
key = _select_key(list(index), source_path, source_parts)
|
||||||
for candidate in _candidates(source_path):
|
if key is None:
|
||||||
if candidate in normalized:
|
return None
|
||||||
return normalized[candidate]
|
return index[key]
|
||||||
for key, value in normalized.items():
|
|
||||||
if any(suffix_match(key, candidate) for candidate in _candidates(source_path)):
|
|
||||||
return value
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def _read(path: Path) -> str | None:
|
def _read(path: Path) -> str | None:
|
||||||
@@ -345,14 +491,7 @@ def _read(path: Path) -> str | None:
|
|||||||
|
|
||||||
|
|
||||||
def _lcov_paths(root: Path) -> list[Path]:
|
def _lcov_paths(root: Path) -> list[Path]:
|
||||||
paths = [
|
paths = list(root.glob("target/coverage/**/lcov.info"))
|
||||||
root / "target" / "coverage" / "lcov.info",
|
|
||||||
root / "coverage" / "lcov.info",
|
|
||||||
root / "target" / "coverage" / "typescript" / "lcov.info",
|
|
||||||
root / "target" / "coverage" / "rust" / "lcov.info",
|
|
||||||
root / "target" / "coverage" / "python" / "lcov.info",
|
|
||||||
]
|
|
||||||
paths.extend(root.glob("target/coverage/**/lcov.info"))
|
|
||||||
paths.extend(root.glob("coverage/**/lcov.info"))
|
paths.extend(root.glob("coverage/**/lcov.info"))
|
||||||
return paths
|
return paths
|
||||||
|
|
||||||
@@ -414,8 +553,7 @@ def _merge_forms(root: Path) -> dict[str, dict[int, tuple[int, int]]]:
|
|||||||
if not text or "forms covered" not in text:
|
if not text or "forms covered" not in text:
|
||||||
continue
|
continue
|
||||||
relative = html_path.relative_to(coverage_dir).as_posix()
|
relative = html_path.relative_to(coverage_dir).as_posix()
|
||||||
if relative.endswith(".html"):
|
relative = relative[: -len(".html")]
|
||||||
relative = relative[: -len(".html")]
|
|
||||||
found[relative] = parse_form_coverage(text)
|
found[relative] = parse_form_coverage(text)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|||||||
+24
-20
@@ -44,6 +44,27 @@ SKIP_DIRS = {
|
|||||||
|
|
||||||
TEST_DIRS = {"test", "tests", "spec", "specs", "__tests__"}
|
TEST_DIRS = {"test", "tests", "spec", "specs", "__tests__"}
|
||||||
|
|
||||||
|
GO_TEST_SUFFIX = "_test.go"
|
||||||
|
CLOJURE_TEST_SUFFIXES = ("_test.clj", "_test.cljc", "_test.cljs", "_test.bb")
|
||||||
|
JS_TEST_SUFFIXES = (
|
||||||
|
".test.ts",
|
||||||
|
".spec.ts",
|
||||||
|
".test.tsx",
|
||||||
|
".spec.tsx",
|
||||||
|
".test.mts",
|
||||||
|
".spec.mts",
|
||||||
|
".test.cts",
|
||||||
|
".spec.cts",
|
||||||
|
".test.js",
|
||||||
|
".spec.js",
|
||||||
|
".test.jsx",
|
||||||
|
".spec.jsx",
|
||||||
|
".test.mjs",
|
||||||
|
".spec.mjs",
|
||||||
|
".test.cjs",
|
||||||
|
".spec.cjs",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def language_of(path: str | Path) -> str | None:
|
def language_of(path: str | Path) -> str | None:
|
||||||
file_path = Path(path)
|
file_path = Path(path)
|
||||||
@@ -56,28 +77,11 @@ def language_of(path: str | Path) -> str | None:
|
|||||||
def is_test_file(path: str | Path) -> bool:
|
def is_test_file(path: str | Path) -> bool:
|
||||||
file_path = Path(path)
|
file_path = Path(path)
|
||||||
name = file_path.name
|
name = file_path.name
|
||||||
if name.endswith("_test.go"):
|
if name.endswith(GO_TEST_SUFFIX):
|
||||||
return True
|
return True
|
||||||
if name.endswith(("_test.clj", "_test.cljc", "_test.cljs", "_test.bb")):
|
if name.endswith(CLOJURE_TEST_SUFFIXES):
|
||||||
return True
|
return True
|
||||||
if name.endswith(
|
if name.endswith(JS_TEST_SUFFIXES):
|
||||||
(
|
|
||||||
".test.ts",
|
|
||||||
".spec.ts",
|
|
||||||
".test.tsx",
|
|
||||||
".spec.tsx",
|
|
||||||
".test.mts",
|
|
||||||
".spec.mts",
|
|
||||||
".test.js",
|
|
||||||
".spec.js",
|
|
||||||
".test.jsx",
|
|
||||||
".spec.jsx",
|
|
||||||
".test.mjs",
|
|
||||||
".spec.mjs",
|
|
||||||
".test.cjs",
|
|
||||||
".spec.cjs",
|
|
||||||
)
|
|
||||||
):
|
|
||||||
return True
|
return True
|
||||||
if name == "conftest.py":
|
if name == "conftest.py":
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -1,27 +1,27 @@
|
|||||||
"""Per-language function extraction."""
|
"""Per-language function extraction."""
|
||||||
|
|
||||||
from crapper.languages.clojure import ClojureFactory
|
from crapper.languages.clojure import functions_in_source as clojure_functions
|
||||||
from crapper.languages.golang import GoFactory
|
from crapper.languages.golang import functions_in_source as go_functions
|
||||||
from crapper.languages.java import JavaFactory
|
from crapper.languages.java import functions_in_source as java_functions
|
||||||
from crapper.languages.language import LanguageFactory, UnknownFactory
|
from crapper.languages.python import functions_in_source as python_functions
|
||||||
from crapper.languages.python import PythonFactory
|
from crapper.languages.rust import functions_in_source as rust_functions
|
||||||
from crapper.languages.rust import RustFactory
|
from crapper.languages.typescript import functions_in_source as typescript_functions
|
||||||
from crapper.languages.typescript import TypeScriptFactory
|
|
||||||
from crapper.model import Function
|
from crapper.model import Function
|
||||||
|
|
||||||
_FACTORIES: dict[str, LanguageFactory] = {
|
_READERS = {
|
||||||
"clojure": ClojureFactory(),
|
"clojure": clojure_functions,
|
||||||
"java": JavaFactory(),
|
"java": java_functions,
|
||||||
"go": GoFactory(),
|
"go": go_functions,
|
||||||
"typescript": TypeScriptFactory(),
|
"typescript": typescript_functions,
|
||||||
"python": PythonFactory(),
|
"python": python_functions,
|
||||||
"rust": RustFactory(),
|
"rust": rust_functions,
|
||||||
}
|
}
|
||||||
_UNKNOWN = UnknownFactory()
|
|
||||||
|
|
||||||
|
|
||||||
def functions_in_file(
|
def functions_in_file(
|
||||||
language: str, source: str, path: str, project_root: str
|
language: str, source: str, path: str, project_root: str
|
||||||
) -> list[Function]:
|
) -> list[Function]:
|
||||||
factory = _FACTORIES.get(language, _UNKNOWN)
|
reader = _READERS.get(language)
|
||||||
return factory.create().functions(source, path, project_root)
|
if reader is None:
|
||||||
|
return []
|
||||||
|
return reader(source, path, project_root)
|
||||||
|
|||||||
@@ -7,14 +7,12 @@ variants, `and`, `or`, `loop`, `catch`, and each clause of `cond`, `condp`,
|
|||||||
|
|
||||||
import re
|
import re
|
||||||
|
|
||||||
from crapper.languages.language import Language, LanguageFactory
|
|
||||||
from crapper.model import Function
|
from crapper.model import Function
|
||||||
|
|
||||||
_DECISION = re.compile(
|
_DECISION = re.compile(
|
||||||
r"\((if-not|if-let|if-some|when-not|when-let|when-some|when-first|if|when|and|or|loop|catch)[\s\)]"
|
r"\((if-not|if-let|if-some|when-not|when-let|when-some|when-first|if|when|and|or|loop|catch)[\s\)]"
|
||||||
)
|
)
|
||||||
_COND = re.compile(r"\((some->>|some->|cond->>|cond->|cond|condp|case)[\s\)]")
|
_COND = re.compile(r"\((some->>|some->|cond->>|cond->|cond|condp|case)[\s\)]")
|
||||||
_DEFN = re.compile(r"(?s)^\(\s*defn-?\s+([^\s\(\)\[\]\{\}\"]+)")
|
|
||||||
_NS = re.compile(r"\(\s*ns\s+([A-Za-z0-9*+!_?.\-/]+)")
|
_NS = re.compile(r"\(\s*ns\s+([A-Za-z0-9*+!_?.\-/]+)")
|
||||||
_IN_NS = re.compile(r"\(\s*in-ns\s+'([A-Za-z0-9*+!_?.\-/]+)\s*\)")
|
_IN_NS = re.compile(r"\(\s*in-ns\s+'([A-Za-z0-9*+!_?.\-/]+)\s*\)")
|
||||||
_IN_NS_QUOTE = re.compile(
|
_IN_NS_QUOTE = re.compile(
|
||||||
@@ -33,38 +31,125 @@ _THREAD_FORMS = {"some->", "some->>"}
|
|||||||
_CHAR_DELIMITERS = set("()[]{}\";,")
|
_CHAR_DELIMITERS = set("()[]{}\";,")
|
||||||
|
|
||||||
|
|
||||||
def _strip_strings(text: str) -> str:
|
def _blank_span(source: str, start: int, end: int, out: list[str]) -> None:
|
||||||
mode = "normal"
|
for ch in source[start:end]:
|
||||||
escaped = False
|
out.append("\n" if ch == "\n" else " ")
|
||||||
out: list[str] = []
|
|
||||||
for ch in text:
|
|
||||||
newline = ch == "\n"
|
|
||||||
if mode == "string":
|
|
||||||
if escaped:
|
|
||||||
out.append(ch if newline else " ")
|
|
||||||
escaped = False
|
|
||||||
elif ch == "\\":
|
|
||||||
out.append(" ")
|
|
||||||
escaped = True
|
|
||||||
elif ch == '"':
|
|
||||||
out.append(ch)
|
|
||||||
mode = "normal"
|
|
||||||
else:
|
|
||||||
out.append(ch if newline else " ")
|
|
||||||
elif ch == '"':
|
|
||||||
out.append(ch)
|
|
||||||
mode = "string"
|
|
||||||
else:
|
|
||||||
out.append(ch)
|
|
||||||
return "".join(out)
|
|
||||||
|
|
||||||
|
|
||||||
def _strip_comments(text: str) -> str:
|
def _advance_past_reader_token(source: str, index: int) -> int | None:
|
||||||
return "\n".join(re.sub(r";.*", "", line) for line in text.splitlines())
|
"""Index after a string, character, or comment, or None when `index` is code."""
|
||||||
|
|
||||||
|
ch = source[index]
|
||||||
|
if ch == '"':
|
||||||
|
return _consume_string(source, index, 1)[0]
|
||||||
|
if ch == "\\":
|
||||||
|
return _char_literal_end(source, index)
|
||||||
|
if ch != ";":
|
||||||
|
return None
|
||||||
|
while index < len(source) and source[index] != "\n":
|
||||||
|
index += 1
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _balanced_end(source: str, index: int) -> int:
|
||||||
|
opener = source[index]
|
||||||
|
closer = {"(": ")", "[": "]", "{": "}"}[opener]
|
||||||
|
depth = 0
|
||||||
|
while index < len(source):
|
||||||
|
nxt = _advance_past_reader_token(source, index)
|
||||||
|
if nxt is not None:
|
||||||
|
index = nxt
|
||||||
|
continue
|
||||||
|
ch = source[index]
|
||||||
|
index += 1
|
||||||
|
if ch == opener:
|
||||||
|
depth += 1
|
||||||
|
elif ch == closer:
|
||||||
|
depth -= 1
|
||||||
|
if depth == 0:
|
||||||
|
return index
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_space_and_comment(source: str, index: int) -> int:
|
||||||
|
while index < len(source) and source[index].isspace():
|
||||||
|
index += 1
|
||||||
|
if index < len(source) and source[index] == ";":
|
||||||
|
while index < len(source) and source[index] != "\n":
|
||||||
|
index += 1
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_ignorable(source: str, index: int) -> int:
|
||||||
|
while True:
|
||||||
|
nxt = _skip_space_and_comment(source, index)
|
||||||
|
if nxt == index:
|
||||||
|
return index
|
||||||
|
index = nxt
|
||||||
|
|
||||||
|
|
||||||
|
def _end_of_atom(source: str, index: int) -> int:
|
||||||
|
while index < len(source) and not source[index].isspace() and source[index] not in "()[]{}\";":
|
||||||
|
index += 1
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _end_of_one_form(source: str, index: int) -> int:
|
||||||
|
ch = source[index]
|
||||||
|
if ch in "([{":
|
||||||
|
return _balanced_end(source, index)
|
||||||
|
if ch == '"':
|
||||||
|
return _consume_string(source, index, 1)[0]
|
||||||
|
if ch == "\\":
|
||||||
|
return _char_literal_end(source, index)
|
||||||
|
if source.startswith("#_", index):
|
||||||
|
return _form_end(source, index + 2)
|
||||||
|
return _end_of_atom(source, index)
|
||||||
|
|
||||||
|
|
||||||
|
def _form_end(source: str, index: int) -> int:
|
||||||
|
"""Index just after the next form. `index` may sit on whitespace before it."""
|
||||||
|
|
||||||
|
index = _skip_ignorable(source, index)
|
||||||
|
if index >= len(source):
|
||||||
|
return index
|
||||||
|
return _end_of_one_form(source, index)
|
||||||
|
|
||||||
|
|
||||||
def without_strings_and_comments(source: str) -> str:
|
def without_strings_and_comments(source: str) -> str:
|
||||||
return _strip_comments(_strip_strings(source))
|
"""Blank strings, comments, character literals, and `#_` discarded forms.
|
||||||
|
|
||||||
|
Character literals are blanked before `"` or `;` can be read, so `\\"` does
|
||||||
|
not open a string and `\\;` does not open a comment.
|
||||||
|
"""
|
||||||
|
|
||||||
|
out: list[str] = []
|
||||||
|
index = 0
|
||||||
|
while index < len(source):
|
||||||
|
if source.startswith("#_", index):
|
||||||
|
end = _form_end(source, index + 2)
|
||||||
|
_blank_span(source, index, end, out)
|
||||||
|
index = end
|
||||||
|
continue
|
||||||
|
ch = source[index]
|
||||||
|
if ch == '"':
|
||||||
|
end = _consume_string(source, index, 1)[0]
|
||||||
|
_blank_span(source, index, end, out)
|
||||||
|
index = end
|
||||||
|
continue
|
||||||
|
if ch == "\\":
|
||||||
|
end = _char_literal_end(source, index)
|
||||||
|
_blank_span(source, index, end, out)
|
||||||
|
index = end
|
||||||
|
continue
|
||||||
|
if ch == ";":
|
||||||
|
while index < len(source) and source[index] != "\n":
|
||||||
|
out.append(" ")
|
||||||
|
index += 1
|
||||||
|
continue
|
||||||
|
out.append(ch)
|
||||||
|
index += 1
|
||||||
|
return "".join(out)
|
||||||
|
|
||||||
|
|
||||||
def _open_bracket(ch: str) -> bool:
|
def _open_bracket(ch: str) -> bool:
|
||||||
@@ -75,6 +160,24 @@ def _close_bracket(ch: str) -> bool:
|
|||||||
return ch in ")}]"
|
return ch in ")}]"
|
||||||
|
|
||||||
|
|
||||||
|
def _on_open_bracket(depth: int, in_form: bool, forms: int) -> tuple[int, bool, int]:
|
||||||
|
if depth == 1 and not in_form:
|
||||||
|
forms += 1
|
||||||
|
return depth + 1, True, forms
|
||||||
|
|
||||||
|
|
||||||
|
def _on_close_bracket(depth: int, in_form: bool, forms: int) -> tuple[int, bool, int]:
|
||||||
|
if depth == 1:
|
||||||
|
in_form = False
|
||||||
|
return depth - 1, in_form, forms
|
||||||
|
|
||||||
|
|
||||||
|
def _on_atom(depth: int, in_form: bool, forms: int) -> tuple[int, bool, int]:
|
||||||
|
if depth == 1 and not in_form:
|
||||||
|
forms += 1
|
||||||
|
return depth, True, forms
|
||||||
|
|
||||||
|
|
||||||
def _count_top_level_forms(text: str, start: int) -> int:
|
def _count_top_level_forms(text: str, start: int) -> int:
|
||||||
depth = 1
|
depth = 1
|
||||||
forms = 0
|
forms = 0
|
||||||
@@ -84,20 +187,13 @@ def _count_top_level_forms(text: str, start: int) -> int:
|
|||||||
while i < n and depth != 0:
|
while i < n and depth != 0:
|
||||||
ch = text[i]
|
ch = text[i]
|
||||||
if _open_bracket(ch):
|
if _open_bracket(ch):
|
||||||
if depth == 1 and not in_form:
|
depth, in_form, forms = _on_open_bracket(depth, in_form, forms)
|
||||||
forms += 1
|
|
||||||
depth += 1
|
|
||||||
in_form = True
|
|
||||||
elif _close_bracket(ch):
|
elif _close_bracket(ch):
|
||||||
if depth == 1:
|
depth, in_form, forms = _on_close_bracket(depth, in_form, forms)
|
||||||
in_form = False
|
|
||||||
depth -= 1
|
|
||||||
elif ch.isspace():
|
elif ch.isspace():
|
||||||
in_form = depth != 1
|
in_form = depth != 1
|
||||||
else:
|
else:
|
||||||
if depth == 1 and not in_form:
|
depth, in_form, forms = _on_atom(depth, in_form, forms)
|
||||||
forms += 1
|
|
||||||
in_form = True
|
|
||||||
i += 1
|
i += 1
|
||||||
return forms
|
return forms
|
||||||
|
|
||||||
@@ -128,7 +224,7 @@ def _count_clauses(text: str, form_type: str, match_start: int) -> int:
|
|||||||
def _count_cond_decisions(clean: str) -> int:
|
def _count_cond_decisions(clean: str) -> int:
|
||||||
total = 0
|
total = 0
|
||||||
for match in _COND.finditer(clean):
|
for match in _COND.finditer(clean):
|
||||||
form_type = _COND.match(match.group(0)).group(1)
|
form_type = match.group(1)
|
||||||
total += _count_clauses(clean, form_type, match.start())
|
total += _count_clauses(clean, form_type, match.start())
|
||||||
return total
|
return total
|
||||||
|
|
||||||
@@ -178,16 +274,73 @@ def _consume_string(source: str, index: int, line: int) -> tuple[int, int]:
|
|||||||
return index, line
|
return index, line
|
||||||
|
|
||||||
|
|
||||||
|
_NAME_STOP = " \t\n\r()[]{}\""
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_space(form: str, index: int) -> int:
|
||||||
|
while index < len(form) and form[index].isspace():
|
||||||
|
index += 1
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _keyword_ends(form: str, after: int) -> bool:
|
||||||
|
return after >= len(form) or form[after] in _NAME_STOP
|
||||||
|
|
||||||
|
|
||||||
|
def _defn_keyword_end(form: str, index: int) -> int | None:
|
||||||
|
for candidate in ("defn-", "defn"):
|
||||||
|
after = index + len(candidate)
|
||||||
|
if form.startswith(candidate, index) and _keyword_ends(form, after):
|
||||||
|
return after
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_metadata(form: str, index: int) -> int:
|
||||||
|
index += 1
|
||||||
|
if index < len(form) and form[index] == "{":
|
||||||
|
return _balanced_end(form, index)
|
||||||
|
while index < len(form) and form[index] not in _NAME_STOP:
|
||||||
|
index += 1
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_leading_metadata(form: str, index: int) -> int:
|
||||||
|
while True:
|
||||||
|
index = _skip_space(form, index)
|
||||||
|
if index < len(form) and form[index] == "^":
|
||||||
|
index = _skip_metadata(form, index)
|
||||||
|
continue
|
||||||
|
return index
|
||||||
|
|
||||||
|
|
||||||
|
def _read_name(form: str, index: int) -> str | None:
|
||||||
|
if index >= len(form) or form[index] in "()[]{}\";":
|
||||||
|
return None
|
||||||
|
start = index
|
||||||
|
while index < len(form) and form[index] not in _NAME_STOP:
|
||||||
|
index += 1
|
||||||
|
return form[start:index]
|
||||||
|
|
||||||
|
|
||||||
|
def _defn_name(form: str) -> str | None:
|
||||||
|
if not form.startswith("("):
|
||||||
|
return None
|
||||||
|
keyword_at = _defn_keyword_end(form, _skip_space(form, 1))
|
||||||
|
if keyword_at is None:
|
||||||
|
return None
|
||||||
|
return _read_name(form, _skip_leading_metadata(form, keyword_at))
|
||||||
|
|
||||||
|
|
||||||
def _remember_form(
|
def _remember_form(
|
||||||
forms: list[dict], source: str, start: int, start_line: int, end: int, line: int
|
forms: list[dict], source: str, start: int, start_line: int, end: int, line: int
|
||||||
) -> None:
|
) -> None:
|
||||||
form_text = source[start : end + 1]
|
form_text = source[start : end + 1]
|
||||||
matched = _DEFN.match(form_text)
|
name = _defn_name(form_text)
|
||||||
if matched is None:
|
if name is None:
|
||||||
return
|
return
|
||||||
forms.append(
|
forms.append(
|
||||||
{
|
{
|
||||||
"name": matched.group(1),
|
"name": name,
|
||||||
"start_line": start_line,
|
"start_line": start_line,
|
||||||
"end_line": line,
|
"end_line": line,
|
||||||
"text": form_text,
|
"text": form_text,
|
||||||
@@ -202,6 +355,20 @@ def _close_form(forms, source, depth, form_start, form_line, index, line):
|
|||||||
return max(0, depth - 1), form_start, form_line
|
return max(0, depth - 1), form_start, form_line
|
||||||
|
|
||||||
|
|
||||||
|
def _skip_reader(source: str, index: int, line: int) -> tuple[int, int] | None:
|
||||||
|
ch = source[index]
|
||||||
|
if ch == ";":
|
||||||
|
return _consume_comment(source, index, line)
|
||||||
|
if ch == '"':
|
||||||
|
return _consume_string(source, index, line)
|
||||||
|
if ch == "\\":
|
||||||
|
return _char_literal_end(source, index), line
|
||||||
|
if source.startswith("#_", index):
|
||||||
|
end = _form_end(source, index + 2)
|
||||||
|
return end, line + source[index:end].count("\n")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _extract_top_level_defns(source: str) -> list[dict]:
|
def _extract_top_level_defns(source: str) -> list[dict]:
|
||||||
forms: list[dict] = []
|
forms: list[dict] = []
|
||||||
index = 0
|
index = 0
|
||||||
@@ -210,16 +377,11 @@ def _extract_top_level_defns(source: str) -> list[dict]:
|
|||||||
form_start = None
|
form_start = None
|
||||||
form_line = None
|
form_line = None
|
||||||
while index < len(source):
|
while index < len(source):
|
||||||
|
skipped = _skip_reader(source, index, line)
|
||||||
|
if skipped is not None:
|
||||||
|
index, line = skipped
|
||||||
|
continue
|
||||||
ch = source[index]
|
ch = source[index]
|
||||||
if ch == ";":
|
|
||||||
index, line = _consume_comment(source, index, line)
|
|
||||||
continue
|
|
||||||
if ch == '"':
|
|
||||||
index, line = _consume_string(source, index, line)
|
|
||||||
continue
|
|
||||||
if ch == "\\":
|
|
||||||
index = _char_literal_end(source, index)
|
|
||||||
continue
|
|
||||||
if ch == "(":
|
if ch == "(":
|
||||||
if depth == 0:
|
if depth == 0:
|
||||||
form_start = index
|
form_start = index
|
||||||
@@ -250,8 +412,9 @@ def extract_functions(source: str) -> list[dict]:
|
|||||||
|
|
||||||
|
|
||||||
def declared_namespace(source: str) -> str | None:
|
def declared_namespace(source: str) -> str | None:
|
||||||
|
clean = without_strings_and_comments(source)
|
||||||
for pattern in (_NS, _IN_NS, _IN_NS_QUOTE):
|
for pattern in (_NS, _IN_NS, _IN_NS_QUOTE):
|
||||||
match = pattern.search(source)
|
match = pattern.search(clean)
|
||||||
if match:
|
if match:
|
||||||
return match.group(1)
|
return match.group(1)
|
||||||
return None
|
return None
|
||||||
@@ -286,13 +449,3 @@ def functions_in_source(
|
|||||||
)
|
)
|
||||||
for item in extract_functions(source)
|
for item in extract_functions(source)
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
class Clojure(Language):
|
|
||||||
def functions(self, source: str, path: str, project_root: str) -> list[Function]:
|
|
||||||
return functions_in_source(source, path, project_root)
|
|
||||||
|
|
||||||
|
|
||||||
class ClojureFactory(LanguageFactory):
|
|
||||||
def create(self) -> Language:
|
|
||||||
return Clojure()
|
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ uml-viewer makes between a class and its operations.
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from crapper.languages.treesitter import (
|
from crapper.languages.treesitter import (
|
||||||
|
absolute_path as _absolute,
|
||||||
binary_logic,
|
binary_logic,
|
||||||
child_of_type,
|
child_of_type,
|
||||||
complexity,
|
complexity,
|
||||||
@@ -19,7 +20,6 @@ from crapper.languages.treesitter import (
|
|||||||
parse,
|
parse,
|
||||||
start_line,
|
start_line,
|
||||||
)
|
)
|
||||||
from crapper.languages.language import Language, LanguageFactory
|
|
||||||
from crapper.model import Function
|
from crapper.model import Function
|
||||||
|
|
||||||
_DECISIONS = {
|
_DECISIONS = {
|
||||||
@@ -106,15 +106,6 @@ def package_namespace(path: str, package_name: str) -> str:
|
|||||||
return f"{module}/{relative}"
|
return f"{module}/{relative}"
|
||||||
|
|
||||||
|
|
||||||
def _absolute(path: str, project_root: str | None) -> str:
|
|
||||||
file_path = Path(path)
|
|
||||||
if file_path.is_absolute():
|
|
||||||
return str(file_path)
|
|
||||||
if project_root:
|
|
||||||
return str((Path(project_root) / file_path).resolve())
|
|
||||||
return str(file_path.resolve())
|
|
||||||
|
|
||||||
|
|
||||||
def functions_in_source(
|
def functions_in_source(
|
||||||
source: str, path: str, project_root: str | None = None
|
source: str, path: str, project_root: str | None = None
|
||||||
) -> list[Function]:
|
) -> list[Function]:
|
||||||
@@ -150,13 +141,3 @@ def functions_in_source(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
class Go(Language):
|
|
||||||
def functions(self, source: str, path: str, project_root: str) -> list[Function]:
|
|
||||||
return functions_in_source(source, path, project_root)
|
|
||||||
|
|
||||||
|
|
||||||
class GoFactory(LanguageFactory):
|
|
||||||
def create(self) -> Language:
|
|
||||||
return Go()
|
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ from crapper.languages.treesitter import (
|
|||||||
parse,
|
parse,
|
||||||
start_line,
|
start_line,
|
||||||
)
|
)
|
||||||
from crapper.languages.language import Language, LanguageFactory
|
|
||||||
from crapper.model import Function
|
from crapper.model import Function
|
||||||
|
|
||||||
_PACKAGE = re.compile(r"(?m)^\s*package\s+([a-zA-Z_][\w.]*)\s*;")
|
_PACKAGE = re.compile(r"(?m)^\s*package\s+([a-zA-Z_][\w.]*)\s*;")
|
||||||
@@ -86,12 +85,16 @@ def _qualify(package: str | None, names: list[str], nested: str) -> str:
|
|||||||
return body
|
return body
|
||||||
|
|
||||||
|
|
||||||
def functions_in_source(source: str, path: str) -> list[Function]:
|
def functions_in_source(
|
||||||
|
source: str, path: str, project_root: str | None = None
|
||||||
|
) -> list[Function]:
|
||||||
|
del project_root
|
||||||
data, tree = parse(source, "java")
|
data, tree = parse(source, "java")
|
||||||
package = _package_name(source)
|
package = _package_name(source)
|
||||||
found: list[Function] = []
|
found: list[Function] = []
|
||||||
|
stack = [tree.root_node]
|
||||||
def visit(node) -> None:
|
while stack:
|
||||||
|
node = stack.pop()
|
||||||
if node.type == "method_declaration" and not _inside_executable(node):
|
if node.type == "method_declaration" and not _inside_executable(node):
|
||||||
if child_of_type(node, "block") is not None:
|
if child_of_type(node, "block") is not None:
|
||||||
ident = child_of_type(node, "identifier")
|
ident = child_of_type(node, "identifier")
|
||||||
@@ -109,19 +112,6 @@ def functions_in_source(source: str, path: str) -> list[Function]:
|
|||||||
jacoco_class=_qualify(package, names, "$"),
|
jacoco_class=_qualify(package, names, "$"),
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
return
|
continue
|
||||||
for child in node.children:
|
stack.extend(reversed(node.children))
|
||||||
visit(child)
|
|
||||||
|
|
||||||
visit(tree.root_node)
|
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
class Java(Language):
|
|
||||||
def functions(self, source: str, path: str, project_root: str) -> list[Function]:
|
|
||||||
return functions_in_source(source, path)
|
|
||||||
|
|
||||||
|
|
||||||
class JavaFactory(LanguageFactory):
|
|
||||||
def create(self) -> Language:
|
|
||||||
return Java()
|
|
||||||
|
|||||||
@@ -1,27 +0,0 @@
|
|||||||
"""The language operation, and the factories that create language instances."""
|
|
||||||
|
|
||||||
from abc import ABC, abstractmethod
|
|
||||||
|
|
||||||
from crapper.model import Function
|
|
||||||
|
|
||||||
|
|
||||||
class Language(ABC):
|
|
||||||
@abstractmethod
|
|
||||||
def functions(self, source: str, path: str, project_root: str) -> list[Function]:
|
|
||||||
"""Functions and methods defined in one source file."""
|
|
||||||
|
|
||||||
|
|
||||||
class Unknown(Language):
|
|
||||||
def functions(self, source: str, path: str, project_root: str) -> list[Function]:
|
|
||||||
return []
|
|
||||||
|
|
||||||
|
|
||||||
class LanguageFactory(ABC):
|
|
||||||
@abstractmethod
|
|
||||||
def create(self) -> Language:
|
|
||||||
"""A language instance ready to read source files."""
|
|
||||||
|
|
||||||
|
|
||||||
class UnknownFactory(LanguageFactory):
|
|
||||||
def create(self) -> Language:
|
|
||||||
return Unknown()
|
|
||||||
@@ -16,7 +16,6 @@ from crapper.languages.treesitter import (
|
|||||||
parse,
|
parse,
|
||||||
start_line,
|
start_line,
|
||||||
)
|
)
|
||||||
from crapper.languages.language import Language, LanguageFactory
|
|
||||||
from crapper.model import Function
|
from crapper.model import Function
|
||||||
|
|
||||||
_DECISIONS = {
|
_DECISIONS = {
|
||||||
@@ -132,13 +131,3 @@ def functions_in_source(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
class Python(Language):
|
|
||||||
def functions(self, source: str, path: str, project_root: str) -> list[Function]:
|
|
||||||
return functions_in_source(source, path, project_root)
|
|
||||||
|
|
||||||
|
|
||||||
class PythonFactory(LanguageFactory):
|
|
||||||
def create(self) -> Language:
|
|
||||||
return Python()
|
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import re
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from crapper.languages.treesitter import (
|
from crapper.languages.treesitter import (
|
||||||
|
absolute_path as _absolute,
|
||||||
binary_logic,
|
binary_logic,
|
||||||
child_of_type,
|
child_of_type,
|
||||||
complexity,
|
complexity,
|
||||||
@@ -20,7 +21,6 @@ from crapper.languages.treesitter import (
|
|||||||
parse,
|
parse,
|
||||||
start_line,
|
start_line,
|
||||||
)
|
)
|
||||||
from crapper.languages.language import Language, LanguageFactory
|
|
||||||
from crapper.model import Function
|
from crapper.model import Function
|
||||||
|
|
||||||
_DECISIONS = {
|
_DECISIONS = {
|
||||||
@@ -64,6 +64,15 @@ def _in_mod_named(data: bytes, node, name: str) -> bool:
|
|||||||
return name in _ancestor_mods(data, node)
|
return name in _ancestor_mods(data, node)
|
||||||
|
|
||||||
|
|
||||||
|
def _first_type_name(data: bytes, node) -> str | None:
|
||||||
|
if node.type == "type_identifier":
|
||||||
|
return node_text(data, node)
|
||||||
|
for item in descendants(node):
|
||||||
|
if item.type == "type_identifier":
|
||||||
|
return node_text(data, item)
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def _impl_type(data: bytes, impl) -> str | None:
|
def _impl_type(data: bytes, impl) -> str | None:
|
||||||
saw_for = False
|
saw_for = False
|
||||||
chosen = None
|
chosen = None
|
||||||
@@ -73,16 +82,7 @@ def _impl_type(data: bytes, impl) -> str | None:
|
|||||||
chosen = None
|
chosen = None
|
||||||
continue
|
continue
|
||||||
if child.type in {"type_identifier", "generic_type", "scoped_type_identifier"}:
|
if child.type in {"type_identifier", "generic_type", "scoped_type_identifier"}:
|
||||||
if child.type == "type_identifier":
|
chosen = _first_type_name(data, child)
|
||||||
chosen = node_text(data, child)
|
|
||||||
else:
|
|
||||||
ident = child_of_type(child, "type_identifier")
|
|
||||||
if ident is None:
|
|
||||||
for item in descendants(child):
|
|
||||||
if item.type == "type_identifier":
|
|
||||||
ident = item
|
|
||||||
break
|
|
||||||
chosen = node_text(data, ident) if ident is not None else None
|
|
||||||
if saw_for:
|
if saw_for:
|
||||||
return chosen
|
return chosen
|
||||||
return chosen
|
return chosen
|
||||||
@@ -105,10 +105,27 @@ def _crate_name(path: str) -> tuple[str, Path | None]:
|
|||||||
return "crate", None
|
return "crate", None
|
||||||
|
|
||||||
|
|
||||||
|
def _rs_parts(relative: str) -> list[str]:
|
||||||
|
parts = relative.split("/")
|
||||||
|
if parts[-1] == "mod.rs":
|
||||||
|
return parts[:-1]
|
||||||
|
if parts[-1].endswith(".rs"):
|
||||||
|
parts[-1] = parts[-1][:-3]
|
||||||
|
return parts
|
||||||
|
|
||||||
|
|
||||||
|
def _with_crate(crate_name: str, parts: list[str]) -> list[str]:
|
||||||
|
if parts[:1] == ["bin"] and len(parts) >= 2:
|
||||||
|
return parts[1:]
|
||||||
|
return [crate_name, *parts]
|
||||||
|
|
||||||
|
|
||||||
def _file_modules(path: str, crate_name: str, crate_root: Path | None) -> list[str]:
|
def _file_modules(path: str, crate_name: str, crate_root: Path | None) -> list[str]:
|
||||||
file_path = Path(path).resolve()
|
file_path = Path(path).resolve()
|
||||||
if crate_root is None:
|
if crate_root is None:
|
||||||
return [crate_name, file_path.stem] if file_path.stem not in {"lib", "main"} else [crate_name]
|
if file_path.stem in {"lib", "main"}:
|
||||||
|
return [crate_name]
|
||||||
|
return [crate_name, file_path.stem]
|
||||||
try:
|
try:
|
||||||
relative = file_path.relative_to(crate_root).as_posix()
|
relative = file_path.relative_to(crate_root).as_posix()
|
||||||
except ValueError:
|
except ValueError:
|
||||||
@@ -117,14 +134,7 @@ def _file_modules(path: str, crate_name: str, crate_root: Path | None) -> list[s
|
|||||||
relative = relative[4:]
|
relative = relative[4:]
|
||||||
if relative in {"lib.rs", "main.rs"}:
|
if relative in {"lib.rs", "main.rs"}:
|
||||||
return [crate_name]
|
return [crate_name]
|
||||||
parts = relative.split("/")
|
return _with_crate(crate_name, _rs_parts(relative))
|
||||||
if parts[-1] == "mod.rs":
|
|
||||||
parts = parts[:-1]
|
|
||||||
elif parts[-1].endswith(".rs"):
|
|
||||||
parts[-1] = parts[-1][:-3]
|
|
||||||
if parts[:1] == ["bin"] and len(parts) >= 2:
|
|
||||||
return parts[1:]
|
|
||||||
return [crate_name, *parts]
|
|
||||||
|
|
||||||
|
|
||||||
def _namespace(modules: list[str], extra: list[str], type_name: str | None) -> str:
|
def _namespace(modules: list[str], extra: list[str], type_name: str | None) -> str:
|
||||||
@@ -134,15 +144,6 @@ def _namespace(modules: list[str], extra: list[str], type_name: str | None) -> s
|
|||||||
return "::".join(part for part in parts if part)
|
return "::".join(part for part in parts if part)
|
||||||
|
|
||||||
|
|
||||||
def _absolute(path: str, project_root: str | None) -> str:
|
|
||||||
file_path = Path(path)
|
|
||||||
if file_path.is_absolute():
|
|
||||||
return str(file_path)
|
|
||||||
if project_root:
|
|
||||||
return str((Path(project_root) / file_path).resolve())
|
|
||||||
return str(file_path.resolve())
|
|
||||||
|
|
||||||
|
|
||||||
def _impl_owner(data: bytes, node):
|
def _impl_owner(data: bytes, node):
|
||||||
parent = node.parent
|
parent = node.parent
|
||||||
if parent is None or parent.type != "declaration_list":
|
if parent is None or parent.type != "declaration_list":
|
||||||
@@ -187,13 +188,3 @@ def functions_in_source(
|
|||||||
if recorded is not None:
|
if recorded is not None:
|
||||||
found.append(recorded)
|
found.append(recorded)
|
||||||
return found
|
return found
|
||||||
|
|
||||||
|
|
||||||
class Rust(Language):
|
|
||||||
def functions(self, source: str, path: str, project_root: str) -> list[Function]:
|
|
||||||
return functions_in_source(source, path, project_root)
|
|
||||||
|
|
||||||
|
|
||||||
class RustFactory(LanguageFactory):
|
|
||||||
def create(self) -> Language:
|
|
||||||
return Rust()
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Shared tree-sitter helpers for Java, Go, TypeScript, Rust, and Python."""
|
"""Shared tree-sitter helpers for Java, Go, TypeScript, Rust, and Python."""
|
||||||
|
|
||||||
from functools import lru_cache
|
from functools import lru_cache
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
|
||||||
@lru_cache(maxsize=None)
|
@lru_cache(maxsize=None)
|
||||||
@@ -47,18 +48,32 @@ def descendants(node):
|
|||||||
stack.extend(reversed(current.children))
|
stack.extend(reversed(current.children))
|
||||||
|
|
||||||
|
|
||||||
|
def absolute_path(path: str, project_root: str | None) -> str:
|
||||||
|
file_path = Path(path)
|
||||||
|
if file_path.is_absolute():
|
||||||
|
return str(file_path)
|
||||||
|
if project_root:
|
||||||
|
return str((Path(project_root) / file_path).resolve())
|
||||||
|
return str(file_path.resolve())
|
||||||
|
|
||||||
|
|
||||||
def complexity(node, is_decision, skip) -> int:
|
def complexity(node, is_decision, skip) -> int:
|
||||||
"""McCabe complexity of `node`: 1 plus each decision in the subtree."""
|
"""McCabe complexity of `node`: 1 plus each decision in the subtree.
|
||||||
|
|
||||||
def walk(current) -> int:
|
The walk is a stack, not recursion. A generated expression can be deeper
|
||||||
|
than Python's call limit.
|
||||||
|
"""
|
||||||
|
|
||||||
|
score = 1
|
||||||
|
stack = [node]
|
||||||
|
while stack:
|
||||||
|
current = stack.pop()
|
||||||
if skip(current):
|
if skip(current):
|
||||||
return 0
|
continue
|
||||||
score = 1 if is_decision(current) else 0
|
if is_decision(current):
|
||||||
for child in current.children:
|
score += 1
|
||||||
score += walk(child)
|
stack.extend(reversed(current.children))
|
||||||
return score
|
return score
|
||||||
|
|
||||||
return 1 + walk(node)
|
|
||||||
|
|
||||||
|
|
||||||
def binary_logic(node) -> bool:
|
def binary_logic(node) -> bool:
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ from crapper.languages.treesitter import (
|
|||||||
parse,
|
parse,
|
||||||
start_line,
|
start_line,
|
||||||
)
|
)
|
||||||
from crapper.languages.language import Language, LanguageFactory
|
|
||||||
from crapper.model import Function
|
from crapper.model import Function
|
||||||
|
|
||||||
_DECISIONS = {
|
_DECISIONS = {
|
||||||
@@ -86,22 +85,35 @@ def _grammar(path: str) -> str:
|
|||||||
return "typescript"
|
return "typescript"
|
||||||
|
|
||||||
|
|
||||||
def _module_namespace(path: str, source_root: str | None) -> str:
|
def _strip_root_prefix(relative: str, source_root: str | None) -> str:
|
||||||
relative = path.replace("\\", "/")
|
|
||||||
root = (source_root or "").replace("\\", "/").rstrip("/")
|
root = (source_root or "").replace("\\", "/").rstrip("/")
|
||||||
if root and root != "." and relative.startswith(root + "/"):
|
if root and root != "." and relative.startswith(root + "/"):
|
||||||
relative = relative[len(root) + 1 :]
|
return relative[len(root) + 1 :]
|
||||||
elif relative.startswith("./"):
|
if relative.startswith("./"):
|
||||||
relative = relative[2:]
|
return relative[2:]
|
||||||
|
return relative
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_src_dir(relative: str) -> str:
|
||||||
if relative.startswith("src/"):
|
if relative.startswith("src/"):
|
||||||
relative = relative[4:]
|
return relative[4:]
|
||||||
elif "/src/" in relative:
|
if "/src/" in relative:
|
||||||
relative = relative.split("/src/", 1)[1]
|
return relative.split("/src/", 1)[1]
|
||||||
|
return relative
|
||||||
|
|
||||||
|
|
||||||
|
def _strip_script_suffix(relative: str) -> str:
|
||||||
for suffix in (".tsx", ".mts", ".cts", ".jsx", ".mjs", ".cjs", ".ts", ".js"):
|
for suffix in (".tsx", ".mts", ".cts", ".jsx", ".mjs", ".cjs", ".ts", ".js"):
|
||||||
if relative.endswith(suffix):
|
if relative.endswith(suffix):
|
||||||
relative = relative[: -len(suffix)]
|
return relative[: -len(suffix)]
|
||||||
break
|
return relative
|
||||||
return relative.replace("/", ".")
|
|
||||||
|
|
||||||
|
def _module_namespace(path: str, source_root: str | None) -> str:
|
||||||
|
relative = path.replace("\\", "/")
|
||||||
|
relative = _strip_root_prefix(relative, source_root)
|
||||||
|
relative = _strip_src_dir(relative)
|
||||||
|
return _strip_script_suffix(relative).replace("/", ".")
|
||||||
|
|
||||||
|
|
||||||
def _inside_function(node) -> bool:
|
def _inside_function(node) -> bool:
|
||||||
@@ -385,13 +397,3 @@ def functions_in_source(
|
|||||||
_append_routes(found, routes, data, module, path)
|
_append_routes(found, routes, data, module, path)
|
||||||
found.sort(key=lambda item: item[0])
|
found.sort(key=lambda item: item[0])
|
||||||
return [function for _, function in found]
|
return [function for _, function in found]
|
||||||
|
|
||||||
|
|
||||||
class TypeScript(Language):
|
|
||||||
def functions(self, source: str, path: str, project_root: str) -> list[Function]:
|
|
||||||
return functions_in_source(source, path, project_root)
|
|
||||||
|
|
||||||
|
|
||||||
class TypeScriptFactory(LanguageFactory):
|
|
||||||
def create(self) -> Language:
|
|
||||||
return TypeScript()
|
|
||||||
|
|||||||
+163
-76
@@ -15,22 +15,39 @@ from pathlib import Path
|
|||||||
|
|
||||||
from crapper.discover import is_test_file, language_of
|
from crapper.discover import is_test_file, language_of
|
||||||
|
|
||||||
_MAVEN = (
|
_MAVEN = [
|
||||||
"mvn -q "
|
"mvn",
|
||||||
"org.jacoco:jacoco-maven-plugin:0.8.12:prepare-agent "
|
"-q",
|
||||||
"test "
|
"org.jacoco:jacoco-maven-plugin:0.8.12:prepare-agent",
|
||||||
"org.jacoco:jacoco-maven-plugin:0.8.12:report"
|
"test",
|
||||||
)
|
"org.jacoco:jacoco-maven-plugin:0.8.12:report",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _warn(message: str) -> None:
|
def _warn(message: str) -> None:
|
||||||
print(message, file=sys.stderr)
|
print(message, file=sys.stderr)
|
||||||
|
|
||||||
|
|
||||||
def run_shell(command: str, cwd: Path) -> int:
|
def _show(command: str | list[str]) -> str:
|
||||||
_warn(f"+ ({cwd}) {command}")
|
if isinstance(command, str):
|
||||||
|
return command
|
||||||
|
return " ".join(shlex.quote(part) for part in command)
|
||||||
|
|
||||||
|
|
||||||
|
def run_shell(command: str | list[str], cwd: Path) -> int:
|
||||||
|
"""Run a coverage command.
|
||||||
|
|
||||||
|
A list is an argument vector and does not go through a shell, so a path
|
||||||
|
with spaces or metacharacters stays one argument. A string is a command
|
||||||
|
the user typed in `--coverage-command` and runs in a shell.
|
||||||
|
"""
|
||||||
|
|
||||||
|
_warn(f"+ ({cwd}) {_show(command)}")
|
||||||
try:
|
try:
|
||||||
completed = subprocess.run(command, cwd=cwd, shell=True)
|
if isinstance(command, str):
|
||||||
|
completed = subprocess.run(command, cwd=cwd, shell=True)
|
||||||
|
else:
|
||||||
|
completed = subprocess.run(list(command), cwd=cwd)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
_warn(f"Coverage command failed to start: {exc}")
|
_warn(f"Coverage command failed to start: {exc}")
|
||||||
return 127
|
return 127
|
||||||
@@ -53,18 +70,49 @@ def _clean_dir(path: Path) -> None:
|
|||||||
shutil.rmtree(path)
|
shutil.rmtree(path)
|
||||||
|
|
||||||
|
|
||||||
|
_COVERAGE_DIRS = {"typescript", "rust", "go", "python"}
|
||||||
|
|
||||||
|
|
||||||
|
def _outside_language_dirs(coverage: Path, path: Path) -> bool:
|
||||||
|
relative = path.relative_to(coverage)
|
||||||
|
if path.name in _COVERAGE_DIRS:
|
||||||
|
return False
|
||||||
|
return not _COVERAGE_DIRS.intersection(relative.parts)
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_cloverage_html(coverage: Path) -> None:
|
||||||
|
for path in coverage.rglob("*.html"):
|
||||||
|
if not _outside_language_dirs(coverage, path):
|
||||||
|
continue
|
||||||
|
path.unlink()
|
||||||
|
|
||||||
|
|
||||||
|
def _remove_empty_dirs(coverage: Path) -> None:
|
||||||
|
directories = [path for path in coverage.rglob("*") if path.is_dir()]
|
||||||
|
for path in sorted(directories, reverse=True):
|
||||||
|
if not _outside_language_dirs(coverage, path):
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
path.rmdir()
|
||||||
|
except OSError:
|
||||||
|
continue
|
||||||
|
|
||||||
|
|
||||||
def _clean_clojure(root: Path) -> None:
|
def _clean_clojure(root: Path) -> None:
|
||||||
|
"""Remove Cloverage's HTML and top-level lcov without touching other reports.
|
||||||
|
|
||||||
|
Other languages keep their directories. Files that are not Cloverage HTML
|
||||||
|
stay, including anything a project keeps under target/coverage itself.
|
||||||
|
"""
|
||||||
|
|
||||||
coverage = root / "target" / "coverage"
|
coverage = root / "target" / "coverage"
|
||||||
if not coverage.is_dir():
|
if not coverage.is_dir():
|
||||||
return
|
return
|
||||||
keep = {"typescript", "rust", "go", "python"}
|
lcov = coverage / "lcov.info"
|
||||||
for path in coverage.iterdir():
|
if lcov.is_file():
|
||||||
if path.name in keep:
|
lcov.unlink()
|
||||||
continue
|
_remove_cloverage_html(coverage)
|
||||||
if path.is_dir():
|
_remove_empty_dirs(coverage)
|
||||||
shutil.rmtree(path)
|
|
||||||
else:
|
|
||||||
path.unlink()
|
|
||||||
|
|
||||||
|
|
||||||
_VITEST_CONFIGS = (
|
_VITEST_CONFIGS = (
|
||||||
@@ -77,15 +125,21 @@ _VITEST_CONFIGS = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class PackageJsonError(Exception):
|
||||||
|
"""package.json is present and cannot be read as an object."""
|
||||||
|
|
||||||
|
|
||||||
def _package_json(package: Path) -> dict | None:
|
def _package_json(package: Path) -> dict | None:
|
||||||
path = package / "package.json"
|
path = package / "package.json"
|
||||||
if not path.is_file():
|
if not path.is_file():
|
||||||
return None
|
return None
|
||||||
try:
|
try:
|
||||||
data = json.loads(path.read_text(encoding="utf-8"))
|
data = json.loads(path.read_text(encoding="utf-8"))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError as exc:
|
||||||
return None
|
raise PackageJsonError(f"{path}: {exc.msg}") from exc
|
||||||
return data if isinstance(data, dict) else None
|
if not isinstance(data, dict):
|
||||||
|
raise PackageJsonError(f"{path}: expected a JSON object")
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
def _uses_vitest(package: Path, scripts: dict) -> bool:
|
def _uses_vitest(package: Path, scripts: dict) -> bool:
|
||||||
@@ -99,7 +153,8 @@ def _vitest_version(package: Path) -> str | None:
|
|||||||
if installed.is_file():
|
if installed.is_file():
|
||||||
try:
|
try:
|
||||||
data = json.loads(installed.read_text(encoding="utf-8"))
|
data = json.loads(installed.read_text(encoding="utf-8"))
|
||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError as exc:
|
||||||
|
_warn(f"{installed}: {exc.msg}")
|
||||||
data = None
|
data = None
|
||||||
if isinstance(data, dict) and isinstance(data.get("version"), str):
|
if isinstance(data, dict) and isinstance(data.get("version"), str):
|
||||||
return data["version"]
|
return data["version"]
|
||||||
@@ -135,8 +190,13 @@ def _ensure_vitest_coverage(package: Path) -> bool:
|
|||||||
"package.json and the lockfile are restored afterward."
|
"package.json and the lockfile are restored afterward."
|
||||||
)
|
)
|
||||||
snapshot = _manifest_snapshot(package)
|
snapshot = _manifest_snapshot(package)
|
||||||
code = run_shell(f"npm install --no-save --no-package-lock {spec}", package)
|
try:
|
||||||
_restore_manifests(snapshot)
|
code = run_shell(
|
||||||
|
["npm", "install", "--no-save", "--no-package-lock", spec],
|
||||||
|
package,
|
||||||
|
)
|
||||||
|
finally:
|
||||||
|
_restore_manifests(snapshot)
|
||||||
ready = code == 0 and (package / "node_modules" / "@vitest" / "coverage-v8").is_dir()
|
ready = code == 0 and (package / "node_modules" / "@vitest" / "coverage-v8").is_dir()
|
||||||
if not ready:
|
if not ready:
|
||||||
_warn("Vitest's coverage provider is missing. TypeScript coverage will score 0%.")
|
_warn("Vitest's coverage provider is missing. TypeScript coverage will score 0%.")
|
||||||
@@ -155,8 +215,10 @@ def typescript_packages(root: Path, files: list[Path]) -> list[Path]:
|
|||||||
return sorted(packages)
|
return sorted(packages)
|
||||||
|
|
||||||
|
|
||||||
def typescript_command(package: Path, files: list[Path], report_dir: Path) -> str | None:
|
def typescript_command(
|
||||||
"""Shell command that writes LCOV into `report_dir`, or None when there is no test script."""
|
package: Path, files: list[Path], report_dir: Path
|
||||||
|
) -> list[str] | None:
|
||||||
|
"""Argument vector that writes LCOV into `report_dir`, or None when there is no test script."""
|
||||||
|
|
||||||
data = _package_json(package)
|
data = _package_json(package)
|
||||||
if data is None:
|
if data is None:
|
||||||
@@ -165,13 +227,21 @@ def typescript_command(package: Path, files: list[Path], report_dir: Path) -> st
|
|||||||
if not isinstance(scripts, dict):
|
if not isinstance(scripts, dict):
|
||||||
scripts = {}
|
scripts = {}
|
||||||
if "coverage" in scripts:
|
if "coverage" in scripts:
|
||||||
return "npm run coverage"
|
return ["npm", "run", "coverage"]
|
||||||
quoted_dir = shlex.quote(str(report_dir))
|
|
||||||
if _uses_vitest(package, scripts):
|
if _uses_vitest(package, scripts):
|
||||||
return _vitest_coverage_command(package, files, quoted_dir)
|
return _vitest_coverage_command(package, files, report_dir)
|
||||||
if "test" not in scripts:
|
if "test" not in scripts:
|
||||||
return None
|
return None
|
||||||
return f"npx --yes c8 --reporter=lcov --reports-dir {quoted_dir} npm test"
|
return [
|
||||||
|
"npx",
|
||||||
|
"--yes",
|
||||||
|
"c8",
|
||||||
|
"--reporter=lcov",
|
||||||
|
"--reports-dir",
|
||||||
|
str(report_dir),
|
||||||
|
"npm",
|
||||||
|
"test",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def _vitest_includes(package: Path, files: list[Path]) -> list[str]:
|
def _vitest_includes(package: Path, files: list[Path]) -> list[str]:
|
||||||
@@ -184,22 +254,28 @@ def _vitest_includes(package: Path, files: list[Path]) -> list[str]:
|
|||||||
relative = Path(file).resolve().relative_to(package)
|
relative = Path(file).resolve().relative_to(package)
|
||||||
except ValueError:
|
except ValueError:
|
||||||
continue
|
continue
|
||||||
includes.append(shlex.quote(relative.as_posix()))
|
includes.append(relative.as_posix())
|
||||||
return includes
|
return includes
|
||||||
|
|
||||||
|
|
||||||
def _vitest_coverage_command(package: Path, files: list[Path], quoted_dir: str) -> str:
|
def _vitest_coverage_command(package: Path, files: list[Path], report_dir: Path) -> list[str]:
|
||||||
binary = package / "node_modules" / ".bin" / "vitest"
|
binary = package / "node_modules" / ".bin" / "vitest"
|
||||||
runner = shlex.quote(str(binary)) if binary.is_file() else "npx vitest"
|
if binary.is_file():
|
||||||
flags = [
|
command = [str(binary)]
|
||||||
"--coverage",
|
else:
|
||||||
"--coverage.reporter=lcov",
|
command = ["npx", "vitest"]
|
||||||
f"--coverage.reportsDirectory={quoted_dir}",
|
command.extend(
|
||||||
"--coverage.reportOnFailure=true",
|
[
|
||||||
]
|
"run",
|
||||||
|
"--coverage",
|
||||||
|
"--coverage.reporter=lcov",
|
||||||
|
f"--coverage.reportsDirectory={report_dir}",
|
||||||
|
"--coverage.reportOnFailure=true",
|
||||||
|
]
|
||||||
|
)
|
||||||
for include in _vitest_includes(package, files):
|
for include in _vitest_includes(package, files):
|
||||||
flags.append(f"--coverage.include={include}")
|
command.append(f"--coverage.include={include}")
|
||||||
return f"{runner} run " + " ".join(flags)
|
return command
|
||||||
|
|
||||||
|
|
||||||
def _coverage_report(root: Path, module: Path, language: str) -> Path:
|
def _coverage_report(root: Path, module: Path, language: str) -> Path:
|
||||||
@@ -212,15 +288,11 @@ def _coverage_report(root: Path, module: Path, language: str) -> Path:
|
|||||||
|
|
||||||
|
|
||||||
def rust_modules(root: Path, files: list[Path]) -> list[Path]:
|
def rust_modules(root: Path, files: list[Path]) -> list[Path]:
|
||||||
modules: set[Path] = set()
|
|
||||||
root = root.resolve()
|
root = root.resolve()
|
||||||
for file in files:
|
return [
|
||||||
if Path(file).suffix != ".rs":
|
module.resolve()
|
||||||
continue
|
for module in _modules_with(files, ".rs", "Cargo.toml", root)
|
||||||
module = _nearest(file, "Cargo.toml", root)
|
]
|
||||||
if module is not None:
|
|
||||||
modules.add(module.resolve())
|
|
||||||
return sorted(modules)
|
|
||||||
|
|
||||||
|
|
||||||
def _rust_kind() -> str | None:
|
def _rust_kind() -> str | None:
|
||||||
@@ -230,10 +302,10 @@ def _rust_kind() -> str | None:
|
|||||||
return "tarpaulin"
|
return "tarpaulin"
|
||||||
_warn("Neither cargo-llvm-cov nor cargo-tarpaulin is installed. Installing cargo-llvm-cov.")
|
_warn("Neither cargo-llvm-cov nor cargo-tarpaulin is installed. Installing cargo-llvm-cov.")
|
||||||
if shutil.which("rustup"):
|
if shutil.which("rustup"):
|
||||||
code = run_shell("rustup component add llvm-tools-preview", Path.home())
|
code = run_shell(["rustup", "component", "add", "llvm-tools-preview"], Path.home())
|
||||||
if code != 0:
|
if code != 0:
|
||||||
_warn("rustup component add llvm-tools-preview failed.")
|
_warn("rustup component add llvm-tools-preview failed.")
|
||||||
code = run_shell("cargo install cargo-llvm-cov --locked", Path.home())
|
code = run_shell(["cargo", "install", "cargo-llvm-cov", "--locked"], Path.home())
|
||||||
if code == 0 and shutil.which("cargo-llvm-cov"):
|
if code == 0 and shutil.which("cargo-llvm-cov"):
|
||||||
return "llvm-cov"
|
return "llvm-cov"
|
||||||
_warn("Rust coverage will score 0%.")
|
_warn("Rust coverage will score 0%.")
|
||||||
@@ -262,7 +334,7 @@ def _python_executable(package: Path) -> str:
|
|||||||
for relative in (".venv/bin/python", "venv/bin/python"):
|
for relative in (".venv/bin/python", "venv/bin/python"):
|
||||||
candidate = package / relative
|
candidate = package / relative
|
||||||
if candidate.is_file():
|
if candidate.is_file():
|
||||||
return shlex.quote(str(candidate))
|
return str(candidate)
|
||||||
return "python3"
|
return "python3"
|
||||||
|
|
||||||
|
|
||||||
@@ -295,38 +367,36 @@ def python_sources(package: Path, files: list[Path]) -> str:
|
|||||||
|
|
||||||
def python_coverage_commands(
|
def python_coverage_commands(
|
||||||
py: str, kind: str, data_file: Path, report: Path, source: str
|
py: str, kind: str, data_file: Path, report: Path, source: str
|
||||||
) -> tuple[str, str]:
|
) -> tuple[list[str], list[str]]:
|
||||||
data = shlex.quote(str(data_file))
|
run = [py, "-m", "coverage", "run", f"--data-file={data_file}"]
|
||||||
out = shlex.quote(str(report))
|
if source:
|
||||||
source_flag = f" --source={shlex.quote(source)}" if source else ""
|
run.append(f"--source={source}")
|
||||||
if kind == "pytest":
|
if kind == "pytest":
|
||||||
module = "pytest"
|
run.extend(["-m", "pytest"])
|
||||||
else:
|
else:
|
||||||
module = "unittest discover -s ."
|
run.extend(["-m", "unittest", "discover", "-s", "."])
|
||||||
run = f"{py} -m coverage run --data-file={data}{source_flag} -m {module}"
|
lcov = [py, "-m", "coverage", "lcov", f"--data-file={data_file}", "-o", str(report)]
|
||||||
lcov = f"{py} -m coverage lcov --data-file={data} -o {out}"
|
|
||||||
return run, lcov
|
return run, lcov
|
||||||
|
|
||||||
|
|
||||||
def _ensure_python_module(py: str, package: Path, module: str) -> bool:
|
def _ensure_python_module(py: str, package: Path, module: str) -> bool:
|
||||||
probe = f"{py} -c {shlex.quote('import ' + module)}"
|
if run_shell([py, "-c", f"import {module}"], package) == 0:
|
||||||
if run_shell(probe, package) == 0:
|
|
||||||
return True
|
return True
|
||||||
_warn(
|
_warn(
|
||||||
f"Installing {module} for Python coverage. "
|
f"Installing {module} for Python coverage. "
|
||||||
"The project requirements are left unchanged."
|
"The project requirements are left unchanged."
|
||||||
)
|
)
|
||||||
code = run_shell(
|
code = run_shell(
|
||||||
f"{py} -m pip install --disable-pip-version-check {module}",
|
[py, "-m", "pip", "install", "--disable-pip-version-check", module],
|
||||||
package,
|
package,
|
||||||
)
|
)
|
||||||
return code == 0
|
return code == 0
|
||||||
|
|
||||||
|
|
||||||
def rust_coverage_command(kind: str, report: Path) -> str:
|
def rust_coverage_command(kind: str, report: Path) -> list[str]:
|
||||||
if kind == "llvm-cov":
|
if kind == "llvm-cov":
|
||||||
return f"cargo llvm-cov --lcov --output-path {shlex.quote(str(report))}"
|
return ["cargo", "llvm-cov", "--lcov", "--output-path", str(report)]
|
||||||
return f"cargo tarpaulin --out Lcov --output-dir {shlex.quote(str(report.parent))}"
|
return ["cargo", "tarpaulin", "--out", "Lcov", "--output-dir", str(report.parent)]
|
||||||
|
|
||||||
|
|
||||||
def _languages_in(files: list[Path]) -> set[str]:
|
def _languages_in(files: list[Path]) -> set[str]:
|
||||||
@@ -354,10 +424,10 @@ def _cover_clojure(root: Path) -> None:
|
|||||||
_warn("No deps.edn or bb.edn; skipping Clojure coverage.")
|
_warn("No deps.edn or bb.edn; skipping Clojure coverage.")
|
||||||
return
|
return
|
||||||
_clean_clojure(root)
|
_clean_clojure(root)
|
||||||
code = run_shell("clj -M:cov --lcov", root)
|
code = run_shell(["clj", "-M:cov", "--lcov"], root)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
_warn("clj -M:cov --lcov failed; retrying without --lcov.")
|
_warn("clj -M:cov --lcov failed; retrying without --lcov.")
|
||||||
code = run_shell("clj -M:cov", root)
|
code = run_shell(["clj", "-M:cov"], root)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
_warn(f"Clojure coverage exited {code}. Clojure coverage will score 0%.")
|
_warn(f"Clojure coverage exited {code}. Clojure coverage will score 0%.")
|
||||||
|
|
||||||
@@ -387,7 +457,7 @@ def _cover_go(root: Path, files: list[Path]) -> None:
|
|||||||
profile.parent.mkdir(parents=True, exist_ok=True)
|
profile.parent.mkdir(parents=True, exist_ok=True)
|
||||||
if profile.exists():
|
if profile.exists():
|
||||||
profile.unlink()
|
profile.unlink()
|
||||||
code = run_shell(f"go test ./... -coverprofile={profile}", module)
|
code = run_shell(["go", "test", "./...", f"-coverprofile={profile}"], module)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
_warn(f"Go coverage exited {code} in {module}. Go coverage will score 0%.")
|
_warn(f"Go coverage exited {code} in {module}. Go coverage will score 0%.")
|
||||||
|
|
||||||
@@ -405,7 +475,11 @@ def _cover_typescript(root: Path, files: list[Path]) -> None:
|
|||||||
return
|
return
|
||||||
for package in packages:
|
for package in packages:
|
||||||
report = _coverage_report(root, package, "typescript")
|
report = _coverage_report(root, package, "typescript")
|
||||||
command = typescript_command(package, files, report.parent)
|
try:
|
||||||
|
command = typescript_command(package, files, report.parent)
|
||||||
|
except PackageJsonError as exc:
|
||||||
|
_warn(str(exc))
|
||||||
|
continue
|
||||||
if command is None:
|
if command is None:
|
||||||
_warn(f"No package.json test script in {package}; skipping TypeScript coverage.")
|
_warn(f"No package.json test script in {package}; skipping TypeScript coverage.")
|
||||||
continue
|
continue
|
||||||
@@ -417,8 +491,10 @@ def _cover_typescript(root: Path, files: list[Path]) -> None:
|
|||||||
_warn(f"TypeScript coverage exited {code} in {package}. TypeScript coverage will score 0%.")
|
_warn(f"TypeScript coverage exited {code} in {package}. TypeScript coverage will score 0%.")
|
||||||
|
|
||||||
|
|
||||||
def _needs_vitest_provider(command: str) -> bool:
|
def _needs_vitest_provider(command: list[str]) -> bool:
|
||||||
return command != "npm run coverage" and "vitest" in command
|
if command[:3] == ["npm", "run", "coverage"]:
|
||||||
|
return False
|
||||||
|
return any(Path(part).name == "vitest" for part in command)
|
||||||
|
|
||||||
|
|
||||||
def _python_kind(py: str, package: Path) -> str:
|
def _python_kind(py: str, package: Path) -> str:
|
||||||
@@ -430,7 +506,9 @@ def _python_kind(py: str, package: Path) -> str:
|
|||||||
return "unittest"
|
return "unittest"
|
||||||
|
|
||||||
|
|
||||||
def _record_python_lcov(package: Path, code: int, data_file: Path, lcov_cmd: str) -> None:
|
def _record_python_lcov(
|
||||||
|
package: Path, code: int, data_file: Path, lcov_cmd: str | list[str]
|
||||||
|
) -> None:
|
||||||
if not data_file.exists():
|
if not data_file.exists():
|
||||||
if code != 0:
|
if code != 0:
|
||||||
_warn(f"Python coverage exited {code} in {package}. Python coverage will score 0%.")
|
_warn(f"Python coverage exited {code} in {package}. Python coverage will score 0%.")
|
||||||
@@ -476,15 +554,23 @@ def _cover_rust(root: Path, files: list[Path]) -> None:
|
|||||||
_warn(f"Rust coverage exited {code} in {module}. Rust coverage will score 0%.")
|
_warn(f"Rust coverage exited {code} in {module}. Rust coverage will score 0%.")
|
||||||
|
|
||||||
|
|
||||||
def run_coverage(root: Path, files: list[Path], command: str | None) -> None:
|
def run_coverage(root: Path, files: list[Path], command: str | None) -> int:
|
||||||
"""Generate coverage reports for the languages present in `files`."""
|
"""Generate coverage reports for the languages present in `files`.
|
||||||
|
|
||||||
|
Returns the custom command's status. A non-zero status means the caller
|
||||||
|
must not read reports already on disk. Per-language runs return 0; a
|
||||||
|
failed tool is reported and that language scores 0% when it wrote nothing.
|
||||||
|
"""
|
||||||
|
|
||||||
root = root.resolve()
|
root = root.resolve()
|
||||||
if command:
|
if command:
|
||||||
code = run_shell(command, root)
|
code = run_shell(command, root)
|
||||||
if code != 0:
|
if code != 0:
|
||||||
_warn(f"Coverage command exited {code}. Coverage may score 0%.")
|
_warn(
|
||||||
return
|
f"Coverage command exited {code}. "
|
||||||
|
"Reports already on disk will not be read."
|
||||||
|
)
|
||||||
|
return code
|
||||||
|
|
||||||
languages = _languages_in(files)
|
languages = _languages_in(files)
|
||||||
if "clojure" in languages:
|
if "clojure" in languages:
|
||||||
@@ -499,3 +585,4 @@ def run_coverage(root: Path, files: list[Path], command: str | None) -> None:
|
|||||||
_cover_python(root, files)
|
_cover_python(root, files)
|
||||||
if "rust" in languages:
|
if "rust" in languages:
|
||||||
_cover_rust(root, files)
|
_cover_rust(root, files)
|
||||||
|
return 0
|
||||||
|
|||||||
+156
-49
@@ -1,11 +1,12 @@
|
|||||||
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from crapper.cli import _changed_files, _positionals, _take, main, parse_args, run
|
from crapper.cli import GitError, _changed_files, _positionals, _take, main, parse_args, run
|
||||||
from crapper.discover import language_of
|
from crapper.discover import is_test_file, language_of
|
||||||
|
|
||||||
|
|
||||||
def test_language_detection():
|
def test_language_detection():
|
||||||
@@ -22,6 +23,11 @@ def test_language_detection():
|
|||||||
assert language_of("src/crapper/cli.py") == "python"
|
assert language_of("src/crapper/cli.py") == "python"
|
||||||
assert language_of("types.d.ts") is None
|
assert language_of("types.d.ts") is None
|
||||||
assert language_of("notes.md") is None
|
assert language_of("notes.md") is None
|
||||||
|
assert language_of("app.cts") == "typescript"
|
||||||
|
assert language_of("app.test.cts") == "typescript"
|
||||||
|
assert is_test_file("app.test.cts") is True
|
||||||
|
assert is_test_file("app.spec.cts") is True
|
||||||
|
assert is_test_file("app.cts") is False
|
||||||
|
|
||||||
|
|
||||||
def _write(root: Path, relative: str, source: str) -> None:
|
def _write(root: Path, relative: str, source: str) -> None:
|
||||||
@@ -30,6 +36,22 @@ def _write(root: Path, relative: str, source: str) -> None:
|
|||||||
path.write_text(source, encoding="utf-8")
|
path.write_text(source, encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
class _Completed:
|
||||||
|
def __init__(self, returncode: int, stdout: str, stderr: str = "") -> None:
|
||||||
|
self.returncode = returncode
|
||||||
|
self.stdout = stdout
|
||||||
|
self.stderr = stderr
|
||||||
|
|
||||||
|
|
||||||
|
def _git_status(root: Path, status: str):
|
||||||
|
def fake(args, **_kwargs):
|
||||||
|
if "rev-parse" in args:
|
||||||
|
return _Completed(0, f"{root.resolve()}\n")
|
||||||
|
return _Completed(0, status)
|
||||||
|
|
||||||
|
return fake
|
||||||
|
|
||||||
|
|
||||||
def test_project_snapshot_is_readable_as_crap4clj_edn(tmp_path):
|
def test_project_snapshot_is_readable_as_crap4clj_edn(tmp_path):
|
||||||
_write(
|
_write(
|
||||||
tmp_path,
|
tmp_path,
|
||||||
@@ -135,7 +157,10 @@ def test_threshold_exits_when_the_worst_score_is_too_high(tmp_path):
|
|||||||
|
|
||||||
def test_help_and_unknown_option(capsys):
|
def test_help_and_unknown_option(capsys):
|
||||||
assert run(["--help"]) == 0
|
assert run(["--help"]) == 0
|
||||||
assert "uml-viewer" in capsys.readouterr().out
|
help_text = capsys.readouterr().out
|
||||||
|
assert "uml-viewer" in help_text
|
||||||
|
assert ".clj-kondo" in help_text
|
||||||
|
assert ".test.cts" in help_text
|
||||||
assert run(["--not-an-option"]) == 1
|
assert run(["--not-an-option"]) == 1
|
||||||
assert run(["--root"]) == 1
|
assert run(["--root"]) == 1
|
||||||
assert run(["--threshold", "nope"]) == 1
|
assert run(["--threshold", "nope"]) == 1
|
||||||
@@ -151,21 +176,21 @@ def test_selects_directories_filters_and_changed_files(tmp_path, monkeypatch, ca
|
|||||||
assert run(["--root", str(tmp_path), "--no-coverage", "notes.md"]) == 0
|
assert run(["--root", str(tmp_path), "--no-coverage", "notes.md"]) == 0
|
||||||
assert "No source files" in capsys.readouterr().out
|
assert "No source files" in capsys.readouterr().out
|
||||||
|
|
||||||
class Result:
|
monkeypatch.setattr(
|
||||||
returncode = 0
|
"crapper.cli.subprocess.run",
|
||||||
stdout = ' M src/demo/core.clj\nR old.clj -> src/demo/core.clj\n?? "src/demo/core.clj"\n'
|
_git_status(tmp_path, " M src/demo/core.clj\0"),
|
||||||
stderr = ""
|
)
|
||||||
|
|
||||||
monkeypatch.setattr("crapper.cli.subprocess.run", lambda *args, **kwargs: Result())
|
|
||||||
assert run(["--root", str(tmp_path), "--no-coverage", "--changed"]) == 0
|
assert run(["--root", str(tmp_path), "--no-coverage", "--changed"]) == 0
|
||||||
|
assert ':name "choose"' in (tmp_path / ".metrics" / "crap.edn").read_text(encoding="utf-8")
|
||||||
|
|
||||||
class Failed:
|
monkeypatch.setattr(
|
||||||
returncode = 1
|
"crapper.cli.subprocess.run",
|
||||||
stdout = ""
|
lambda *_args, **_kwargs: _Completed(128, "", "fatal: not a git repository"),
|
||||||
stderr = "git failed"
|
)
|
||||||
|
assert run(["--root", str(tmp_path), "--no-coverage", "--changed"]) == 128
|
||||||
monkeypatch.setattr("crapper.cli.subprocess.run", lambda *args, **kwargs: Failed())
|
captured = capsys.readouterr()
|
||||||
assert run(["--root", str(tmp_path), "--no-coverage", "--changed"]) == 0
|
assert "not a git repository" in captured.err
|
||||||
|
assert "No source files" not in captured.out
|
||||||
|
|
||||||
|
|
||||||
def test_run_asks_for_coverage_when_it_is_enabled(tmp_path, monkeypatch):
|
def test_run_asks_for_coverage_when_it_is_enabled(tmp_path, monkeypatch):
|
||||||
@@ -201,43 +226,46 @@ def test_flags_that_only_store_a_boolean():
|
|||||||
|
|
||||||
def test_changed_files_follow_git_status(tmp_path, monkeypatch, capsys):
|
def test_changed_files_follow_git_status(tmp_path, monkeypatch, capsys):
|
||||||
seen = {}
|
seen = {}
|
||||||
|
named = tmp_path / "x"
|
||||||
|
named.write_text("x", encoding="utf-8")
|
||||||
|
keep = tmp_path / "sources" / "keep.py"
|
||||||
|
keep.parent.mkdir()
|
||||||
|
keep.write_text("x = 1\n", encoding="utf-8")
|
||||||
|
|
||||||
class Result:
|
def fake(args, **kwargs):
|
||||||
returncode = 0
|
seen["args"] = args
|
||||||
stdout = "\nM x\n?? sources/keep.py\n"
|
|
||||||
stderr = ""
|
|
||||||
|
|
||||||
def fake(*_args, **kwargs):
|
|
||||||
seen.update(kwargs)
|
seen.update(kwargs)
|
||||||
return Result()
|
if "rev-parse" in args:
|
||||||
|
return _Completed(0, f"{tmp_path.resolve()}\n")
|
||||||
|
return _Completed(0, " M x\0?? sources/keep.py\0")
|
||||||
|
|
||||||
monkeypatch.setattr("crapper.cli.subprocess.run", fake)
|
monkeypatch.setattr("crapper.cli.subprocess.run", fake)
|
||||||
found = _changed_files(tmp_path)
|
found = _changed_files(tmp_path)
|
||||||
assert seen["check"] is False
|
assert seen["check"] is False
|
||||||
assert seen["capture_output"] is True
|
assert seen["capture_output"] is True
|
||||||
assert seen["text"] is True
|
assert seen["text"] is True
|
||||||
assert found == [
|
assert seen["encoding"] == "utf-8"
|
||||||
(tmp_path / "x").resolve(),
|
assert "-z" in seen["args"]
|
||||||
(tmp_path / "sources" / "keep.py").resolve(),
|
assert "--no-renames" in seen["args"]
|
||||||
]
|
assert "--untracked-files=all" in seen["args"]
|
||||||
|
assert found == [named.resolve(), keep.resolve()]
|
||||||
|
|
||||||
class Failed:
|
monkeypatch.setattr(
|
||||||
returncode = 1
|
"crapper.cli.subprocess.run",
|
||||||
stdout = "?? sources/keep.py\n"
|
lambda *_a, **_k: _Completed(1, "", "git failed"),
|
||||||
stderr = "git failed"
|
)
|
||||||
|
with pytest.raises(GitError, match="git failed") as failed:
|
||||||
|
_changed_files(tmp_path)
|
||||||
|
assert failed.value.code == 1
|
||||||
|
assert capsys.readouterr().err == ""
|
||||||
|
|
||||||
monkeypatch.setattr("crapper.cli.subprocess.run", lambda *_a, **_k: Failed())
|
monkeypatch.setattr(
|
||||||
assert _changed_files(tmp_path) == []
|
"crapper.cli.subprocess.run",
|
||||||
assert capsys.readouterr().err.strip() == "git failed"
|
lambda *_a, **_k: _Completed(128, "", ""),
|
||||||
|
)
|
||||||
class Silent:
|
with pytest.raises(GitError, match="git status failed") as silent:
|
||||||
returncode = 1
|
_changed_files(tmp_path)
|
||||||
stdout = ""
|
assert silent.value.code == 128
|
||||||
stderr = ""
|
|
||||||
|
|
||||||
monkeypatch.setattr("crapper.cli.subprocess.run", lambda *_a, **_k: Silent())
|
|
||||||
assert _changed_files(tmp_path) == []
|
|
||||||
assert capsys.readouterr().err.strip() == "git status failed"
|
|
||||||
|
|
||||||
|
|
||||||
def test_relative_paths_are_resolved_under_the_project_root(tmp_path):
|
def test_relative_paths_are_resolved_under_the_project_root(tmp_path):
|
||||||
@@ -255,12 +283,10 @@ def test_changed_selection_ignores_files_git_did_not_name(tmp_path, monkeypatch,
|
|||||||
_write(tmp_path, "src/kept.clj", "(ns demo.kept)\n\n(defn kept [] 1)\n")
|
_write(tmp_path, "src/kept.clj", "(ns demo.kept)\n\n(defn kept [] 1)\n")
|
||||||
_write(tmp_path, "src/other.clj", "(ns demo.other)\n\n(defn other [] 1)\n")
|
_write(tmp_path, "src/other.clj", "(ns demo.other)\n\n(defn other [] 1)\n")
|
||||||
|
|
||||||
class Result:
|
monkeypatch.setattr(
|
||||||
returncode = 0
|
"crapper.cli.subprocess.run",
|
||||||
stdout = "?? src/kept.clj\n"
|
_git_status(tmp_path, "?? src/kept.clj\0"),
|
||||||
stderr = ""
|
)
|
||||||
|
|
||||||
monkeypatch.setattr("crapper.cli.subprocess.run", lambda *_a, **_k: Result())
|
|
||||||
assert run(["--root", str(tmp_path), "--no-coverage", "--changed"]) == 0
|
assert run(["--root", str(tmp_path), "--no-coverage", "--changed"]) == 0
|
||||||
text = (tmp_path / ".metrics" / "crap.edn").read_text(encoding="utf-8")
|
text = (tmp_path / ".metrics" / "crap.edn").read_text(encoding="utf-8")
|
||||||
assert ':name "kept"' in text
|
assert ':name "kept"' in text
|
||||||
@@ -284,6 +310,87 @@ def test_threshold_with_no_scores_does_not_call_max_on_an_empty_list(tmp_path):
|
|||||||
assert run(["--root", str(tmp_path), "--no-coverage", "--threshold", "1"]) == 0
|
assert run(["--root", str(tmp_path), "--no-coverage", "--threshold", "1"]) == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_failed_coverage_command_ignores_a_stale_report(tmp_path, monkeypatch):
|
||||||
|
_write(tmp_path, "src/demo/core.clj", "(ns demo.core)\n\n(defn choose [x]\n (if x 1 0))\n")
|
||||||
|
lcov = tmp_path / "target" / "coverage" / "lcov.info"
|
||||||
|
lcov.parent.mkdir(parents=True)
|
||||||
|
lcov.write_text("SF:src/demo/core.clj\nDA:3,1\nDA:4,1\nend_of_record\n", encoding="utf-8")
|
||||||
|
monkeypatch.setattr("crapper.cli.run_coverage", lambda *_args, **_kwargs: 3)
|
||||||
|
code = run(
|
||||||
|
["--root", str(tmp_path), "--coverage-command", "exit 3", "src/demo/core.clj"]
|
||||||
|
)
|
||||||
|
assert code == 0
|
||||||
|
text = (tmp_path / ".metrics" / "crap.edn").read_text(encoding="utf-8")
|
||||||
|
assert ":coverage 0.0" in text
|
||||||
|
assert ":crap 6.0" in text
|
||||||
|
|
||||||
|
monkeypatch.setattr("crapper.cli.run_coverage", lambda *_args, **_kwargs: 0)
|
||||||
|
assert run(
|
||||||
|
["--root", str(tmp_path), "--coverage-command", "true", "src/demo/core.clj"]
|
||||||
|
) == 0
|
||||||
|
text = (tmp_path / ".metrics" / "crap.edn").read_text(encoding="utf-8")
|
||||||
|
assert ":coverage 100.0" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_files_from_a_real_git_status(tmp_path):
|
||||||
|
_git = ["git", "-C", str(tmp_path)]
|
||||||
|
subprocess.run([*_git, "init"], check=True, capture_output=True)
|
||||||
|
subprocess.run([*_git, "config", "user.email", "[email protected]"], check=True)
|
||||||
|
subprocess.run([*_git, "config", "user.name", "t"], check=True)
|
||||||
|
keep = tmp_path / "keep.go"
|
||||||
|
keep.write_text("package demo\nfunc Keep() int { return 1 }\n", encoding="utf-8")
|
||||||
|
subprocess.run([*_git, "add", "keep.go"], check=True)
|
||||||
|
subprocess.run([*_git, "commit", "-m", "init"], check=True, capture_output=True)
|
||||||
|
keep.unlink()
|
||||||
|
cafe = tmp_path / "café.go"
|
||||||
|
cafe.write_text("package demo\nfunc Cafe() int { return 1 }\n", encoding="utf-8")
|
||||||
|
fresh = tmp_path / "fresh"
|
||||||
|
fresh.mkdir()
|
||||||
|
(fresh / "new.go").write_text("package demo\nfunc New() int { return 1 }\n", encoding="utf-8")
|
||||||
|
|
||||||
|
assert run(["--root", str(tmp_path), "--no-coverage", "--changed"]) == 0
|
||||||
|
text = (tmp_path / ".metrics" / "crap.edn").read_text(encoding="utf-8")
|
||||||
|
assert ':name "Cafe"' in text
|
||||||
|
assert ':name "New"' in text
|
||||||
|
assert "Keep" not in text
|
||||||
|
|
||||||
|
pkg = tmp_path / "pkg"
|
||||||
|
pkg.mkdir()
|
||||||
|
(pkg / "inside.go").write_text("package pkg\nfunc Inside() int { return 1 }\n", encoding="utf-8")
|
||||||
|
(tmp_path / "outside.go").write_text(
|
||||||
|
"package demo\nfunc Outside() int { return 1 }\n",
|
||||||
|
encoding="utf-8",
|
||||||
|
)
|
||||||
|
assert run(["--root", str(pkg), "--no-coverage", "--changed"]) == 0
|
||||||
|
nested = (pkg / ".metrics" / "crap.edn").read_text(encoding="utf-8")
|
||||||
|
assert ':name "Inside"' in nested
|
||||||
|
assert "Outside" not in nested
|
||||||
|
assert "Cafe" not in nested
|
||||||
|
assert "New" not in nested
|
||||||
|
|
||||||
|
|
||||||
|
def test_changed_outside_a_repository_returns_gits_status(tmp_path, capsys):
|
||||||
|
_write(tmp_path, "src/app.py", "def run():\n return 1\n")
|
||||||
|
code = run(["--root", str(tmp_path), "--no-coverage", "--changed"])
|
||||||
|
assert code != 0
|
||||||
|
captured = capsys.readouterr()
|
||||||
|
assert "No source files" not in captured.out
|
||||||
|
assert captured.err.strip()
|
||||||
|
|
||||||
|
|
||||||
|
def test_uml_loader_rejects_a_missing_viewer(tmp_path):
|
||||||
|
script = Path(__file__).resolve().parents[1] / "uml"
|
||||||
|
completed = subprocess.run(
|
||||||
|
[str(script)],
|
||||||
|
env={**os.environ, "UML_VIEWER_ROOT": str(tmp_path / "missing")},
|
||||||
|
capture_output=True,
|
||||||
|
text=True,
|
||||||
|
check=False,
|
||||||
|
)
|
||||||
|
assert completed.returncode == 1
|
||||||
|
assert "uml-viewer checkout not found" in completed.stderr
|
||||||
|
|
||||||
|
|
||||||
def test_main_exits_with_the_status(monkeypatch):
|
def test_main_exits_with_the_status(monkeypatch):
|
||||||
monkeypatch.setattr("crapper.cli.run", lambda _argv=None: 4)
|
monkeypatch.setattr("crapper.cli.run", lambda _argv=None: 4)
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -101,6 +101,68 @@ def test_strings_comments_and_character_literals_stay_inside_one_form():
|
|||||||
assert [item["name"] for item in extract_functions(source)] == ["foo"]
|
assert [item["name"] for item in extract_functions(source)] == ["foo"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_discards_and_metadata_cover_each_reader_form():
|
||||||
|
source = r"""
|
||||||
|
#_
|
||||||
|
; gone
|
||||||
|
(defn ghost [] (if x 1 0))
|
||||||
|
#_ "string"
|
||||||
|
#_ \x
|
||||||
|
#_ symbol
|
||||||
|
#_ #_(defn inner [] (if x 1 0))
|
||||||
|
#_(defn boxed []
|
||||||
|
"str"
|
||||||
|
\;
|
||||||
|
; comment
|
||||||
|
(if x 1 0))
|
||||||
|
(defn ^{:doc "hi" :private true} kept [] 1)
|
||||||
|
(defn ^{:flag \; :private true} also [] 1)
|
||||||
|
(defn ^{:private true ; note
|
||||||
|
} third [] 1)
|
||||||
|
( defn spaced [] 1)
|
||||||
|
(defn)
|
||||||
|
(defn ^)
|
||||||
|
"""
|
||||||
|
assert [item["name"] for item in extract_functions(source)] == [
|
||||||
|
"kept",
|
||||||
|
"also",
|
||||||
|
"third",
|
||||||
|
"spaced",
|
||||||
|
]
|
||||||
|
assert extract_functions("(defn ^{:private kept [] 1)") == []
|
||||||
|
|
||||||
|
|
||||||
def test_character_literals_do_not_swallow_the_next_defn():
|
def test_character_literals_do_not_swallow_the_next_defn():
|
||||||
source = '(def x #{\\"})\n(defn foo [] 1)'
|
source = '(def x #{\\"})\n(defn foo [] 1)'
|
||||||
assert [item["name"] for item in extract_functions(source)] == ["foo"]
|
assert [item["name"] for item in extract_functions(source)] == ["foo"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_reader_keeps_the_real_namespace_name_and_decisions():
|
||||||
|
source = r""";; (ns demo.wrong)
|
||||||
|
(ns demo.core)
|
||||||
|
#_(defn dropped [x]
|
||||||
|
(if x 1 0))
|
||||||
|
(defn ^:private kept [x]
|
||||||
|
#_(if x 1 0)
|
||||||
|
1)
|
||||||
|
(defn ^{:private true} also [] 1)
|
||||||
|
(defn quoted []
|
||||||
|
\"
|
||||||
|
(if x 1 0))
|
||||||
|
(defn semi []
|
||||||
|
\;
|
||||||
|
(if true 1 2))
|
||||||
|
"""
|
||||||
|
found = functions_in_source(source, "src/demo/core.clj", "/proj")
|
||||||
|
assert [(item.name, item.complexity) for item in found] == [
|
||||||
|
("kept", 1),
|
||||||
|
("also", 1),
|
||||||
|
("quoted", 2),
|
||||||
|
("semi", 2),
|
||||||
|
]
|
||||||
|
assert {item.namespace for item in found} == {"demo.core"}
|
||||||
|
assert cyclomatic_complexity(r'(defn semi [] \; (if true 1 2))') == 2
|
||||||
|
assert cyclomatic_complexity(r'(defn quoted [] \" (if x 1 0))') == 2
|
||||||
|
assert extract_functions("#_(defn dropped [] (if x 1 0))\n(defn kept [] 1)\n") == [
|
||||||
|
{"name": "kept", "start_line": 2, "end_line": 2, "complexity": 1}
|
||||||
|
]
|
||||||
|
|||||||
@@ -123,6 +123,52 @@ def test_go_coverage_is_read_from_the_bundle():
|
|||||||
assert bundle.percent_for(function()) == 100.0
|
assert bundle.percent_for(function()) == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_most_specific_prefix_wins_and_a_tie_matches_nothing():
|
||||||
|
specific = parse_go_profile(
|
||||||
|
"mode: set\n"
|
||||||
|
"other/proj/src/a.go:1.1,2.2 1 0\n"
|
||||||
|
"proj/src/a.go:1.1,2.2 1 1\n"
|
||||||
|
)
|
||||||
|
assert go_percent(specific, "src/a.go", 1, 2) == 100.0
|
||||||
|
tied = parse_go_profile(
|
||||||
|
"mode: set\nproj/a.go:1.1,2.2 1 1\nother/a.go:1.1,2.2 1 0\n"
|
||||||
|
)
|
||||||
|
assert go_percent(tied, "a.go", 1, 2) is None
|
||||||
|
assert go_percent(specific, "/", 1, 2) is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_exact_profile_key_wins_over_a_suffix():
|
||||||
|
profile = parse_go_profile(
|
||||||
|
"mode: set\n"
|
||||||
|
"a/b/main.go:1.1,2.2 1 1\n"
|
||||||
|
"b/main.go:1.1,2.2 1 0\n"
|
||||||
|
)
|
||||||
|
assert go_percent(profile, "b/main.go", 1, 2) == 0.0
|
||||||
|
assert go_percent(profile, "a/b/main.go", 1, 2) == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_shorter_file_does_not_take_a_longer_files_coverage():
|
||||||
|
bundle = CoverageBundle(
|
||||||
|
go_profile=parse_go_profile("mode: set\na/b/main.go:1.1,2.2 1 1\n")
|
||||||
|
)
|
||||||
|
bundle.bind_sources(["b/main.go", "a/b/main.go"])
|
||||||
|
short = function(path="b/main.go", start_line=1, end_line=2)
|
||||||
|
long = function(path="a/b/main.go", start_line=1, end_line=2)
|
||||||
|
assert bundle.percent_for(short) is None
|
||||||
|
assert bundle.percent_for(long) == 100.0
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_short_report_key_matches_one_longer_source():
|
||||||
|
bundle = CoverageBundle(
|
||||||
|
go_profile=parse_go_profile("mode: set\nboard.go:4.1,6.2 2 1\n")
|
||||||
|
)
|
||||||
|
bundle.bind_sources(["cmd/board.go"])
|
||||||
|
assert bundle.percent_for(function(path="cmd/board.go")) == 100.0
|
||||||
|
bundle.bind_sources(["cmd/board.go", "other/board.go"])
|
||||||
|
assert bundle.percent_for(function(path="cmd/board.go")) is None
|
||||||
|
assert bundle.percent_for(function(path="other/board.go")) is None
|
||||||
|
|
||||||
|
|
||||||
def test_suffix_lookup_finds_a_report_keyed_by_a_longer_path():
|
def test_suffix_lookup_finds_a_report_keyed_by_a_longer_path():
|
||||||
bundle = CoverageBundle(lcov={"proj/src/demo/core.clj": {3: (1, 1), 4: (0, 1)}})
|
bundle = CoverageBundle(lcov={"proj/src/demo/core.clj": {3: (1, 1), 4: (0, 1)}})
|
||||||
fn = function(
|
fn = function(
|
||||||
@@ -264,6 +310,35 @@ def test_python_function_without_a_name_is_skipped():
|
|||||||
assert [fn.name for fn in python_functions(source, "src/app.py", "/proj")] == ["choose"]
|
assert [fn.name for fn in python_functions(source, "src/app.py", "/proj")] == ["choose"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_deep_expression_does_not_recurse_off_the_stack():
|
||||||
|
expr = " + ".join(["1"] * 1200)
|
||||||
|
python = python_functions(f"def huge():\n return {expr}\n", "huge.py", None)
|
||||||
|
assert [fn.name for fn in python] == ["huge"]
|
||||||
|
java = java_functions(
|
||||||
|
f"class Huge {{\n int huge() {{ return {expr}; }}\n}}\n",
|
||||||
|
"Huge.java",
|
||||||
|
)
|
||||||
|
assert [fn.name for fn in java] == ["huge"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_analyze_does_not_give_a_short_path_the_longer_files_coverage(tmp_path):
|
||||||
|
body = "package demo\nfunc Place() int { return 1 }\n"
|
||||||
|
short = tmp_path / "b" / "main.go"
|
||||||
|
long = tmp_path / "a" / "b" / "main.go"
|
||||||
|
short.parent.mkdir(parents=True)
|
||||||
|
long.parent.mkdir(parents=True)
|
||||||
|
short.write_text(body, encoding="utf-8")
|
||||||
|
long.write_text(body, encoding="utf-8")
|
||||||
|
bundle = CoverageBundle(
|
||||||
|
go_profile=parse_go_profile("mode: set\na/b/main.go:1.1,2.2 1 1\n")
|
||||||
|
)
|
||||||
|
entries = analyze_files([short, long], tmp_path, bundle)
|
||||||
|
assert [(entry.name, entry.path, entry.coverage) for entry in entries] == [
|
||||||
|
("Place", "b/main.go", 0.0),
|
||||||
|
("Place", "a/b/main.go", 100.0),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_rust_relative_path_and_a_trait_method(tmp_path):
|
def test_rust_relative_path_and_a_trait_method(tmp_path):
|
||||||
(tmp_path / "Cargo.toml").write_text(
|
(tmp_path / "Cargo.toml").write_text(
|
||||||
'[package]\nname = "demo"\nversion = "0.1.0"\n',
|
'[package]\nname = "demo"\nversion = "0.1.0"\n',
|
||||||
|
|||||||
+140
-29
@@ -1,8 +1,11 @@
|
|||||||
import json
|
import json
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from crapper.discover import is_test_file
|
from crapper.discover import is_test_file
|
||||||
from crapper.runners import (
|
from crapper.runners import (
|
||||||
|
PackageJsonError,
|
||||||
_clean_clojure,
|
_clean_clojure,
|
||||||
_clean_dir,
|
_clean_dir,
|
||||||
_coverage_report,
|
_coverage_report,
|
||||||
@@ -29,6 +32,12 @@ from crapper.runners import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _as_text(command) -> str:
|
||||||
|
if isinstance(command, list):
|
||||||
|
return " ".join(command)
|
||||||
|
return command
|
||||||
|
|
||||||
|
|
||||||
def _write(root: Path, relative: str, source: str) -> Path:
|
def _write(root: Path, relative: str, source: str) -> Path:
|
||||||
path = root / relative
|
path = root / relative
|
||||||
path.parent.mkdir(parents=True, exist_ok=True)
|
path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
@@ -48,12 +57,13 @@ def test_vitest_uses_its_own_coverage_instead_of_c8(tmp_path):
|
|||||||
report_dir = tmp_path / "target" / "coverage" / "typescript"
|
report_dir = tmp_path / "target" / "coverage" / "typescript"
|
||||||
command = typescript_command(tmp_path, [source, tmp_path / "src" / "book.test.ts"], report_dir)
|
command = typescript_command(tmp_path, [source, tmp_path / "src" / "book.test.ts"], report_dir)
|
||||||
assert command is not None
|
assert command is not None
|
||||||
assert "c8" not in command
|
text = _as_text(command)
|
||||||
assert "vitest run --coverage" in command
|
assert "c8" not in text
|
||||||
assert "--coverage.reporter=lcov" in command
|
assert "vitest run --coverage" in text
|
||||||
assert f"--coverage.reportsDirectory={report_dir}" in command
|
assert "--coverage.reporter=lcov" in text
|
||||||
assert "--coverage.include=src/book.ts" in command
|
assert f"--coverage.reportsDirectory={report_dir}" in text
|
||||||
assert "book.test.ts" not in command
|
assert "--coverage.include=src/book.ts" in text
|
||||||
|
assert "book.test.ts" not in text
|
||||||
|
|
||||||
|
|
||||||
def test_existing_coverage_script_is_left_alone(tmp_path):
|
def test_existing_coverage_script_is_left_alone(tmp_path):
|
||||||
@@ -63,14 +73,23 @@ def test_existing_coverage_script_is_left_alone(tmp_path):
|
|||||||
json.dumps({"scripts": {"coverage": "vitest run --coverage", "test": "vitest run"}}),
|
json.dumps({"scripts": {"coverage": "vitest run --coverage", "test": "vitest run"}}),
|
||||||
)
|
)
|
||||||
command = typescript_command(tmp_path, [], tmp_path / "coverage")
|
command = typescript_command(tmp_path, [], tmp_path / "coverage")
|
||||||
assert command == "npm run coverage"
|
assert command == ["npm", "run", "coverage"]
|
||||||
|
|
||||||
|
|
||||||
def test_node_test_script_still_uses_c8(tmp_path):
|
def test_node_test_script_still_uses_c8(tmp_path):
|
||||||
_write(tmp_path, "package.json", json.dumps({"scripts": {"test": "node --test"}}))
|
_write(tmp_path, "package.json", json.dumps({"scripts": {"test": "node --test"}}))
|
||||||
report_dir = tmp_path / "target" / "coverage" / "typescript"
|
report_dir = tmp_path / "target" / "coverage" / "typescript"
|
||||||
command = typescript_command(tmp_path, [], report_dir)
|
command = typescript_command(tmp_path, [], report_dir)
|
||||||
assert command == f"npx --yes c8 --reporter=lcov --reports-dir {report_dir} npm test"
|
assert command == [
|
||||||
|
"npx",
|
||||||
|
"--yes",
|
||||||
|
"c8",
|
||||||
|
"--reporter=lcov",
|
||||||
|
"--reports-dir",
|
||||||
|
str(report_dir),
|
||||||
|
"npm",
|
||||||
|
"test",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_typescript_package_is_found_from_a_source_file(tmp_path):
|
def test_typescript_package_is_found_from_a_source_file(tmp_path):
|
||||||
@@ -112,10 +131,25 @@ def test_python_project_uses_pytest_and_coverage_lcov(tmp_path):
|
|||||||
data = tmp_path / "target" / "coverage" / "python" / ".coverage"
|
data = tmp_path / "target" / "coverage" / "python" / ".coverage"
|
||||||
report = data.parent / "lcov.info"
|
report = data.parent / "lcov.info"
|
||||||
run, lcov = python_coverage_commands("python3", "pytest", data, report, "src")
|
run, lcov = python_coverage_commands("python3", "pytest", data, report, "src")
|
||||||
assert run == (
|
assert run == [
|
||||||
f"python3 -m coverage run --data-file={data} --source=src -m pytest"
|
"python3",
|
||||||
)
|
"-m",
|
||||||
assert lcov == f"python3 -m coverage lcov --data-file={data} -o {report}"
|
"coverage",
|
||||||
|
"run",
|
||||||
|
f"--data-file={data}",
|
||||||
|
"--source=src",
|
||||||
|
"-m",
|
||||||
|
"pytest",
|
||||||
|
]
|
||||||
|
assert lcov == [
|
||||||
|
"python3",
|
||||||
|
"-m",
|
||||||
|
"coverage",
|
||||||
|
"lcov",
|
||||||
|
f"--data-file={data}",
|
||||||
|
"-o",
|
||||||
|
str(report),
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
def test_python_package_without_pytest_uses_unittest(tmp_path):
|
def test_python_package_without_pytest_uses_unittest(tmp_path):
|
||||||
@@ -123,20 +157,21 @@ def test_python_package_without_pytest_uses_unittest(tmp_path):
|
|||||||
source = _write(tmp_path, "demo/app.py", "def run():\n return 1\n")
|
source = _write(tmp_path, "demo/app.py", "def run():\n return 1\n")
|
||||||
assert not uses_pytest(tmp_path)
|
assert not uses_pytest(tmp_path)
|
||||||
run, _lcov = python_coverage_commands("python3", "unittest", Path("data"), Path("out"), "demo")
|
run, _lcov = python_coverage_commands("python3", "unittest", Path("data"), Path("out"), "demo")
|
||||||
assert "-m unittest discover -s ." in run
|
assert _as_text(run).endswith("-m unittest discover -s .")
|
||||||
assert python_roots(tmp_path, [source]) == [tmp_path.resolve()]
|
assert python_roots(tmp_path, [source]) == [tmp_path.resolve()]
|
||||||
|
|
||||||
|
|
||||||
def test_rust_lcov_path_is_absolute():
|
def test_rust_lcov_path_is_absolute():
|
||||||
report = Path("/tmp/bookwriter/target/coverage/rust/src-tauri/lcov.info")
|
report = Path("/tmp/bookwriter/target/coverage/rust/src-tauri/lcov.info")
|
||||||
command = rust_coverage_command("llvm-cov", report)
|
command = rust_coverage_command("llvm-cov", report)
|
||||||
assert command == f"cargo llvm-cov --lcov --output-path {report}"
|
assert command == ["cargo", "llvm-cov", "--lcov", "--output-path", str(report)]
|
||||||
|
|
||||||
|
|
||||||
def test_vitest_version_and_provider_install(tmp_path, monkeypatch):
|
def test_vitest_version_and_provider_install(tmp_path, monkeypatch, capsys):
|
||||||
assert _vitest_version(tmp_path) is None
|
assert _vitest_version(tmp_path) is None
|
||||||
_write(tmp_path, "node_modules/vitest/package.json", "{not json")
|
_write(tmp_path, "node_modules/vitest/package.json", "{not json")
|
||||||
assert _vitest_version(tmp_path) is None
|
assert _vitest_version(tmp_path) is None
|
||||||
|
assert "package.json" in capsys.readouterr().err
|
||||||
_write(tmp_path, "node_modules/vitest/package.json", '{"version": "4.1.11"}\n')
|
_write(tmp_path, "node_modules/vitest/package.json", '{"version": "4.1.11"}\n')
|
||||||
assert _vitest_version(tmp_path) == "4.1.11"
|
assert _vitest_version(tmp_path) == "4.1.11"
|
||||||
provider = tmp_path / "node_modules" / "@vitest" / "coverage-v8"
|
provider = tmp_path / "node_modules" / "@vitest" / "coverage-v8"
|
||||||
@@ -176,7 +211,7 @@ def test_rust_kind_installs_llvm_cov_when_it_is_missing(monkeypatch):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
def shell(command, cwd):
|
def shell(command, cwd):
|
||||||
if "cargo install" in command:
|
if "cargo install" in _as_text(command):
|
||||||
installed["llvm"] = True
|
installed["llvm"] = True
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -189,18 +224,19 @@ def test_rust_kind_installs_llvm_cov_when_it_is_missing(monkeypatch):
|
|||||||
|
|
||||||
|
|
||||||
def test_coverage_commands_are_issued_per_language(tmp_path, monkeypatch):
|
def test_coverage_commands_are_issued_per_language(tmp_path, monkeypatch):
|
||||||
commands: list[str] = []
|
commands: list[str | list[str]] = []
|
||||||
|
|
||||||
def shell(command, cwd):
|
def shell(command, cwd):
|
||||||
commands.append(command)
|
commands.append(command)
|
||||||
if "--data-file=" in command:
|
text = _as_text(command)
|
||||||
data = Path(command.split("--data-file=", 1)[1].split()[0])
|
if "--data-file=" in text:
|
||||||
|
data = Path(text.split("--data-file=", 1)[1].split()[0])
|
||||||
data.parent.mkdir(parents=True, exist_ok=True)
|
data.parent.mkdir(parents=True, exist_ok=True)
|
||||||
data.write_text("x", encoding="utf-8")
|
data.write_text("x", encoding="utf-8")
|
||||||
return 1
|
return 1
|
||||||
if command.startswith("clj") or command.startswith("mvn") or command.startswith("go "):
|
if text.startswith("clj") or text.startswith("mvn") or text.startswith("go "):
|
||||||
return 1
|
return 1
|
||||||
if "vitest" in command or command.startswith("cargo "):
|
if "vitest" in text or text.startswith("cargo "):
|
||||||
return 1
|
return 1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
@@ -236,7 +272,7 @@ def test_coverage_commands_are_issued_per_language(tmp_path, monkeypatch):
|
|||||||
tmp_path / "src/lib.rs",
|
tmp_path / "src/lib.rs",
|
||||||
]
|
]
|
||||||
run_coverage(tmp_path, files, None)
|
run_coverage(tmp_path, files, None)
|
||||||
text = "\n".join(commands)
|
text = "\n".join(_as_text(command) for command in commands)
|
||||||
assert "clj -M:cov --lcov" in text
|
assert "clj -M:cov --lcov" in text
|
||||||
assert "clj -M:cov\n" in text or text.endswith("clj -M:cov") or "clj -M:cov" in text
|
assert "clj -M:cov\n" in text or text.endswith("clj -M:cov") or "clj -M:cov" in text
|
||||||
assert "mvn " in text
|
assert "mvn " in text
|
||||||
@@ -271,13 +307,22 @@ def test_run_shell_returns_the_child_status_and_reports_a_failed_start(tmp_path,
|
|||||||
|
|
||||||
|
|
||||||
def test_clean_removes_a_stale_directory_and_ignores_a_missing_one(tmp_path):
|
def test_clean_removes_a_stale_directory_and_ignores_a_missing_one(tmp_path):
|
||||||
stale = tmp_path / "target" / "coverage" / "old"
|
coverage = tmp_path / "target" / "coverage"
|
||||||
|
stale = coverage / "old"
|
||||||
stale.mkdir(parents=True)
|
stale.mkdir(parents=True)
|
||||||
(stale / "lcov.info").write_text("x", encoding="utf-8")
|
(stale / "index.html").write_text("x", encoding="utf-8")
|
||||||
kept = tmp_path / "target" / "coverage" / "python"
|
(coverage / "notes.txt").write_text("keep", encoding="utf-8")
|
||||||
|
nested = coverage / "notes"
|
||||||
|
nested.mkdir()
|
||||||
|
(nested / "keep.txt").write_text("x", encoding="utf-8")
|
||||||
|
kept = coverage / "python"
|
||||||
kept.mkdir()
|
kept.mkdir()
|
||||||
|
(kept / "index.html").write_text("py", encoding="utf-8")
|
||||||
_clean_clojure(tmp_path)
|
_clean_clojure(tmp_path)
|
||||||
assert not stale.exists()
|
assert not stale.exists()
|
||||||
|
assert (coverage / "notes.txt").is_file()
|
||||||
|
assert (nested / "keep.txt").is_file()
|
||||||
|
assert (kept / "index.html").is_file()
|
||||||
assert kept.is_dir()
|
assert kept.is_dir()
|
||||||
_clean_dir(tmp_path / "target" / "coverage")
|
_clean_dir(tmp_path / "target" / "coverage")
|
||||||
assert not (tmp_path / "target" / "coverage").exists()
|
assert not (tmp_path / "target" / "coverage").exists()
|
||||||
@@ -288,9 +333,11 @@ def test_clean_removes_a_stale_directory_and_ignores_a_missing_one(tmp_path):
|
|||||||
def test_package_json_without_a_test_script_has_no_coverage_command(tmp_path):
|
def test_package_json_without_a_test_script_has_no_coverage_command(tmp_path):
|
||||||
assert typescript_command(tmp_path, [], tmp_path / "cov") is None
|
assert typescript_command(tmp_path, [], tmp_path / "cov") is None
|
||||||
_write(tmp_path, "package.json", "{")
|
_write(tmp_path, "package.json", "{")
|
||||||
assert typescript_command(tmp_path, [], tmp_path / "cov") is None
|
with pytest.raises(PackageJsonError, match="package.json"):
|
||||||
|
typescript_command(tmp_path, [], tmp_path / "cov")
|
||||||
_write(tmp_path, "package.json", "[]")
|
_write(tmp_path, "package.json", "[]")
|
||||||
assert typescript_command(tmp_path, [], tmp_path / "cov") is None
|
with pytest.raises(PackageJsonError, match="expected a JSON object"):
|
||||||
|
typescript_command(tmp_path, [], tmp_path / "cov")
|
||||||
_write(tmp_path, "package.json", '{"scripts": ["nope"]}')
|
_write(tmp_path, "package.json", '{"scripts": ["nope"]}')
|
||||||
assert typescript_command(tmp_path, [], tmp_path / "cov") is None
|
assert typescript_command(tmp_path, [], tmp_path / "cov") is None
|
||||||
|
|
||||||
@@ -344,13 +391,13 @@ def test_missing_python_module_is_installed(tmp_path, monkeypatch):
|
|||||||
|
|
||||||
def shell(command, _cwd):
|
def shell(command, _cwd):
|
||||||
commands.append(command)
|
commands.append(command)
|
||||||
if command.startswith("python3 -c"):
|
if _as_text(command).startswith("python3 -c"):
|
||||||
return 1
|
return 1
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
monkeypatch.setattr("crapper.runners.run_shell", shell)
|
monkeypatch.setattr("crapper.runners.run_shell", shell)
|
||||||
assert _ensure_python_module("python3", tmp_path, "coverage") is True
|
assert _ensure_python_module("python3", tmp_path, "coverage") is True
|
||||||
assert any("pip install" in command for command in commands)
|
assert any("pip install" in _as_text(command) for command in commands)
|
||||||
commands.clear()
|
commands.clear()
|
||||||
|
|
||||||
def already(_command, _cwd):
|
def already(_command, _cwd):
|
||||||
@@ -431,3 +478,67 @@ def test_python_coverage_module_missing_is_reported(tmp_path, monkeypatch, capsy
|
|||||||
source = _write(tmp_path, "src/app.py", "def run():\n return 1\n")
|
source = _write(tmp_path, "src/app.py", "def run():\n return 1\n")
|
||||||
run_coverage(tmp_path, [source], None)
|
run_coverage(tmp_path, [source], None)
|
||||||
assert "coverage is missing" in capsys.readouterr().err
|
assert "coverage is missing" in capsys.readouterr().err
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_argument_vector_is_not_a_shell(tmp_path, monkeypatch):
|
||||||
|
seen = []
|
||||||
|
|
||||||
|
def fake(command, cwd, shell=False):
|
||||||
|
seen.append((command, shell))
|
||||||
|
|
||||||
|
class Done:
|
||||||
|
returncode = 0
|
||||||
|
|
||||||
|
return Done()
|
||||||
|
|
||||||
|
monkeypatch.setattr("crapper.runners.subprocess.run", fake)
|
||||||
|
assert run_shell("exit 3", tmp_path) == 0
|
||||||
|
marker = tmp_path / "pwned"
|
||||||
|
assert run_shell(["true", f"a;touch {marker}"], tmp_path) == 0
|
||||||
|
assert seen[0] == ("exit 3", True)
|
||||||
|
assert seen[1][1] is False
|
||||||
|
assert seen[1][0] == ["true", f"a;touch {marker}"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_argument_vector_cannot_run_a_second_command(tmp_path):
|
||||||
|
marker = tmp_path / "pwned"
|
||||||
|
assert run_shell(["true", f"a;touch {marker}"], tmp_path) == 0
|
||||||
|
assert not marker.exists()
|
||||||
|
|
||||||
|
|
||||||
|
def test_go_coverprofile_stays_one_argument(tmp_path, monkeypatch):
|
||||||
|
root = tmp_path / "my module"
|
||||||
|
root.mkdir()
|
||||||
|
_write(root, "go.mod", "module example.com/demo\n")
|
||||||
|
source = _write(root, "board.go", "package demo\nfunc Place() int { return 1 }\n")
|
||||||
|
seen = []
|
||||||
|
monkeypatch.setattr("crapper.runners.run_shell", lambda command, cwd: seen.append(command) or 0)
|
||||||
|
run_coverage(root, [source], None)
|
||||||
|
command = seen[0]
|
||||||
|
assert command[:3] == ["go", "test", "./..."]
|
||||||
|
assert command[3].startswith("-coverprofile=")
|
||||||
|
assert " " in command[3]
|
||||||
|
assert len(command) == 4
|
||||||
|
|
||||||
|
|
||||||
|
def test_invalid_package_json_is_not_a_missing_test_script(tmp_path, monkeypatch, capsys):
|
||||||
|
monkeypatch.setattr("crapper.runners.run_shell", lambda *_args: 0)
|
||||||
|
_write(tmp_path, "package.json", "{ not json")
|
||||||
|
source = _write(tmp_path, "src/app.ts", "export const n = 1\n")
|
||||||
|
run_coverage(tmp_path, [source], None)
|
||||||
|
err = capsys.readouterr().err
|
||||||
|
assert "package.json" in err
|
||||||
|
assert "No package.json test script" not in err
|
||||||
|
|
||||||
|
|
||||||
|
def test_vitest_install_restores_manifests_when_install_changes_them(tmp_path, monkeypatch):
|
||||||
|
package_json = tmp_path / "package.json"
|
||||||
|
package_json.write_bytes(b'{"name":"demo"}\n')
|
||||||
|
|
||||||
|
def shell(command, cwd):
|
||||||
|
package_json.write_bytes(b'{"name":"changed"}\n')
|
||||||
|
return 1
|
||||||
|
|
||||||
|
monkeypatch.setattr("crapper.runners.run_shell", shell)
|
||||||
|
assert _ensure_vitest_coverage(tmp_path) is False
|
||||||
|
assert package_json.read_bytes() == b'{"name":"demo"}\n'
|
||||||
|
|||||||
+7
-1
@@ -1,4 +1,6 @@
|
|||||||
from crapper.languages.rust import functions_in_source
|
from pathlib import Path
|
||||||
|
|
||||||
|
from crapper.languages.rust import _file_modules, functions_in_source
|
||||||
|
|
||||||
SOURCE = """
|
SOURCE = """
|
||||||
pub fn choose(x: i32) -> i32 {
|
pub fn choose(x: i32) -> i32 {
|
||||||
@@ -80,6 +82,10 @@ def test_module_paths_outside_the_usual_layout(tmp_path):
|
|||||||
assert bare[0].name == "bare"
|
assert bare[0].name == "bare"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_file_outside_the_crate_root_uses_the_crate_name():
|
||||||
|
assert _file_modules("/tmp/loose.rs", "demo", Path("/projects/demo")) == ["demo"]
|
||||||
|
|
||||||
|
|
||||||
def test_nested_module_path(tmp_path):
|
def test_nested_module_path(tmp_path):
|
||||||
(tmp_path / "Cargo.toml").write_text(
|
(tmp_path / "Cargo.toml").write_text(
|
||||||
'[package]\nname = "demo"\nversion = "0.1.0"\n',
|
'[package]\nname = "demo"\nversion = "0.1.0"\n',
|
||||||
|
|||||||
@@ -5,7 +5,18 @@ set -euo pipefail
|
|||||||
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
ROOT="$(cd "$(dirname "$0")" && pwd)"
|
||||||
cd "$ROOT"
|
cd "$ROOT"
|
||||||
|
|
||||||
DEPS='{:deps {uml-viewer/uml-viewer {:local/root "/Users/unclebob/projects/uml-viewer"} quil/quil {:mvn/version "4.3.1563"}}}'
|
if [[ -n "${UML_VIEWER_ROOT:-}" ]]; then
|
||||||
|
VIEWER="$UML_VIEWER_ROOT"
|
||||||
|
else
|
||||||
|
VIEWER="$ROOT/../uml-viewer"
|
||||||
|
fi
|
||||||
|
if [[ ! -d "$VIEWER" ]]; then
|
||||||
|
echo "uml-viewer checkout not found at $VIEWER" >&2
|
||||||
|
echo "Set UML_VIEWER_ROOT to the checkout." >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
VIEWER="$(cd "$VIEWER" && pwd)"
|
||||||
|
DEPS="{:deps {uml-viewer/uml-viewer {:local/root \"$VIEWER\"} quil/quil {:mvn/version \"4.3.1563\"}}}"
|
||||||
|
|
||||||
edn_path() {
|
edn_path() {
|
||||||
local name base f
|
local name base f
|
||||||
|
|||||||
Reference in New Issue
Block a user