Skip to content

Commit 93b9d74

Browse files
Pedro Anisio Silvaclaude
andcommitted
fix(decomposer): granularity-qualified cycle findings with real edge evidence
Review of the v0.1.0 self-decomposition found the cycle findings citing fabricated evidence: the rendered "cycle path" was the alphabetically sorted SCC joined with arrows, asserting import edges that do not exist, graded certain/error on a self-declared probable module model. - Compute cycles at file AND directory granularity. File-level cycles are error/certain with inducing file edges attached; directory-level cycles become an explicitly labeled directory_aggregation_cycle gate at warning/probable (confidence capped at the module model's), citing every real module edge with weights plus sample file edges. - New granularity_divergence finding when the file graph is a DAG but directory cycles exist, so DAG-vs-cycles disagreements between pipeline artifacts surface as findings instead of passing silently. - shared_kernel independence check now catches sibling-package imports (was top-level-only, missing the real shared_kernel -> inspection edge that pyproject's import-linter contract exempts via ignore_imports). - Bidirectional-coupling violations cite file edges in both directions. - Interface fallback honors its documented contract: file basenames only when no symbol xrefs cross in (was mixing filenames with symbols). - test_gap gate restricted to TESTABLE_LANGUAGES, dropping docker/proto false positives while keeping domain/ports and ui/views signal. - Report: bundle provenance line (path, run_manifest sha256, generated_at, extensions), unique part ids in tables, domain overlays out of the parts inventory, gate example subjects, {a <-> b} cycle groups in build order, full column legend and ABox confidence-vocabulary mapping. - model/parts: operational + documentation part kinds from a parallel session's work on parts.py; included to keep the committed tree coherent. 10 new regression tests (test_cycle_findings.py) plus 7 report-contract assertions in the smoke suite; full suite 95 passed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 288b49b commit 93b9d74

9 files changed

Lines changed: 556 additions & 50 deletions

File tree

decomposer/architecture.py

Lines changed: 51 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,12 @@
1212

1313
from .evidence import EvidenceGraph
1414
from .model import Architecture, Confidence, Hypothesis, Violation
15-
from .parts import ModuleGraph, ROOT
15+
from .parts import ModuleGraph, ROOT, file_edges_between
1616

1717

