sourcecode 2.5.20__py3-none-any.whl → 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.

Potentially problematic release.


This version of sourcecode might be problematic. Click here for more details.

sourcecode/__init__.py CHANGED
@@ -4,4 +4,4 @@ ASK Engine is the product. ``ask`` is the canonical CLI command; ``sourcecode``
4
4
  the legacy compatibility alias and the Python/PyPI package name. See
5
5
  docs/PRODUCT_IDENTITY.md (normative)."""
6
6
 
7
- __version__ = "2.5.20"
7
+ __version__ = "2.6.0"
@@ -0,0 +1,408 @@
1
+ """architectural_baseline.py — D5 Architectural knowledge persistence.
2
+
3
+ Engineering Decision Support, Part B / D5. Turns ASK from a stateless per-run tool
4
+ into one that REMEMBERS a repository's architecture over time. Generalizes the perf
5
+ harness's frozen-schema + same-baseline-diff discipline (`perf-baseline-v1`,
6
+ `docs/perf/baselines/<ver>/`) from timing to architectural metrics: a committed,
7
+ versioned `architectural-baseline-v1` artifact per ref that persists the measured
8
+ structural fingerprint (totals + fan-in hotspots + the full endpoint surface and
9
+ fan-in map needed to diff faithfully).
10
+
11
+ Three capabilities, one schema:
12
+ * capture — freeze the current CIR's measured metrics into a baseline artifact.
13
+ * diff — diff the live working tree against a stored baseline (this is D1's
14
+ `diff_metrics` with the base snapshot loaded from disk instead of a
15
+ second checkout — "D1 auto-diffs against the stored baseline").
16
+ * trend — a multi-ref series over N stored baselines (totals over time + per
17
+ hotspot fan-in trajectory).
18
+
19
+ Moat line: every field is MEASURED and derived from the CIR alone (via D1's
20
+ `extract_metrics`). Persistence adds no inference, no verdict, no ROI — it stores and
21
+ replays measured facts. Trend reports movement; it never labels a trajectory
22
+ "improving"/"degrading" (that reading is the engineer's).
23
+
24
+ Determinism & comparability (carried from the perf harness): the metric content of a
25
+ baseline is a pure function of the CIR — same repo state → identical totals/hotspots/
26
+ surface (only `captured_at`/`commit`/env vary). An env fingerprint is recorded so a
27
+ consumer can refuse to compare baselines captured on incomparable hosts, exactly as
28
+ the perf baselines do.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import json
33
+ import subprocess
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+ from typing import TYPE_CHECKING
37
+
38
+ from sourcecode import __version__ as TOOL_VERSION
39
+ from sourcecode.architectural_delta import ArchMetrics, diff_metrics, extract_metrics
40
+ from sourcecode.perf import collect_env
41
+
42
+ if TYPE_CHECKING:
43
+ from sourcecode.canonical_ir import CanonicalRepositoryIR
44
+
45
+ # Frozen schema tags — versioned like perf-baseline-v1 / architectural-delta-v1.
46
+ ARCH_BASELINE_SCHEMA: str = "architectural-baseline-v1"
47
+ ARCH_TREND_SCHEMA: str = "architectural-trend-v1"
48
+ # Emitted by `diff_baseline` when the two snapshots are not comparable and the
49
+ # caller did NOT opt into an override — a refusal, not a delta.
50
+ ARCH_NOT_COMPARABLE_SCHEMA: str = "architectural-not-comparable-v1"
51
+
52
+ # Comparability verdicts.
53
+ COMPARABLE: str = "COMPARABLE"
54
+ NOT_COMPARABLE: str = "NOT_COMPARABLE"
55
+ # `diff_baseline(..., allow_incomparable=True)` still emits a delta but stamps this
56
+ # so the output can never be mistaken for a clean comparison.
57
+ OVERRIDDEN: str = "OVERRIDDEN"
58
+
59
+ # The axes that make two architectural baselines comparable. Architectural metrics
60
+ # are a PURE FUNCTION OF SOURCE — they do not depend on CPU, cores, or host — so the
61
+ # comparability contract is SEMANTIC, not hardware. Gating on hostname/cpu (as the
62
+ # perf harness must, because timing IS hardware-sensitive) would be theater here and
63
+ # would falsely refuse a valid cross-host architectural diff. What genuinely makes a
64
+ # delta misleading is a change in the measurement itself: the baseline schema, the
65
+ # analysis (tool) version that extracted the metrics, or the underlying IR schema.
66
+ # A difference in any of these means a symbol/endpoint/fan-in delta could reflect the
67
+ # extractor changing rather than the code changing — so ASK refuses.
68
+ _COMPARABILITY_AXES: tuple[tuple[str, str], ...] = (
69
+ ("schema", "baseline schema"),
70
+ ("tool_version", "analysis (tool) version"),
71
+ ("ir_schema_version", "IR schema"),
72
+ )
73
+
74
+ # How many top fan-in symbols to record as named hotspots. The FULL fan-in map is
75
+ # stored separately for faithful diffs; hotspots are the human-facing projection.
76
+ _HOTSPOT_N: int = 20
77
+
78
+
79
+ def _utc_now() -> str:
80
+ # Microsecond precision (DR-2): whole-second timestamps collided whenever two
81
+ # baselines were captured in the same second (common in CI / rapid capture), and
82
+ # the trend sort then tie-broke on the commit STRING — which carries no temporal
83
+ # order — scrambling the series. Sub-second resolution makes real captures
84
+ # distinct; lexicographic ISO order stays chronological.
85
+ return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ")
86
+
87
+
88
+ def git_commit(repo: Path) -> str | None:
89
+ """Best-effort short commit of `repo`'s HEAD, or None when not a git repo."""
90
+ try:
91
+ out = subprocess.run(
92
+ ["git", "-C", str(repo), "rev-parse", "--short", "HEAD"],
93
+ capture_output=True, text=True, timeout=10,
94
+ )
95
+ except (OSError, subprocess.SubprocessError):
96
+ return None
97
+ commit = out.stdout.strip()
98
+ return commit or None
99
+
100
+
101
+ def _hotspots(fan_in: dict[str, int], top_n: int) -> list[dict]:
102
+ """Top-N symbols by fan-in (desc), then FQN asc. Zero-fan-in symbols excluded."""
103
+ ranked = sorted(
104
+ ((s, c) for s, c in fan_in.items() if c > 0),
105
+ key=lambda kv: (-kv[1], kv[0]),
106
+ )
107
+ return [{"symbol": s, "fan_in": c} for s, c in ranked[:top_n]]
108
+
109
+
110
+ def build_baseline(
111
+ cir: "CanonicalRepositoryIR",
112
+ *,
113
+ ref: str | None = None,
114
+ commit: str | None = None,
115
+ hotspot_n: int = _HOTSPOT_N,
116
+ captured_at: str | None = None,
117
+ env: dict | None = None,
118
+ tool_version: str | None = None,
119
+ ) -> dict:
120
+ """Freeze a CIR's measured architectural metrics into a baseline artifact.
121
+
122
+ Stores the full endpoint surface and fan-in map (not just hotspots) so a later
123
+ `diff_baseline` reproduces exactly what a two-checkout D1 delta would report.
124
+ Records the analysis provenance (`tool_version`, `ir_schema_version`) that the
125
+ comparability contract gates on — a delta is only trustworthy between baselines
126
+ that share them."""
127
+ m = extract_metrics(cir)
128
+ endpoints = sorted([meth, path] for (meth, path) in m.endpoints)
129
+ fan_in = dict(sorted(m.fan_in.items()))
130
+ cycles = sorted((sorted(c) for c in m.cycles), key=lambda mm: (len(mm), mm))
131
+ return {
132
+ "schema": ARCH_BASELINE_SCHEMA,
133
+ "tool_version": tool_version if tool_version is not None else TOOL_VERSION,
134
+ "ir_schema_version": str(getattr(cir, "schema_version", "")),
135
+ "ref": ref,
136
+ "commit": commit,
137
+ "captured_at": captured_at or _utc_now(),
138
+ "cir_hash": m.cir_hash,
139
+ "totals": {
140
+ "files": m.file_count,
141
+ "symbols": m.symbol_count,
142
+ "endpoints": m.endpoint_count,
143
+ "dependency_edges": m.dependency_edge_count,
144
+ "import_cycles": len(m.cycles),
145
+ },
146
+ "hotspots": _hotspots(m.fan_in, hotspot_n),
147
+ "endpoint_surface": endpoints,
148
+ "fan_in": fan_in,
149
+ "import_cycles": cycles,
150
+ "env": env if env is not None else collect_env(),
151
+ "provenance": (
152
+ "architectural_baseline (D5): a measured structural fingerprint of one "
153
+ "CIR, persisted per ref. Metric content is a pure function of the repo "
154
+ "state. No verdict, no ROI — stores and replays measured facts."
155
+ ),
156
+ }
157
+
158
+
159
+ def _analysis_meta(source: dict) -> dict:
160
+ """Extract the comparability axes from a baseline (or a synthesized head meta)."""
161
+ return {axis: source.get(axis) for axis, _ in _COMPARABILITY_AXES}
162
+
163
+
164
+ def _head_meta(head_cir: "CanonicalRepositoryIR") -> dict:
165
+ """The comparability meta for a live head CIR: current tool + its IR schema."""
166
+ return {
167
+ "schema": ARCH_BASELINE_SCHEMA,
168
+ "tool_version": TOOL_VERSION,
169
+ "ir_schema_version": str(getattr(head_cir, "schema_version", "")),
170
+ }
171
+
172
+
173
+ def comparability(base: dict, head: dict) -> dict:
174
+ """Verdict on whether two snapshots' metrics are comparable.
175
+
176
+ Gates on the SEMANTIC axes only (schema / tool version / IR schema) — see
177
+ `_COMPARABILITY_AXES` for why hardware is deliberately excluded. Any mismatch
178
+ yields NOT_COMPARABLE with an itemized reason per differing axis."""
179
+ reasons: list[dict] = []
180
+ for axis, label in _COMPARABILITY_AXES:
181
+ bv, hv = base.get(axis), head.get(axis)
182
+ if bv != hv:
183
+ reasons.append({
184
+ "axis": axis,
185
+ "base": bv,
186
+ "head": hv,
187
+ "detail": f"{label} differs: {bv!r} (base) vs {hv!r} (head)",
188
+ })
189
+ return {
190
+ "status": COMPARABLE if not reasons else NOT_COMPARABLE,
191
+ "reasons": reasons,
192
+ "base": _analysis_meta(base),
193
+ "head": _analysis_meta(head),
194
+ }
195
+
196
+
197
+ def baseline_to_metrics(baseline: dict) -> ArchMetrics:
198
+ """Reconstruct an `ArchMetrics` from a stored baseline for a faithful D1 diff."""
199
+ totals = baseline.get("totals", {})
200
+ endpoints = frozenset(
201
+ (str(pair[0]).upper(), str(pair[1]))
202
+ for pair in baseline.get("endpoint_surface", [])
203
+ if isinstance(pair, (list, tuple)) and len(pair) == 2
204
+ )
205
+ return ArchMetrics(
206
+ cir_hash=str(baseline.get("cir_hash", "")),
207
+ file_count=int(totals.get("files", 0)),
208
+ symbol_count=int(totals.get("symbols", 0)),
209
+ endpoint_count=int(totals.get("endpoints", 0)),
210
+ dependency_edge_count=int(totals.get("dependency_edges", 0)),
211
+ endpoints=endpoints,
212
+ fan_in={str(k): int(v) for k, v in (baseline.get("fan_in") or {}).items()},
213
+ cycles=frozenset(
214
+ frozenset(str(m) for m in members)
215
+ for members in baseline.get("import_cycles", [])
216
+ if isinstance(members, (list, tuple))
217
+ ),
218
+ )
219
+
220
+
221
+ def _base_descriptor(baseline: dict) -> dict:
222
+ """Self-describing block naming the stored baseline the head was diffed against."""
223
+ return {
224
+ "ref": baseline.get("ref"),
225
+ "commit": baseline.get("commit"),
226
+ "captured_at": baseline.get("captured_at"),
227
+ "tool_version": baseline.get("tool_version"),
228
+ "ir_schema_version": baseline.get("ir_schema_version"),
229
+ }
230
+
231
+
232
+ def diff_baseline(
233
+ baseline: dict,
234
+ head_cir: "CanonicalRepositoryIR",
235
+ *,
236
+ allow_incomparable: bool = False,
237
+ ) -> dict:
238
+ """Diff a live CIR against a stored baseline — D1's delta, base loaded from disk.
239
+
240
+ Comparability first: if the base and the live head disagree on the semantic axes
241
+ (baseline schema / analysis version / IR schema), a delta would conflate the
242
+ measurement change with the code change. ASK REFUSES by default, returning an
243
+ `architectural-not-comparable-v1` payload (status NOT_COMPARABLE) instead of a
244
+ misleading delta — an honest refusal is preferred over a plausible-looking lie.
245
+
246
+ Set `allow_incomparable=True` to override: the delta is still computed but stamped
247
+ `status=OVERRIDDEN` with the reasons, so it can never be read as a clean diff.
248
+
249
+ When comparable, the payload is the `architectural-delta-v1` schema (identical to
250
+ a two-checkout `ask delta`), stamped `status=COMPARABLE`, plus a `base` block."""
251
+ verdict = comparability(baseline, _head_meta(head_cir))
252
+
253
+ if verdict["status"] == NOT_COMPARABLE and not allow_incomparable:
254
+ return {
255
+ "schema": ARCH_NOT_COMPARABLE_SCHEMA,
256
+ "status": NOT_COMPARABLE,
257
+ "comparability": verdict,
258
+ "base": _base_descriptor(baseline),
259
+ "message": (
260
+ "Refusing to diff: base and head are not comparable "
261
+ f"({'; '.join(r['detail'] for r in verdict['reasons'])}). "
262
+ "A delta would conflate the analysis change with the code change. "
263
+ "Re-capture the base with the current tool (ask baseline capture) for "
264
+ "a clean diff, or pass --allow-incomparable to override."
265
+ ),
266
+ "provenance": (
267
+ "architectural_baseline (D5): comparability refusal. Architectural "
268
+ "metrics are a pure function of source; a delta is only trustworthy "
269
+ "when the baseline schema, analysis version, and IR schema all match."
270
+ ),
271
+ }
272
+
273
+ base = baseline_to_metrics(baseline)
274
+ head = extract_metrics(head_cir)
275
+ delta = diff_metrics(base, head)
276
+ delta["status"] = (
277
+ OVERRIDDEN if verdict["status"] == NOT_COMPARABLE else COMPARABLE
278
+ )
279
+ delta["comparability"] = verdict
280
+ delta["base"] = _base_descriptor(baseline)
281
+ return delta
282
+
283
+
284
+ # ── Persistence I/O ───────────────────────────────────────────────────────────
285
+
286
+
287
+ def _baseline_filename(baseline: dict) -> str:
288
+ """Stable filename for a baseline: commit if known, else the capture timestamp."""
289
+ stem = baseline.get("commit") or baseline.get("captured_at", "baseline")
290
+ safe = "".join(c if (c.isalnum() or c in "-_.") else "-" for c in str(stem))
291
+ return f"{safe}.json"
292
+
293
+
294
+ def write_baseline(baseline: dict, out_dir: Path) -> Path:
295
+ """Write `baseline` as deterministic JSON under `out_dir`; return the file path."""
296
+ out_dir.mkdir(parents=True, exist_ok=True)
297
+ path = out_dir / _baseline_filename(baseline)
298
+ path.write_text(
299
+ json.dumps(baseline, sort_keys=True, indent=2) + "\n", encoding="utf-8"
300
+ )
301
+ return path
302
+
303
+
304
+ def load_baseline(path: Path) -> dict:
305
+ """Load and validate one baseline artifact."""
306
+ data = json.loads(Path(path).read_text(encoding="utf-8"))
307
+ if not isinstance(data, dict) or data.get("schema") != ARCH_BASELINE_SCHEMA:
308
+ raise ValueError(
309
+ f"{path}: not an {ARCH_BASELINE_SCHEMA} artifact "
310
+ f"(schema={data.get('schema') if isinstance(data, dict) else type(data).__name__})."
311
+ )
312
+ return data
313
+
314
+
315
+ def load_baselines_dir(directory: Path) -> list[dict]:
316
+ """Load every architectural baseline in `directory`, sorted by capture time."""
317
+ out: list[dict] = []
318
+ for p in sorted(Path(directory).glob("*.json")):
319
+ try:
320
+ out.append(load_baseline(p))
321
+ except (ValueError, json.JSONDecodeError):
322
+ continue # skip foreign JSON; a baseline dir may hold unrelated files
323
+ # Order by capture time (DR-2). captured_at now carries microsecond resolution,
324
+ # so real captures are distinct and lexicographic ISO order == chronological.
325
+ # commit is only a deterministic fallback for the degenerate exact-timestamp tie
326
+ # (same instant → order is genuinely ambiguous); it never reorders distinct times.
327
+ out.sort(key=lambda b: (str(b.get("captured_at", "")), str(b.get("commit", ""))))
328
+ return out
329
+
330
+
331
+ # ── Trend (multi-ref series) ──────────────────────────────────────────────────
332
+
333
+
334
+ def build_trend(baselines: list[dict]) -> dict:
335
+ """A multi-ref series over N baselines: totals over time + hotspot fan-in tracks.
336
+
337
+ Deterministic projection: points ordered by capture time; hotspot tracks cover
338
+ the union of every baseline's named hotspots, each track a per-point fan-in
339
+ series (0 where the symbol is absent). Reports movement only — no trajectory
340
+ label, no verdict."""
341
+ points = [
342
+ {
343
+ "ref": b.get("ref"),
344
+ "commit": b.get("commit"),
345
+ "captured_at": b.get("captured_at"),
346
+ "tool_version": b.get("tool_version"),
347
+ "ir_schema_version": b.get("ir_schema_version"),
348
+ "totals": b.get("totals", {}),
349
+ }
350
+ for b in baselines
351
+ ]
352
+
353
+ # Comparability across the SERIES. A trend is not refused — cross-version history
354
+ # is still worth seeing — but every point-to-point transition that crosses an
355
+ # analysis-version boundary is flagged, because a metric jump there may be the
356
+ # extractor changing, not the code. The reader must attribute those with care.
357
+ boundaries: list[dict] = []
358
+ for i in range(1, len(baselines)):
359
+ v = comparability(baselines[i - 1], baselines[i])
360
+ if v["status"] == NOT_COMPARABLE:
361
+ boundaries.append({
362
+ "from_index": i - 1,
363
+ "to_index": i,
364
+ "reasons": v["reasons"],
365
+ })
366
+ distinct_versions = sorted(
367
+ {str(b.get("tool_version")) for b in baselines}
368
+ )
369
+ series_comparability = {
370
+ "status": COMPARABLE if not boundaries else NOT_COMPARABLE,
371
+ "distinct_tool_versions": distinct_versions,
372
+ "boundaries": boundaries,
373
+ }
374
+
375
+ tracked: set[str] = set()
376
+ for b in baselines:
377
+ for h in b.get("hotspots", []):
378
+ if isinstance(h, dict) and h.get("symbol"):
379
+ tracked.add(str(h["symbol"]))
380
+
381
+ hotspot_trends: list[dict] = []
382
+ for sym in sorted(tracked):
383
+ series = [int((b.get("fan_in") or {}).get(sym, 0)) for b in baselines]
384
+ first, last = series[0], series[-1]
385
+ hotspot_trends.append({
386
+ "symbol": sym,
387
+ "fan_in_series": series,
388
+ "first": first,
389
+ "last": last,
390
+ "delta": last - first,
391
+ })
392
+ # Largest absolute movement first, then symbol name — deterministic.
393
+ hotspot_trends.sort(key=lambda t: (-abs(t["delta"]), t["symbol"]))
394
+
395
+ return {
396
+ "schema": ARCH_TREND_SCHEMA,
397
+ "baseline_count": len(baselines),
398
+ "comparability": series_comparability,
399
+ "points": points,
400
+ "hotspot_trends": hotspot_trends,
401
+ "provenance": (
402
+ "architectural_trend (D5): a measured series over persisted baselines. "
403
+ "Reports totals and fan-in movement over time; assigns no "
404
+ "improving/degrading label — the reading is the engineer's. Transitions "
405
+ "that cross an analysis-version boundary are flagged in comparability: a "
406
+ "metric jump there may reflect the extractor changing, not the code."
407
+ ),
408
+ }
@@ -13,15 +13,19 @@ D2's contract-diff, a distinct axis). A reviewer reads the deltas and decides.
13
13
  Determinism: `extract_metrics` reads only the CIR; `diff_metrics` is a pure
14
14
  function of two metric snapshots. Same two snapshots → byte-identical delta.
15
15
 
16
- Increment scope (D1-a): counts + HTTP-surface set delta + fan-in shifts. Import
17
- cycles and blast-radius deltas are deferred to a later increment (heavier compute,
18
- own metric extractor) — omitted here rather than approximated.
16
+ Increment scope: D1-a delivered counts + HTTP-surface set delta + fan-in shifts.
17
+ D1-b adds import-cycle deltas (cheap Tarjan SCC over the dependency graph, folded into
18
+ the pure `diff_metrics`) and blast-radius deltas (transitive reach, computed in the
19
+ `architectural_delta` wrapper because it needs the reverse graph — scoped to the
20
+ bounded fan-in-shift set so a hub cannot blow up the traversal).
19
21
  """
20
22
  from __future__ import annotations
21
23
 
22
- from dataclasses import dataclass
24
+ from dataclasses import dataclass, field
23
25
  from typing import TYPE_CHECKING
24
26
 
27
+ from sourcecode.repository_ir import _all_callers_from_rg
28
+
25
29
  if TYPE_CHECKING:
26
30
  from sourcecode.canonical_ir import CanonicalRepositoryIR
27
31
 
@@ -32,6 +36,10 @@ ARCH_DELTA_SCHEMA: str = "architectural-delta-v1"
32
36
  # fan-in shifts). The counts are always exact; the lists are a bounded sample.
33
37
  _LIST_CAP: int = 50
34
38
 
39
+ # Safety bound on the transitive-reach traversal so a hub symbol cannot make a
40
+ # blast-radius delta walk the whole graph.
41
+ _REACH_NODE_CAP: int = 4000
42
+
35
43
  # reverse_graph edge types that are NOT a dependency. `contained_in` is pure
36
44
  # structural membership (a class "contains" its own methods/ctor) — counting it
37
45
  # would make every class report a fan-in equal to its own member count (a
@@ -59,6 +67,74 @@ def _fan_in_map(cir: "CanonicalRepositoryIR") -> dict[str, int]:
59
67
  return out
60
68
 
61
69
 
70
+ def _dependency_cycles(cir: "CanonicalRepositoryIR") -> frozenset[frozenset[str]]:
71
+ """Import-visible cyclic groups (SCCs of size > 1) in the dependency graph.
72
+
73
+ Builds a directed graph from `cir.dependencies` (from → to over import/extends/
74
+ implements edges) and returns each strongly-connected component with more than
75
+ one member as a frozenset of FQNs — the classic "import cycle" a reviewer cares
76
+ about. A self-loop alone is not a cycle here (single-node SCC). Deterministic:
77
+ iterative Tarjan over a sorted adjacency.
78
+
79
+ Scope (DR-3): this counts cycles that are visible through import/extends/implements
80
+ edges. A cycle formed ONLY by field-type or call edges within a single package —
81
+ where no `import` is needed — is not represented in `cir.dependencies` and is
82
+ therefore NOT counted. This is why the CLI names the metric "import cycle": folding
83
+ field/call edges into the cycle graph is a broader inference, deferred to the
84
+ capability phase rather than silently widened here."""
85
+ adjacency: dict[str, set[str]] = {}
86
+ nodes: set[str] = set()
87
+ for e in (cir.dependencies or []):
88
+ u, v = str(e.get("from", "")), str(e.get("to", ""))
89
+ if not u or not v or u == v:
90
+ continue
91
+ adjacency.setdefault(u, set()).add(v)
92
+ nodes.add(u)
93
+ nodes.add(v)
94
+
95
+ index_of: dict[str, int] = {}
96
+ low: dict[str, int] = {}
97
+ on_stack: set[str] = set()
98
+ stack: list[str] = []
99
+ counter = 0
100
+ cycles: list[frozenset[str]] = []
101
+
102
+ for root in sorted(nodes):
103
+ if root in index_of:
104
+ continue
105
+ work: list[tuple[str, int]] = [(root, 0)]
106
+ while work:
107
+ v, pi = work[-1]
108
+ if pi == 0:
109
+ index_of[v] = low[v] = counter
110
+ counter += 1
111
+ stack.append(v)
112
+ on_stack.add(v)
113
+ succ = sorted(adjacency.get(v, ()))
114
+ if pi < len(succ):
115
+ work[-1] = (v, pi + 1)
116
+ w = succ[pi]
117
+ if w not in index_of:
118
+ work.append((w, 0))
119
+ elif w in on_stack:
120
+ low[v] = min(low[v], index_of[w])
121
+ continue
122
+ if low[v] == index_of[v]:
123
+ members: list[str] = []
124
+ while True:
125
+ w = stack.pop()
126
+ on_stack.discard(w)
127
+ members.append(w)
128
+ if w == v:
129
+ break
130
+ if len(members) > 1:
131
+ cycles.append(frozenset(members))
132
+ work.pop()
133
+ if work:
134
+ low[work[-1][0]] = min(low[work[-1][0]], low[v])
135
+ return frozenset(cycles)
136
+
137
+
62
138
  @dataclass(frozen=True)
63
139
  class ArchMetrics:
64
140
  """A deterministic architectural snapshot extracted from one CIR."""
@@ -70,6 +146,7 @@ class ArchMetrics:
70
146
  dependency_edge_count: int
71
147
  endpoints: frozenset[tuple[str, str]] # (METHOD, path) — the HTTP contract surface
72
148
  fan_in: dict[str, int] # symbol FQN → distinct caller count
149
+ cycles: frozenset[frozenset[str]] = field(default_factory=frozenset) # import cycles
73
150
 
74
151
 
75
152
  def extract_metrics(cir: "CanonicalRepositoryIR") -> ArchMetrics:
@@ -85,6 +162,7 @@ def extract_metrics(cir: "CanonicalRepositoryIR") -> ArchMetrics:
85
162
  dependency_edge_count=len(cir.dependencies or []),
86
163
  endpoints=endpoints,
87
164
  fan_in=_fan_in_map(cir),
165
+ cycles=_dependency_cycles(cir),
88
166
  )
89
167
 
90
168
 
@@ -110,6 +188,16 @@ def diff_metrics(base: ArchMetrics, head: ArchMetrics) -> dict:
110
188
  shifts.append({"symbol": sym, "base": b, "head": h, "delta": h - b})
111
189
  shifts.sort(key=lambda s: (-abs(s["delta"]), s["symbol"]))
112
190
 
191
+ # Import-cycle delta: cycles compared by exact membership. A cycle whose members
192
+ # changed at all reads as the old one removed + a new one added — the honest,
193
+ # deterministic choice. Rendered as sorted member lists.
194
+ added_cycles = sorted(
195
+ (sorted(c) for c in (head.cycles - base.cycles)), key=lambda m: (len(m), m)
196
+ )
197
+ removed_cycles = sorted(
198
+ (sorted(c) for c in (base.cycles - head.cycles)), key=lambda m: (len(m), m)
199
+ )
200
+
113
201
  return {
114
202
  "schema": ARCH_DELTA_SCHEMA,
115
203
  "base_cir_hash": base.cir_hash,
@@ -121,6 +209,7 @@ def diff_metrics(base: ArchMetrics, head: ArchMetrics) -> dict:
121
209
  "dependency_edges": _count_block(
122
210
  base.dependency_edge_count, head.dependency_edge_count
123
211
  ),
212
+ "import_cycles": _count_block(len(base.cycles), len(head.cycles)),
124
213
  },
125
214
  "endpoint_surface": {
126
215
  "added_count": len(added),
@@ -132,6 +221,12 @@ def diff_metrics(base: ArchMetrics, head: ArchMetrics) -> dict:
132
221
  "changed_count": len(shifts),
133
222
  "shifts": shifts[:_LIST_CAP],
134
223
  },
224
+ "import_cycle_shifts": {
225
+ "added_count": len(added_cycles),
226
+ "removed_count": len(removed_cycles),
227
+ "added": added_cycles[:_LIST_CAP],
228
+ "removed": removed_cycles[:_LIST_CAP],
229
+ },
135
230
  "provenance": (
136
231
  "architectural_delta (D1): measured deltas derived from the CIR of two "
137
232
  "snapshots. No breaking/non-breaking classification (D2), no verdict, "
@@ -140,8 +235,58 @@ def diff_metrics(base: ArchMetrics, head: ArchMetrics) -> dict:
140
235
  }
141
236
 
142
237
 
238
+ def _transitive_reach(fqn: str, reverse_graph: dict, node_cap: int) -> int:
239
+ """Size of the transitive reverse-dependency set of `fqn` (its blast radius).
240
+
241
+ BFS over the reverse graph via the canonical `_all_callers_from_rg` traversal
242
+ (containment/imports excluded, DI owners normalized — exactly how blast-radius/
243
+ impact-chain compute reach), bounded by `node_cap`."""
244
+ seen: set[str] = {fqn}
245
+ queue: list[str] = [fqn]
246
+ while queue and len(seen) < node_cap:
247
+ cur = queue.pop(0)
248
+ for dep in _all_callers_from_rg(cur, reverse_graph):
249
+ if dep not in seen:
250
+ seen.add(dep)
251
+ queue.append(dep)
252
+ return len(seen) - 1 # exclude the seed itself
253
+
254
+
255
+ def _blast_radius_shifts(
256
+ base_cir: "CanonicalRepositoryIR",
257
+ head_cir: "CanonicalRepositoryIR",
258
+ seeds: list[str],
259
+ ) -> list[dict]:
260
+ """Transitive blast-radius (reachable-dependent count) change for `seeds`.
261
+
262
+ Scoped to the fan-in-shift symbols: those are exactly where reach is most likely
263
+ to have moved, and bounding to them keeps the traversal cheap (no whole-repo
264
+ all-pairs closure). A symbol absent on one side has reach 0 there."""
265
+ b_rg = base_cir.reverse_graph or {}
266
+ h_rg = head_cir.reverse_graph or {}
267
+ out: list[dict] = []
268
+ for sym in seeds:
269
+ b = _transitive_reach(sym, b_rg, _REACH_NODE_CAP) if sym in b_rg else 0
270
+ h = _transitive_reach(sym, h_rg, _REACH_NODE_CAP) if sym in h_rg else 0
271
+ if b != h:
272
+ out.append({"symbol": sym, "base": b, "head": h, "delta": h - b})
273
+ out.sort(key=lambda s: (-abs(s["delta"]), s["symbol"]))
274
+ return out
275
+
276
+
143
277
  def architectural_delta(
144
278
  base_cir: "CanonicalRepositoryIR", head_cir: "CanonicalRepositoryIR"
145
279
  ) -> dict:
146
- """Convenience: extract both snapshots and diff them."""
147
- return diff_metrics(extract_metrics(base_cir), extract_metrics(head_cir))
280
+ """Convenience: extract both snapshots, diff them, and add blast-radius shifts.
281
+
282
+ Blast-radius deltas need the reverse graph (not carried in the pure snapshot), so
283
+ they are computed here over the fan-in-shift set the pure diff already surfaced."""
284
+ payload = diff_metrics(extract_metrics(base_cir), extract_metrics(head_cir))
285
+ seeds = [s["symbol"] for s in payload["fan_in_shifts"]["shifts"]]
286
+ shifts = _blast_radius_shifts(base_cir, head_cir, seeds)
287
+ payload["blast_radius_shifts"] = {
288
+ "scope": "fan_in_shift_symbols",
289
+ "changed_count": len(shifts),
290
+ "shifts": shifts[:_LIST_CAP],
291
+ }
292
+ return payload