gitmole 0.14.0__tar.gz → 0.16.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {gitmole-0.14.0 → gitmole-0.16.0}/PKG-INFO +14 -1
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/__init__.py +1 -1
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/cli.py +17 -4
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/findings.py +292 -1
- gitmole-0.16.0/gitmole/hygiene.py +506 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/leaks.py +69 -2
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/load.py +4 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/render.py +45 -3
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/run.py +12 -2
- gitmole-0.16.0/gitmole/sarif.py +203 -0
- gitmole-0.16.0/gitmole/signing.py +127 -0
- gitmole-0.16.0/gitmole/structure.py +614 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/watch.py +26 -3
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole.egg-info/PKG-INFO +14 -1
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole.egg-info/SOURCES.txt +8 -0
- gitmole-0.16.0/gitmole.egg-info/requires.txt +21 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/pyproject.toml +16 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_cli.py +40 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_findings.py +153 -0
- gitmole-0.16.0/tests/test_hygiene.py +220 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_leaks.py +81 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_load.py +13 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_render.py +41 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_run.py +19 -1
- gitmole-0.16.0/tests/test_sarif.py +100 -0
- gitmole-0.16.0/tests/test_signing.py +69 -0
- gitmole-0.16.0/tests/test_structure.py +154 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_watch.py +12 -0
- gitmole-0.14.0/gitmole.egg-info/requires.txt +0 -5
- {gitmole-0.14.0 → gitmole-0.16.0}/LICENSE +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/README.md +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/__main__.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/backtest.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/banner.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/blame.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/classify.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/clean.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/compare.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/coupling.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/deps.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/duplicates.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/evaluate.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/filetypes.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/functions.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/hook.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/hotspots.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/identity.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/knowledge.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/loss.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/maat.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/szz.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/textfmt.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/trend.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole.egg-info/dependency_links.txt +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole.egg-info/entry_points.txt +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/gitmole.egg-info/top_level.txt +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/setup.cfg +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_backtest.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_banner.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_blame.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_classify.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_clean.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_compare.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_coupling.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_deps.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_duplicates.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_evaluate.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_filetypes.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_functions.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_golden.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_hook.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_hotspots.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_identity.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_knowledge.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_loss.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_maat.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_packaging.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_render_examples.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_szz.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_textfmt.py +0 -0
- {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_trend.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: gitmole
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.16.0
|
|
4
4
|
Summary: Offline git repository analysis with a terminal report: hotspots, coupling, ownership, code age, secrets, repo health.
|
|
5
5
|
License: MIT
|
|
6
6
|
Project-URL: Homepage, https://github.com/antvinni/gitmole
|
|
@@ -16,6 +16,19 @@ Requires-Dist: rich>=13
|
|
|
16
16
|
Requires-Dist: lizard>=1.24
|
|
17
17
|
Provides-Extra: plots
|
|
18
18
|
Requires-Dist: git-of-theseus; extra == "plots"
|
|
19
|
+
Provides-Extra: structure
|
|
20
|
+
Requires-Dist: tree-sitter>=0.23; python_version >= "3.10" and extra == "structure"
|
|
21
|
+
Requires-Dist: tree-sitter-python>=0.23; python_version >= "3.10" and extra == "structure"
|
|
22
|
+
Requires-Dist: tree-sitter-javascript>=0.23; python_version >= "3.10" and extra == "structure"
|
|
23
|
+
Requires-Dist: tree-sitter-typescript>=0.23; python_version >= "3.10" and extra == "structure"
|
|
24
|
+
Requires-Dist: tree-sitter-go>=0.23; python_version >= "3.10" and extra == "structure"
|
|
25
|
+
Requires-Dist: tree-sitter-rust>=0.23; python_version >= "3.10" and extra == "structure"
|
|
26
|
+
Requires-Dist: tree-sitter-java>=0.23; python_version >= "3.10" and extra == "structure"
|
|
27
|
+
Requires-Dist: tree-sitter-c>=0.23; python_version >= "3.10" and extra == "structure"
|
|
28
|
+
Requires-Dist: tree-sitter-cpp>=0.23; python_version >= "3.10" and extra == "structure"
|
|
29
|
+
Requires-Dist: tree-sitter-ruby>=0.23; python_version >= "3.10" and extra == "structure"
|
|
30
|
+
Requires-Dist: tree-sitter-c-sharp>=0.23; python_version >= "3.10" and extra == "structure"
|
|
31
|
+
Requires-Dist: tree-sitter-php>=0.23; python_version >= "3.10" and extra == "structure"
|
|
19
32
|
Dynamic: license-file
|
|
20
33
|
|
|
21
34
|
<img src="https://raw.githubusercontent.com/antvinni/gitmole/main/docs/banner.svg" width="912" alt="gitmole">
|
|
@@ -41,6 +41,9 @@ def parse_args(argv):
|
|
|
41
41
|
p.add_argument("--full", action="store_true", help="every section, column and row in the terminal report: adds hotspots, size, activity and code age, and the test files the default tables hide (the default is the tighter, readable one)")
|
|
42
42
|
p.add_argument("--json", metavar="PATH", help="write the report and findings as JSON to PATH, or - for stdout")
|
|
43
43
|
p.add_argument("--markdown", metavar="PATH", help="write the report as Markdown to PATH, or - for stdout")
|
|
44
|
+
p.add_argument("--sarif", metavar="PATH", help="write the findings as SARIF 2.1.0 to PATH, or - for stdout, for GitHub code scanning and GitLab")
|
|
45
|
+
p.add_argument("--sarif-scope", choices=["head", "history"], default="head",
|
|
46
|
+
help="with --sarif: head keeps only results whose file is in the tree (default); history keeps every result, the commit in its properties")
|
|
44
47
|
p.add_argument("--fail-on", choices=findings.SEVERITIES, help="exit 3 if any finding is at this severity or worse")
|
|
45
48
|
p.add_argument("--risk", metavar="BASE", help="score the files changed since BASE (merge base with HEAD) by their share of the repository's revisions × lines of code; needs a local path")
|
|
46
49
|
p.add_argument("--risk-threshold", type=float, metavar="N", help="with --risk: exit 3 when the changed files hold more than N percent of the repository's revisions × lines of code")
|
|
@@ -66,7 +69,8 @@ def interrupt(*_):
|
|
|
66
69
|
|
|
67
70
|
|
|
68
71
|
def main(argv=None, console: Console = None, tool_check=run.missing_tools, planner=run.plan, estimator=run.estimate_blames,
|
|
69
|
-
lister=run.list_repos, cloner=run.clone, lizard_check=run.has_lizard, ask=None, stdin=None
|
|
72
|
+
lister=run.list_repos, cloner=run.clone, lizard_check=run.has_lizard, ask=None, stdin=None,
|
|
73
|
+
structure_check=run.has_structure) -> int:
|
|
70
74
|
global _control
|
|
71
75
|
_control = run.Control()
|
|
72
76
|
if threading.current_thread() is threading.main_thread():
|
|
@@ -80,7 +84,7 @@ def main(argv=None, console: Console = None, tool_check=run.missing_tools, plann
|
|
|
80
84
|
if args.clean:
|
|
81
85
|
return _clean(args, console, ask or (lambda q: console.input(q, markup=False)))
|
|
82
86
|
# When an export goes to stdout, everything else (banner, progress, report) moves to stderr.
|
|
83
|
-
quiet = "-" in (args.json, args.markdown)
|
|
87
|
+
quiet = "-" in (args.json, args.markdown, args.sarif)
|
|
84
88
|
ui = Console(stderr=True) if quiet else console
|
|
85
89
|
|
|
86
90
|
rc, now = _resolve_time(args, err, ui)
|
|
@@ -112,6 +116,7 @@ def main(argv=None, console: Console = None, tool_check=run.missing_tools, plann
|
|
|
112
116
|
err.print(f"brew install {' '.join(run.REQUIRED_TOOLS)}; see README.md for other ways")
|
|
113
117
|
return 2
|
|
114
118
|
args.lizard = lizard_check() # decided once, for every repository this run analyses
|
|
119
|
+
args.structure = structure_check()
|
|
115
120
|
|
|
116
121
|
rc, repo_dir, out_dir = _resolve_target(kind, target, args, console, ui, err, planner, estimator, lister, cloner)
|
|
117
122
|
if rc is not None:
|
|
@@ -356,6 +361,8 @@ def _meta_for_run(repo_dir: str, args, estimate, age_ok: bool, plots_ok: bool, p
|
|
|
356
361
|
meta["plots"] = {"status": "run" if plots_ok else "skipped", "blames": estimate["blames"], "samples": estimate["samples"], "budget": args.budget}
|
|
357
362
|
lizard_ok = args.lizard
|
|
358
363
|
meta["functions"] = {"status": "planned" if lizard_ok else "skipped"} # "run" only once the step has finished
|
|
364
|
+
meta["structure"] = ({"status": "planned"} if getattr(args, "structure", False)
|
|
365
|
+
else {"status": "skipped", "install": "pip install 'gitmole[structure]'"})
|
|
359
366
|
meta["duplicates"] = {"status": "planned" if duplicates_ok else "skipped", "text_mb": round(estimate.get("text_bytes", 0) / 1e6, 1),
|
|
360
367
|
"budget_mb": run.DUPLICATES_BUDGET_MB}
|
|
361
368
|
meta["trend"] = {"status": "planned"}
|
|
@@ -381,6 +388,8 @@ def _record_statuses(meta, results, age_ok: bool, plots_ok: bool, lizard_ok: boo
|
|
|
381
388
|
meta["plots"]["status"] = status("git-of-theseus")
|
|
382
389
|
if lizard_ok:
|
|
383
390
|
meta["functions"]["status"] = status("functions")
|
|
391
|
+
if (meta.get("structure") or {}).get("status") == "planned":
|
|
392
|
+
meta["structure"]["status"] = status("structure")
|
|
384
393
|
if duplicates_ok and "duplicates" in results:
|
|
385
394
|
meta["duplicates"]["status"] = status("duplicates")
|
|
386
395
|
if "trend" in results:
|
|
@@ -417,7 +426,8 @@ def _analyse(repo_dir: str, out_dir: str, args, ui: Console, planner, estimator)
|
|
|
417
426
|
lizard_ok = args.lizard
|
|
418
427
|
run.clear_outputs(out_dir)
|
|
419
428
|
steps = planner(repo_dir, out_dir, branch=meta["branch"], age=age_ok, plots=plots_ok, ignore=ignore, types=types_spec, now=args.now,
|
|
420
|
-
since=args.since_date, lizard=lizard_ok, duplicates=duplicates_ok, backtest=cut, ignore_revs=run.ignore_revs_files(repo_dir)
|
|
429
|
+
since=args.since_date, lizard=lizard_ok, duplicates=duplicates_ok, backtest=cut, ignore_revs=run.ignore_revs_files(repo_dir),
|
|
430
|
+
structure=getattr(args, "structure", False))
|
|
421
431
|
run.save_meta(meta, out_dir)
|
|
422
432
|
results = _execute(steps, log_path, repo_dir, args.workers, ui, timeout=args.timeout)
|
|
423
433
|
if _control.cancelled.is_set():
|
|
@@ -582,7 +592,10 @@ def _render(out_dir: str, console: Console, ui: Console, args, err: Console) ->
|
|
|
582
592
|
_write(json.dumps(render.to_json(report, found, risk=risk, compare=comparison), indent=2) + "\n", args.json, console)
|
|
583
593
|
if args.markdown:
|
|
584
594
|
_write(render.markdown(report, found, full=args.full, risk=risk, base=args.risk, compare=comparison), args.markdown, console)
|
|
585
|
-
if
|
|
595
|
+
if args.sarif:
|
|
596
|
+
from . import sarif
|
|
597
|
+
_write(sarif.dumps(report, found, scope=args.sarif_scope), args.sarif, console)
|
|
598
|
+
if "-" not in (args.json, args.markdown, args.sarif):
|
|
586
599
|
render.report(report, found, console, full=args.full, risk=risk, base=args.risk, compare=comparison)
|
|
587
600
|
if args.fail_on and any(findings.SEVERITIES.index(f["severity"]) <= findings.SEVERITIES.index(args.fail_on) for f in found):
|
|
588
601
|
return 3
|
|
@@ -731,9 +731,300 @@ def vulnerable_dependencies(report: dict) -> list:
|
|
|
731
731
|
return out
|
|
732
732
|
|
|
733
733
|
|
|
734
|
+
def _files_list(items: list, n: int = 3) -> str:
|
|
735
|
+
return textfmt.join_and(items[:n]) + (f" and {len(items) - n} more" if len(items) > n else "")
|
|
736
|
+
|
|
737
|
+
|
|
738
|
+
def hygiene_findings(report: dict) -> list:
|
|
739
|
+
"""The hygiene checks (hygiene.py), one finding per rule, each naming the OpenSSF Scorecard check it
|
|
740
|
+
stands in for without the GitHub API. Nothing for an output directory from before the step."""
|
|
741
|
+
h = report.get("hygiene") or {}
|
|
742
|
+
out = []
|
|
743
|
+
for check in (_hygiene_actions, _hygiene_lockfiles, _hygiene_updates, _hygiene_presence, _hygiene_confusion, _hygiene_install, _hygiene_binaries, _hygiene_submodules, _hygiene_symlinks, _hygiene_trojan):
|
|
744
|
+
check(h, out)
|
|
745
|
+
return out
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _hygiene_actions(h: dict, out: list) -> None:
|
|
749
|
+
|
|
750
|
+
a = h.get("actions") or {}
|
|
751
|
+
if a.get("unpinned"):
|
|
752
|
+
n, total = a.get("unpinned_count", len(a["unpinned"])), a.get("unpinned_count", len(a["unpinned"])) + (a.get("pinned") or 0)
|
|
753
|
+
by_file = {}
|
|
754
|
+
for u in a["unpinned"]:
|
|
755
|
+
if u["uses"] not in by_file.setdefault(u["file"], []):
|
|
756
|
+
by_file[u["file"]].append(u["uses"])
|
|
757
|
+
listed = "; ".join(f"{textfmt.join_and(v[:3])}{' and more' if len(v) > 3 else ''} in {k}" for k, v in list(by_file.items())[:3])
|
|
758
|
+
third = [u for u in a["unpinned"] if not u["uses"].split("/", 1)[0] in ("actions", "github")]
|
|
759
|
+
first = (third or a["unpinned"])[0]["uses"]
|
|
760
|
+
out.append(_f("warning", "Actions pinned by tag or branch",
|
|
761
|
+
f"{n} of {total} workflow steps use an action by tag or branch: {listed}. Whoever controls the action can move the tag to other code.",
|
|
762
|
+
f"Pin {first} to a full commit SHA first, with the tag in a comment; Dependabot and Renovate keep such pins current.",
|
|
763
|
+
rule={"id": "unpinned_actions", "scorecard": "Pinned-Dependencies"}, evidence={"count": n, "pinned": a.get("pinned", 0), "unpinned": a["unpinned"][:10]}))
|
|
764
|
+
|
|
765
|
+
|
|
766
|
+
def _hygiene_lockfiles(h: dict, out: list) -> None:
|
|
767
|
+
lf = h.get("lockfiles") or {}
|
|
768
|
+
if lf.get("drift"):
|
|
769
|
+
d = lf["drift"]
|
|
770
|
+
listed = "; ".join(f"{x['manifest']} changed on {x['manifest_date']}, after {x['lockfile']} last did on {x['lockfile_date']}" for x in d[:3])
|
|
771
|
+
out.append(_f("warning", "Lock files behind their manifests", f"{_plural(lf.get('drift_count', len(d)), 'manifest')} changed after the lock file that pins it: {listed}.",
|
|
772
|
+
f"Regenerate {d[0]['lockfile']} and commit it with the manifest; a frozen install does not catch this.",
|
|
773
|
+
rule={"id": "lockfile_drift", "by": "last commit time"}, evidence={"count": lf.get("drift_count", len(d)), "drift": d[:10]}))
|
|
774
|
+
if lf.get("missing"):
|
|
775
|
+
m = lf["missing"]
|
|
776
|
+
listed = "; ".join(f"{x['manifest']} has no {x['expected'][0]}" for x in m[:3])
|
|
777
|
+
out.append(_f("info", "Manifests without a lock file", f"{listed}{' and ' + str(len(m) - 3) + ' more' if len(m) > 3 else ''}.",
|
|
778
|
+
"Commit the lock file so every install resolves the same versions, and the vulnerability scan can read them.",
|
|
779
|
+
rule={"id": "lockfile_missing", "scorecard": "Pinned-Dependencies"}, evidence={"missing": m[:10]}))
|
|
780
|
+
|
|
781
|
+
|
|
782
|
+
def _hygiene_updates(h: dict, out: list) -> None:
|
|
783
|
+
up = h.get("updates") or {}
|
|
784
|
+
if up.get("uncovered"):
|
|
785
|
+
names = textfmt.join_and(up["uncovered"])
|
|
786
|
+
if up.get("tool"):
|
|
787
|
+
statement = f"dependabot.yml covers {textfmt.join_and(up['covered']) or 'nothing'} but not {names}, which have lock files here."
|
|
788
|
+
else:
|
|
789
|
+
statement = f"No dependency update tool is declared for {names}, which have lock files here."
|
|
790
|
+
out.append(_f("info", "Dependencies without an update tool", statement,
|
|
791
|
+
f"Add a package-ecosystem entry for {up['uncovered'][0]} to .github/dependabot.yml, or a renovate.json.",
|
|
792
|
+
rule={"id": "dependency_updates", "scorecard": "Dependency-Update-Tool"}, evidence=dict(up)))
|
|
793
|
+
|
|
794
|
+
|
|
795
|
+
def _hygiene_presence(h: dict, out: list) -> None:
|
|
796
|
+
pr = h.get("presence") or {}
|
|
797
|
+
if pr and (not pr.get("license") or not pr.get("security_policy") or pr.get("codeowners_missing")):
|
|
798
|
+
parts = []
|
|
799
|
+
if not pr.get("license"):
|
|
800
|
+
parts.append("No licence file at the root")
|
|
801
|
+
if not pr.get("security_policy"):
|
|
802
|
+
parts.append("no security policy (SECURITY.md)" if parts else "No security policy (SECURITY.md)")
|
|
803
|
+
missing = pr.get("codeowners_missing") or []
|
|
804
|
+
if missing:
|
|
805
|
+
parts.append(f"{pr['codeowners']} names {_plural(len(missing), 'path')} that {'matches' if len(missing) == 1 else 'match'} no tracked file: {_files_list(missing)}")
|
|
806
|
+
advice = ("Add a LICENSE; without one nobody may reuse the code." if not pr.get("license") else
|
|
807
|
+
"Add a SECURITY.md that says how to report a vulnerability privately." if not pr.get("security_policy") else
|
|
808
|
+
f"Remove or fix {missing[0]} in {pr['codeowners']}; a stale owner line assigns reviews to nothing.")
|
|
809
|
+
out.append(_f("info", "Repository policy files", "; ".join(parts) + ".", advice,
|
|
810
|
+
rule={"id": "repo_policy", "scorecard": "Security-Policy, License"}, evidence=dict(pr)))
|
|
811
|
+
|
|
812
|
+
|
|
813
|
+
def _hygiene_confusion(h: dict, out: list) -> None:
|
|
814
|
+
cf = h.get("confusion") or {}
|
|
815
|
+
if cf.get("scoped_public") or cf.get("registries") or cf.get("pip_extra_index"):
|
|
816
|
+
parts = [f"{x['package']} resolved from {x['registry']} in {x['lockfile']}, though .npmrc sends {x['package'].split('/')[0]} to {x['declared']}"
|
|
817
|
+
for x in (cf.get("scoped_public") or [])[:3]]
|
|
818
|
+
parts += [f"{k} mixes {textfmt.join_and(v)}" for k, v in list((cf.get("registries") or {}).items())[:2]]
|
|
819
|
+
if cf.get("pip_extra_index"):
|
|
820
|
+
parts.append(f"{_files_list(cf['pip_extra_index'])} {'adds' if len(cf['pip_extra_index']) == 1 else 'add'} a pip extra-index-url, which lets a public package shadow a private one")
|
|
821
|
+
sev = "warning" if cf.get("scoped_public") else "info"
|
|
822
|
+
advice = (f"Reinstall {cf['scoped_public'][0]['package']} from {cf['scoped_public'][0]['declared']} and check the published copy is yours."
|
|
823
|
+
if cf.get("scoped_public") else "Use one index per package: --index-url for the private one, or a scoped registry, rather than an extra index.")
|
|
824
|
+
out.append(_f(sev, "Dependency confusion shapes", "; ".join(parts) + ".", advice,
|
|
825
|
+
rule={"id": "dependency_confusion"}, evidence={"scoped_public": (cf.get("scoped_public") or [])[:10],
|
|
826
|
+
"registries": cf.get("registries") or {}, "pip_extra_index": cf.get("pip_extra_index") or []}))
|
|
827
|
+
|
|
828
|
+
|
|
829
|
+
def _hygiene_install(h: dict, out: list) -> None:
|
|
830
|
+
ins = h.get("install") or {}
|
|
831
|
+
if ins.get("lockfile") or ins.get("manifests") or ins.get("setup_py"):
|
|
832
|
+
parts = []
|
|
833
|
+
if ins.get("lockfile"):
|
|
834
|
+
n = ins.get("lockfile_count", len(ins["lockfile"]))
|
|
835
|
+
parts.append(f"{n} locked package{'s' if n != 1 else ''} {'runs' if n == 1 else 'run'} an install script ({_files_list([x['package'] for x in ins['lockfile']])})")
|
|
836
|
+
parts += [f"{m['file']} declares {textfmt.join_and(m['scripts'])}" for m in (ins.get("manifests") or [])[:3]]
|
|
837
|
+
parts += [f"{s_['file']} calls {textfmt.join_and(s_['calls'])}" for s_ in (ins.get("setup_py") or [])[:3]]
|
|
838
|
+
out.append(_f("info", "Code that runs at install", "; ".join(parts) + ".",
|
|
839
|
+
"Install with scripts disabled where the build allows it (npm ci --ignore-scripts) and review what the rest run.",
|
|
840
|
+
rule={"id": "install_scripts"}, evidence={k: ins.get(k) for k in ("lockfile", "manifests", "setup_py")}))
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _hygiene_binaries(h: dict, out: list) -> None:
|
|
844
|
+
b = h.get("binaries") or {}
|
|
845
|
+
exe = [x for x in b.get("executables") or [] if not _aside_path(x["file"])]
|
|
846
|
+
if exe or b.get("lfs_unpointed"):
|
|
847
|
+
parts = []
|
|
848
|
+
if exe:
|
|
849
|
+
named = [f"{x['file']} ({x['format']})" for x in exe]
|
|
850
|
+
parts.append(f"{_plural(len(exe), 'executable')} committed: {_files_list(named)}")
|
|
851
|
+
lfs = b.get("lfs_unpointed") or []
|
|
852
|
+
if lfs:
|
|
853
|
+
parts.append(f"{_files_list(lfs)} {'is' if len(lfs) == 1 else 'are'} committed as a blob though .gitattributes sends {'it' if len(lfs) == 1 else 'them'} to LFS")
|
|
854
|
+
out.append(_f("warning" if exe else "info", "Committed binaries", "; ".join(parts) + ".",
|
|
855
|
+
f"Build {exe[0]['file']} in CI and publish it as a release asset instead; nobody can review a binary in a diff." if exe
|
|
856
|
+
else "Run git lfs migrate import for those paths, or drop the filter=lfs line that does not apply.",
|
|
857
|
+
rule={"id": "committed_binaries", "scorecard": "Binary-Artifacts"}, evidence={"executables": exe[:10], "lfs_unpointed": lfs[:10]}))
|
|
858
|
+
|
|
859
|
+
|
|
860
|
+
def _hygiene_submodules(h: dict, out: list) -> None:
|
|
861
|
+
sm = h.get("submodules") or {}
|
|
862
|
+
if sm.get("credentials") or sm.get("insecure") or sm.get("relative") or sm.get("floating"):
|
|
863
|
+
parts = [f"{x['name']} carries credentials in its URL" for x in sm.get("credentials") or []]
|
|
864
|
+
parts += [f"{x['name']} is fetched over {x['url'].split(':', 1)[0]}://" for x in sm.get("insecure") or []]
|
|
865
|
+
parts += [f"{x['name']} has a relative URL ({x['url']})" for x in sm.get("relative") or []]
|
|
866
|
+
parts += [f"{x['name']} follows branch {x['branch']}" for x in sm.get("floating") or []]
|
|
867
|
+
sev = "critical" if sm.get("credentials") else "warning" if sm.get("insecure") else "info"
|
|
868
|
+
advice = ("Rotate that credential and remove it from .gitmodules; history keeps it." if sm.get("credentials") else
|
|
869
|
+
"Switch those URLs to https://; a plain-text fetch can be rewritten on the way." if sm.get("insecure") else
|
|
870
|
+
"Use absolute https:// URLs and let the pinned commit, not a branch, say what is checked out.")
|
|
871
|
+
out.append(_f(sev, "Submodule URLs", "; ".join(parts[:6]) + ".", advice, rule={"id": "submodule_urls"},
|
|
872
|
+
evidence={k: sm.get(k) or [] for k in ("credentials", "insecure", "relative", "floating")}))
|
|
873
|
+
|
|
874
|
+
|
|
875
|
+
def _hygiene_symlinks(h: dict, out: list) -> None:
|
|
876
|
+
sl = h.get("symlinks") or {}
|
|
877
|
+
if sl.get("outside") or sl.get("into_git"):
|
|
878
|
+
parts = [f"{x['link']} points outside the tree ({x['target']})" for x in sl.get("outside") or []]
|
|
879
|
+
parts += [f"{x['link']} points into .git ({x['target']})" for x in sl.get("into_git") or []]
|
|
880
|
+
out.append(_f("warning", "Symlinks out of the tree", "; ".join(parts[:5]) + ".",
|
|
881
|
+
"Replace them with files or relative links inside the tree; a checkout that follows them reads or writes where it should not.",
|
|
882
|
+
rule={"id": "unsafe_symlinks"}, evidence={"outside": sl.get("outside") or [], "into_git": sl.get("into_git") or []}))
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
def _hygiene_trojan(h: dict, out: list) -> None:
|
|
886
|
+
tj = h.get("trojan") or {}
|
|
887
|
+
if tj.get("bidi") or tj.get("mixed_script"):
|
|
888
|
+
parts = [f"{x['file']}:{x['line']} holds {x['char']}" for x in (tj.get("bidi") or [])[:3]]
|
|
889
|
+
parts += [f"{x['token']} at {x['file']}:{x['line']} mixes {textfmt.join_and(x['scripts'])}" for x in (tj.get("mixed_script") or [])[:3]]
|
|
890
|
+
sev = "critical" if tj.get("bidi") else "warning"
|
|
891
|
+
first = (tj.get("bidi") or tj.get("mixed_script"))[0]
|
|
892
|
+
out.append(_f(sev, "Trojan Source characters", "; ".join(parts) + ". Code can read one way in review and compile another.",
|
|
893
|
+
f"Look at {first['file']}:{first['line']} in a hex view first, and remove the character unless it is in a string that must hold it.",
|
|
894
|
+
rule={"id": "trojan_source", "cve": "CVE-2021-42574"},
|
|
895
|
+
evidence={"bidi": (tj.get("bidi") or [])[:10], "mixed_script": (tj.get("mixed_script") or [])[:10]}))
|
|
896
|
+
|
|
897
|
+
|
|
898
|
+
def _aside_path(path: str) -> bool:
|
|
899
|
+
return filetypes.is_test_path(path) or filetypes.is_sample_path(path) or filetypes.is_vendor_path(path)
|
|
900
|
+
|
|
901
|
+
|
|
902
|
+
STRUCTURE_LANGUAGES = {"python", "javascript", "typescript", "tsx", "c", "cpp", "ruby"} # where the import graph resolves at all
|
|
903
|
+
|
|
904
|
+
|
|
905
|
+
def _structure(report: dict) -> dict:
|
|
906
|
+
s = report.get("structure") or {}
|
|
907
|
+
return s if s.get("status") == "run" else {}
|
|
908
|
+
|
|
909
|
+
|
|
910
|
+
def _scored_top(report: dict, n: int = 10) -> list:
|
|
911
|
+
from . import classify
|
|
912
|
+
cls = classify.Classifier(report)
|
|
913
|
+
return [h["entity"] for h in hotspots.ranked(report) if h["code"] is not None and cls.reason(h["entity"]) is None][:n]
|
|
914
|
+
|
|
915
|
+
|
|
916
|
+
def debt_in_hotspots(report: dict, min_markers: int = 3, min_files: int = 2, top_n: int = 10) -> list:
|
|
917
|
+
"""Top hotspots whose own comments say they are unfinished: TODO, FIXME, XXX, HACK. Methods carrying
|
|
918
|
+
such self-admitted debt were revised more than twice as often and carried about twice the bug ratio
|
|
919
|
+
(Maldonado and Shihab's markers; the 2024 study of 774,051 Java methods), and most of it is never
|
|
920
|
+
removed: a hot file its authors flagged is a reason no churn number gives."""
|
|
921
|
+
s = _structure(report)
|
|
922
|
+
if not s:
|
|
923
|
+
return []
|
|
924
|
+
files = s.get("files") or {}
|
|
925
|
+
top = _scored_top(report, top_n)
|
|
926
|
+
flagged = [(f, files[f]["debt"]) for f in top if (files.get(f) or {}).get("debt")]
|
|
927
|
+
if not flagged or (max(n for _, n in flagged) < min_markers and len(flagged) < min_files):
|
|
928
|
+
return [] # one marker in one hotspot is ordinary; three in one, or markers in two, is a pattern
|
|
929
|
+
first = max(flagged, key=lambda t: t[1])[0]
|
|
930
|
+
sample = (files[first].get("debt_sample") or [{}])[0]
|
|
931
|
+
at = f", starting at line {sample['line']}" if sample.get("line") else ""
|
|
932
|
+
listed = "; ".join(f"{f} ({n})" for f, n in flagged[:5]) + (f" and {len(flagged) - 5} more" if len(flagged) > 5 else "")
|
|
933
|
+
return [_f("info", "Debt the authors flagged in hotspots",
|
|
934
|
+
f"{len(flagged)} of the top {len(top)} hotspots carry TODO, FIXME, XXX or HACK comments: {listed}.",
|
|
935
|
+
f"Resolve or ticket the markers in {first} first{at}; it changes often and its authors said it is unfinished.",
|
|
936
|
+
rule={"id": "debt_in_hotspots", "markers": ["TODO", "FIXME", "XXX", "HACK"], "min_markers": min_markers, "top_n": top_n,
|
|
937
|
+
"ref": "Maldonado and Shihab, MTD 2015"},
|
|
938
|
+
evidence={"files": [{"file": f, "markers": n} for f, n in flagged[:10]]})]
|
|
939
|
+
|
|
940
|
+
|
|
941
|
+
def deep_nesting(report: dict, min_nesting: int = 5, min_bumps: int = 3, top_n: int = 10) -> list:
|
|
942
|
+
"""Functions nested five levels or more, or with three or more separate chunks of nested logic (a
|
|
943
|
+
bumpy road), in this repository's own source: CodeScene's nesting and bumpy-road factors, measured
|
|
944
|
+
by tree-sitter in every language it parses, with Sonar's cognitive complexity beside them. A
|
|
945
|
+
warning when one sits in a top hotspot."""
|
|
946
|
+
s = _structure(report)
|
|
947
|
+
if not s:
|
|
948
|
+
return []
|
|
949
|
+
generated, vendored = _generated(report), filetypes.vendor_dirs(report)
|
|
950
|
+
deep = [f for f in s.get("functions") or [] if (f["nesting"] >= min_nesting or f["bumps"] >= min_bumps)
|
|
951
|
+
and not (filetypes.is_test_path(f["file"]) or filetypes.is_sample_path(f["file"]) or filetypes.is_vendored(f["file"], vendored)
|
|
952
|
+
or f["file"] in generated)]
|
|
953
|
+
if not deep:
|
|
954
|
+
return []
|
|
955
|
+
deep.sort(key=lambda f: (-f["cognitive"], -f["nesting"], f["file"], f["start"]))
|
|
956
|
+
top = set(_scored_top(report, top_n))
|
|
957
|
+
sev = "warning" if any(f["file"] in top for f in deep) else "info"
|
|
958
|
+
|
|
959
|
+
def one(f):
|
|
960
|
+
return f"{f['name']} ({f['file']}:{f['start']}) nested {f['nesting']} deep, cognitive complexity {f['cognitive']}, {f['bumps']} bump{'s' if f['bumps'] != 1 else ''}"
|
|
961
|
+
listed = "; ".join(one(f) for f in deep[:5]) + (f" and {len(deep) - 5} more" if len(deep) > 5 else "")
|
|
962
|
+
first = next((f for f in deep if f["file"] in top), deep[0])
|
|
963
|
+
return [_f(sev, "Deeply nested code", f"{_plural(len(deep), 'function')} nest {min_nesting} levels or more or carry {min_bumps}+ separate nested chunks: {listed}.",
|
|
964
|
+
f"Flatten {first['name']} in {first['file']} first: return early and move each nested chunk into a function of its own.",
|
|
965
|
+
rule={"id": "deep_nesting", "min_nesting": min_nesting, "min_bumps": min_bumps, "measure": "tree-sitter",
|
|
966
|
+
"ref": "SonarSource cognitive complexity; CodeScene code health"},
|
|
967
|
+
evidence={"count": len(deep), "functions": [{k: f[k] for k in ("file", "name", "start", "nesting", "cognitive", "bumps")} for f in deep[:10]]})]
|
|
968
|
+
|
|
969
|
+
|
|
970
|
+
def hidden_coupling(report: dict, min_degree: int = 60, min_revs: int = 5, min_resolved: float = 0.6) -> list:
|
|
971
|
+
"""Pairs that change together without an import between them, in either direction. Ajienka and
|
|
972
|
+
Capiluppi found across 79 projects that many co-changed pairs have no structural dependency at
|
|
973
|
+
all: such a pair is a shared format, a duplicated rule or copy-paste, and neither a pure-git nor a
|
|
974
|
+
pure-static tool can print it. Only for languages whose imports this graph mostly resolves."""
|
|
975
|
+
s = _structure(report)
|
|
976
|
+
if not s:
|
|
977
|
+
return []
|
|
978
|
+
files, resolved, tree = s.get("files") or {}, s.get("resolved") or {}, _tree(report)
|
|
979
|
+
derived = _generated(report)
|
|
980
|
+
|
|
981
|
+
def graphed(p):
|
|
982
|
+
info = files.get(p)
|
|
983
|
+
return info is not None and info.get("language") in STRUCTURE_LANGUAGES and resolved.get(info["language"], 0) >= min_resolved
|
|
984
|
+
|
|
985
|
+
hidden = []
|
|
986
|
+
for p in report.get("coupling") or []:
|
|
987
|
+
a, b = p["entity"], p["coupled"]
|
|
988
|
+
if p["degree"] < min_degree or p["average-revs"] < min_revs or not (graphed(a) and graphed(b)):
|
|
989
|
+
continue
|
|
990
|
+
if filetypes.is_test_path(a) or filetypes.is_test_path(b) or filetypes.is_header_pair(a, b) or a in derived or b in derived:
|
|
991
|
+
continue
|
|
992
|
+
if tree and (a not in tree or b not in tree):
|
|
993
|
+
continue
|
|
994
|
+
if b in (files[a].get("imports") or []) or a in (files[b].get("imports") or []):
|
|
995
|
+
continue
|
|
996
|
+
hidden.append(p)
|
|
997
|
+
if not hidden:
|
|
998
|
+
return []
|
|
999
|
+
hidden.sort(key=lambda p: (-p["degree"], -p["average-revs"], p["entity"]))
|
|
1000
|
+
listed = "; ".join(f"{p['entity']} and {p['coupled']} change together {p['degree']}% of the time, and neither imports the other" for p in hidden[:3])
|
|
1001
|
+
more = f" ({len(hidden) - 3} more pairs like them)" if len(hidden) > 3 else ""
|
|
1002
|
+
first = hidden[0]
|
|
1003
|
+
return [_f("info", "Coupling with no import behind it", f"{listed}{more}.",
|
|
1004
|
+
f"Look at why {first['entity']} and {first['coupled']} move together: a shared format, a duplicated rule or copied code is the usual answer.",
|
|
1005
|
+
rule={"id": "hidden_coupling", "min_degree": min_degree, "min_revs": min_revs, "min_resolved": min_resolved,
|
|
1006
|
+
"ref": "Ajienka and Capiluppi, JSS 2017"},
|
|
1007
|
+
evidence={"pairs": [{"a": p["entity"], "b": p["coupled"], "degree": p["degree"], "revs": p["average-revs"]} for p in hidden[:10]]})]
|
|
1008
|
+
|
|
1009
|
+
|
|
1010
|
+
def unreferenced_files(report: dict) -> list:
|
|
1011
|
+
"""Files nothing in the tree imports that are no entry point by convention or declaration, from the
|
|
1012
|
+
structure step, which already leaves out languages whose graph is too blind to judge. Never
|
|
1013
|
+
"dead": Romano et al. found no comprehension cost to dead code in controlled experiments, and a
|
|
1014
|
+
dynamic import cannot be seen from here, so this is a list to check, not to delete."""
|
|
1015
|
+
s = _structure(report)
|
|
1016
|
+
paths = s.get("unreferenced") or []
|
|
1017
|
+
if not paths:
|
|
1018
|
+
return []
|
|
1019
|
+
n = s.get("unreferenced_count", len(paths))
|
|
1020
|
+
return [_f("info", "Possibly unreferenced files", f"{_plural(n, 'file')} {'is' if n == 1 else 'are'} imported by nothing in the tree and {'is' if n == 1 else 'are'} no entry point: {_files_list(paths, 5)}.",
|
|
1021
|
+
f"Check {paths[0]} before anything else; dynamic imports, plugins loaded by name and framework routing do not show in an import graph.",
|
|
1022
|
+
rule={"id": "unreferenced_files", "ref": "Romano et al., TSE 2020"}, evidence={"count": n, "files": paths[:10]})]
|
|
1023
|
+
|
|
1024
|
+
|
|
734
1025
|
RULES = [dormant, secrets_found, credential_files, vulnerable_dependencies, placeholder_identity, bus_factor, sizer_concerns, hotspot_dominance, bug_magnets,
|
|
735
1026
|
minor_contributors, reverts, brain_methods, complexity_growth, tight_coupling, duplication, stale_files, knowledge_islands, knowledge_loss,
|
|
736
|
-
sweeping_commits, tangled_commits]
|
|
1027
|
+
sweeping_commits, tangled_commits, hygiene_findings, debt_in_hotspots, deep_nesting, hidden_coupling, unreferenced_files]
|
|
737
1028
|
|
|
738
1029
|
|
|
739
1030
|
def evaluate(report: dict) -> list:
|