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.
Files changed (81) hide show
  1. {gitmole-0.14.0 → gitmole-0.16.0}/PKG-INFO +14 -1
  2. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/__init__.py +1 -1
  3. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/cli.py +17 -4
  4. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/findings.py +292 -1
  5. gitmole-0.16.0/gitmole/hygiene.py +506 -0
  6. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/leaks.py +69 -2
  7. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/load.py +4 -0
  8. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/render.py +45 -3
  9. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/run.py +12 -2
  10. gitmole-0.16.0/gitmole/sarif.py +203 -0
  11. gitmole-0.16.0/gitmole/signing.py +127 -0
  12. gitmole-0.16.0/gitmole/structure.py +614 -0
  13. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/watch.py +26 -3
  14. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole.egg-info/PKG-INFO +14 -1
  15. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole.egg-info/SOURCES.txt +8 -0
  16. gitmole-0.16.0/gitmole.egg-info/requires.txt +21 -0
  17. {gitmole-0.14.0 → gitmole-0.16.0}/pyproject.toml +16 -0
  18. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_cli.py +40 -0
  19. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_findings.py +153 -0
  20. gitmole-0.16.0/tests/test_hygiene.py +220 -0
  21. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_leaks.py +81 -0
  22. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_load.py +13 -0
  23. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_render.py +41 -0
  24. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_run.py +19 -1
  25. gitmole-0.16.0/tests/test_sarif.py +100 -0
  26. gitmole-0.16.0/tests/test_signing.py +69 -0
  27. gitmole-0.16.0/tests/test_structure.py +154 -0
  28. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_watch.py +12 -0
  29. gitmole-0.14.0/gitmole.egg-info/requires.txt +0 -5
  30. {gitmole-0.14.0 → gitmole-0.16.0}/LICENSE +0 -0
  31. {gitmole-0.14.0 → gitmole-0.16.0}/README.md +0 -0
  32. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/__main__.py +0 -0
  33. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/backtest.py +0 -0
  34. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/banner.py +0 -0
  35. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/blame.py +0 -0
  36. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/classify.py +0 -0
  37. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/clean.py +0 -0
  38. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/compare.py +0 -0
  39. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/coupling.py +0 -0
  40. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/deps.py +0 -0
  41. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/duplicates.py +0 -0
  42. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/evaluate.py +0 -0
  43. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/filetypes.py +0 -0
  44. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/functions.py +0 -0
  45. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/hook.py +0 -0
  46. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/hotspots.py +0 -0
  47. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/identity.py +0 -0
  48. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/knowledge.py +0 -0
  49. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/loss.py +0 -0
  50. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/maat.py +0 -0
  51. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/szz.py +0 -0
  52. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/textfmt.py +0 -0
  53. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole/trend.py +0 -0
  54. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole.egg-info/dependency_links.txt +0 -0
  55. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole.egg-info/entry_points.txt +0 -0
  56. {gitmole-0.14.0 → gitmole-0.16.0}/gitmole.egg-info/top_level.txt +0 -0
  57. {gitmole-0.14.0 → gitmole-0.16.0}/setup.cfg +0 -0
  58. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_backtest.py +0 -0
  59. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_banner.py +0 -0
  60. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_blame.py +0 -0
  61. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_classify.py +0 -0
  62. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_clean.py +0 -0
  63. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_compare.py +0 -0
  64. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_coupling.py +0 -0
  65. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_deps.py +0 -0
  66. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_duplicates.py +0 -0
  67. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_evaluate.py +0 -0
  68. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_filetypes.py +0 -0
  69. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_functions.py +0 -0
  70. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_golden.py +0 -0
  71. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_hook.py +0 -0
  72. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_hotspots.py +0 -0
  73. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_identity.py +0 -0
  74. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_knowledge.py +0 -0
  75. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_loss.py +0 -0
  76. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_maat.py +0 -0
  77. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_packaging.py +0 -0
  78. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_render_examples.py +0 -0
  79. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_szz.py +0 -0
  80. {gitmole-0.14.0 → gitmole-0.16.0}/tests/test_textfmt.py +0 -0
  81. {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.14.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">
@@ -1,3 +1,3 @@
1
1
  """gitmole: offline git repository analysis with a terminal report."""
2
2
 
3
- __version__ = "0.14.0"
3
+ __version__ = "0.16.0"
@@ -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) -> int:
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 "-" not in (args.json, args.markdown):
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: