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,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,6 @@
1
+ """Cross-cutting analysis — pair mode + discover mode over 3 signals."""
2
+
3
+ from .discover import discover
4
+ from .pair import pair
5
+
6
+ __all__ = ["discover", "pair"]
@@ -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