gitmole 0.16.0__tar.gz → 0.17.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 (82) hide show
  1. {gitmole-0.16.0 → gitmole-0.17.0}/PKG-INFO +1 -1
  2. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/__init__.py +1 -1
  3. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/cli.py +4 -1
  4. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/duplicates.py +44 -2
  5. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/findings.py +68 -1
  6. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/load.py +6 -2
  7. gitmole-0.17.0/gitmole/provenance.py +241 -0
  8. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/render.py +29 -2
  9. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/run.py +5 -3
  10. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/PKG-INFO +1 -1
  11. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/SOURCES.txt +2 -0
  12. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_duplicates.py +13 -0
  13. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_findings.py +40 -0
  14. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_load.py +6 -0
  15. gitmole-0.17.0/tests/test_provenance.py +128 -0
  16. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_render.py +26 -0
  17. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_run.py +9 -1
  18. {gitmole-0.16.0 → gitmole-0.17.0}/LICENSE +0 -0
  19. {gitmole-0.16.0 → gitmole-0.17.0}/README.md +0 -0
  20. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/__main__.py +0 -0
  21. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/backtest.py +0 -0
  22. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/banner.py +0 -0
  23. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/blame.py +0 -0
  24. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/classify.py +0 -0
  25. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/clean.py +0 -0
  26. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/compare.py +0 -0
  27. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/coupling.py +0 -0
  28. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/deps.py +0 -0
  29. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/evaluate.py +0 -0
  30. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/filetypes.py +0 -0
  31. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/functions.py +0 -0
  32. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/hook.py +0 -0
  33. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/hotspots.py +0 -0
  34. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/hygiene.py +0 -0
  35. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/identity.py +0 -0
  36. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/knowledge.py +0 -0
  37. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/leaks.py +0 -0
  38. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/loss.py +0 -0
  39. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/maat.py +0 -0
  40. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/sarif.py +0 -0
  41. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/signing.py +0 -0
  42. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/structure.py +0 -0
  43. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/szz.py +0 -0
  44. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/textfmt.py +0 -0
  45. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/trend.py +0 -0
  46. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole/watch.py +0 -0
  47. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/dependency_links.txt +0 -0
  48. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/entry_points.txt +0 -0
  49. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/requires.txt +0 -0
  50. {gitmole-0.16.0 → gitmole-0.17.0}/gitmole.egg-info/top_level.txt +0 -0
  51. {gitmole-0.16.0 → gitmole-0.17.0}/pyproject.toml +0 -0
  52. {gitmole-0.16.0 → gitmole-0.17.0}/setup.cfg +0 -0
  53. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_backtest.py +0 -0
  54. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_banner.py +0 -0
  55. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_blame.py +0 -0
  56. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_classify.py +0 -0
  57. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_clean.py +0 -0
  58. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_cli.py +0 -0
  59. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_compare.py +0 -0
  60. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_coupling.py +0 -0
  61. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_deps.py +0 -0
  62. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_evaluate.py +0 -0
  63. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_filetypes.py +0 -0
  64. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_functions.py +0 -0
  65. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_golden.py +0 -0
  66. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_hook.py +0 -0
  67. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_hotspots.py +0 -0
  68. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_hygiene.py +0 -0
  69. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_identity.py +0 -0
  70. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_knowledge.py +0 -0
  71. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_leaks.py +0 -0
  72. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_loss.py +0 -0
  73. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_maat.py +0 -0
  74. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_packaging.py +0 -0
  75. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_render_examples.py +0 -0
  76. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_sarif.py +0 -0
  77. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_signing.py +0 -0
  78. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_structure.py +0 -0
  79. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_szz.py +0 -0
  80. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_textfmt.py +0 -0
  81. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_trend.py +0 -0
  82. {gitmole-0.16.0 → gitmole-0.17.0}/tests/test_watch.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.16.0
3
+ Version: 0.17.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
@@ -1,3 +1,3 @@
1
1
  """gitmole: offline git repository analysis with a terminal report."""
2
2
 
3
- __version__ = "0.16.0"
3
+ __version__ = "0.17.0"
@@ -369,6 +369,9 @@ def _meta_for_run(repo_dir: str, args, estimate, age_ok: bool, plots_ok: bool, p
369
369
  from . import maat as _maat
370
370
  cut = _maat.months_before(meta["last_date"], 6) if meta["last_date"] else None
371
371
  first = meta.get("first_date_all") or meta["first_date"] # the backtest reads the whole history, window or not
372
+ year = _maat.months_before(meta["last_date"], 12) if meta["last_date"] else None
373
+ if year and first and first <= year:
374
+ meta["duplicates"]["then"] = year # the rate a year back, when the history reaches it
372
375
  if cut and first and first <= _maat.months_before(cut, 6):
373
376
  meta["backtest"] = {"status": "planned", "until": cut}
374
377
  else:
@@ -427,7 +430,7 @@ def _analyse(repo_dir: str, out_dir: str, args, ui: Console, planner, estimator)
427
430
  run.clear_outputs(out_dir)
428
431
  steps = planner(repo_dir, out_dir, branch=meta["branch"], age=age_ok, plots=plots_ok, ignore=ignore, types=types_spec, now=args.now,
429
432
  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))
433
+ structure=getattr(args, "structure", False), duplicates_then=meta["duplicates"].get("then"))
431
434
  run.save_meta(meta, out_dir)
432
435
  results = _execute(steps, log_path, repo_dir, args.workers, ui, timeout=args.timeout)
433
436
  if _control.cancelled.is_set():
@@ -6,8 +6,10 @@ jscpd walks the working tree and reports every pair of matching fragments. This
6
6
  whose two sides are both tracked files inside the analysed types, folds the pairs of one fragment into
7
7
  a block with all its places, measures the duplicated share over the kept files, and writes
8
8
  duplicates.json. jscpd's own report quotes every fragment, so it is written to a temporary directory
9
- under OUT and removed before this returns: no source text lands in the output directory. Standalone,
10
- like maat.py and blame.py.
9
+ under OUT and removed before this returns: no source text lands in the output directory. With --then
10
+ DATE the tree at the last commit before that date is exported under OUT, measured the same way and
11
+ removed, so the rate has a direction: GitClear's longitudinal data shows duplication is where the
12
+ change is, and one number without the year before it says little. Standalone, like maat.py and blame.py.
11
13
  """
12
14
  from __future__ import annotations
13
15
 
@@ -118,6 +120,40 @@ def run_jscpd(repo: str, out: str, procs: int, ignore=()) -> tuple:
118
120
  shutil.rmtree(tmp, ignore_errors=True)
119
121
 
120
122
 
123
+ def rev_before(repo: str, date: str):
124
+ out = subprocess.run(["git", "rev-list", "-1", f"--before={date}T00:00:00", "HEAD"], cwd=repo, capture_output=True, text=True)
125
+ return out.stdout.strip() or None
126
+
127
+
128
+ def files_at(repo: str, rev: str, ignore=(), types_spec: str = None) -> list:
129
+ """The tracked text files of the tree at `rev`, in the analysed types, as select_files lists HEAD's."""
130
+ import fnmatch
131
+ proc = subprocess.run([*filetypes.GIT, "grep", "-I", "--name-only", "-z", "-e", "", rev], cwd=repo, capture_output=True)
132
+ types = filetypes.parse(types_spec)
133
+ paths = sorted(p.decode("utf-8", "surrogateescape").split(":", 1)[1] for p in proc.stdout.split(b"\0") if p)
134
+ return [f for f in paths if filetypes.matches(f, types) and not any(fnmatch.fnmatch(f, g) for g in ignore)]
135
+
136
+
137
+ def rate_at(repo: str, out: str, rev: str, procs: int, ignore=(), types_spec: str = None):
138
+ """The duplicated share of the tree at `rev`: the tree exported through a temporary index under
139
+ `out` (as the backtest exports its cut-off), jscpd over it, the same fold and rate as HEAD's."""
140
+ tmp = tempfile.mkdtemp(prefix=".dup-then-", dir=out)
141
+ try:
142
+ tree = os.path.join(tmp, "tree")
143
+ os.makedirs(tree)
144
+ env = dict(os.environ, GIT_INDEX_FILE=os.path.join(tmp, "index"))
145
+ subprocess.run(["git", "read-tree", rev], cwd=repo, env=env, check=True, capture_output=True)
146
+ subprocess.run(["git", "checkout-index", "-a", f"--prefix={tree}/"], cwd=repo, env=env, check=True, capture_output=True)
147
+ files = files_at(repo, rev, ignore, types_spec)
148
+ rc, report = run_jscpd(tree, out, procs, ignore)
149
+ if rc != 0:
150
+ return None
151
+ blocks = fold(report.get("duplicates") or [], set(files))
152
+ return {"files": len(files), "rate": rate(blocks, tree, files)}
153
+ finally:
154
+ shutil.rmtree(tmp, ignore_errors=True)
155
+
156
+
121
157
  def write(result: dict, target: str) -> None:
122
158
  fd, tmp = tempfile.mkstemp(dir=os.path.dirname(os.path.abspath(target)), prefix=".duplicates-", suffix=".json")
123
159
  try:
@@ -136,6 +172,7 @@ def main(argv=None) -> int:
136
172
  p.add_argument("--procs", type=int, default=1)
137
173
  p.add_argument("--ignore", action="append", default=[])
138
174
  p.add_argument("--types", default=None, help="file types spec as for gitmole --file-types")
175
+ p.add_argument("--then", metavar="YYYY-MM-DD", help="also measure the tree at the last commit before this date, for the direction")
139
176
  args = p.parse_args(argv)
140
177
  repo, out = os.path.abspath(args.repo), os.path.abspath(args.out)
141
178
  files = select_files(repo, args.ignore, args.types)
@@ -145,6 +182,11 @@ def main(argv=None) -> int:
145
182
  blocks = fold(report.get("duplicates") or [], set(files))
146
183
  result = {"tool": "jscpd", "files": len(files), "clones": sum(len(b["places"]) - 1 for b in blocks),
147
184
  "rate": rate(blocks, repo, files), "blocks": blocks[:BLOCKS_KEPT]}
185
+ if args.then:
186
+ rev = rev_before(repo, args.then)
187
+ measured = rate_at(repo, out, rev, args.procs, args.ignore, args.types) if rev else None
188
+ if measured:
189
+ result["then"] = {"date": args.then, "rev": rev[:12], **measured}
148
190
  write(result, os.path.join(out, "duplicates.json"))
149
191
  return 0
150
192
 
@@ -1022,9 +1022,76 @@ def unreferenced_files(report: dict) -> list:
1022
1022
  rule={"id": "unreferenced_files", "ref": "Romano et al., TSE 2020"}, evidence={"count": n, "files": paths[:10]})]
1023
1023
 
1024
1024
 
1025
+ def _agents(report: dict) -> dict:
1026
+ return ((report.get("provenance") or {}).get("agents")) or {}
1027
+
1028
+
1029
+ def agent_approval_disabled(report: dict) -> list:
1030
+ """A committed agent configuration that turns approval prompts off: every clone that picks the
1031
+ settings up runs the agent's tools without asking."""
1032
+ rows = _agents(report).get("approval_disabled") or []
1033
+ if not rows:
1034
+ return []
1035
+ listed = "; ".join(f"{r['file']} sets {r['setting']}" for r in rows)
1036
+ return [_f("warning", "Agent approval prompts turned off in the repository", f"{listed}. Anyone who opens the clone with that agent runs its tools unasked.",
1037
+ "Move the setting to your personal settings file, which is not committed, and keep the shared one to what everyone should get.",
1038
+ rule={"id": "agent_approval_disabled", "by": "tracked agent settings"}, evidence={"settings": rows})]
1039
+
1040
+
1041
+ def agent_local_settings(report: dict) -> list:
1042
+ rows = _agents(report).get("local_settings") or []
1043
+ if not rows:
1044
+ return []
1045
+ return [_f("warning", "Personal agent settings tracked", f"{_files_list(rows)} {'is' if len(rows) == 1 else 'are'} tracked; the file is meant for one machine and to stay out of git.",
1046
+ f"git rm --cached {rows[0]} and add it to .gitignore.",
1047
+ rule={"id": "agent_local_settings", "by": "path convention"}, evidence={"files": rows})]
1048
+
1049
+
1050
+ def mcp_literal_env(report: dict) -> list:
1051
+ """An MCP server declaration whose environment holds a literal value long enough to be a
1052
+ credential rather than a ${VAR} reference. The key is named, the value never."""
1053
+ rows = [(m["file"], x) for m in _agents(report).get("mcp") or [] for x in m.get("literal_env") or []]
1054
+ if not rows:
1055
+ return []
1056
+ listed = "; ".join(f"{x['server']} sets {x['key']} in {f} to a literal value" for f, x in rows[:5])
1057
+ f0, x0 = rows[0]
1058
+ return [_f("warning", "Literal values in MCP server declarations", f"{listed}. A committed declaration shares whatever it holds.",
1059
+ f"Replace the value of {x0['key']} with ${{{x0['key']}}} and set it in the environment; rotate it if it was a live credential.",
1060
+ rule={"id": "mcp_literal_env", "min_length": 16, "placeholders": "left out"},
1061
+ evidence={"entries": [{"file": f, "server": x["server"], "key": x["key"]} for f, x in rows[:10]]})]
1062
+
1063
+
1064
+ def agent_instructions_drift(report: dict, min_months: int = 6, min_commits: int = 100) -> list:
1065
+ """Agent instruction files (AGENTS.md and the like) far behind the code they describe."""
1066
+ last = (report.get("meta") or {}).get("last_date") or ""
1067
+ stale = [r for r in _agents(report).get("instructions") or []
1068
+ if last and _months_apart(r["last"], last) >= min_months and r["commits_behind"] >= min_commits]
1069
+ if not stale:
1070
+ return []
1071
+ listed = "; ".join(f"{r['file']} last changed on {r['last']}, {_months_apart(r['last'], last)} months and {r['commits_behind']:,} commits before the last commit" for r in stale)
1072
+ return [_f("info", "Agent instructions behind the code", f"{listed}.",
1073
+ f"Read {stale[0]['file']} against the tree and update what moved; an agent follows it literally.",
1074
+ rule={"id": "agent_instructions_drift", "min_months": min_months, "min_commits": min_commits},
1075
+ evidence={"files": stale})]
1076
+
1077
+
1078
+ def signoff_by_co_author(report: dict, min_commits: int = 2) -> list:
1079
+ """An identity that signs off commits it co-authors but never authors one: the Linux kernel's policy
1080
+ on coding assistants forbids an agent to add Signed-off-by, since the DCO is a human's statement."""
1081
+ rows = [r for r in ((report.get("provenance") or {}).get("trailers") or {}).get("signoff_by_co_author") or [] if r["commits"] >= min_commits]
1082
+ if not rows:
1083
+ return []
1084
+ listed = "; ".join(f"{r['name']} <{r['email']}> signs off {_plural(r['commits'], 'commit')} but never authors one" for r in rows[:3])
1085
+ return [_f("info", "Sign-offs by identities that only co-author", f"{listed}.",
1086
+ "A Signed-off-by line certifies the Developer Certificate of Origin; have a person who authors commits add it.",
1087
+ rule={"id": "signoff_by_co_author", "min_commits": min_commits, "ref": "Linux kernel, Documentation/process/coding-assistants.rst"},
1088
+ evidence={"identities": rows[:10]})]
1089
+
1090
+
1025
1091
  RULES = [dormant, secrets_found, credential_files, vulnerable_dependencies, placeholder_identity, bus_factor, sizer_concerns, hotspot_dominance, bug_magnets,
1026
1092
  minor_contributors, reverts, brain_methods, complexity_growth, tight_coupling, duplication, stale_files, knowledge_islands, knowledge_loss,
1027
- sweeping_commits, tangled_commits, hygiene_findings, debt_in_hotspots, deep_nesting, hidden_coupling, unreferenced_files]
1093
+ sweeping_commits, tangled_commits, hygiene_findings, debt_in_hotspots, deep_nesting, hidden_coupling, unreferenced_files,
1094
+ agent_approval_disabled, agent_local_settings, mcp_literal_env, agent_instructions_drift, signoff_by_co_author]
1028
1095
 
1029
1096
 
1030
1097
  def evaluate(report: dict) -> list:
@@ -197,7 +197,10 @@ def parse_duplicates_json(data) -> dict | None:
197
197
  blocks = [{"lines": _num(b.get("lines")), "places": sorted(tuple(p[:3]) for p in b.get("places") or [] if len(p) >= 3)}
198
198
  for b in data.get("blocks") or []]
199
199
  rate = data.get("rate")
200
- return {"rate": float(rate) if rate is not None else None, "blocks": blocks, "files": _num(data.get("files"))}
200
+ out = {"rate": float(rate) if rate is not None else None, "blocks": blocks, "files": _num(data.get("files"))}
201
+ if isinstance(data.get("then"), dict):
202
+ out["then"] = data["then"] # the rate at the last commit a year before: the direction
203
+ return out
201
204
 
202
205
 
203
206
  def parse_duplicates(text: str) -> dict:
@@ -331,6 +334,7 @@ def load_report(out_dir: str, nested: bool = True) -> dict:
331
334
  "signing": _read_json(out_dir, "signing.json", {}) or {}, # commit signing coverage; {} before the step or after a killed one
332
335
  "hygiene": _read_json(out_dir, "hygiene.json", {}) or {}, # the hygiene checks (hygiene.py); {} before 0.15
333
336
  "unreachable": _read_json(out_dir, "unreachable.json", {}) or {},
334
- "structure": _read_json(out_dir, "structure.json", {}) or {}, # tree-sitter metrics (structure.py); {} without gitmole[structure] # what the secrets step found outside reachable history
337
+ "structure": _read_json(out_dir, "structure.json", {}) or {},
338
+ "provenance": _read_json(out_dir, "provenance.json", {}) or {}, # trailers, cohorts, commit shape, agent files; {} before 0.17 # tree-sitter metrics (structure.py); {} without gitmole[structure] # what the secrets step found outside reachable history
335
339
  "backtest": _nested(out_dir) if nested else None,
336
340
  }