1818
def detect_architecture(
19-
ev: EvidenceGraph, mg: ModuleGraph, module_cycles: list[list[str]]
19+
ev: EvidenceGraph, mg: ModuleGraph,
20+
module_cycles: list[list[str]], file_cycles: list[list[str]],
2021
) -> Architecture:
2122
top_segments = {_top(m) for m in mg.modules() if m != ROOT}
2223
all_segments = {seg for m in mg.modules() for seg in PurePosixPath(m).parts}
@@ -79,7 +80,7 @@ def detect_architecture(
7980
# ── choose dominant style ───────────────────────────────────────────────
8081
style, style_conf = _dominant(labels)
8182

82-
violations = _violations(ev, mg, module_cycles)
83+
violations = _violations(ev, mg, module_cycles, file_cycles)
8384
return Architecture(
8485
style=style, confidence=style_conf,
8586
evidence=_dedupe(evidence), violations=violations, hypotheses=hypotheses,
@@ -103,45 +104,78 @@ def _dominant(labels: list[tuple[str, Confidence]]) -> tuple[str, Confidence]:
103104

104105

105106
def _violations(
106-
ev: EvidenceGraph, mg: ModuleGraph, module_cycles: list[list[str]]
107+
ev: EvidenceGraph, mg: ModuleGraph,
108+
module_cycles: list[list[str]], file_cycles: list[list[str]],
107109
) -> list[Violation]:
108110
out: list[Violation] = []
109111

110-
for cyc in module_cycles:
112+
# File-level cycles are extractor-graph facts: CERTAIN, with the edges named.
113+
for cyc in file_cycles:
114+
members = set(cyc)
115+
edges = sorted(
116+
f"{a} -> {b}"
117+
for a in cyc for b in ev.imports_out.get(a, []) if b in members
118+
)
111119
out.append(Violation(
112120
kind="circular_dependency",
113-
description=f"Import cycle across {len(cyc)} modules: {' -> '.join(cyc)} -> {cyc[0]}.",
114-
confidence=Confidence.CERTAIN, subjects=[f"module:{m}" for m in cyc],
121+
description=(f"File-level import cycle among {len(cyc)} files: "
122+
+ "; ".join(edges) + "."),
123+
confidence=Confidence.CERTAIN, subjects=sorted(cyc),
124+
))
125+
126+
# Directory-level cycles exist only under the module==directory aggregation
127+
# (a `probable` model), so the finding inherits that confidence and cites
128+
# the real inducing edges instead of a chain through the sorted SCC.
129+
for cyc in module_cycles:
130+
members = set(cyc)
131+
edge_strs = [
132+
f"{a} -> {b} (x{w})" for (a, b), w in sorted(mg.edge_weight.items())
133+
if a in members and b in members
134+
]
135+
out.append(Violation(
136+
kind="directory_aggregation_cycle",
137+
description=(
138+
f"{len(cyc)} directories are mutually reachable under "
139+
f"directory aggregation (file-level graph "
140+
f"{'is acyclic' if not file_cycles else 'also has cycles'}). "
141+
f"Inducing edges: " + "; ".join(edge_strs) + "."),
142+
confidence=Confidence.PROBABLE, subjects=[f"module:{m}" for m in cyc],
115143
))
116144

117-
# Shared-kernel independence: a kernel package should not import outward into
118-
# sibling feature packages. Any such edge is a graph fact (CERTAIN) and a
119-
# recognized anti-pattern; the repo's own import-linter encodes the same rule.
145+
# Shared-kernel independence: a kernel package should not import outward —
146+
# not into sibling feature packages, not into its parent package. The cited
147+
# file edges are graph facts (CERTAIN); this mirrors the import-linter
148+
# "forbidden" contract shape (source: kernel, forbidden: everything outside).
120149
for m in mg.modules():
121150
segs = set(PurePosixPath(m).parts)
122151
if not (segs & {"shared_kernel", "kernel"}):
123152
continue
124-
kernel_top = _top(m)
125153
outward = [d for d in mg.adjacency.get(m, [])
126-
if _top(d) != kernel_top]
154+
if not (d == m or d.startswith(m + "/"))]
127155
if outward:
156+
edges = [e for d in outward for e in file_edges_between(ev, mg, m, d)]
128157
out.append(Violation(
129158
kind="shared_kernel_not_independent",
130159
description=(f"Shared-kernel module `{m}` imports outward into "
131-
f"{outward}, coupling the kernel to feature packages."),
160+
f"{outward}, coupling the kernel to the packages "
161+
f"it should serve. File edges: " + "; ".join(edges) + "."),
132162
confidence=Confidence.CERTAIN,
133163
subjects=[f"module:{m}"] + [f"module:{d}" for d in outward],
134164
))
135165

136-
# Bidirectional module coupling (two-way import between packages).
166+
# Bidirectional module coupling (two-way import between directories) —
167+
# phrased on the directory model, hence PROBABLE, with file edges cited.
137168
seen: set[frozenset[str]] = set()
138-
for (a, b) in mg.edge_weight:
169+
for (a, b) in sorted(mg.edge_weight):
139170
if (b, a) in mg.edge_weight and frozenset({a, b}) not in seen:
140171
seen.add(frozenset({a, b}))
172+
fwd = file_edges_between(ev, mg, a, b, limit=2)
173+
rev = file_edges_between(ev, mg, b, a, limit=2)
141174
out.append(Violation(
142175
kind="bidirectional_coupling",
143-
description=f"Modules `{a}` and `{b}` import each other.",
144-
confidence=Confidence.CERTAIN,
176+
description=(f"Directories `{a}` and `{b}` import each other. "
177+
f"File edges: " + "; ".join(fwd + rev) + "."),
178+
confidence=Confidence.PROBABLE,
145179
subjects=[f"module:{a}", f"module:{b}"],
146180
))
147181
return out

