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,253 @@
|
|
|
1
|
+
"""Compose deterministic contract checks with advisory diff review."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..checks import contracts, frontmatter, references, skill_structure
|
|
8
|
+
from ..domain.finding import Finding, Severity
|
|
9
|
+
from ..domain.result import AnalysisResult
|
|
10
|
+
from ..infra.git import git_root
|
|
11
|
+
from ..infra.git_tree import materialize_tree
|
|
12
|
+
from . import context_review
|
|
13
|
+
from .context_layout import RepositoryContextLayout, discover, is_ignored_contract_path
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _relative_owners(layout: RepositoryContextLayout) -> tuple[Path, ...]:
|
|
17
|
+
roots = {
|
|
18
|
+
*(path.parent for path in layout.specs),
|
|
19
|
+
*(tree.path.parent for tree in layout.adr_trees),
|
|
20
|
+
*(tree.path for tree in layout.docs_trees),
|
|
21
|
+
*(path.parent for path in layout.agents_files),
|
|
22
|
+
*(tree for tree in layout.skill_roots),
|
|
23
|
+
}
|
|
24
|
+
relative = (path.relative_to(layout.root) for path in roots)
|
|
25
|
+
return tuple(sorted(relative, key=lambda path: path.as_posix()))
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _affected_roots(
|
|
29
|
+
owners: tuple[Path, ...], changed: list[dict[str, str]], *, all_context: bool
|
|
30
|
+
) -> tuple[Path, ...]:
|
|
31
|
+
if all_context:
|
|
32
|
+
return owners
|
|
33
|
+
affected: set[Path] = set()
|
|
34
|
+
for entry in changed:
|
|
35
|
+
for raw_path in (entry.get("path"), entry.get("old_path")):
|
|
36
|
+
if raw_path is None:
|
|
37
|
+
continue
|
|
38
|
+
path = Path(raw_path)
|
|
39
|
+
matches = [root for root in owners if path == root or root in path.parents]
|
|
40
|
+
if matches:
|
|
41
|
+
affected.add(max(matches, key=lambda root: len(root.parts)))
|
|
42
|
+
return tuple(sorted(affected, key=lambda path: path.as_posix()))
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _deleted_contract_path(entry: dict[str, str]) -> bool:
|
|
46
|
+
"""Return whether a changed path removes a non-template SPEC contract."""
|
|
47
|
+
path = Path(entry["path"])
|
|
48
|
+
if entry.get("status") == "deleted":
|
|
49
|
+
return path.name == "SPEC.md" and not is_ignored_contract_path(path)
|
|
50
|
+
if entry.get("status") == "renamed":
|
|
51
|
+
old_path = Path(entry.get("old_path", ""))
|
|
52
|
+
return (
|
|
53
|
+
old_path.name == "SPEC.md"
|
|
54
|
+
and not is_ignored_contract_path(old_path)
|
|
55
|
+
and path.name != "SPEC.md"
|
|
56
|
+
)
|
|
57
|
+
return False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _finding_in_roots(finding: Finding, tree: Path, roots: tuple[Path, ...]) -> bool:
|
|
61
|
+
if not roots:
|
|
62
|
+
return False
|
|
63
|
+
path = Path(finding.file)
|
|
64
|
+
if path.is_absolute():
|
|
65
|
+
try:
|
|
66
|
+
relative = path.resolve().relative_to(tree.resolve())
|
|
67
|
+
except ValueError:
|
|
68
|
+
return False
|
|
69
|
+
else:
|
|
70
|
+
relative = path
|
|
71
|
+
return any(relative == root or root in relative.parents for root in roots)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _normalized_finding(finding: Finding, module: Path) -> Finding:
|
|
75
|
+
path = Path(finding.file)
|
|
76
|
+
if path.is_absolute():
|
|
77
|
+
try:
|
|
78
|
+
file = path.resolve().relative_to(module.resolve()).as_posix()
|
|
79
|
+
except ValueError:
|
|
80
|
+
file = finding.file
|
|
81
|
+
else:
|
|
82
|
+
file = finding.file
|
|
83
|
+
return Finding(
|
|
84
|
+
file=file,
|
|
85
|
+
line=finding.line,
|
|
86
|
+
severity=finding.severity,
|
|
87
|
+
code=finding.code,
|
|
88
|
+
message=finding.message.replace(str(module), "."),
|
|
89
|
+
hint=finding.hint,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _validate(module: Path) -> list[Finding]:
|
|
94
|
+
findings = [
|
|
95
|
+
*contracts.analyze(module).findings,
|
|
96
|
+
*frontmatter.lint(module).findings,
|
|
97
|
+
*references.lint(module).findings,
|
|
98
|
+
]
|
|
99
|
+
for skill_root in discover(module).skill_roots:
|
|
100
|
+
findings.extend(skill_structure.lint(skill_root, recurse=True).findings)
|
|
101
|
+
return findings
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def analyze(
|
|
105
|
+
module: Path,
|
|
106
|
+
*,
|
|
107
|
+
base: str,
|
|
108
|
+
head: str = "HEAD",
|
|
109
|
+
all_context: bool = False,
|
|
110
|
+
) -> AnalysisResult:
|
|
111
|
+
"""Run one read-only PR-time journey with deterministic and advisory outputs."""
|
|
112
|
+
module = module.resolve()
|
|
113
|
+
review = context_review.analyze(module, base=base, head=head)
|
|
114
|
+
repo = git_root(module)
|
|
115
|
+
if repo is None or review.has_errors:
|
|
116
|
+
return AnalysisResult(
|
|
117
|
+
target=str(module),
|
|
118
|
+
data={**review.data, "affected_context_roots": [], "deterministic_findings": []},
|
|
119
|
+
findings=review.findings,
|
|
120
|
+
)
|
|
121
|
+
relative_module = module.relative_to(repo)
|
|
122
|
+
exact_base = str(review.data["range"]["merge_base"])
|
|
123
|
+
exact_head = str(review.data["range"]["head"])
|
|
124
|
+
findings: list[Finding] = [
|
|
125
|
+
Finding(
|
|
126
|
+
entry["path"],
|
|
127
|
+
0,
|
|
128
|
+
Severity.ERROR,
|
|
129
|
+
"context-check-contract-deleted",
|
|
130
|
+
"A SPEC.md contract was deleted in this range",
|
|
131
|
+
)
|
|
132
|
+
for entry in review.data["changed_paths"]
|
|
133
|
+
if _deleted_contract_path(entry)
|
|
134
|
+
]
|
|
135
|
+
affected: tuple[Path, ...] = ()
|
|
136
|
+
with materialize_tree(repo, exact_base) as (base_tree, base_error):
|
|
137
|
+
if base_error is not None or base_tree is None:
|
|
138
|
+
findings.append(
|
|
139
|
+
Finding(
|
|
140
|
+
str(module),
|
|
141
|
+
0,
|
|
142
|
+
Severity.ERROR,
|
|
143
|
+
"context-check-tree-unavailable",
|
|
144
|
+
base_error or "base tree is unavailable",
|
|
145
|
+
)
|
|
146
|
+
)
|
|
147
|
+
with materialize_tree(repo, exact_head) as (head_tree, head_error):
|
|
148
|
+
if head_error is not None or head_tree is None:
|
|
149
|
+
findings.append(
|
|
150
|
+
Finding(
|
|
151
|
+
str(module),
|
|
152
|
+
0,
|
|
153
|
+
Severity.ERROR,
|
|
154
|
+
"context-check-tree-unavailable",
|
|
155
|
+
head_error or "head tree is unavailable",
|
|
156
|
+
)
|
|
157
|
+
)
|
|
158
|
+
else:
|
|
159
|
+
head_module = (head_tree / relative_module).resolve()
|
|
160
|
+
base_module = (
|
|
161
|
+
(base_tree / relative_module).resolve() if base_tree is not None else None
|
|
162
|
+
)
|
|
163
|
+
if not head_module.is_dir():
|
|
164
|
+
findings.append(
|
|
165
|
+
Finding(
|
|
166
|
+
str(relative_module),
|
|
167
|
+
0,
|
|
168
|
+
Severity.ERROR,
|
|
169
|
+
"context-check-target-deleted",
|
|
170
|
+
"The target module does not exist in the head tree",
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
head_layout = discover(head_module) if head_module.is_dir() else None
|
|
174
|
+
base_layout = (
|
|
175
|
+
discover(base_module)
|
|
176
|
+
if base_module is not None and base_module.is_dir()
|
|
177
|
+
else None
|
|
178
|
+
)
|
|
179
|
+
head_owners = _relative_owners(head_layout) if head_layout is not None else ()
|
|
180
|
+
base_owners = _relative_owners(base_layout) if base_layout is not None else ()
|
|
181
|
+
owners = tuple(
|
|
182
|
+
sorted(set((*head_owners, *base_owners)), key=lambda path: path.as_posix())
|
|
183
|
+
)
|
|
184
|
+
affected = _affected_roots(
|
|
185
|
+
owners,
|
|
186
|
+
list(review.data["changed_paths"]),
|
|
187
|
+
all_context=all_context,
|
|
188
|
+
)
|
|
189
|
+
changed_endpoints = {
|
|
190
|
+
path
|
|
191
|
+
for entry in review.data["changed_paths"]
|
|
192
|
+
for path in (entry.get("path"), entry.get("old_path"))
|
|
193
|
+
if path is not None
|
|
194
|
+
}
|
|
195
|
+
if base_layout is not None:
|
|
196
|
+
base_specs = {
|
|
197
|
+
path.relative_to(base_layout.root).as_posix() for path in base_layout.specs
|
|
198
|
+
}
|
|
199
|
+
head_specs = (
|
|
200
|
+
{
|
|
201
|
+
path.relative_to(head_layout.root).as_posix()
|
|
202
|
+
for path in head_layout.specs
|
|
203
|
+
}
|
|
204
|
+
if head_layout is not None
|
|
205
|
+
else set()
|
|
206
|
+
)
|
|
207
|
+
findings.extend(
|
|
208
|
+
Finding(
|
|
209
|
+
path,
|
|
210
|
+
0,
|
|
211
|
+
Severity.ERROR,
|
|
212
|
+
"context-check-contract-deleted",
|
|
213
|
+
"A SPEC.md contract was removed from this context owner",
|
|
214
|
+
)
|
|
215
|
+
for path in sorted(base_specs - head_specs)
|
|
216
|
+
if path in changed_endpoints
|
|
217
|
+
)
|
|
218
|
+
if head_module.is_dir():
|
|
219
|
+
head_candidates = [
|
|
220
|
+
_normalized_finding(item, head_module) for item in _validate(head_module)
|
|
221
|
+
]
|
|
222
|
+
if all_context:
|
|
223
|
+
findings.extend(head_candidates)
|
|
224
|
+
else:
|
|
225
|
+
head_candidates = [
|
|
226
|
+
item
|
|
227
|
+
for item in head_candidates
|
|
228
|
+
if _finding_in_roots(item, head_module, affected)
|
|
229
|
+
]
|
|
230
|
+
base_candidates: set[Finding] = set()
|
|
231
|
+
if base_module is not None and base_module.is_dir():
|
|
232
|
+
base_candidates = {
|
|
233
|
+
_normalized_finding(item, base_module)
|
|
234
|
+
for item in _validate(base_module)
|
|
235
|
+
if _finding_in_roots(item, base_module, affected)
|
|
236
|
+
}
|
|
237
|
+
findings.extend(
|
|
238
|
+
item for item in head_candidates if item not in base_candidates
|
|
239
|
+
)
|
|
240
|
+
findings = sorted(set(findings))
|
|
241
|
+
return AnalysisResult(
|
|
242
|
+
target=str(module),
|
|
243
|
+
data={
|
|
244
|
+
"range": review.data["range"],
|
|
245
|
+
"changed_paths": review.data["changed_paths"],
|
|
246
|
+
"evidence": review.data["evidence"],
|
|
247
|
+
"affected_context_roots": [path.as_posix() or "." for path in affected],
|
|
248
|
+
"deterministic_findings": [finding.to_dict() for finding in findings],
|
|
249
|
+
"advisory_routes": review.data["advisory_routes"],
|
|
250
|
+
"write_performed": False,
|
|
251
|
+
},
|
|
252
|
+
findings=findings,
|
|
253
|
+
)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
"""Discover portable repository context owners from current filesystem truth."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ..infra.filesystem import EXCLUDED_DIRS
|
|
10
|
+
|
|
11
|
+
_ADR_NAMES = {"adr", "adrs", "decisions"}
|
|
12
|
+
_ADR_PARENTS = {"architecture", "docs"}
|
|
13
|
+
_AGENT_TEMPLATE_ROOTS = ((".ai", "plans", "_templates"),)
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def is_ignored_contract_path(path: Path) -> bool:
|
|
17
|
+
"""Return whether a repository-relative path is an agent template artifact."""
|
|
18
|
+
return any(path.parts[: len(root)] == root for root in _AGENT_TEMPLATE_ROOTS)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
@dataclass(frozen=True)
|
|
22
|
+
class ContextTree:
|
|
23
|
+
path: Path
|
|
24
|
+
framework: str | None = None
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class RepositoryContextLayout:
|
|
29
|
+
root: Path
|
|
30
|
+
contract_roots: tuple[Path, ...]
|
|
31
|
+
specs: tuple[Path, ...]
|
|
32
|
+
adr_trees: tuple[ContextTree, ...]
|
|
33
|
+
docs_trees: tuple[ContextTree, ...]
|
|
34
|
+
agents_files: tuple[Path, ...]
|
|
35
|
+
skill_roots: tuple[Path, ...]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def _sort_paths(paths: set[Path]) -> tuple[Path, ...]:
|
|
39
|
+
return tuple(sorted(paths, key=lambda path: path.as_posix()))
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _docs_framework(path: Path) -> str | None:
|
|
43
|
+
"""Return the framework that owns a docs tree, when one is evidenced."""
|
|
44
|
+
parent = path.parent
|
|
45
|
+
markers = (
|
|
46
|
+
(parent / "mkdocs.yml", "mkdocs"),
|
|
47
|
+
(parent / "mkdocs.yaml", "mkdocs"),
|
|
48
|
+
(parent / "docusaurus.config.js", "docusaurus"),
|
|
49
|
+
(parent / "docusaurus.config.ts", "docusaurus"),
|
|
50
|
+
(path / "conf.py", "sphinx"),
|
|
51
|
+
(parent / "book.toml", "mdbook"),
|
|
52
|
+
)
|
|
53
|
+
return next((framework for marker, framework in markers if marker.is_file()), None)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def discover(root: Path) -> RepositoryContextLayout:
|
|
57
|
+
"""Return one conservative layout consumed by context checks and review."""
|
|
58
|
+
root = root.resolve()
|
|
59
|
+
directories: set[Path] = set()
|
|
60
|
+
specs: set[Path] = set()
|
|
61
|
+
agents_files: set[Path] = set()
|
|
62
|
+
docs_trees: set[Path] = set()
|
|
63
|
+
skill_roots: set[Path] = set()
|
|
64
|
+
for current_text, names, files in os.walk(root):
|
|
65
|
+
current = Path(current_text)
|
|
66
|
+
names[:] = sorted(
|
|
67
|
+
name
|
|
68
|
+
for name in names
|
|
69
|
+
if name not in EXCLUDED_DIRS and (not name.startswith(".") or name == ".agents")
|
|
70
|
+
)
|
|
71
|
+
directories.add(current)
|
|
72
|
+
if current.name == "docs" and "docs" not in current.relative_to(root).parts[:-1]:
|
|
73
|
+
docs_trees.add(current)
|
|
74
|
+
if current.name == "skills":
|
|
75
|
+
skill_roots.add(current)
|
|
76
|
+
if "SPEC.md" in files:
|
|
77
|
+
spec = current / "SPEC.md"
|
|
78
|
+
if not is_ignored_contract_path(spec.relative_to(root)):
|
|
79
|
+
specs.add(spec)
|
|
80
|
+
if "AGENTS.md" in files:
|
|
81
|
+
agents_files.add(current / "AGENTS.md")
|
|
82
|
+
|
|
83
|
+
spec_roots = {spec.parent for spec in specs}
|
|
84
|
+
adr_trees = {
|
|
85
|
+
directory
|
|
86
|
+
for directory in directories
|
|
87
|
+
if directory != root
|
|
88
|
+
and directory.name.casefold() in _ADR_NAMES
|
|
89
|
+
and (
|
|
90
|
+
directory.parent == root
|
|
91
|
+
or directory.parent.name.casefold() in _ADR_PARENTS
|
|
92
|
+
or directory.parent in spec_roots
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
contract_roots = {root, *spec_roots}
|
|
96
|
+
for tree in adr_trees:
|
|
97
|
+
if tree.parent.name.casefold() in _ADR_PARENTS:
|
|
98
|
+
contract_roots.add(tree.parent.parent)
|
|
99
|
+
else:
|
|
100
|
+
contract_roots.add(tree.parent)
|
|
101
|
+
return RepositoryContextLayout(
|
|
102
|
+
root=root,
|
|
103
|
+
contract_roots=_sort_paths(contract_roots),
|
|
104
|
+
specs=_sort_paths(specs),
|
|
105
|
+
adr_trees=tuple(ContextTree(path) for path in _sort_paths(adr_trees)),
|
|
106
|
+
docs_trees=tuple(
|
|
107
|
+
ContextTree(path, framework=_docs_framework(path)) for path in _sort_paths(docs_trees)
|
|
108
|
+
),
|
|
109
|
+
agents_files=_sort_paths(agents_files),
|
|
110
|
+
skill_roots=_sort_paths(skill_roots),
|
|
111
|
+
)
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""Inspect a Git diff without turning semantic routing hints into authority."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
from ..domain.finding import Finding, Severity
|
|
8
|
+
from ..domain.result import AnalysisResult
|
|
9
|
+
from ..infra.git import git_root, resolve_commit, resolve_merge_base
|
|
10
|
+
from ..infra.git_evidence import read_diff
|
|
11
|
+
|
|
12
|
+
_STATUS = {
|
|
13
|
+
"A": "added",
|
|
14
|
+
"C": "copied",
|
|
15
|
+
"D": "deleted",
|
|
16
|
+
"M": "modified",
|
|
17
|
+
"R": "renamed",
|
|
18
|
+
"T": "type-changed",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _diff(
|
|
23
|
+
repo: Path,
|
|
24
|
+
module: Path,
|
|
25
|
+
base: str,
|
|
26
|
+
head: str,
|
|
27
|
+
) -> tuple[list[dict[str, str]], str | None]:
|
|
28
|
+
relative = module.relative_to(repo).as_posix()
|
|
29
|
+
changes, error = read_diff(repo, base=base, head=head)
|
|
30
|
+
if error:
|
|
31
|
+
return [], error
|
|
32
|
+
changed: list[dict[str, str]] = []
|
|
33
|
+
for change in changes:
|
|
34
|
+
raw_status = change.status
|
|
35
|
+
code = raw_status[:1]
|
|
36
|
+
entry = {"status": _STATUS.get(code, raw_status.casefold()), "path": change.path}
|
|
37
|
+
if change.old_path is not None:
|
|
38
|
+
entry["old_path"] = change.old_path
|
|
39
|
+
paths = [entry["path"], *([entry["old_path"]] if "old_path" in entry else [])]
|
|
40
|
+
in_scope = relative == "." or any(
|
|
41
|
+
path == relative or path.startswith(f"{relative}/") for path in paths
|
|
42
|
+
)
|
|
43
|
+
if not in_scope:
|
|
44
|
+
continue
|
|
45
|
+
if relative != ".":
|
|
46
|
+
for key in ("path", "old_path"):
|
|
47
|
+
if key in entry and entry[key].startswith(f"{relative}/"):
|
|
48
|
+
entry[key] = entry[key][len(relative) + 1 :]
|
|
49
|
+
changed.append(entry)
|
|
50
|
+
return sorted(changed, key=lambda item: (item["path"], item["status"])), None
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _route(path: str) -> list[tuple[str, str]]:
|
|
54
|
+
lowered = path.casefold()
|
|
55
|
+
routes: list[tuple[str, str]] = []
|
|
56
|
+
if Path(lowered).name == "spec.md":
|
|
57
|
+
routes.append(("SPEC.md", "the diff directly changes the current contract"))
|
|
58
|
+
has_adr_path = any(part in lowered for part in ("/adr/", "/adrs/", "/decisions/"))
|
|
59
|
+
if has_adr_path or lowered.startswith(("adr/", "adrs/")):
|
|
60
|
+
routes.append(("ADR", "the diff directly changes a decision record"))
|
|
61
|
+
if lowered.endswith("agents.md"):
|
|
62
|
+
routes.append(("AGENTS.md", "the diff changes agent routing or repository gotchas"))
|
|
63
|
+
if lowered.endswith("skill.md") or "/skills/" in lowered or lowered.startswith("skills/"):
|
|
64
|
+
routes.append(("skill", "the diff changes a repeatable agent workflow"))
|
|
65
|
+
has_doc_path = "/reference/" in lowered or "/explanation/" in lowered
|
|
66
|
+
if has_doc_path or lowered.startswith(("docs/reference/", "docs/explanation/")):
|
|
67
|
+
routes.append(
|
|
68
|
+
(
|
|
69
|
+
"architecture/reference docs",
|
|
70
|
+
"the diff changes durable explanatory or lookup context",
|
|
71
|
+
)
|
|
72
|
+
)
|
|
73
|
+
source_suffixes = {
|
|
74
|
+
".bash",
|
|
75
|
+
".c",
|
|
76
|
+
".cc",
|
|
77
|
+
".cjs",
|
|
78
|
+
".cpp",
|
|
79
|
+
".cs",
|
|
80
|
+
".cxx",
|
|
81
|
+
".dart",
|
|
82
|
+
".ex",
|
|
83
|
+
".exs",
|
|
84
|
+
".fish",
|
|
85
|
+
".go",
|
|
86
|
+
".h",
|
|
87
|
+
".hcl",
|
|
88
|
+
".hpp",
|
|
89
|
+
".java",
|
|
90
|
+
".js",
|
|
91
|
+
".json",
|
|
92
|
+
".jsx",
|
|
93
|
+
".kt",
|
|
94
|
+
".kts",
|
|
95
|
+
".lua",
|
|
96
|
+
".mjs",
|
|
97
|
+
".php",
|
|
98
|
+
".py",
|
|
99
|
+
".rb",
|
|
100
|
+
".rs",
|
|
101
|
+
".scala",
|
|
102
|
+
".sh",
|
|
103
|
+
".sql",
|
|
104
|
+
".svelte",
|
|
105
|
+
".swift",
|
|
106
|
+
".tf",
|
|
107
|
+
".toml",
|
|
108
|
+
".ts",
|
|
109
|
+
".tsx",
|
|
110
|
+
".vue",
|
|
111
|
+
".xml",
|
|
112
|
+
".yaml",
|
|
113
|
+
".yml",
|
|
114
|
+
".zsh",
|
|
115
|
+
}
|
|
116
|
+
if not routes and Path(lowered).suffix in source_suffixes:
|
|
117
|
+
routes.extend(
|
|
118
|
+
(
|
|
119
|
+
(
|
|
120
|
+
"SPEC.md",
|
|
121
|
+
"implementation or configuration changed; inspect whether current "
|
|
122
|
+
"behavior or invariants moved",
|
|
123
|
+
),
|
|
124
|
+
(
|
|
125
|
+
"ADR",
|
|
126
|
+
"implementation or configuration changed; inspect whether a lasting "
|
|
127
|
+
"decision was introduced",
|
|
128
|
+
),
|
|
129
|
+
)
|
|
130
|
+
)
|
|
131
|
+
if not routes:
|
|
132
|
+
routes.append(("no update", "no deterministic context owner follows from the path alone"))
|
|
133
|
+
return routes
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def analyze(module: Path, *, base: str, head: str = "HEAD") -> AnalysisResult:
|
|
137
|
+
module = module.resolve()
|
|
138
|
+
repo = git_root(module)
|
|
139
|
+
if repo is None:
|
|
140
|
+
finding = Finding(
|
|
141
|
+
str(module),
|
|
142
|
+
0,
|
|
143
|
+
Severity.ERROR,
|
|
144
|
+
"context-review-not-git",
|
|
145
|
+
"Target is not in a Git repository",
|
|
146
|
+
)
|
|
147
|
+
return AnalysisResult(
|
|
148
|
+
target=str(module),
|
|
149
|
+
data={
|
|
150
|
+
"range": {"base": base, "head": head},
|
|
151
|
+
"changed_paths": [],
|
|
152
|
+
"evidence": [],
|
|
153
|
+
"advisory_routes": [],
|
|
154
|
+
"deterministic_errors": [
|
|
155
|
+
{"code": "not-a-git-repository", "message": finding.message}
|
|
156
|
+
],
|
|
157
|
+
"write_performed": False,
|
|
158
|
+
},
|
|
159
|
+
findings=[finding],
|
|
160
|
+
)
|
|
161
|
+
resolved_base, base_error = resolve_commit(repo, base)
|
|
162
|
+
resolved_head, head_error = resolve_commit(repo, head)
|
|
163
|
+
findings: list[Finding] = []
|
|
164
|
+
deterministic_errors: list[dict[str, str]] = []
|
|
165
|
+
merge_base: str | None = None
|
|
166
|
+
error = base_error or head_error
|
|
167
|
+
if error is None:
|
|
168
|
+
assert resolved_base is not None and resolved_head is not None
|
|
169
|
+
merge_base, error = resolve_merge_base(repo, resolved_base, resolved_head)
|
|
170
|
+
changed: list[dict[str, str]] = []
|
|
171
|
+
if error is None:
|
|
172
|
+
assert resolved_base is not None and resolved_head is not None
|
|
173
|
+
changed, error = _diff(repo, module, resolved_base, resolved_head)
|
|
174
|
+
if error:
|
|
175
|
+
deterministic_errors.append({"code": "invalid-git-range", "message": error})
|
|
176
|
+
findings.append(
|
|
177
|
+
Finding(str(module), 0, Severity.ERROR, "context-review-invalid-range", error)
|
|
178
|
+
)
|
|
179
|
+
exact_base = resolved_base or base
|
|
180
|
+
exact_head = resolved_head or head
|
|
181
|
+
evidence = [
|
|
182
|
+
{**entry, "source": "git-diff", "range": f"{exact_base}...{exact_head}"}
|
|
183
|
+
for entry in changed
|
|
184
|
+
]
|
|
185
|
+
advisory_routes: list[dict[str, object]] = []
|
|
186
|
+
for index, entry in enumerate(changed):
|
|
187
|
+
route_reasons: dict[str, str] = {}
|
|
188
|
+
route_paths: dict[str, list[str]] = {}
|
|
189
|
+
paths = [entry["path"]]
|
|
190
|
+
if "old_path" in entry:
|
|
191
|
+
paths.append(entry["old_path"])
|
|
192
|
+
for path in paths:
|
|
193
|
+
for route, reason in _route(path):
|
|
194
|
+
route_reasons.setdefault(route, reason)
|
|
195
|
+
route_paths.setdefault(route, []).append(path)
|
|
196
|
+
for route, reason in route_reasons.items():
|
|
197
|
+
advisory_routes.append(
|
|
198
|
+
{
|
|
199
|
+
"path": entry["path"],
|
|
200
|
+
"paths": route_paths[route],
|
|
201
|
+
"route": route,
|
|
202
|
+
"reason": reason,
|
|
203
|
+
"authority": "advisory",
|
|
204
|
+
"evidence_index": index,
|
|
205
|
+
}
|
|
206
|
+
)
|
|
207
|
+
return AnalysisResult(
|
|
208
|
+
target=str(module),
|
|
209
|
+
data={
|
|
210
|
+
"range": {
|
|
211
|
+
"base": exact_base,
|
|
212
|
+
"head": exact_head,
|
|
213
|
+
"merge_base": merge_base,
|
|
214
|
+
"requested_base": base,
|
|
215
|
+
"requested_head": head,
|
|
216
|
+
},
|
|
217
|
+
"changed_paths": changed,
|
|
218
|
+
"evidence": evidence,
|
|
219
|
+
"advisory_routes": advisory_routes,
|
|
220
|
+
"deterministic_errors": deterministic_errors,
|
|
221
|
+
"write_performed": False,
|
|
222
|
+
},
|
|
223
|
+
findings=findings,
|
|
224
|
+
)
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Author-overlap signal — who ships across multiple modules?"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
from collections.abc import Iterable
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from ...domain.commit import Commit
|
|
10
|
+
from ...infra.git import git_log
|
|
11
|
+
from .buckets import infer, module_for_path
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def activity_by_author(
|
|
15
|
+
commits: Iterable[Commit], module_roots: list[str]
|
|
16
|
+
) -> dict[str, Counter]:
|
|
17
|
+
"""author → Counter{module_root: commits_touching_it}"""
|
|
18
|
+
out: dict[str, Counter] = defaultdict(Counter)
|
|
19
|
+
for commit in commits:
|
|
20
|
+
touched = {m for f in commit.files if (m := module_for_path(f, module_roots))}
|
|
21
|
+
for m in touched:
|
|
22
|
+
out[commit.author][m] += 1
|
|
23
|
+
return dict(out)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def cross_cutting_authors(
|
|
27
|
+
activity: dict[str, Counter], *, min_modules: int = 2
|
|
28
|
+
) -> list[tuple[str, dict[str, int]]]:
|
|
29
|
+
out = [
|
|
30
|
+
(author, dict(counts))
|
|
31
|
+
for author, counts in activity.items()
|
|
32
|
+
if len(counts) >= min_modules
|
|
33
|
+
]
|
|
34
|
+
out.sort(key=lambda x: sum(x[1].values()), reverse=True)
|
|
35
|
+
return out
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def discover_partner_modules(
|
|
39
|
+
repo_root: Path, target_module: str, lookback_days: int
|
|
40
|
+
) -> dict[str, Counter]:
|
|
41
|
+
"""For authors of `target_module`, which *other* buckets do they ship to?"""
|
|
42
|
+
target_authors: set[str] = set()
|
|
43
|
+
author_commits: dict[str, list[list[str]]] = defaultdict(list)
|
|
44
|
+
|
|
45
|
+
for commit in git_log(repo_root, since_days=lookback_days):
|
|
46
|
+
author_commits[commit.author].append(list(commit.files))
|
|
47
|
+
if commit.touches(target_module):
|
|
48
|
+
target_authors.add(commit.author)
|
|
49
|
+
|
|
50
|
+
partners: dict[str, Counter] = defaultdict(Counter)
|
|
51
|
+
for author in target_authors:
|
|
52
|
+
for files in author_commits[author]:
|
|
53
|
+
for f in files:
|
|
54
|
+
bucket = infer(f)
|
|
55
|
+
if bucket and bucket != target_module:
|
|
56
|
+
partners[author][bucket] += 1
|
|
57
|
+
return dict(partners)
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
"""Infer a module bucket from a repo-relative file path.
|
|
2
|
+
|
|
3
|
+
Used by discover mode to answer "what bucket does this file belong to?" The
|
|
4
|
+
patterns below cover common monorepo layouts. Anything else falls back to the
|
|
5
|
+
top-level directory.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
# Common generic monorepo containers. For paths under these directories, use the
|
|
11
|
+
# next segment as the module name: `services/billing/api.py` -> `services/billing`.
|
|
12
|
+
_PATTERNS: tuple[tuple[str, ...], ...] = (
|
|
13
|
+
("apps",),
|
|
14
|
+
("packages",),
|
|
15
|
+
("services",),
|
|
16
|
+
("libs",),
|
|
17
|
+
("plugins",),
|
|
18
|
+
("tools",),
|
|
19
|
+
)
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def infer(path: str) -> str | None:
|
|
23
|
+
"""Return the enclosing module bucket for `path`, or None for top-level files."""
|
|
24
|
+
parts = path.split("/")
|
|
25
|
+
if len(parts) < 2:
|
|
26
|
+
return None
|
|
27
|
+
for pattern in _PATTERNS:
|
|
28
|
+
if len(parts) > len(pattern) and tuple(parts[: len(pattern)]) == pattern:
|
|
29
|
+
return "/".join(parts[: len(pattern) + 1])
|
|
30
|
+
return parts[0]
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def module_for_path(rel_path: str, module_roots: list[str]) -> str | None:
|
|
34
|
+
"""Match `rel_path` to the longest prefix in `module_roots`, or None."""
|
|
35
|
+
best: str | None = None
|
|
36
|
+
for root in module_roots:
|
|
37
|
+
if rel_path == root or rel_path.startswith(root.rstrip("/") + "/"):
|
|
38
|
+
if best is None or len(root) > len(best):
|
|
39
|
+
best = root
|
|
40
|
+
return best
|