@@ -0,0 +1,241 @@
1
+ """Provenance: what the history declares about how its commits were made, and the agent configuration
2
+ the tree declares, read and never inferred.
3
+
4
+ Runs as a pipeline step, `python -m gitmole.provenance OUT_DIR`, from inside the repository, and
5
+ writes provenance.json:
6
+
7
+ - trailers: every trailer key and how many commits carry it; the co-authors who never author a commit
8
+ here (a structural fact about the repository, no list of products); sign-offs by such identities,
9
+ which the Linux kernel's policy on coding assistants forbids an agent to add.
10
+ - cohort: commits with an `Assisted-by` trailer or a never-authoring co-author, against the rest: how
11
+ many were reverted (by git's own `Revert "subject"`), how many are fixes, and how many had a file
12
+ changed again by another commit within two weeks. This repository against itself, with the share
13
+ of commits the cohort covers beside it; no prior from elsewhere, since the best-controlled study
14
+ found the spread between agents larger than the pooled difference.
15
+ - shape: neutral descriptors (commits landing in bursts, conventional-commit subjects, how many hours
16
+ of the day commits come in). Every one has a benign cause, and none is labelled.
17
+ - agents: the agent instruction files by path convention (AGENTS.md, CLAUDE.md, GEMINI.md,
18
+ .github/copilot-instructions.md) and how far behind HEAD each is, the hook files that declare
19
+ guardrails, tracked personal settings, settings that turn approval prompts off, and MCP server
20
+ declarations whose environment carries literal values rather than references. Values are never
21
+ written."""
22
+ from __future__ import annotations
23
+
24
+ import bisect
25
+ import datetime as dt
26
+ import json
27
+ import os
28
+ import re
29
+ import subprocess
30
+ import sys
31
+ from collections import Counter
32
+
33
+ try:
34
+ from . import filetypes, leaks
35
+ except ImportError: # run as a script: the package directory is sys.path[0]
36
+ import filetypes
37
+ import leaks
38
+
39
+ SEP, END = "\x1f", "\x1e"
40
+ # a trailer key is hyphenated by convention (Co-authored-by, Signed-off-by, Change-Id); git's parser also
41
+ # accepts a URL or a line of prose that happens to end the message, and those are not trailers
42
+ _TRAILER_LINE = re.compile(r"^([A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+):\s*(?!//)(.+)$")
43
+ _IDENT = re.compile(r"^\s*(?P<name>[^<]*?)\s*<(?P<email>[^>]*)>\s*$")
44
+ _CONVENTIONAL = re.compile(r"^(feat|fix|docs|style|refactor|perf|test|build|ci|chore|revert)(\([^)]*\))?!?: \S")
45
+ BURST_SIZE, BURST_SECONDS = 5, 600
46
+ RETOUCH_DAYS = 14
47
+
48
+
49
+ def read_commits(repo: str) -> list:
50
+ """[{hash, time, author, email, subject, trailers: [(key, value)], files}] of HEAD's history, oldest
51
+ first, through one git log: trailers by git's own parser, files from --name-only."""
52
+ fmt = f"{END}%H{SEP}%at{SEP}%aN{SEP}%aE{SEP}%s{SEP}%(trailers:unfold,only){SEP}"
53
+ out = subprocess.run([*filetypes.GIT, "log", "HEAD", "--use-mailmap", "--name-only", f"--format={fmt}"], cwd=repo,
54
+ capture_output=True, check=True).stdout.decode("utf-8", "replace")
55
+ commits = []
56
+ for chunk in out.split(END)[1:]:
57
+ parts = chunk.split(SEP)
58
+ if len(parts) < 7:
59
+ continue
60
+ h, at, name, email, subject, trailer_text, files = parts[:7]
61
+ trailers = [(m.group(1), m.group(2).strip()) for m in (_TRAILER_LINE.match(l) for l in trailer_text.split("\n")) if m]
62
+ commits.append({"hash": h, "time": int(at), "author": name, "email": email.lower(), "subject": subject,
63
+ "trailers": trailers, "files": [f for f in files.split("\n") if f.strip()]})
64
+ commits.reverse()
65
+ return commits
66
+
67
+
68
+ def _ident(value: str):
69
+ m = _IDENT.match(value)
70
+ return (m.group("name"), m.group("email").lower()) if m else None
71
+
72
+
73
+ def trailers(commits: list) -> dict:
74
+ keys = Counter()
75
+ authors = {c["email"] for c in commits} | {c["author"] for c in commits}
76
+ co, signed = Counter(), Counter()
77
+ names = {}
78
+ for c in commits:
79
+ for k in {k for k, _ in c["trailers"]}:
80
+ keys[k] += 1
81
+ for k, v in c["trailers"]:
82
+ ident = _ident(v)
83
+ if not ident:
84
+ continue
85
+ names.setdefault(ident[1], ident[0])
86
+ if k.lower() == "co-authored-by":
87
+ co[ident[1]] += 1
88
+ elif k.lower() == "signed-off-by":
89
+ signed[ident[1]] += 1
90
+ never = {e for e in co if e not in authors and names[e] not in authors}
91
+ listing = sorted(({"name": names[e], "email": e, "commits": co[e]} for e in never), key=lambda x: (-x["commits"], x["name"]))
92
+ signoff = sorted(({"name": names[e], "email": e, "commits": signed[e]} for e in never if signed.get(e)), key=lambda x: (-x["commits"], x["name"]))
93
+ return {"commits": len(commits), "keys": dict(sorted(keys.items(), key=lambda kv: (-kv[1], kv[0]))[:25]),
94
+ "with_any": sum(1 for c in commits if c["trailers"]), "never_author": listing[:50], "signoff_by_co_author": signoff[:50]}
95
+
96
+
97
+ def cohort(commits: list, inventory: dict) -> dict:
98
+ """The commits an `Assisted-by` trailer or a never-authoring co-author marks, against the rest."""
99
+ marked_emails = {x["email"] for x in inventory["never_author"]}
100
+
101
+ def marked(c):
102
+ for k, v in c["trailers"]:
103
+ if k.lower() == "assisted-by":
104
+ return True
105
+ ident = _ident(v)
106
+ if k.lower() == "co-authored-by" and ident and ident[1] in marked_emails:
107
+ return True
108
+ return False
109
+
110
+ reverted = {c["subject"][len('Revert "'):-1] for c in commits if c["subject"].startswith('Revert "') and c["subject"].endswith('"')}
111
+ by_file = {}
112
+ for i, c in enumerate(commits):
113
+ for f in c["files"]:
114
+ by_file.setdefault(f, []).append(i)
115
+ stats = {True: Counter(), False: Counter()}
116
+ for i, c in enumerate(commits):
117
+ s = stats[marked(c)]
118
+ s["commits"] += 1
119
+ s["reverted"] += c["subject"] in reverted
120
+ s["fixes"] += bool(re.match(r"^(fix|hotfix|bugfix)(\([^)]*\))?!?:", c["subject"], re.I) or re.search(r"\b(fix|fixes|fixed|bug)\b", c["subject"], re.I))
121
+ again = False
122
+ for f in c["files"]: # the next commit to touch each file, by position: the list is in commit order
123
+ k = bisect.bisect_right(by_file[f], i)
124
+ if k < len(by_file[f]) and commits[by_file[f][k]]["time"] - c["time"] <= RETOUCH_DAYS * 86400:
125
+ again = True
126
+ break
127
+ s["retouched"] += again
128
+ total = len(commits)
129
+ return {"definition": "an Assisted-by trailer, or a co-author who never authors a commit here",
130
+ "share": round(stats[True]["commits"] / total, 3) if total else 0.0,
131
+ "cohort": dict(stats[True]) or {"commits": 0}, "rest": dict(stats[False]) or {"commits": 0}}
132
+
133
+
134
+ def shape(commits: list) -> dict:
135
+ """How commits arrive, as neutral numbers: the share landing in runs of five or more by one author
136
+ within ten minutes of each other, the share with conventional-commit subjects, and how many hours
137
+ of the day (in UTC) see commits."""
138
+ burst = set()
139
+ by_author = {}
140
+ for i, c in enumerate(commits):
141
+ by_author.setdefault(c["email"], []).append(i)
142
+ for idx in by_author.values():
143
+ run = [idx[0]]
144
+ for a, b in zip(idx, idx[1:]):
145
+ if commits[b]["time"] - commits[a]["time"] <= BURST_SECONDS:
146
+ run.append(b)
147
+ else:
148
+ if len(run) >= BURST_SIZE:
149
+ burst.update(run)
150
+ run = [b]
151
+ if len(run) >= BURST_SIZE:
152
+ burst.update(run)
153
+ n = len(commits)
154
+ hours = {dt.datetime.fromtimestamp(c["time"], dt.timezone.utc).hour for c in commits}
155
+ return {"burst_share": round(len(burst) / n, 3) if n else 0.0,
156
+ "conventional_share": round(sum(bool(_CONVENTIONAL.match(c["subject"])) for c in commits) / n, 3) if n else 0.0,
157
+ "hours_used": len(hours)}
158
+
159
+
160
+ INSTRUCTIONS = ("AGENTS.md", "CLAUDE.md", "GEMINI.md", ".github/copilot-instructions.md")
161
+ GUARDRAILS = (".claude/settings.json", ".cursor/hooks.json")
162
+ MCP = (".mcp.json", ".cursor/mcp.json", ".vscode/mcp.json")
163
+ LOCAL = (".claude/settings.local.json",)
164
+ _REFERENCE = re.compile(r"^\$\{?[A-Za-z_][A-Za-z0-9_]*\}?$|^\$\{[^}]+\}$|^\{\{.*\}\}$")
165
+
166
+
167
+ def _json(repo: str, path: str):
168
+ try:
169
+ with open(os.path.join(repo, path), encoding="utf-8", errors="replace") as fh:
170
+ return json.load(fh)
171
+ except (OSError, ValueError):
172
+ return None
173
+
174
+
175
+ def _literal_secret(value) -> bool:
176
+ """A literal environment value that could be a credential: a string, not a ${VAR} reference, not a
177
+ placeholder shape, and long enough to be a key (a short word is configuration)."""
178
+ if not isinstance(value, str):
179
+ return False
180
+ v = value.strip()
181
+ return len(v) >= 16 and not _REFERENCE.match(v) and not leaks.is_placeholder(v)
182
+
183
+
184
+ def agents(repo: str) -> dict:
185
+ tracked = set(filetypes.git_paths(repo, "ls-files"))
186
+ head_count = int(subprocess.run(["git", "rev-list", "--count", "HEAD"], cwd=repo, capture_output=True, text=True).stdout.strip() or 0)
187
+ instructions = []
188
+ for path in sorted(p for p in tracked if p.rsplit("/", 1)[-1] in ("AGENTS.md", "CLAUDE.md", "GEMINI.md") or p in INSTRUCTIONS):
189
+ last = subprocess.run(["git", "log", "-1", "--format=%H%x1f%cs", "--", path], cwd=repo, capture_output=True, text=True).stdout.strip()
190
+ if not last:
191
+ continue
192
+ sha, day = last.split("\x1f")
193
+ behind = int(subprocess.run(["git", "rev-list", "--count", f"{sha}..HEAD"], cwd=repo, capture_output=True, text=True).stdout.strip() or 0)
194
+ instructions.append({"file": path, "last": day, "commits_behind": behind})
195
+ guard, disabled = [], []
196
+ for path in GUARDRAILS:
197
+ data = _json(repo, path) if path in tracked else None
198
+ if not isinstance(data, dict):
199
+ continue
200
+ if data.get("hooks"):
201
+ guard.append(path)
202
+ perms = data.get("permissions") if isinstance(data.get("permissions"), dict) else {}
203
+ if perms.get("defaultMode") == "bypassPermissions":
204
+ disabled.append({"file": path, "setting": "permissions.defaultMode=bypassPermissions"})
205
+ if data.get("skipDangerousModePermissionPrompt") is True:
206
+ disabled.append({"file": path, "setting": "skipDangerousModePermissionPrompt=true"})
207
+ mcp = []
208
+ for path in MCP:
209
+ data = _json(repo, path) if path in tracked else None
210
+ if not isinstance(data, dict):
211
+ continue
212
+ servers = data.get("mcpServers") or data.get("servers") or {}
213
+ if not isinstance(servers, dict):
214
+ continue
215
+ literal = [{"server": name, "key": key} for name, spec in sorted(servers.items()) if isinstance(spec, dict)
216
+ for key, value in sorted((spec.get("env") or {}).items()) if _literal_secret(value)]
217
+ mcp.append({"file": path, "servers": len(servers), "literal_env": literal})
218
+ return {"instructions": instructions, "head_commits": head_count, "guardrails": guard, "approval_disabled": disabled,
219
+ "local_settings": sorted(p for p in tracked if p in LOCAL or p.endswith("/.claude/settings.local.json")), "mcp": mcp}
220
+
221
+
222
+ def main(argv=None) -> int:
223
+ args = sys.argv[1:] if argv is None else argv
224
+ if len(args) != 1:
225
+ print("usage: provenance.py OUT_DIR", file=sys.stderr)
226
+ return 2
227
+ repo = os.getcwd()
228
+ try:
229
+ commits = read_commits(repo)
230
+ except subprocess.CalledProcessError as e:
231
+ print(f"provenance.py: {(e.stderr or b'').decode('utf-8', 'replace').strip() or e}", file=sys.stderr)
232
+ return 1
233
+ inventory = trailers(commits)
234
+ result = {"trailers": inventory, "cohort": cohort(commits, inventory), "shape": shape(commits), "agents": agents(repo)}
235
+ with open(os.path.join(args[0], "provenance.json"), "w", encoding="utf-8") as fh:
236
+ json.dump(result, fh)
237
+ return 0
238
+
239
+
240
+ if __name__ == "__main__":
241
+ sys.exit(main())
@@ -289,6 +289,12 @@ def pulse(report: dict) -> list:
289
289
  signed = signing_phrase(report)
