context-engineering-cli 2.6.0__py3-none-any.whl

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 (55) hide show
  1. context_engineering/__init__.py +3 -0
  2. context_engineering/__main__.py +2 -0
  3. context_engineering/analysis/__init__.py +1 -0
  4. context_engineering/analysis/backfill.py +1064 -0
  5. context_engineering/analysis/context_check.py +253 -0
  6. context_engineering/analysis/context_layout.py +111 -0
  7. context_engineering/analysis/context_review.py +224 -0
  8. context_engineering/analysis/cross_cutting/__init__.py +6 -0
  9. context_engineering/analysis/cross_cutting/authors.py +57 -0
  10. context_engineering/analysis/cross_cutting/buckets.py +40 -0
  11. context_engineering/analysis/cross_cutting/co_change.py +47 -0
  12. context_engineering/analysis/cross_cutting/discover.py +75 -0
  13. context_engineering/analysis/cross_cutting/imports.py +61 -0
  14. context_engineering/analysis/cross_cutting/pair.py +118 -0
  15. context_engineering/analysis/impact.py +77 -0
  16. context_engineering/analysis/sessions.py +27 -0
  17. context_engineering/analysis/staleness.py +179 -0
  18. context_engineering/analysis/tier.py +91 -0
  19. context_engineering/checks/__init__.py +1 -0
  20. context_engineering/checks/antipatterns/__init__.py +5 -0
  21. context_engineering/checks/antipatterns/context.py +23 -0
  22. context_engineering/checks/antipatterns/density.py +72 -0
  23. context_engineering/checks/antipatterns/line_limits.py +52 -0
  24. context_engineering/checks/antipatterns/runner.py +137 -0
  25. context_engineering/checks/antipatterns/splitting.py +97 -0
  26. context_engineering/checks/antipatterns/volatile.py +38 -0
  27. context_engineering/checks/antipatterns/watermark.py +113 -0
  28. context_engineering/checks/contracts.py +456 -0
  29. context_engineering/checks/depth.py +82 -0
  30. context_engineering/checks/frontmatter.py +125 -0
  31. context_engineering/checks/references.py +325 -0
  32. context_engineering/checks/skill_structure.py +124 -0
  33. context_engineering/cli/__init__.py +3 -0
  34. context_engineering/cli/dispatch.py +90 -0
  35. context_engineering/cli/registry.py +33 -0
  36. context_engineering/cli/render.py +92 -0
  37. context_engineering/cli/subcommands.py +587 -0
  38. context_engineering/domain/__init__.py +0 -0
  39. context_engineering/domain/commit.py +19 -0
  40. context_engineering/domain/evidence.py +57 -0
  41. context_engineering/domain/finding.py +37 -0
  42. context_engineering/domain/result.py +59 -0
  43. context_engineering/infra/__init__.py +13 -0
  44. context_engineering/infra/filesystem.py +22 -0
  45. context_engineering/infra/git.py +153 -0
  46. context_engineering/infra/git_evidence.py +357 -0
  47. context_engineering/infra/git_tree.py +139 -0
  48. context_engineering/infra/markdown.py +58 -0
  49. context_engineering/infra/yaml_frontmatter.py +70 -0
  50. context_engineering_cli-2.6.0.dist-info/METADATA +27 -0
  51. context_engineering_cli-2.6.0.dist-info/RECORD +55 -0
  52. context_engineering_cli-2.6.0.dist-info/WHEEL +4 -0
  53. context_engineering_cli-2.6.0.dist-info/entry_points.txt +2 -0
  54. context_engineering_cli-2.6.0.dist-info/licenses/LICENSE +21 -0
  55. provenance.json +1 -0