decomposer/decompose.py

Lines changed: 19 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,15 +33,19 @@ def decompose(bundle_dir: str | Path) -> Decomposition:
3333

3434
module_names = [m for m in mg.modules() if _has_code(ev, mg, m)]
3535
module_cycles = _cycles(module_names, mg.adjacency)
36+
# Cycles at file granularity too: directory aggregation both manufactures
37+
# cycles (parent/child re-exports) and hides them, so topology claims are
38+
# only honest when both granularities are computed and reported.
39+
file_cycles = _cycles([f["path"] for f in ev.files], ev.imports_out)
3640
cycle_modules = {m for cyc in module_cycles for m in cyc}
3741

3842
module_parts = build_module_parts(ev, mg, cycle_modules)
3943
cross_parts = build_cross_cutting_parts(ev, mg)
4044
parts = module_parts + cross_parts
4145

4246
relationships = _relationships(ev, mg)
43-
architecture = detect_architecture(ev, mg, module_cycles)
44-
gates = run_gates(ev, mg, module_cycles)
47+
architecture = detect_architecture(ev, mg, module_cycles, file_cycles)
48+
gates = run_gates(ev, mg, module_cycles, file_cycles)
4549

4650
module_part_ids = {p.id for p in module_parts}
4751
order_layers = _build_order(module_names, mg.adjacency)
@@ -51,8 +55,8 @@ def decompose(bundle_dir: str | Path) -> Decomposition:
5155
]
5256
build_order = [layer for layer in build_order if layer]
5357

54-
repository = _repository_header(ev, parts, module_cycles)
55-
provenance = _provenance(ev, mg, parts, gates)
58+
repository = _repository_header(ev, parts, module_cycles, file_cycles)
59+
provenance = _provenance(ev, mg, parts, gates, module_cycles)
5660

5761
return Decomposition(
5862
repository=repository,
@@ -129,7 +133,8 @@ def _relationships(ev: EvidenceGraph, mg: ModuleGraph) -> list[Relationship]:
129133

130134

131135
def _repository_header(
132-
ev: EvidenceGraph, parts: list[Part], module_cycles: list[list[str]]
136+
ev: EvidenceGraph, parts: list[Part],
137+
module_cycles: list[list[str]], file_cycles: list[list[str]],
133138
) -> dict:
134139
m = ev.manifest
135140
purpose, purpose_conf = _purpose(ev)
@@ -149,6 +154,7 @@ def _repository_header(
149154
"files": (m.get("counts") or {}).get("files"),
150155
"n_parts": len(parts),
151156
"n_module_cycles": len(module_cycles),
157+
"n_file_cycles": len(file_cycles),
152158
}
153159

154160

@@ -169,12 +175,19 @@ def _purpose(ev: EvidenceGraph) -> tuple[str, Confidence]:
169175
return ("Not determinable from bundle evidence.", Confidence.UNKNOWN)
170176

171177

172-
def _provenance(ev: EvidenceGraph, mg: ModuleGraph, parts, gates) -> dict:
178+
def _provenance(
179+
ev: EvidenceGraph, mg: ModuleGraph, parts, gates,
180+
module_cycles: list[list[str]],
181+
) -> dict:
173182
from collections import Counter
174183
kinds = Counter(p.kind for p in parts)
175184
return {
176185
"tool": TOOL_ID,
177186
"bundle_dir": str(ev.bundle_dir),
187+
"run_manifest_sha256": ev.manifest_sha256,
188+
"bundle_generated_at": ev.manifest.get("generated_at"),
189+
"bundle_extensions": sorted((ev.manifest.get("extensions") or {}).keys()),
190+
"module_cycles": [list(c) for c in module_cycles],
178191
"evidence_basis": (
179192
"Structural parts, dependencies, coupling metrics (Ca/Ce, Martin "
180193
"instability), cycles and build order are mechanically derived from "

decomposer/evidence.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
"""
1818
from __future__ import annotations
1919

20+
import hashlib
2021
import json
2122
from dataclasses import dataclass, field
2223
from pathlib import Path
@@ -52,6 +53,7 @@ class EvidenceGraph:
5253
schema_purposes: dict[str, dict[str, Any]] # path -> {text, model, ...} (LLM, unverified)
5354
phases: dict[str, list[str]] # path -> phase local names
5455
rust_items: list[dict[str, Any]] = field(default_factory=list)
56+
manifest_sha256: str = "" # run identity for provenance
5557

5658
# ── convenience accessors ────────────────────────────────────────────────
5759
def code_files(self) -> list[dict[str, Any]]:
@@ -102,9 +104,21 @@ def load_evidence(bundle_dir: str | Path) -> EvidenceGraph:
102104
schema_purposes=b.enrichment_schema_purpose,
103105
phases=phases,
104106
rust_items=b.rust_items,
107+
manifest_sha256=_manifest_sha256(bundle_dir),
105108
)
106109

107110

111+
def _manifest_sha256(bundle_dir: Path) -> str:
112+
"""Hash of run_manifest.json — the bundle's run identity. Decomposition
113+
consumers need it to tell apart bundles built from the same commit with
114+
different plugin sets (concepts present vs. absent, etc.)."""
115+
manifest = bundle_dir / "run_manifest.json"
116+
try:
117+
return hashlib.sha256(manifest.read_bytes()).hexdigest()
118+
except OSError:
119+
return ""
120+
121+
108122
def _load_bundle(bundle_dir: Path):
109123
"""Import seam. Kept in its own function so the ``frontend`` dependency has a
110124
single, replaceable call site (see module docstring)."""

decomposer/model.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ def weakest(cls, *values: "Confidence") -> "Confidence":
5656
PART_KINDS = frozenset({
5757
"file", "module", "package", "application", "service", "library",
5858
"external_dependency", "entrypoint", "domain", "data_schema",
59-
"generated_artifact",
59+
"generated_artifact", "operational", "documentation",
6060
})
6161
ROLES = frozenset({
6262
"core", "supporting", "infrastructure", "adapter", "test", "generated",

decomposer/parts.py

Lines changed: 93 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -120,16 +120,20 @@ def _compute_interfaces(ev: EvidenceGraph, mg: ModuleGraph) -> None:
120120
if sym and sym != "<file>":
121121
iface.setdefault(dm, set()).add(sym)
122122

123-
# Fallback: files imported across the module boundary are public surface.
123+
# Fallback: files imported across the module boundary are public surface —
124+
# only for modules with NO symbol-level xrefs crossing in, so a module's
125+
# interface list is either symbols or file basenames, never a mix.
126+
fallback: dict[str, set[str]] = {}
124127
for src, targets in ev.imports_out.items():
125128
sm = mg.module_of_file.get(src)
126129
for dst in targets:
127130
dm = mg.module_of_file.get(dst)
128131
if dm and sm != dm:
129-
iface.setdefault(dm, set()).add(PurePosixPath(dst).name)
132+
fallback.setdefault(dm, set()).add(PurePosixPath(dst).name)
130133

131134
for m in mg.files_of_module:
132-
mg.interfaces[m] = sorted(iface.get(m, set()))[:40]
135+
surface = iface.get(m) or fallback.get(m, set())
136+
mg.interfaces[m] = sorted(surface)[:40]
133137
mg.xref_in[m] = xin.get(m, 0)
134138
mg.xref_out[m] = xout.get(m, 0)
135139

@@ -229,6 +233,8 @@ def build_cross_cutting_parts(ev: EvidenceGraph, mg: ModuleGraph) -> list[Part]:
229233
parts.extend(_domain_parts(ev))
230234
parts.extend(_data_schema_parts(ev, mg))
231235
parts.extend(_generated_parts(ev, mg))
236+
parts.extend(_operational_parts(ev))
237+
parts.extend(_documentation_part(ev))
232238
return parts
233239

234240

@@ -442,7 +448,91 @@ def _generated_parts(ev: EvidenceGraph, mg: ModuleGraph) -> list[Part]:
442448
return parts
443449

444450

451+
# Operational categories, keyed by the extractor's *certain* file-type facts.
452+
# Each category becomes one ``operational`` part so build/CI/deploy/runtime-env
453+
# concerns are first-class parts even when their files live outside any
454+
# code-bearing module (e.g. a root Dockerfile).
455+
_OPERATIONAL_CATEGORIES: list[tuple[str, frozenset[str], str]] = [
456+
("build_system", frozenset({"build_script"}),
457+
"Build orchestration (make/gradle/cmake-style entry tasks)."),
458+
("dependency_management", frozenset({"dependency_manifest", "lockfile"}),
459+
"Dependency declaration and version pinning."),
460+
("ci_cd", frozenset({"ci_cd"}),
461+
"Continuous integration / delivery pipeline definitions."),
462+
("deployment", frozenset({"container"}),
463+
"Container / deployment topology definitions."),
464+
("runtime_configuration", frozenset({"configuration", "environment"}),
465+
"Runtime and tooling configuration, environment variables."),
466+
]
467+
468+
469+
def _operational_parts(ev: EvidenceGraph) -> list[Part]:
470+
parts: list[Part] = []
471+
for name, types, responsibility in _OPERATIONAL_CATEGORIES:
472+
files = sorted(f["path"] for f in ev.files if f.get("type") in types)
473+
if not files:
474+
continue
475+
parts.append(Part(
476+
id=f"ops:{name}", name=name, kind="operational",
477+
layer="operational",
478+
responsibility=responsibility,
479+
responsibility_confidence=Confidence.STRONG,
480+
evidence=Evidence(
481+
files=files,
482+
signals=[f"{len(files)} file(s) of type {sorted(types)}"],
483+
),
484+
classification=Classification(
485+
role="infrastructure", role_confidence=Confidence.CERTAIN,
486+
reusability="internal", risk="low",
487+
),
488+
metrics={"n_files": len(files), "file_types": sorted(types)},
489+
overall_confidence=Confidence.CERTAIN,
490+
))
491+
return parts
492+
493+
494+
def _documentation_part(ev: EvidenceGraph) -> list[Part]:
495+
files = sorted(f["path"] for f in ev.files if f.get("type") == "documentation")
496+
if not files:
497+
return []
498+
return [Part(
499+
id="docs:documentation", name="documentation", kind="documentation",
500+
layer="operational",
501+
responsibility="Project documentation (READMEs, guides, specs).",
502+
responsibility_confidence=Confidence.CERTAIN,
503+
evidence=Evidence(
504+
files=files,
505+
signals=[f"{len(files)} documentation file(s)"],
506+
),
507+
classification=Classification(
508+
role="supporting", role_confidence=Confidence.CERTAIN,
509+
reusability="internal", risk="low",
510+
),
511+
metrics={"n_files": len(files)},
512+
overall_confidence=Confidence.CERTAIN,
513+
)]
514+
515+
445516
# ── helpers ───────────────────────────────────────────────────────────────────
517+
def file_edges_between(
518+
ev: EvidenceGraph, mg: ModuleGraph, src_mod: str, dst_mod: str, limit: int = 3,
519+
) -> list[str]:
520+
"""The file-level import edges inducing the aggregated ``src_mod -> dst_mod``
521+
module edge, as ``"src.py -> dst.py"`` strings. This is the evidence every
522+
cycle/coupling finding must cite: module-level claims are only as good as
523+
the file edges underneath them."""
524+
out: list[str] = []
525+
for src in sorted(ev.imports_out):
526+
if mg.module_of_file.get(src) != src_mod:
527+
continue
528+
for dst in sorted(ev.imports_out[src]):
529+
if mg.module_of_file.get(dst) == dst_mod:
530+
out.append(f"{src} -> {dst}")
531+
if len(out) >= limit:
532+
return out
533+
return out
534+
535+
446536
def _subtree_files(ev: EvidenceGraph, root: str) -> list[str]:
447537
prefix = "" if root == ROOT else root + "/"
448538
return [f["path"] for f in ev.files

0 commit comments

Comments
 (0)