290
290
  if signed:
291
291
  out.append(signed)
292
+ dup = report.get("duplicates") or {}
293
+ then = dup.get("then") or {}
294
+ if dup.get("rate") is not None and then.get("rate") is not None and max(dup["rate"], then["rate"]) >= 0.5:
295
+ now, before = dup["rate"], then["rate"]
296
+ way = "as a year before" if round(now, 1) == round(before, 1) else f"{'up' if now > before else 'down'} from {before:.1f}% a year before"
297
+ out.append(f"{now:.1f}% of lines duplicated, {way}")
292
298
  return out
293
299
 
294
300
 
@@ -517,6 +523,27 @@ def signing_section(report: dict, full: bool = True, width=None) -> dict:
517
523
  return _section("Signing by year", columns, rows, caption="; ".join(parts))
518
524
 
519
525
 
526
+ def trailers_section(report: dict, full: bool = True, width=None) -> dict:
527
+ """The trailer keys the history carries, with the cohort comparison and the neutral commit-shape
528
+ descriptors below: --full and Markdown only. Read, never inferred; nothing is labelled."""
529
+ prov = report.get("provenance") or {}
530
+ tr, co, sh = prov.get("trailers") or {}, prov.get("cohort") or {}, prov.get("shape") or {}
531
+ columns = [("trailer", {}), ("commits", RIGHT), ("share", RIGHT)]
532
+ total = tr.get("commits") or 0
533
+ rows = [(k, n, _pct(n, total)) for k, n in (tr.get("keys") or {}).items()]
534
+ notes = []
535
+ marked, rest = co.get("cohort") or {}, co.get("rest") or {}
536
+ if marked.get("commits"):
537
+ def pair(key):
538
+ return f"{_pct(marked.get(key, 0), marked['commits'])} against {_pct(rest.get(key, 0), rest.get('commits') or 0)}"
539
+ notes.append(f"marked commits ({co.get('definition')}): {marked['commits']:,}, {round(100 * co.get('share', 0))}% of the history; "
540
+ f"reverted {pair('reverted')} for the rest, fixes {pair('fixes')}, a file changed again within two weeks {pair('retouched')}")
541
+ if sh:
542
+ notes.append(f"{round(100 * sh.get('burst_share', 0))}% of commits land in bursts of five or more within ten minutes; "
543
+ f"{round(100 * sh.get('conventional_share', 0))}% have conventional-commit subjects; commits come in {sh.get('hours_used', 0)} hours of the day")
544
+ return _section("Trailers", columns, rows, note=None if rows else "no trailers", caption="\n".join(notes) or None)
545
+
546
+
520
547
  def hotspots_section(report: dict, full: bool = True, width=None) -> dict:
