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.
- context_engineering/__init__.py +3 -0
- context_engineering/__main__.py +2 -0
- context_engineering/analysis/__init__.py +1 -0
- context_engineering/analysis/backfill.py +1064 -0
- context_engineering/analysis/context_check.py +253 -0
- context_engineering/analysis/context_layout.py +111 -0
- context_engineering/analysis/context_review.py +224 -0
- context_engineering/analysis/cross_cutting/__init__.py +6 -0
- context_engineering/analysis/cross_cutting/authors.py +57 -0
- context_engineering/analysis/cross_cutting/buckets.py +40 -0
- context_engineering/analysis/cross_cutting/co_change.py +47 -0
- context_engineering/analysis/cross_cutting/discover.py +75 -0
- context_engineering/analysis/cross_cutting/imports.py +61 -0
- context_engineering/analysis/cross_cutting/pair.py +118 -0
- context_engineering/analysis/impact.py +77 -0
- context_engineering/analysis/sessions.py +27 -0
- context_engineering/analysis/staleness.py +179 -0
- context_engineering/analysis/tier.py +91 -0
- context_engineering/checks/__init__.py +1 -0
- context_engineering/checks/antipatterns/__init__.py +5 -0
- context_engineering/checks/antipatterns/context.py +23 -0
- context_engineering/checks/antipatterns/density.py +72 -0
- context_engineering/checks/antipatterns/line_limits.py +52 -0
- context_engineering/checks/antipatterns/runner.py +137 -0
- context_engineering/checks/antipatterns/splitting.py +97 -0
- context_engineering/checks/antipatterns/volatile.py +38 -0
- context_engineering/checks/antipatterns/watermark.py +113 -0
- context_engineering/checks/contracts.py +456 -0
- context_engineering/checks/depth.py +82 -0
- context_engineering/checks/frontmatter.py +125 -0
- context_engineering/checks/references.py +325 -0
- context_engineering/checks/skill_structure.py +124 -0
- context_engineering/cli/__init__.py +3 -0
- context_engineering/cli/dispatch.py +90 -0
- context_engineering/cli/registry.py +33 -0
- context_engineering/cli/render.py +92 -0
- context_engineering/cli/subcommands.py +587 -0
- context_engineering/domain/__init__.py +0 -0
- context_engineering/domain/commit.py +19 -0
- context_engineering/domain/evidence.py +57 -0
- context_engineering/domain/finding.py +37 -0
- context_engineering/domain/result.py +59 -0
- context_engineering/infra/__init__.py +13 -0
- context_engineering/infra/filesystem.py +22 -0
- context_engineering/infra/git.py +153 -0
- context_engineering/infra/git_evidence.py +357 -0
- context_engineering/infra/git_tree.py +139 -0
- context_engineering/infra/markdown.py +58 -0
- context_engineering/infra/yaml_frontmatter.py +70 -0
- context_engineering_cli-2.6.0.dist-info/METADATA +27 -0
- context_engineering_cli-2.6.0.dist-info/RECORD +55 -0
- context_engineering_cli-2.6.0.dist-info/WHEEL +4 -0
- context_engineering_cli-2.6.0.dist-info/entry_points.txt +2 -0
- context_engineering_cli-2.6.0.dist-info/licenses/LICENSE +21 -0
- 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
|
+
)
|