@@ -0,0 +1,1064 @@
1
+ """Extract provenance-preserving local Git evidence for contract backfill."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime
6
+ import os
7
+ import re
8
+ import subprocess
9
+ from dataclasses import dataclass
10
+ from pathlib import Path
11
+ from typing import TypedDict
12
+ from urllib.parse import urlsplit, urlunsplit
13
+
14
+ from ..domain.evidence import (
15
+ EvidenceBoundary,
16
+ EvidenceCompleteness,
17
+ EvidenceLimitation,
18
+ EvidenceScopeKind,
19
+ )
20
+ from ..domain.finding import Finding, Severity
21
+ from ..domain.result import AnalysisResult
22
+ from ..infra.git import git_root, resolve_commit
23
+ from ..infra.git_evidence import (
24
+ CompleteHistoryRecord,
25
+ CompletePathChange,
26
+ EvidenceBudget,
27
+ communicate_bounded,
28
+ read_complete_history,
29
+ read_history,
30
+ )
31
+
32
+ _FIELD = "\x1f"
33
+ _DESIGN_PATH_RE = re.compile(
34
+ r"(?:^|/)(?:docs?|agent[-_]?docs?|design[-_]?docs?|architecture|design|decisions?|adrs?)(?:/|$)|"
35
+ r"(?:^|/)(?:SPEC|README|CHANGELOG)\.md$",
36
+ re.IGNORECASE,
37
+ )
38
+ _SUBJECT_SIGNALS = (
39
+ "adopt",
40
+ "architecture",
41
+ "conversion",
42
+ "design",
43
+ "lifecycle",
44
+ "migrate",
45
+ "pipeline",
46
+ "refactor",
47
+ "replace",
48
+ "supersede",
49
+ )
50
+ _SENSITIVE_SUBJECT_RE = re.compile(
51
+ r"(?:[\w.+-]+@[\w.-]+\.[A-Za-z]{2,}|password|secret|api[-_ ]?key|access[-_ ]?token)",
52
+ re.IGNORECASE,
53
+ )
54
+ _SENSITIVE_PATH_RE = re.compile(
55
+ r"(?:^|/)(?:\.env(?:\.|$)|credentials?|secrets?|private[-_]?keys?)(?:/|\.|$)",
56
+ re.IGNORECASE,
57
+ )
58
+ _LEAD_WEIGHTS = {
59
+ "design-artifact": 5,
60
+ "renamed-or-copied-path": 4,
61
+ "deleted-path": 3,
62
+ "architecture-subject-lead": 2,
63
+ "merged-pr-subject-lead": 1,
64
+ }
65
+ _MAX_HIGH_SIGNAL_COMMITS = 100
66
+
67
+
68
+ class QuickHistoryRecord(TypedDict):
69
+ commit: str
70
+ date: str
71
+ subject: str
72
+ paths: list[str]
73
+
74
+
75
+ class LeadRecord(CompleteHistoryRecord):
76
+ reasons: list[str]
77
+
78
+
79
+ @dataclass
80
+ class _EvidenceMeter:
81
+ budget: EvidenceBudget
82
+ records: int = 0
83
+ paths: int = 0
84
+ bytes: int = 0
85
+ subprocesses: int = 0
86
+
87
+ def consume(
88
+ self,
89
+ *,
90
+ records: int = 0,
91
+ paths: int = 0,
92
+ bytes_read: int = 0,
93
+ subprocesses: int = 0,
94
+ ) -> str | None:
95
+ self.records += records
96
+ self.paths += paths
97
+ self.bytes += bytes_read
98
+ self.subprocesses += subprocesses
99
+ if self.records > self.budget.max_records:
100
+ return "evidence exceeds the configured record ceiling"
101
+ if self.paths > self.budget.max_paths:
102
+ return "evidence exceeds the configured path ceiling"
103
+ if self.bytes > self.budget.max_bytes:
104
+ return "evidence exceeds the configured byte ceiling"
105
+ if self.subprocesses > self.budget.max_subprocesses:
106
+ return "evidence exceeds the configured subprocess ceiling"
107
+ return None
108
+
109
+ def to_dict(self) -> dict[str, int]:
110
+ return {
111
+ "records": self.records,
112
+ "paths": self.paths,
113
+ "bytes": self.bytes,
114
+ "subprocesses": self.subprocesses,
115
+ }
116
+
117
+
118
+ def _history(
119
+ repo: Path,
120
+ module: Path,
121
+ cutoff: str,
122
+ max_commits: int,
123
+ ) -> tuple[list[QuickHistoryRecord], str | None]:
124
+ relative = module.relative_to(repo).as_posix()
125
+ records, error = read_history(
126
+ repo,
127
+ cutoff=cutoff,
128
+ relative_path=relative,
129
+ max_count=max_commits,
130
+ )
131
+ return [
132
+ {
133
+ "commit": record.commit,
134
+ "date": record.date,
135
+ "subject": record.subject,
136
+ "paths": list(record.paths),
137
+ }
138
+ for record in records
139
+ ], error
140
+
141
+
142
+ def _complete_history(
143
+ repo: Path,
144
+ cutoff: str,
145
+ ) -> tuple[list[CompleteHistoryRecord], str | None]:
146
+ records, _counts, error = read_complete_history(repo, cutoff=cutoff)
147
+ return records, error
148
+
149
+
150
+ def _git_value(
151
+ repo: Path, *args: str, meter: _EvidenceMeter | None = None
152
+ ) -> tuple[str | None, str | None]:
153
+ command = ["git", *args]
154
+ if meter is None:
155
+ result = subprocess.run(command, cwd=repo, capture_output=True, text=True)
156
+ if result.returncode != 0:
157
+ return None, result.stderr.strip() or f"git {' '.join(args)} exited {result.returncode}"
158
+ return result.stdout.strip(), None
159
+ remaining = meter.budget.max_bytes - meter.bytes
160
+ if remaining < 0:
161
+ return None, "evidence exceeds the configured byte ceiling"
162
+ try:
163
+ process = subprocess.Popen(
164
+ command,
165
+ cwd=repo,
166
+ stdout=subprocess.PIPE,
167
+ stderr=subprocess.PIPE,
168
+ )
169
+ except OSError as exc:
170
+ return None, f"could not run Git: {exc}"
171
+ stdout, stderr, transport_error = communicate_bounded(process, max_bytes=remaining)
172
+ ceiling_error = meter.consume(
173
+ bytes_read=len(stdout) + len(stderr),
174
+ subprocesses=1,
175
+ )
176
+ if transport_error is not None or ceiling_error is not None:
177
+ error = transport_error or ceiling_error
178
+ assert error is not None
179
+ return None, (
180
+ "evidence exceeds the configured byte ceiling" if "ceiling" in error else error
181
+ )
182
+ if process.returncode != 0:
183
+ diagnostic = os.fsdecode(stderr).strip()
184
+ return None, diagnostic or f"git {' '.join(args)} exited {process.returncode}"
185
+ return os.fsdecode(stdout).strip(), None
186
+
187
+
188
+ def _empty_commit_record(
189
+ repo: Path,
190
+ commit: str,
191
+ meter: _EvidenceMeter | None = None,
192
+ ) -> tuple[CompleteHistoryRecord | None, str | None]:
193
+ value, error = _git_value(
194
+ repo,
195
+ "show",
196
+ "-s",
197
+ f"--format=%H{_FIELD}%aI{_FIELD}%s{_FIELD}%P{_FIELD}END",
198
+ commit,
199
+ meter=meter,
200
+ )
201
+ if error or value is None:
202
+ return None, error
203
+ fields = value.split(_FIELD, 4)
204
+ if len(fields) != 5 or fields[4] != "END":
205
+ return None, f"git show returned malformed metadata for {commit}"
206
+ return {
207
+ "commit": fields[0],
208
+ "date": fields[1][:10],
209
+ "subject": fields[2],
210
+ "parents": fields[3].split() if fields[3] else [],
211
+ "changes": [],
212
+ }, None
213
+
214
+
215
+ def _path_is_within(path: str, prefix: str) -> bool:
216
+ return prefix == "." or path == prefix or path.startswith(prefix + "/")
217
+
218
+
219
+ def _relative_path(path: str, prefix: str) -> str:
220
+ if prefix == ".":
221
+ return path
222
+ if path == prefix:
223
+ return ""
224
+ return path[len(prefix) + 1 :]
225
+
226
+
227
+ def _prefix_before_suffix(path: str, suffix: str) -> str | None:
228
+ if not suffix:
229
+ return path
230
+ if path == suffix:
231
+ return "."
232
+ marker = "/" + suffix
233
+ if not path.endswith(marker):
234
+ return None
235
+ return path[: -len(marker)] or "."
236
+
237
+
238
+ def _tree_paths(
239
+ repo: Path,
240
+ commit: str,
241
+ prefix: str,
242
+ meter: _EvidenceMeter | None = None,
243
+ ) -> tuple[list[str], str | None]:
244
+ args = ["git", "ls-tree", "-r", "-z", "--name-only", commit, "--"]
245
+ if prefix != ".":
246
+ args.append(prefix)
247
+ if meter is None:
248
+ result = subprocess.run(args, cwd=repo, capture_output=True)
249
+ if result.returncode != 0:
250
+ diagnostic = os.fsdecode(result.stderr).strip()
251
+ return [], diagnostic or f"git ls-tree exited {result.returncode}"
252
+ return [os.fsdecode(path) for path in result.stdout.split(b"\0") if path], None
253
+ remaining = meter.budget.max_bytes - meter.bytes
254
+ if remaining < 0:
255
+ return [], "evidence exceeds the configured byte ceiling"
256
+ try:
257
+ process = subprocess.Popen(args, cwd=repo, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
258
+ except OSError as exc:
259
+ return [], f"could not run Git: {exc}"
260
+ stdout, stderr, transport_error = communicate_bounded(process, max_bytes=remaining)
261
+ if transport_error is not None:
262
+ meter.consume(bytes_read=len(stdout) + len(stderr), subprocesses=1)
263
+ return [], (
264
+ "evidence exceeds the configured byte ceiling"
265
+ if "ceiling" in transport_error
266
+ else transport_error
267
+ )
268
+ paths = [os.fsdecode(path) for path in stdout.split(b"\0") if path]
269
+ ceiling_error = meter.consume(
270
+ paths=len(paths),
271
+ bytes_read=len(stdout) + len(stderr),
272
+ subprocesses=1,
273
+ )
274
+ if ceiling_error:
275
+ return [], ceiling_error
276
+ if process.returncode != 0:
277
+ diagnostic = os.fsdecode(stderr).strip()
278
+ return [], diagnostic or f"git ls-tree exited {process.returncode}"
279
+ return paths, None
280
+
281
+
282
+ def _exact_previous_prefix(
283
+ repo: Path,
284
+ record: CompleteHistoryRecord,
285
+ current_prefix: str,
286
+ parent: str,
287
+ incoming_renames: list[CompletePathChange],
288
+ meter: _EvidenceMeter | None = None,
289
+ ) -> tuple[str | None, str | None]:
290
+ candidates: set[str] = set()
291
+ for change in incoming_renames:
292
+ relative = _relative_path(change["path"], current_prefix)
293
+ candidate = _prefix_before_suffix(change["old_path"], relative)
294
+ if candidate is None:
295
+ return None, "rename pairs do not preserve one target-relative path layout"
296
+ candidates.add(candidate)
297
+ if len(candidates) != 1:
298
+ return None, "rename pairs originate from multiple module prefixes"
299
+ previous_prefix = candidates.pop()
300
+ previous_paths, previous_error = _tree_paths(repo, parent, previous_prefix, meter)
301
+ current_parent_paths, current_parent_error = _tree_paths(repo, parent, current_prefix, meter)
302
+ current_paths, current_error = _tree_paths(
303
+ repo, str(record["commit"]), current_prefix, meter
304
+ )
305
+ tree_error = previous_error or current_parent_error or current_error
306
+ if tree_error:
307
+ return None, tree_error
308
+ if not previous_paths or current_parent_paths or not current_paths:
309
+ return None, "tree state does not establish an exact whole-module move"
310
+ rename_pairs = {(change["old_path"], change["path"]) for change in incoming_renames}
311
+ deleted_paths = {change["path"] for change in record["changes"] if change["status"] == "D"}
312
+ for previous_path in previous_paths:
313
+ relative = _relative_path(previous_path, previous_prefix)
314
+ expected_path = (
315
+ relative if current_prefix == "." else f"{current_prefix}/{relative}".rstrip("/")
316
+ )
317
+ if (
318
+ previous_path,
319
+ expected_path,
320
+ ) not in rename_pairs and previous_path not in deleted_paths:
321
+ return None, "not every prior module path is accounted for by the move"
322
+ return previous_prefix, None
323
+
324
+
325
+ def _module_history(
326
+ repo: Path,
327
+ records: list[CompleteHistoryRecord],
328
+ current_prefix: str,
329
+ cutoff: str,
330
+ meter: _EvidenceMeter | None = None,
331
+ ) -> tuple[list[CompleteHistoryRecord], list[dict[str, str]], str | None]:
332
+ if current_prefix == ".":
333
+ return records, [], None
334
+ cutoff_paths, cutoff_error = _tree_paths(repo, cutoff, current_prefix, meter)
335
+ if cutoff_error:
336
+ return [], [], cutoff_error
337
+ if not cutoff_paths:
338
+ return [], [], f"target path does not exist at cutoff: {current_prefix}"
339
+
340
+ selected: list[CompleteHistoryRecord] = []
341
+ lineage: list[dict[str, str]] = []
342
+ active_prefix = current_prefix
343
+ for record in reversed(records):
344
+ changes = record["changes"]
345
+ parents = record["parents"]
346
+ relevant_changes = [
347
+ change
348
+ for change in changes
349
+ if (
350
+ _path_is_within(str(change["path"]), active_prefix)
351
+ or (
352
+ "old_path" in change and _path_is_within(str(change["old_path"]), active_prefix)
353
+ )
354
+ )
355
+ ]
356
+ if not relevant_changes:
357
+ continue
358
+
359
+ parent_states: list[bool] = []
360
+ for parent in parents:
361
+ parent_paths, parent_error = _tree_paths(repo, str(parent), active_prefix, meter)
362
+ if parent_error:
363
+ return [], [], parent_error
364
+ parent_states.append(bool(parent_paths))
365
+ if parents and not any(parent_states):
366
+ incoming_renames = [
367
+ change
368
+ for change in relevant_changes
369
+ if str(change["status"]).startswith("R")
370
+ and "old_path" in change
371
+ and _path_is_within(str(change["path"]), active_prefix)
372
+ and not _path_is_within(str(change["old_path"]), active_prefix)
373
+ ]
374
+ if incoming_renames:
375
+ if len(parents) != 1:
376
+ return [], [], "module move occurs at a multi-parent commit"
377
+ previous_prefix, lineage_error = _exact_previous_prefix(
378
+ repo,
379
+ record,
380
+ active_prefix,
381
+ str(parents[0]),
382
+ incoming_renames,
383
+ meter,
384
+ )
385
+ if lineage_error or previous_prefix is None:
386
+ return [], [], lineage_error or "module lineage is ambiguous"
387
+ lineage_changes = [
388
+ change
389
+ for change in changes
390
+ if (
391
+ _path_is_within(str(change["path"]), active_prefix)
392
+ or _path_is_within(str(change["path"]), previous_prefix)
393
+ or (
394
+ "old_path" in change
395
+ and (
396
+ _path_is_within(str(change["old_path"]), active_prefix)
397
+ or _path_is_within(str(change["old_path"]), previous_prefix)
398
+ )
399
+ )
400
+ )
401
+ ]
402
+ selected.append({**record, "changes": lineage_changes})
403
+ lineage.append(
404
+ {
405
+ "commit": str(record["commit"]),
406
+ "old_path": previous_prefix,
407
+ "path": active_prefix,
408
+ }
409
+ )
410
+ active_prefix = previous_prefix
411
+ continue
412
+ out_of_scope_deletions = [
413
+ change
414
+ for change in changes
415
+ if str(change["status"]) == "D"
416
+ and not _path_is_within(str(change["path"]), active_prefix)
417
+ ]
418
+ if out_of_scope_deletions:
419
+ return (
420
+ [],
421
+ [],
422
+ "target appears after out-of-scope deletions without a proven rename",
423
+ )
424
+ selected.append({**record, "changes": relevant_changes})
425
+ break
426
+ selected.append({**record, "changes": relevant_changes})
427
+ return list(reversed(selected)), list(reversed(lineage)), None
428
+
429
+
430
+ def _signal_reasons(record: CompleteHistoryRecord) -> list[str]:
431
+ reasons: set[str] = set()
432
+ changes = record["changes"]
433
+ for change in changes:
434
+ status = str(change["status"])
435
+ paths = [str(change["path"])]
436
+ if "old_path" in change:
437
+ paths.append(str(change["old_path"]))
438
+ if status == "D":
439
+ reasons.add("deleted-path")
440
+ if status.startswith(("R", "C")):
441
+ reasons.add("renamed-or-copied-path")
442
+ if any(_DESIGN_PATH_RE.search(path) for path in paths):
443
+ reasons.add("design-artifact")
444
+ subject = str(record["subject"]).casefold()
445
+ if any(token in subject for token in _SUBJECT_SIGNALS):
446
+ reasons.add("architecture-subject-lead")
447
+ if re.search(r"\(#\d+\)\s*$", subject):
448
+ reasons.add("merged-pr-subject-lead")
449
+ return sorted(reasons)
450
+
451
+
452
+ def _historical_design_artifacts(
453
+ records: list[CompleteHistoryRecord],
454
+ ) -> list[dict[str, object]]:
455
+ artifacts: list[dict[str, object]] = []
456
+ for record in records:
457
+ changes = record["changes"]
458
+ for change in changes:
459
+ paths = [str(change["path"])]
460
+ if "old_path" in change:
461
+ paths.append(str(change["old_path"]))
462
+ if not any(_DESIGN_PATH_RE.search(path) for path in paths):
463
+ continue
464
+ artifacts.append(
465
+ {
466
+ "status": change["status"],
467
+ "path": change["path"],
468
+ **({"old_path": change["old_path"]} if "old_path" in change else {}),
469
+ "provenance": {
470
+ "source": "local-git",
471
+ "commit": record["commit"],
472
+ "paths": paths,
473
+ },
474
+ }
475
+ )
476
+ return artifacts
477
+
478
+
479
+ def _rank_leads(records: list[CompleteHistoryRecord]) -> list[LeadRecord]:
480
+ leads: list[LeadRecord] = [
481
+ {**record, "reasons": reasons} for record in records if (reasons := _signal_reasons(record))
482
+ ]
483
+ return sorted(
484
+ leads,
485
+ key=lambda record: (
486
+ -sum(_LEAD_WEIGHTS[reason] for reason in record["reasons"]),
487
+ str(record["date"]),
488
+ str(record["commit"]),
489
+ ),
490
+ )[:_MAX_HIGH_SIGNAL_COMMITS]
491
+
492
+
493
+ def _remote(repo: Path) -> str | None:
494
+ result = subprocess.run(
495
+ ["git", "remote", "get-url", "origin"],
496
+ cwd=repo,
497
+ capture_output=True,
498
+ text=True,
499
+ )
500
+ if result.returncode != 0 or not result.stdout.strip():
501
+ return None
502
+ remote = result.stdout.strip()
503
+ parts = urlsplit(remote)
504
+ if parts.scheme == "file" or Path(remote).is_absolute():
505
+ return None
506
+ if not parts.scheme or not parts.netloc:
507
+ scp_match = re.fullmatch(r"(?:[^@/:]+@)?([^/:]+):(.+)", remote)
508
+ if scp_match is None:
509
+ return None
510
+ return f"{scp_match.group(1)}:{scp_match.group(2)}"
511
+ safe_netloc = parts.netloc.rsplit("@", 1)[-1]
512
+ return urlunsplit((parts.scheme, safe_netloc, parts.path, "", ""))
513
+
514
+
515
+ def _analysis_target(module: Path, repo: Path | None, *, publish_safe: bool) -> str:
516
+ if not publish_safe:
517
+ return str(module)
518
+ if repo is None:
519
+ return module.name
520
+ identity = _remote(repo) or repo.name
521
+ relative = module.relative_to(repo).as_posix()
522
+ return identity if relative == "." else f"{identity}:{relative}"
523
+
524
+
525
+ def _sensitivity_flags(records: list[CompleteHistoryRecord]) -> list[dict[str, str]]:
526
+ flags: list[dict[str, str]] = []
527
+ for record in records:
528
+ commit = str(record["commit"])
529
+ if _SENSITIVE_SUBJECT_RE.search(str(record["subject"])):
530
+ flags.append({"commit": commit, "field": "subject", "reason": "sensitive-text-pattern"})
531
+ for change in record["changes"]:
532
+ for field in ("path", "old_path"):
533
+ value = change.get(field)
534
+ if value is not None and _SENSITIVE_PATH_RE.search(str(value)):
535
+ flags.append(
536
+ {
537
+ "commit": commit,
538
+ "field": field,
539
+ "reason": "sensitive-path-pattern",
540
+ }
541
+ )
542
+ return flags
543
+
544
+
545
+ def _publish_safe_leads(records: list[LeadRecord]) -> list[dict[str, object]]:
546
+ return [
547
+ {
548
+ "commit": record["commit"],
549
+ "date": record["date"],
550
+ "reasons": record["reasons"],
551
+ }
552
+ for record in records
553
+ ]
554
+
555
+
556
+ def _quick_sensitivity_flags(records: list[QuickHistoryRecord]) -> list[dict[str, str]]:
557
+ flags: list[dict[str, str]] = []
558
+ for record in records:
559
+ commit = str(record["commit"])
560
+ if _SENSITIVE_SUBJECT_RE.search(str(record["subject"])):
561
+ flags.append({"commit": commit, "field": "subject", "reason": "sensitive-text-pattern"})
562
+ paths = record["paths"]
563
+ if any(_SENSITIVE_PATH_RE.search(str(path)) for path in paths):
564
+ flags.append({"commit": commit, "field": "paths", "reason": "sensitive-path-pattern"})
565
+ return flags
566
+
567
+
568
+ def analyze(
569
+ module: Path,
570
+ *,
571
+ cutoff: str = "HEAD",
572
+ max_commits: int | None = None,
573
+ quick: bool = False,
574
+ publish_safe: bool = False,
575
+ ) -> AnalysisResult:
576
+ module = module.resolve()
577
+ mode = "quick" if quick else "complete-picture"
578
+ reconstructed = datetime.datetime.now(datetime.UTC).date().isoformat()
579
+ repo = git_root(module)
580
+ result_target = _analysis_target(module, repo, publish_safe=publish_safe)
581
+ empty_data: dict[str, object] = {
582
+ "mode": mode,
583
+ "cutoff": cutoff,
584
+ "reconstruction_date": reconstructed,
585
+ "github_enrichment": "not-requested",
586
+ "publication": {
587
+ "mode": "publish-safe" if publish_safe else "local-evidence",
588
+ "durable_doc_propagation_requires_approval": True,
589
+ },
590
+ "claims": [],
591
+ "claims_by_kind": {"verified_fact": [], "inference": [], "contradiction": []},
592
+ "proposed_spec": None,
593
+ "adr_candidates": [],
594
+ "write_performed": False,
595
+ }
596
+ if not quick and max_commits is not None:
597
+ message = "max_commits requires quick mode"
598
+ finding = Finding(
599
+ result_target,
600
+ 0,
601
+ Severity.ERROR,
602
+ "context-backfill-max-commits-requires-quick",
603
+ message,
604
+ )
605
+ data = {
606
+ **empty_data,
607
+ "deterministic_errors": [{"code": "max-commits-requires-quick", "message": message}],
608
+ }
609
+ return AnalysisResult(result_target, data, [finding])
610
+ history_limit = 100 if max_commits is None else max_commits
611
+ if history_limit < 0:
612
+ message = "max_commits must be non-negative"
613
+ finding = Finding(
614
+ result_target,
615
+ 0,
616
+ Severity.ERROR,
617
+ "context-backfill-invalid-max-commits",
618
+ message,
619
+ )
620
+ data = {
621
+ **empty_data,
622
+ "deterministic_errors": [{"code": "invalid-max-commits", "message": message}],
623
+ }
624
+ return AnalysisResult(result_target, data, [finding])
625
+ if repo is None:
626
+ finding = Finding(
627
+ result_target,
628
+ 0,
629
+ Severity.ERROR,
630
+ "context-backfill-not-git",
631
+ "Target is not in a Git repository",
632
+ )
633
+ data = {
634
+ **empty_data,
635
+ "deterministic_errors": [{"code": "not-a-git-repository", "message": finding.message}],
636
+ }
637
+ return AnalysisResult(result_target, data, [finding])
638
+ resolved_cutoff, error = resolve_commit(repo, cutoff)
639
+ if error:
640
+ finding = Finding(
641
+ result_target,
642
+ 0,
643
+ Severity.ERROR,
644
+ "context-backfill-invalid-cutoff",
645
+ error,
646
+ )
647
+ data = {
648
+ **empty_data,
649
+ "deterministic_errors": [{"code": "invalid-cutoff", "message": error}],
650
+ }
651
+ return AnalysisResult(result_target, data, [finding])
652
+ assert resolved_cutoff is not None
653
+ if not quick:
654
+ relative = module.relative_to(repo).as_posix()
655
+ scope_kind = EvidenceScopeKind.REPOSITORY if relative == "." else EvidenceScopeKind.MODULE
656
+ budget = EvidenceBudget()
657
+ meter = _EvidenceMeter(budget)
658
+ cutoff_tree, tree_error = _git_value(
659
+ repo, "rev-parse", f"{resolved_cutoff}^{{tree}}", meter=meter
660
+ )
661
+ shallow, shallow_error = _git_value(
662
+ repo, "rev-parse", "--is-shallow-repository", meter=meter
663
+ )
664
+ state_error = tree_error or shallow_error
665
+ if state_error or shallow not in {"true", "false"}:
666
+ message = state_error or f"Git returned an invalid shallow state: {shallow!r}"
667
+ finding = Finding(
668
+ result_target,
669
+ 0,
670
+ Severity.ERROR,
671
+ "context-backfill-unknown-history",
672
+ message,
673
+ )
674
+ boundary = EvidenceBoundary(
675
+ completeness=EvidenceCompleteness.UNKNOWN,
676
+ scope_kind=scope_kind,
677
+ scope_path=relative,
678
+ cutoff_sha=resolved_cutoff,
679
+ cutoff_tree=cutoff_tree,
680
+ limitations=(EvidenceLimitation("history-state-unknown", message),),
681
+ )
682
+ return AnalysisResult(
683
+ result_target,
684
+ {
685
+ **empty_data,
686
+ "cutoff": resolved_cutoff,
687
+ "requested_cutoff": cutoff,
688
+ "cutoff_tree": cutoff_tree,
689
+ "evidence_boundary": boundary.to_dict(),
690
+ "raw_evidence": [],
691
+ "deterministic_errors": [{"code": "history-state-unknown", "message": message}],
692
+ },
693
+ [finding],
694
+ )
695
+ if shallow == "true":
696
+ message = "Local Git history is shallow; repository genesis cannot be proven"
697
+ finding = Finding(
698
+ result_target,
699
+ 0,
700
+ Severity.ERROR,
701
+ "context-backfill-incomplete-history",
702
+ message,
703
+ )
704
+ boundary = EvidenceBoundary(
705
+ completeness=EvidenceCompleteness.INCOMPLETE,
706
+ scope_kind=scope_kind,
707
+ scope_path=relative,
708
+ cutoff_sha=resolved_cutoff,
709
+ cutoff_tree=cutoff_tree,
710
+ limitations=(EvidenceLimitation("shallow-repository", message),),
711
+ )
712
+ return AnalysisResult(
713
+ result_target,
714
+ {
715
+ **empty_data,
716
+ "cutoff": resolved_cutoff,
717
+ "requested_cutoff": cutoff,
718
+ "cutoff_tree": cutoff_tree,
719
+ "evidence_boundary": boundary.to_dict(),
720
+ "raw_evidence": [],
721
+ "deterministic_errors": [{"code": "shallow-repository", "message": message}],
722
+ },
723
+ [finding],
724
+ )
725
+ remaining_budget = EvidenceBudget(
726
+ max_records=budget.max_records - meter.records,
727
+ max_paths=budget.max_paths - meter.paths,
728
+ max_bytes=budget.max_bytes - meter.bytes,
729
+ max_subprocesses=budget.max_subprocesses - meter.subprocesses,
730
+ )
731
+ repository_history, history_counts, error = read_complete_history(
732
+ repo,
733
+ cutoff=resolved_cutoff,
734
+ budget=remaining_budget,
735
+ )
736
+ meter_error = meter.consume(
737
+ records=history_counts.records,
738
+ paths=history_counts.paths,
739
+ bytes_read=history_counts.bytes,
740
+ subprocesses=history_counts.subprocesses,
741
+ )
742
+ error = error or meter_error
743
+ if error:
744
+ resource_limited = "ceiling" in error
745
+ code = (
746
+ "context-backfill-resource-ceiling"
747
+ if resource_limited
748
+ else "context-backfill-git-error"
749
+ )
750
+ finding = Finding(result_target, 0, Severity.ERROR, code, error)
751
+ boundary = EvidenceBoundary(
752
+ completeness=EvidenceCompleteness.INCOMPLETE,
753
+ scope_kind=scope_kind,
754
+ scope_path=relative,
755
+ cutoff_sha=resolved_cutoff,
756
+ cutoff_tree=cutoff_tree,
757
+ limitations=(
758
+ EvidenceLimitation(
759
+ "resource-ceiling" if resource_limited else "history-read-failed",
760
+ error,
761
+ ),
762
+ ),
763
+ )
764
+ data = {
765
+ **empty_data,
766
+ "cutoff": resolved_cutoff,
767
+ "requested_cutoff": cutoff,
768
+ "evidence_boundary": boundary.to_dict(),
769
+ "resource_counts": meter.to_dict(),
770
+ "raw_evidence": [],
771
+ "deterministic_errors": [
772
+ {
773
+ "code": "resource-ceiling" if resource_limited else "git-history-error",
774
+ "message": error,
775
+ }
776
+ ],
777
+ }
778
+ return AnalysisResult(result_target, data, [finding])
779
+ genesis, genesis_error = _git_value(
780
+ repo,
781
+ "rev-list",
782
+ "--max-parents=0",
783
+ "--reverse",
784
+ resolved_cutoff,
785
+ meter=meter,
786
+ )
787
+ identity_error = genesis_error
788
+ if identity_error:
789
+ finding = Finding(
790
+ result_target,
791
+ 0,
792
+ Severity.ERROR,
793
+ "context-backfill-git-error",
794
+ identity_error,
795
+ )
796
+ return AnalysisResult(
797
+ result_target,
798
+ {
799
+ **empty_data,
800
+ "cutoff": resolved_cutoff,
801
+ "requested_cutoff": cutoff,
802
+ "deterministic_errors": [
803
+ {"code": "git-identity-error", "message": identity_error}
804
+ ],
805
+ },
806
+ [finding],
807
+ )
808
+ genesis_commits = genesis.splitlines() if genesis else []
809
+ present_commits = {str(record["commit"]) for record in repository_history}
810
+ for genesis_commit in reversed(genesis_commits):
811
+ if genesis_commit in present_commits:
812
+ continue
813
+ genesis_record, genesis_record_error = _empty_commit_record(
814
+ repo, genesis_commit, meter
815
+ )
816
+ if genesis_record_error is None and genesis_record is not None:
817
+ genesis_record_error = meter.consume(records=1)
818
+ if genesis_record_error or genesis_record is None:
819
+ message = genesis_record_error or "Git did not return genesis metadata"
820
+ resource_limited = "ceiling" in message
821
+ finding = Finding(
822
+ result_target,
823
+ 0,
824
+ Severity.ERROR,
825
+ (
826
+ "context-backfill-resource-ceiling"
827
+ if resource_limited
828
+ else "context-backfill-git-error"
829
+ ),
830
+ message,
831
+ )
832
+ return AnalysisResult(
833
+ result_target,
834
+ {
835
+ **empty_data,
836
+ "cutoff": resolved_cutoff,
837
+ "requested_cutoff": cutoff,
838
+ "resource_counts": meter.to_dict(),
839
+ "deterministic_errors": [
840
+ {
841
+ "code": (
842
+ "resource-ceiling"
843
+ if resource_limited
844
+ else "git-genesis-error"
845
+ ),
846
+ "message": message,
847
+ }
848
+ ],
849
+ },
850
+ [finding],
851
+ )
852
+ repository_history.insert(0, genesis_record)
853
+ complete_history, target_path_lineage, lineage_error = _module_history(
854
+ repo,
855
+ repository_history,
856
+ relative,
857
+ resolved_cutoff,
858
+ meter,
859
+ )
860
+ if lineage_error:
861
+ resource_limited = "ceiling" in lineage_error
862
+ boundary = EvidenceBoundary(
863
+ completeness=EvidenceCompleteness.INCOMPLETE,
864
+ scope_kind=scope_kind,
865
+ scope_path=relative,
866
+ cutoff_sha=resolved_cutoff,
867
+ cutoff_tree=cutoff_tree,
868
+ repository_roots=tuple(genesis_commits),
869
+ limitations=(
870
+ EvidenceLimitation(
871
+ "resource-ceiling" if resource_limited else "module-lineage-ambiguous",
872
+ lineage_error,
873
+ ),
874
+ ),
875
+ )
876
+ finding = Finding(
877
+ result_target,
878
+ 0,
879
+ Severity.ERROR,
880
+ (
881
+ "context-backfill-resource-ceiling"
882
+ if resource_limited
883
+ else "context-backfill-module-lineage-ambiguous"
884
+ ),
885
+ lineage_error,
886
+ )
887
+ return AnalysisResult(
888
+ result_target,
889
+ {
890
+ **empty_data,
891
+ "cutoff": resolved_cutoff,
892
+ "requested_cutoff": cutoff,
893
+ "cutoff_tree": cutoff_tree,
894
+ "genesis": genesis_commits[0] if genesis_commits else None,
895
+ "genesis_commits": genesis_commits,
896
+ "repository_genesis": genesis_commits[0] if genesis_commits else None,
897
+ "repository_genesis_commits": genesis_commits,
898
+ "target_first_evidenced_commit": None,
899
+ "target_path_at_cutoff": relative,
900
+ "target_path_lineage": [],
901
+ "evidence_boundary": boundary.to_dict(),
902
+ "resource_counts": meter.to_dict(),
903
+ "raw_evidence": [],
904
+ "deterministic_errors": [
905
+ {
906
+ "code": (
907
+ "resource-ceiling"
908
+ if resource_limited
909
+ else "module-lineage-ambiguous"
910
+ ),
911
+ "message": lineage_error,
912
+ }
913
+ ],
914
+ },
915
+ [finding],
916
+ )
917
+ high_signal = _rank_leads(complete_history)
918
+ repository = _remote(repo) or repo.name
919
+ sensitivity_flags = _sensitivity_flags(complete_history)
920
+ boundary = EvidenceBoundary(
921
+ completeness=EvidenceCompleteness.COMPLETE,
922
+ scope_kind=scope_kind,
923
+ scope_path=relative,
924
+ cutoff_sha=resolved_cutoff,
925
+ cutoff_tree=cutoff_tree,
926
+ repository_roots=tuple(genesis_commits),
927
+ )
928
+ return AnalysisResult(
929
+ target=result_target,
930
+ data={
931
+ **empty_data,
932
+ "cutoff": resolved_cutoff,
933
+ "requested_cutoff": cutoff,
934
+ "cutoff_tree": cutoff_tree,
935
+ "genesis": genesis_commits[0] if genesis_commits else None,
936
+ "genesis_commits": genesis_commits,
937
+ "repository_genesis": genesis_commits[0] if genesis_commits else None,
938
+ "repository_genesis_commits": genesis_commits,
939
+ "target_first_evidenced_commit": (
940
+ str(complete_history[0]["commit"]) if complete_history else None
941
+ ),
942
+ "target_path_at_cutoff": relative,
943
+ "target_path_lineage": target_path_lineage,
944
+ "evidence_boundary": boundary.to_dict(),
945
+ "repository": repository,
946
+ "resource_counts": meter.to_dict(),
947
+ "sensitivity_flags": sensitivity_flags,
948
+ "sources": [
949
+ {
950
+ "source": "local-git",
951
+ "repository": repository,
952
+ "cutoff": resolved_cutoff,
953
+ "scope": (
954
+ "genesis-through-cutoff"
955
+ if relative == "."
956
+ else "target-lineage-through-cutoff"
957
+ ),
958
+ }
959
+ ],
960
+ "raw_evidence": [] if publish_safe else complete_history,
961
+ "high_signal_commits": (
962
+ _publish_safe_leads(high_signal) if publish_safe else high_signal
963
+ ),
964
+ "historical_design_artifacts": (
965
+ [] if publish_safe else _historical_design_artifacts(complete_history)
966
+ ),
967
+ "publish_safe_omissions": (
968
+ ["commit subjects", "historical paths", "design-artifact paths"]
969
+ if publish_safe
970
+ else []
971
+ ),
972
+ "verified_facts": [],
973
+ "inferences": [],
974
+ "contradictions_and_supersessions": [],
975
+ "phase_timeline": [],
976
+ "decision_inventory": [],
977
+ "adr_promotion_suggestions": [],
978
+ "rejected_candidates": [],
979
+ "evidence_gaps": [],
980
+ "proposed_spec": None,
981
+ "proposed_docs": {"architecture": None, "project_evolution": None},
982
+ "merged_pr_evidence": {
983
+ "optional": True,
984
+ "availability": "unknown",
985
+ "used": False,
986
+ "items": [],
987
+ },
988
+ "deterministic_errors": [],
989
+ },
990
+ )
991
+ history, error = _history(repo, module, resolved_cutoff, history_limit)
992
+ if error:
993
+ finding = Finding(result_target, 0, Severity.ERROR, "context-backfill-git-error", error)
994
+ data = {
995
+ **empty_data,
996
+ "cutoff": resolved_cutoff,
997
+ "requested_cutoff": cutoff,
998
+ "deterministic_errors": [{"code": "git-history-error", "message": error}],
999
+ }
1000
+ return AnalysisResult(result_target, data, [finding])
1001
+ repository = _remote(repo) or repo.name
1002
+ boundary = EvidenceBoundary(
1003
+ completeness=EvidenceCompleteness.BOUNDED,
1004
+ scope_kind=EvidenceScopeKind.RECENT_HISTORY,
1005
+ scope_path=module.relative_to(repo).as_posix(),
1006
+ cutoff_sha=resolved_cutoff,
1007
+ cutoff_tree=None,
1008
+ limitations=(
1009
+ EvidenceLimitation(
1010
+ "quick-mode",
1011
+ "Quick mode intentionally limits local Git history",
1012
+ ),
1013
+ ),
1014
+ )
1015
+ claims = [
1016
+ {
1017
+ "kind": "verified_fact",
1018
+ "claim": (
1019
+ f"Commit {record['commit'][:12]} ({record['subject']}) changed "
1020
+ f"{', '.join(record['paths'])}."
1021
+ ),
1022
+ "effective_date": record["date"],
1023
+ "reconstruction_date": reconstructed,
1024
+ "provenance": [
1025
+ {
1026
+ "source": "local-git",
1027
+ "repository": repository,
1028
+ "commit": record["commit"],
1029
+ "paths": record["paths"],
1030
+ }
1031
+ ],
1032
+ }
1033
+ for record in history
1034
+ ]
1035
+ sensitivity_flags = _quick_sensitivity_flags(history)
1036
+ if publish_safe:
1037
+ claims = []
1038
+ return AnalysisResult(
1039
+ target=result_target,
1040
+ data={
1041
+ **empty_data,
1042
+ "cutoff": resolved_cutoff,
1043
+ "requested_cutoff": cutoff,
1044
+ "repository": repository,
1045
+ "resource_counts": {
1046
+ "records": len(history),
1047
+ "paths": sum(len(record["paths"]) for record in history),
1048
+ "bytes": None,
1049
+ "subprocesses": 1,
1050
+ },
1051
+ "sensitivity_flags": sensitivity_flags,
1052
+ "publish_safe_omissions": (
1053
+ ["commit subjects", "historical paths"] if publish_safe else []
1054
+ ),
1055
+ "evidence_boundary": boundary.to_dict(),
1056
+ "claims": claims,
1057
+ "claims_by_kind": {
1058
+ "verified_fact": list(range(len(claims))),
1059
+ "inference": [],
1060
+ "contradiction": [],
1061
+ },
1062
+ "deterministic_errors": [],
1063
+ },
1064
+ )