521
548
  """Change frequency times size, Tornhill-style. Files no longer in the tree sort last. Drawn
522
549
  under `--full` and in the Markdown export only; the default terminal report leaves it to the
@@ -740,10 +767,10 @@ def compare_section(result: dict) -> dict:
740
767
 
741
768
 
742
769
  BUILDERS = [watch_section, size_section, people_section, knowledge_section, activity_section, timeline_section,
743
- hotspots_section, coupling_section, signing_section, age_section, functions_section, health_section]
770
+ hotspots_section, coupling_section, signing_section, trailers_section, age_section, functions_section, health_section]
744
771
  # `--full` and Markdown only: Size, Activity and Code age are interesting once and rarely change what you
745
772
  # do next; Hotspots ranks the files the watch list already leads with, by the same product.
746
- FULL_ONLY = {"size", "activity", "age", "hotspots", "signing"}
773
+ FULL_ONLY = {"size", "activity", "age", "hotspots", "signing", "trailers"}
747
774
 
748
775
 
749
776
  def sections(report: dict, full: bool = True, width=None) -> list:
@@ -195,7 +195,7 @@ def manifest(repo_dir: str, args, version_of=tool_version) -> dict:
195
195
  # Everything a run writes besides meta.json and run.log. Removed before each run so a reused
196
196
  # --out directory never shows a previous run's data as this run's (a step skipped or killed
197
197
  # this time would otherwise leave last time's file in place).
198
- OUTPUTS = ["size.json", "repo-health.txt", "secrets.json", "dependencies.json", "log.txt", "activity.json", "functions.csv", "signing.json", "hygiene.json", "unreachable.json", "structure.json",
198
+ OUTPUTS = ["size.json", "repo-health.txt", "secrets.json", "dependencies.json", "log.txt", "activity.json", "functions.csv", "signing.json", "hygiene.json", "unreachable.json", "structure.json", "provenance.json",
199
199
  "duplicates.json", "duplicates.txt", # duplicates.txt: what lizard's finder wrote before jscpd
200
200
  "theseus/cohorts.json", "theseus/authors.json", "theseus/survival.json", "code-age.png", "survival.png", "trend.json"]
201
201
  OUTPUT_GLOBS = ["maat-*.csv"]
@@ -242,7 +242,7 @@ def ignore_revs_files(repo_dir: str) -> list:
242
242
  def plan(repo_dir: str, out_dir: str, branch: str = "HEAD", age: bool = True, plots: bool = False,
243
243
  procs: int = None, interval: int = MONTH, ignore=(), types: str = None, now: str = None, since: str = None,
244
244
  lizard: bool = False, duplicates: bool = True, trend: bool = True, samples: int = 12, backtest: str = None,
245
- ignore_revs=(), structure: bool = False) -> list:
245
+ ignore_revs=(), structure: bool = False, duplicates_then: str = None) -> list:
246
246
  o = lambda name: os.path.join(out_dir, name) # noqa: E731
247
247
  log = o("log.txt")
248
248
  ignores = [x for pattern in ignore for x in ("--ignore", pattern)]
@@ -263,6 +263,7 @@ def plan(repo_dir: str, out_dir: str, branch: str = "HEAD", age: bool = True, pl
263
263
  {"name": "change analysis", "argv": [sys.executable, MAAT_SCRIPT, log, out_dir, *type_args, *(["--now", now] if now else []), *(["--since", since] if since else []), "--aliases", o("meta.json"), *revs_args], "stdout": None, "deps": ["git-log"]},
264
264
  {"name": "signing", "argv": [sys.executable, "-m", "gitmole.signing", out_dir], "stdout": None, "deps": []}, # the gpgsig headers, no keyring
265
265
  {"name": "hygiene", "argv": [sys.executable, "-m", "gitmole.hygiene", out_dir], "stdout": None, "deps": []}, # the Scorecard checks, from the clone
266
+ {"name": "provenance", "argv": [sys.executable, "-m", "gitmole.provenance", out_dir], "stdout": None, "deps": []}, # trailers, cohorts, agent files
266
267
  ]
267
268
  workers = procs or blame.default_procs()
268
269
  if lizard:
@@ -271,7 +272,8 @@ def plan(repo_dir: str, out_dir: str, branch: str = "HEAD", age: bool = True, pl
271
272
  if structure: # tree-sitter: nesting, cognitive complexity, debt markers, the import graph; gitmole[structure] only
272
273
  steps.append({"name": "structure", "argv": [sys.executable, "-m", "gitmole.structure", out_dir, "--procs", str(workers)], "stdout": None, "deps": []})
273
274
  if duplicates:
274
- steps.append({"name": "duplicates", "argv": [sys.executable, DUPLICATES_SCRIPT, repo_dir, out_dir, "--procs", str(workers), *ignores, *type_args],
275
+ steps.append({"name": "duplicates", "argv": [sys.executable, DUPLICATES_SCRIPT, repo_dir, out_dir, "--procs", str(workers), *ignores, *type_args,
276
+ *(["--then", duplicates_then] if duplicates_then else [])], # the rate a year back: a direction
275
277
  "stdout": None, "deps": []})
276
278
  if trend:
277
279
  steps.append({"name": "trend", "argv": [sys.executable, "-m", "gitmole.trend", out_dir, "--samples", str(samples)],
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: gitmole
3
- Version: 0.16.0
3
+ Version: 0.17.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
@@ -26,6 +26,7 @@ gitmole/leaks.py
26
26
  gitmole/load.py
27
27
  gitmole/loss.py
28
28
  gitmole/maat.py
29
+ gitmole/provenance.py
29
30
  gitmole/render.py
30
31
  gitmole/run.py
31
32
  gitmole/sarif.py
@@ -66,6 +67,7 @@ tests/test_load.py
66
67
  tests/test_loss.py
67
68
  tests/test_maat.py
68
69
  tests/test_packaging.py
70
+ tests/test_provenance.py
69
71
  tests/test_render.py
70
72
  tests/test_render_examples.py
71
73
  tests/test_run.py
@@ -103,6 +103,19 @@ class Script(unittest.TestCase):
103
103
  pair("src/a.py", "untracked.py", fragment="def f(x): secret sauce")],
104
104
  "statistics": {"total": {"percentage": 50.0}}}
105
105
 
106
+ def test_then_measures_the_tree_as_it_stood_at_a_date_as_well(self):
107
+ p, argv, cwd, written, leftovers, repo = self._run(self.REPORT, extra=["--then", "2099-01-01"])
108
+ self.assertEqual(p.returncode, 0, p.stderr)
109
+ then = written["then"]
110
+ self.assertEqual(then["date"], "2099-01-01")
111
+ self.assertEqual(len(then["rev"]), 12)
112
+ self.assertEqual(then["files"], written["files"], "the code files tracked at that commit: here the same as HEAD's")
113
+ self.assertEqual(then["rate"], written["rate"], "the same tree, the same rate")
114
+ self.assertEqual(leftovers, ["duplicates.json"], "the exported tree is removed again")
115
+ p, _, _, written, _, _ = self._run(self.REPORT, extra=["--then", "1990-01-01"])
116
+ self.assertEqual(p.returncode, 0, p.stderr)
117
+ self.assertNotIn("then", written, "no commit before that date: nothing to compare")
118
+
106
119
  def test_runs_jscpd_in_the_repo_and_writes_the_folded_blocks_without_the_fragments(self):
107
120
  p, argv, cwd, written, leftovers, repo = self._run(self.REPORT, extra=["--ignore", "vendor/**"])
108
121
  self.assertEqual(p.returncode, 0, p.stderr)
@@ -1207,3 +1207,43 @@ class Structure(unittest.TestCase):
1207
1207
  r = self.base()
1208
1208
  r["structure"] = {"status": "not-installed"}
1209
1209
  self.assertFalse({"debt_in_hotspots", "deep_nesting", "hidden_coupling", "unreferenced_files"} & set(self.by_id(r)))
1210
+
1211
+
1212
+ class AgentSurface(unittest.TestCase):
1213
+ def rep(self, agents=None, trailers=None):
1214
+ return report(provenance={"agents": agents or {}, "trailers": trailers or {"never_author": [], "signoff_by_co_author": []}},
1215
+ meta={"name": "r", "commits": 1000, "identities": [], "last_date": "2026-09-01"})
1216
+
1217
+ def by_id(self, r):
1218
+ return {f["rule"]["id"]: f for f in findings.evaluate(r)}
1219
+
1220
+ def test_approval_prompts_turned_off_and_personal_settings_tracked_are_warnings(self):
1221
+ found = self.by_id(self.rep(agents={"approval_disabled": [{"file": ".claude/settings.json", "setting": "permissions.defaultMode=bypassPermissions"}],
1222
+ "local_settings": [".claude/settings.local.json"]}))
1223
+ f = found["agent_approval_disabled"]
1224
+ self.assertEqual(f["severity"], "warning")
1225
+ self.assertIn(".claude/settings.json sets permissions.defaultMode=bypassPermissions", f["detail"])
1226
+ self.assertEqual(found["agent_local_settings"]["severity"], "warning")
1227
+ self.assertIn(".claude/settings.local.json is tracked", found["agent_local_settings"]["detail"])
1228
+
1229
+ def test_literal_values_in_an_mcp_declaration_name_the_key_never_the_value(self):
1230
+ f = self.by_id(self.rep(agents={"mcp": [{"file": ".mcp.json", "servers": 2, "literal_env": [{"server": "db", "key": "DB_URL"}]}]}))["mcp_literal_env"]
1231
+ self.assertEqual(f["severity"], "warning")
1232
+ self.assertIn("db sets DB_URL in .mcp.json to a literal value", f["detail"])
1233
+ self.assertIn("${DB_URL}", f["advice"])
1234
+
1235
+ def test_stale_instructions(self):
1236
+ found = self.by_id(self.rep(agents={"instructions": [{"file": "AGENTS.md", "last": "2025-01-01", "commits_behind": 640},
1237
+ {"file": "CLAUDE.md", "last": "2026-08-20", "commits_behind": 12}]}))
1238
+ f = found["agent_instructions_drift"]
1239
+ self.assertEqual(f["severity"], "info")
1240
+ self.assertIn("AGENTS.md last changed on 2025-01-01, 20 months and 640 commits before the last commit", f["detail"])
1241
+ self.assertNotIn("CLAUDE.md", f["detail"])
1242
+
1243
+ def test_a_sign_off_by_an_identity_that_only_co_authors(self):
1244
+ f = self.by_id(self.rep(trailers={"never_author": [], "signoff_by_co_author": [{"name": "Ghost", "email": "ghost@x.com", "commits": 3}]}))["signoff_by_co_author"]
1245
+ self.assertEqual(f["severity"], "info")
1246
+ self.assertIn("Ghost <ghost@x.com> signs off 3 commits but never authors one", f["detail"])
1247
+ self.assertEqual(self.by_id(self.rep()).get("signoff_by_co_author"), None)
1248
+ one = self.rep(trailers={"never_author": [], "signoff_by_co_author": [{"name": "Ghost", "email": "ghost@x.com", "commits": 1}]})
1249
+ self.assertIsNone(self.by_id(one).get("signoff_by_co_author"), "one commit is not a habit")
@@ -367,6 +367,11 @@ class LoadReport(unittest.TestCase):
367
367
  r = load.load_report(out)
368
368
  self.assertEqual(r["theseus_authors"], {"Bob": 90, "Ann": 10})
369
369
 
370
+ def test_the_duplication_rate_a_year_back_is_kept(self):
371
+ d = load.parse_duplicates_json({"rate": 6.1, "files": 10, "blocks": [], "then": {"date": "2025-09-17", "rev": "abc", "files": 9, "rate": 4.2}})
372
+ self.assertEqual(d["then"], {"date": "2025-09-17", "rev": "abc", "files": 9, "rate": 4.2})
373
+ self.assertNotIn("then", load.parse_duplicates_json({"rate": 6.1, "blocks": []}))
374
+
370
375
  def test_no_signing_file_is_an_empty_record(self):
371
376
  import os, tempfile
372
377
  with tempfile.TemporaryDirectory() as out:
@@ -376,6 +381,7 @@ class LoadReport(unittest.TestCase):
376
381
  self.assertEqual(load.load_report(out)["hygiene"], {})
377
382
  self.assertEqual(load.load_report(out)["unreachable"], {})
378
383
  self.assertEqual(load.load_report(out)["structure"], {})
384
+ self.assertEqual(load.load_report(out)["provenance"], {})
379
385
 
380
386
  def test_missing_optional_file_gives_empty_value(self):
381
387
  import tempfile
@@ -0,0 +1,128 @@
1
+ import json
2
+ import os
3
+ import subprocess
4
+ import sys
5
+ import tempfile
6
+ import unittest
7
+
8
+ from gitmole import provenance
9
+
10
+ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
11
+
12
+
13
+ class Repo:
14
+ def __init__(self, d):
15
+ self.d = d
16
+ self.git("init", "-q", "-b", "main")
17
+
18
+ def git(self, *args, name="Ann", email="ann@x.com", date="2026-01-05T10:00:00"):
19
+ date = date + "+00:00" # explicit: the hour count must not depend on the machine's time zone
20
+ env = dict(os.environ, GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null", GIT_AUTHOR_NAME=name, GIT_AUTHOR_EMAIL=email,
21
+ GIT_COMMITTER_NAME=name, GIT_COMMITTER_EMAIL=email, GIT_AUTHOR_DATE=date, GIT_COMMITTER_DATE=date)
22
+ return subprocess.run(["git", *args], cwd=self.d, check=True, capture_output=True, env=env, text=True).stdout
23
+
24
+ def commit(self, path, text, message, **who):
25
+ full = os.path.join(self.d, path)
26
+ os.makedirs(os.path.dirname(full) or self.d, exist_ok=True)
27
+ with open(full, "a") as fh:
28
+ fh.write(text)
29
+ self.git("add", "-A", "-f", path, **who) # -f: a personal excludes file may ignore .claude/settings.local.json
30
+ self.git("commit", "-q", "-m", message, **who)
31
+
32
+
33
+ def history(d):
34
+ r = Repo(d)
35
+ r.commit("a.py", "1\n", "feat: start", date="2026-01-05T10:00:00")
36
+ r.commit("a.py", "2\n", "feat: helper\n\nCo-authored-by: Helper Agent <agent-bot@tools.example>", date="2026-01-05T10:02:00")
37
+ r.commit("b.py", "3\n", "feat: more\n\nAssisted-by: SomeModel v2\nSigned-off-by: Ann <ann@x.com>", date="2026-01-05T10:04:00")
38
+ r.commit("a.py", "4\n", "fix: a bug", date="2026-01-05T10:06:00")
39
+ r.commit("c.py", "5\n", "pair work\n\nCo-authored-by: Bob <bob@x.com>", date="2026-01-05T10:08:00")
40
+ r.commit("c.py", "6\n", "Bob's own", name="Bob", email="bob@x.com", date="2026-03-01T12:00:00")
41
+ r.commit("d.py", "7\n", "Revert \"feat: helper\"", date="2026-03-02T09:00:00")
42
+ r.commit("e.py", "8\n", "odd\n\nCo-authored-by: Ghost <ghost@x.com>\nSigned-off-by: Ghost <ghost@x.com>", date="2026-04-01T09:00:00")
43
+ r.commit("f.py", "9\n", "see the docs\n\nhttps://example.com/x\nNOTE: prose that ends a message", date="2026-04-02T09:00:00")
44
+ return r
45
+
46
+
47
+ class Trailers(unittest.TestCase):
48
+ def test_inventory_and_the_co_author_who_never_authors(self):
49
+ with tempfile.TemporaryDirectory() as d:
50
+ history(d)
51
+ commits = provenance.read_commits(d)
52
+ out = provenance.trailers(commits)
53
+ self.assertEqual(out["commits"], 9)
54
+ self.assertEqual(out["keys"], {"Co-authored-by": 3, "Signed-off-by": 2, "Assisted-by": 1}, "a URL and a line of prose are not trailers")
55
+ self.assertEqual(out["with_any"], 4)
56
+ self.assertEqual(out["never_author"], [{"name": "Ghost", "email": "ghost@x.com", "commits": 1},
57
+ {"name": "Helper Agent", "email": "agent-bot@tools.example", "commits": 1}],
58
+ "Bob co-authors and also authors, so he is not listed")
59
+ self.assertEqual(out["signoff_by_co_author"], [{"name": "Ghost", "email": "ghost@x.com", "commits": 1}],
60
+ "a sign-off by an identity that only ever co-authors: what the kernel's policy forbids agents")
61
+
62
+
63
+ class Cohorts(unittest.TestCase):
64
+ def test_trailer_cohort_against_the_rest(self):
65
+ with tempfile.TemporaryDirectory() as d:
66
+ history(d)
67
+ commits = provenance.read_commits(d)
68
+ out = provenance.cohort(commits, provenance.trailers(commits))
69
+ self.assertEqual(out["definition"], "an Assisted-by trailer, or a co-author who never authors a commit here")
70
+ self.assertEqual((out["cohort"]["commits"], out["rest"]["commits"]), (3, 6))
71
+ self.assertEqual(out["cohort"]["reverted"], 1, "the helper commit was reverted by subject")
72
+ self.assertEqual(out["rest"]["reverted"], 0)
73
+ self.assertEqual(out["cohort"]["fixes"], 0)
74
+ self.assertEqual(out["rest"]["fixes"], 1)
75
+ self.assertEqual(out["cohort"]["retouched"], 1, "a.py changed again two minutes after the helper commit")
76
+ self.assertEqual(out["share"], 0.333)
77
+
78
+
79
+ class Shape(unittest.TestCase):
80
+ def test_neutral_descriptors_of_how_commits_arrive(self):
81
+ with tempfile.TemporaryDirectory() as d:
82
+ history(d)
83
+ out = provenance.shape(provenance.read_commits(d))
84
+ self.assertEqual(out["burst_share"], 0.556, "five of nine commits land in a run of five or more within ten minutes of each other")
85
+ self.assertEqual(out["conventional_share"], 0.444, "feat: x3 and fix: x1")
86
+ self.assertEqual(out["hours_used"], 3)
87
+ self.assertNotIn("ai", json.dumps(out).lower().replace("assisted", ""), "a descriptor is never labelled")
88
+
89
+
90
+ class Agents(unittest.TestCase):
91
+ def test_agent_configuration_as_a_declared_surface(self):
92
+ with tempfile.TemporaryDirectory() as d:
93
+ r = Repo(d)
94
+ r.commit("AGENTS.md", "rules\n", "agents", date="2025-01-01T10:00:00")
95
+ r.commit(".claude/settings.json", json.dumps({"hooks": {"PostToolUse": []}, "permissions": {"defaultMode": "bypassPermissions"}}), "settings",
96
+ date="2025-01-02T10:00:00")
97
+ r.commit(".claude/settings.local.json", "{}", "oops", date="2025-01-03T10:00:00")
98
+ r.commit(".mcp.json", json.dumps({"mcpServers": {"db": {"command": "x", "env": {"DB_URL": "postgres://u:" + "p4ss" * 4 + "@h/db",
99
+ "TOKEN": "${TOKEN}", "MODE": "readonly"}},
100
+ "web": {"command": "y"}}}), "mcp", date="2025-01-04T10:00:00")
101
+ for i in range(5):
102
+ r.commit("src.py", f"{i}\n", f"work {i}", date=f"2026-0{i + 1}-01T10:00:00")
103
+ out = provenance.agents(d)
104
+ self.assertEqual(out["instructions"], [{"file": "AGENTS.md", "last": "2025-01-01", "commits_behind": 8}])
105
+ self.assertEqual(out["guardrails"], [".claude/settings.json"])
106
+ self.assertEqual(out["approval_disabled"], [{"file": ".claude/settings.json", "setting": "permissions.defaultMode=bypassPermissions"}])
107
+ self.assertEqual(out["local_settings"], [".claude/settings.local.json"])
108
+ self.assertEqual(out["mcp"], [{"file": ".mcp.json", "servers": 2, "literal_env": [{"server": "db", "key": "DB_URL"}]}],
109
+ "a ${VAR} reference is where a secret is read from; a short word is configuration; the values themselves are never written")
110
+ self.assertNotIn("p4ss", json.dumps(out))
111
+
112
+
113
+ class Step(unittest.TestCase):
114
+ def test_the_step_writes_provenance_json(self):
115
+ with tempfile.TemporaryDirectory() as d:
116
+ history(d)
117
+ out = os.path.join(d, "out")
118
+ os.makedirs(out)
119
+ p = subprocess.run([sys.executable, "-m", "gitmole.provenance", out], cwd=d, capture_output=True, text=True,
120
+ env=dict(os.environ, PYTHONPATH=ROOT, GIT_CONFIG_GLOBAL="/dev/null", GIT_CONFIG_SYSTEM="/dev/null"))
121
+ self.assertEqual(p.returncode, 0, p.stderr)
122
+ with open(os.path.join(out, "provenance.json")) as fh:
123
+ data = json.load(fh)
124
+ self.assertEqual(set(data), {"trailers", "cohort", "shape", "agents"})
125
+
126
+
127
+ if __name__ == "__main__":
128
+ unittest.main()
@@ -257,6 +257,32 @@ class Report(unittest.TestCase):
257
257
  self.assertEqual(short, " · ".join(many[:6]) + " · 3 more")
258
258
  self.assertEqual(full, " · ".join(many))
259
259
 
260
+ def test_the_duplication_direction_is_a_header_phrase(self):
261
+ r = sample_report()
262
+ r["duplicates"] = {"rate": 6.1, "blocks": [], "then": {"date": "2025-09-10", "rate": 4.2, "files": 30}}
263
+ self.assertIn("6.1% of lines duplicated, up from 4.2% a year before", render.pulse(r))
264
+ r["duplicates"]["then"]["rate"] = 6.1
265
+ self.assertIn("6.1% of lines duplicated, as a year before", render.pulse(r))
266
+ del r["duplicates"]["then"]
267
+ self.assertFalse([x for x in render.pulse(r) if "duplicated" in x])
268
+
269
+ def test_provenance_is_a_full_only_section_of_trailers_with_the_cohort_and_shape_below(self):
270
+ r = sample_report()
271
+ r["provenance"] = {"trailers": {"commits": 363, "keys": {"Co-authored-by": 40, "Signed-off-by": 12, "Assisted-by": 5}, "with_any": 50,
272
+ "never_author": [{"name": "Helper", "email": "h@x", "commits": 30}], "signoff_by_co_author": []},
273
+ "cohort": {"definition": "an Assisted-by trailer, or a co-author who never authors a commit here", "share": 0.096,
274
+ "cohort": {"commits": 35, "reverted": 2, "fixes": 4, "retouched": 20},
275
+ "rest": {"commits": 328, "reverted": 3, "fixes": 60, "retouched": 150}},
276
+ "shape": {"burst_share": 0.12, "conventional_share": 0.8, "hours_used": 20}, "agents": {}}
277
+ self.assertNotIn("Trailers", rendered(r, [], width=200))
278
+ block = _section_text(rendered(r, [], width=200, full=True), "Trailers")
279
+ self.assertRegex(block, r"Co-authored-by\s+40\s+11%")
280
+ caption = render.trailers_section(r)["caption"]
281
+ self.assertIn("marked commits (an Assisted-by trailer, or a co-author who never authors a commit here): 35, 10% of the history; "
282
+ "reverted 6% against 1% for the rest, fixes 11% against 18%, a file changed again within two weeks 57% against 46%", caption)
283
+ self.assertIn("12% of commits land in bursts of five or more within ten minutes; 80% have conventional-commit subjects; commits come in 20 hours of the day", caption)
284
+ self.assertIn("## Trailers", render.markdown(r, []))
285
+
260
286
  def test_the_secrets_line_says_what_lay_outside_reachable_history(self):
261
287
  r = sample_report()
262
288
  r["unreachable"] = {"objects": 0, "blobs": 0, "scanned": 0, "findings": 0}
@@ -226,7 +226,7 @@ class Plan(unittest.TestCase):
226
226
  def test_lists_every_tool_and_theseus_plots_depend_on_analyze(self):
227
227
  steps = run.plan("/r", "/o")
228
228
  names = [s["name"] for s in steps]
229
- for expected in ["scc", "git-sizer", "betterleaks", "git-log", "change analysis", "code age", "signing", "hygiene"]:
229
+ for expected in ["scc", "git-sizer", "betterleaks", "git-log", "change analysis", "code age", "signing", "hygiene", "provenance"]:
230
230
  self.assertIn(expected, names)
231
231
  self.assertEqual(by_name(steps)["signing"]["argv"][1:], ["-m", "gitmole.signing", "/o"], "commit signing coverage, read from the objects, no keyring")
232
232
  self.assertEqual(by_name(steps)["signing"]["deps"], [], "it reads meta.json for the bot names, written before the steps start")
@@ -250,6 +250,14 @@ class Plan(unittest.TestCase):
250
250
  self.assertNotIn("--no-renames", by["git-log"]["argv"])
251
251
  self.assertEqual(by["git-log"]["argv"][:4], ["git", "-c", "core.quotePath=false", "log"], "non-ASCII paths must not be octal-escaped and quoted")
252
252
 
253
+ def test_provenance_runs_as_a_module_and_duplicates_measure_a_year_back(self):
254
+ steps = by_name(run.plan("/r", "/o", duplicates_then="2025-09-17"))
255
+ self.assertEqual(steps["provenance"]["argv"][1:], ["-m", "gitmole.provenance", "/o"])
256
+ argv = steps["duplicates"]["argv"]
257
+ self.assertEqual(argv[argv.index("--then") + 1], "2025-09-17")
258
+ self.assertNotIn("--then", by_name(run.plan("/r", "/o"))["duplicates"]["argv"])
259
+ self.assertIn("provenance.json", run.OUTPUTS)
260
+
253
261
  def test_the_structure_step_is_optional_and_runs_the_module(self):
254
262
  self.assertNotIn("structure", by_name(run.plan("/r", "/o")))
255
263
  step = by_name(run.plan("/r", "/o", structure=True, procs=3))["structure"]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes