sourcecode 4.10.5__py3-none-any.whl → 4.10.7__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__ = "4.10.5"
7
+ __version__ = "4.10.7"
@@ -136,6 +136,19 @@ def _fan_in_from_stored(value: Any) -> dict[str, int]:
136
136
  return {}
137
137
 
138
138
 
139
+ def _comparability_env() -> dict:
140
+ """The environment fields a delta actually depends on (C3-68).
141
+
142
+ A baseline is comparable to another when the platform and the tool agree;
143
+ the CPU model, the core count and a hash of the hostname change none of
144
+ that. `--record-env` restores the full set for someone benchmarking runs
145
+ against each other on one machine.
146
+ """
147
+ import sys as _sys
148
+
149
+ return {"os": _sys.platform, "tool_version": TOOL_VERSION, "recorded": "comparability_only"}
150
+
151
+
139
152
  def build_baseline(
140
153
  cir: "CanonicalRepositoryIR",
141
154
  *,
@@ -145,6 +158,7 @@ def build_baseline(
145
158
  captured_at: str | None = None,
146
159
  env: dict | None = None,
147
160
  tool_version: str | None = None,
161
+ record_env: bool = False,
148
162
  ) -> dict:
149
163
  """Freeze a CIR's measured architectural metrics into a baseline artifact.
150
164
 
@@ -168,6 +182,21 @@ def build_baseline(
168
182
  "files": m.file_count,
169
183
  "symbols": m.symbol_count,
170
184
  "endpoints": m.endpoint_count,
185
+ # C1-37: this artefact's declared purpose is to be a replayable
186
+ # fingerprint, and it disagreed with itself — `totals.endpoints`
187
+ # 3 574 against `len(endpoint_surface)` 3 538 on the field
188
+ # repository. Neither figure was wrong: `endpoints` counts handler
189
+ # mappings and `endpoint_surface` is the set of distinct
190
+ # (METHOD, path) contract pairs, so 36 routes are declared by more
191
+ # than one handler. Two facts, two names, and the invariant the
192
+ # battery can actually hold.
193
+ "endpoint_contract_pairs": len(m.endpoints),
194
+ "endpoints_unit": (
195
+ "handler mappings declared in the repository; "
196
+ "`endpoint_contract_pairs` counts the distinct (METHOD, path) "
197
+ "pairs those mappings expose, which is what `endpoint_surface` "
198
+ "lists and what a diff compares"
199
+ ),
171
200
  "dependency_edges": m.dependency_edge_count,
172
201
  "import_cycles": len(m.cycles),
173
202
  },
@@ -175,7 +204,16 @@ def build_baseline(
175
204
  "endpoint_surface": endpoints,
176
205
  "fan_in": _fan_in_to_list(m.fan_in),
177
206
  "import_cycles": cycles,
178
- "env": env if env is not None else collect_env(),
207
+ # C3-68: this artefact is written inside the work tree, so what it
208
+ # records is what a `git add -A` publishes. `hostname_hash`, `cpu` and
209
+ # `cores` describe the developer's machine and affect nothing a delta
210
+ # compares — from a tool whose own `ask config` says telemetry is off.
211
+ # The default is now the two fields that DO decide comparability; the
212
+ # rest is opt-in per capture.
213
+ "env": (
214
+ env if env is not None
215
+ else (collect_env() if record_env else _comparability_env())
216
+ ),
179
217
  "provenance": (
180
218
  "architectural_baseline (D5): a measured structural fingerprint of one "
181
219
  "CIR, persisted per ref. Metric content is a pure function of the repo "
@@ -319,9 +357,50 @@ def _baseline_filename(baseline: dict) -> str:
319
357
  return f"{safe}.json"
320
358
 
321
359
 
360
+ #: What `.ask/` gets the moment it is created, and why.
361
+ #:
362
+ #: C3-68: `baseline capture` writes into the work tree and nothing protected it.
363
+ #: The field verified `grep -nE "^\.ask|sourcecode" .gitignore` → no matches, so
364
+ #: a `git add -A` commits the artefact — which carries a machine fingerprint and
365
+ #: a 14 587-entry structural inventory of the code. A self-confined
366
+ #: `.ask/.gitignore` is the honest form: it never touches the user's own
367
+ #: `.gitignore`, and it stops applying the moment they delete the directory.
368
+ _ASK_DIR_GITIGNORE = (
369
+ "# Written by `ask` the first time it created this directory.\n"
370
+ "# These are local analysis artefacts: a structural inventory of your code\n"
371
+ "# and, if you asked for it, the machine that measured it. Nothing here\n"
372
+ "# belongs in a commit. Delete this file to version them deliberately.\n"
373
+ "*\n"
374
+ )
375
+
376
+
377
+ def protect_ask_dir(out_dir: Path) -> None:
378
+ """Give a freshly created `.ask/` its own `.gitignore` (C3-68).
379
+
380
+ Best-effort and never overwriting: a user who edited it decided something,
381
+ and a tool that re-imposes its default on every run is worse than one that
382
+ never wrote it.
383
+ """
384
+ try:
385
+ ask_dir = Path(out_dir)
386
+ while ask_dir.name and ask_dir.name != ".ask":
387
+ if ask_dir.parent == ask_dir:
388
+ return
389
+ ask_dir = ask_dir.parent
390
+ if ask_dir.name != ".ask":
391
+ return
392
+ marker = ask_dir / ".gitignore"
393
+ if not marker.exists():
394
+ ask_dir.mkdir(parents=True, exist_ok=True)
395
+ marker.write_text(_ASK_DIR_GITIGNORE, encoding="utf-8")
396
+ except OSError:
397
+ return
398
+
399
+
322
400
  def write_baseline(baseline: dict, out_dir: Path) -> Path:
323
401
  """Write `baseline` as deterministic JSON under `out_dir`; return the file path."""
324
402
  out_dir.mkdir(parents=True, exist_ok=True)
403
+ protect_ask_dir(out_dir)
325
404
  path = out_dir / _baseline_filename(baseline)
326
405
  path.write_text(
327
406
  json.dumps(baseline, sort_keys=True, indent=2) + "\n", encoding="utf-8"
sourcecode/cache.py CHANGED
@@ -336,10 +336,15 @@ def cache_dir(repo_root: Path) -> Path:
336
336
  def status(repo_root: Path) -> dict[str, Any]:
337
337
  """Return a stats dict describing the current cache state for *repo_root*.
338
338
 
339
- Keys: ``cache_dir``, ``cores``, ``views``, ``cas_blobs``,
339
+ Keys: ``cache_dir``, ``cached_cores``, ``views``, ``cas_blobs``,
340
340
  ``total_size_bytes``, ``total_size_mb``, ``ris_exists``, ``ris_git_head``,
341
341
  ``ris_last_updated_at``, ``ris_is_stale``, ``current_git_head``.
342
342
 
343
+ ``cached_cores`` counts ``core-*.json.gz`` entries in this cache. It was
344
+ named ``cores`` until 4.10.7, which is the field name a captured baseline
345
+ uses for the machine's CPU count (``env.cores``) — two different facts under
346
+ one name, on the same machine, minutes apart (C4-18).
347
+
343
348
  Note: ``snapshots`` is a legacy v1 field — always 0 in v2 (kept for
344
349
  backward compatibility; v2 writes ``core-*`` and ``view-*`` files only).
345
350
  """
@@ -377,7 +382,7 @@ def status(repo_root: Path) -> dict[str, Any]:
377
382
  if not cache_d.exists():
378
383
  return {
379
384
  "cache_dir": str(cache_d),
380
- "cores": 0, "snapshots": 0, "views": 0, "cas_blobs": 0,
385
+ "cached_cores": 0, "snapshots": 0, "views": 0, "cas_blobs": 0,
381
386
  "total_size_bytes": 0, "total_size_mb": 0.0,
382
387
  "current_git_head": current_head,
383
388
  "stores": _store_breakdown(repo_root, 0),
@@ -391,7 +396,7 @@ def status(repo_root: Path) -> dict[str, Any]:
391
396
  total_bytes = sum(f.stat().st_size for f in all_files if f.exists())
392
397
  return {
393
398
  "cache_dir": str(cache_d),
394
- "cores": len(cores),
399
+ "cached_cores": len(cores),
395
400
  "snapshots": len(snapshots),
396
401
  "views": len(views),
397
402
  "cas_blobs": len(cas_blobs),
sourcecode/cache_model.py CHANGED
@@ -27,6 +27,7 @@ warm buys `endpoints` and `migrate-check` **nothing**, and on a small diff it ma
27
27
  from __future__ import annotations
28
28
 
29
29
  from dataclasses import dataclass
30
+ from typing import Optional
30
31
 
31
32
 
32
33
  @dataclass(frozen=True)
@@ -54,6 +55,32 @@ class CommandCache:
54
55
  #: then the command — because measuring them in sequence lets one command warm the
55
56
  #: next and reports a benefit the user will not get.
56
57
  measured: str = ""
58
+ #: What the command has to touch to answer, in the vocabulary the budget
59
+ #: floors already use (C4-19). This is the axis that decides how a command can
60
+ #: be *run* — in the foreground, after a warm, or detached — as opposed to how
61
+ #: much a warm saves it, which is `warm` above:
62
+ #: ``core`` — a symbol, one file, a diff, or stored metadata
63
+ #: ``repo-wide`` — one pass over the repository
64
+ #: ``deep`` — a composition of several repo-wide passes
65
+ #: ``none`` — not an analysis of a repository at all
66
+ #: It lives here because this is where the measurements live; `cli`'s budget
67
+ #: floors and `detach.detachable_commands()` read it rather than keeping a
68
+ #: second table, which is how `posture`, `modernize` and `repo-ir` — three of
69
+ #: the commands the field could not run — ended up undetachable.
70
+ analysis_class: str = "repo-wide"
71
+ #: The same figures `measured` describes, in machine form: seconds on the
72
+ #: reference repository, cold and after `cache warm`. ``None`` where the row
73
+ #: was never timed — an axis that could not be measured is never a number.
74
+ cold_seconds: "Optional[float]" = None
75
+ warm_seconds: "Optional[float]" = None
76
+ #: The other measured point: this command on the field repository
77
+ #: (3 342 Java files, Windows 11 / PowerShell 5.1 / pipx, cold), from field
78
+ #: evaluations #13 and #14. Seconds where a run finished; `field_blocked`
79
+ #: where it did not — the harness promoted the process and the session died,
80
+ #: which is a measurement of a different kind and is never written as a
81
+ #: duration. Rows with neither were not attempted there.
82
+ field_seconds: "Optional[float]" = None
83
+ field_blocked: bool = False
57
84
 
58
85
 
59
86
  #: Where every `measured` figure comes from.
@@ -61,6 +88,25 @@ REFERENCE_REPOSITORY = (
61
88
  "BroadleafCommerce (2 000+ Java files, dirty tree), warm machine, 3.2.2"
62
89
  )
63
90
 
91
+ #: The size of that repository, so a reader on a different one knows what the
92
+ #: figures are relative to.
93
+ REFERENCE_JAVA_FILES = 2000
94
+
95
+ #: The other end of the measured range, and the reason this module publishes a
96
+ #: *class* rather than a projected duration (C4-19). Field evaluation #13, on
97
+ #: Windows 11 / PowerShell 5.1 / pipx / Python 3.10: `spring-audit` on a
98
+ #: 3 342-file repository ran **408 s** — 46× the 8.8 s the same command takes on
99
+ #: the 2 000-file reference. Cost does not scale with file count in any way this
100
+ #: product has measured, so a projected "~15 s on your repository" would be a
101
+ #: confident falsehood of exactly the kind the ledger exists to prevent. What can
102
+ #: be said honestly is the class, the two measured anchors, and how to run it.
103
+ FIELD_ANCHOR = (
104
+ "field evaluation #13: spring-audit on 3 342 Java files took 408 s "
105
+ "(Windows, pipx, cold) against 8.8 s on the 2 000-file reference"
106
+ )
107
+ FIELD_ANCHOR_JAVA_FILES = 3342
108
+ FIELD_ANCHOR_SECONDS = 408.0
109
+
64
110
 
65
111
  #: Every layer keys on `cache.worktree_signature` — the exact tree state (C1-9).
66
112
  #: One sentence covers all of them: **any change to the analysed files invalidates,
@@ -125,113 +171,120 @@ COMMANDS: tuple[CommandCache, ...] = (
125
171
  "`--env-map`, `--depth N` and `--exclude` change the *analysis*, so they miss the "
126
172
  "warmed core and rescan — this is the 171 s the field measured after a 103 s warm.",
127
173
  "--compact 13.3 s cold → 0.3 s warm (cold re-measured on 3.7.0: was 19.3 s, C3-6); "
128
- "--agent --full --env-map --depth 20 34.7 s → 33.9 s (no gain)"),
174
+ "--agent --full --env-map --depth 20 34.7 s → 33.9 s (no gain)", analysis_class="repo-wide", cold_seconds=13.3, warm_seconds=0.3, field_seconds=115.0),
129
175
  CommandCache("posture", ("cir", "parse"), "shared", False,
130
176
  "Resolves the conditional bean graph on every run, over the shared CIR a warm "
131
177
  "builds — the parse it used to repeat for itself. `--diff` compares two profile "
132
178
  "sets over that one IR, so the second side costs the resolution only.",
133
- "10.1 s → 1.6 s"),
179
+ "10.1 s → 1.6 s", analysis_class="repo-wide", cold_seconds=10.1, warm_seconds=1.6, field_blocked=True),
134
180
  CommandCache("risk", ("cir", "parse"), "shared", False,
135
181
  "Composes what the audit, impact-chain and the posture already answer, so it "
136
182
  "pays each of their costs once over the shared CIR a warm builds — one parse "
137
183
  "for the whole composition, and the reachability query is cached per symbol "
138
184
  "within the run.",
139
185
  "not measured on the battery yet — the composition is bounded by the "
140
- "`spring-audit` + `impact-chain` costs listed here, not by new analysis"),
186
+ "`spring-audit` + `impact-chain` costs listed here, not by new analysis", analysis_class="deep"),
141
187
  CommandCache("enrich", ("cir", "parse"), "shared", False,
142
188
  "Runs the same composition as `risk` over the repository, then joins a SARIF "
143
189
  "log to it. Reading the log is negligible; everything a warm helps with is the "
144
190
  "repository side, so what it buys is what it buys `risk`.",
145
191
  "not measured on the battery yet — bounded by the `risk` composition, plus "
146
- "reading one JSON file"),
192
+ "reading one JSON file", analysis_class="deep"),
147
193
  CommandCache("audit-report", ("cir", "parse"), "shared", False,
148
194
  "Packages `risk` and `posture` evidence. A warm helps the repository side; "
149
195
  "`--from-risk risk.json` skips risk recomputation entirely and only builds "
150
196
  "the posture/report projection.",
151
197
  "not measured on the battery yet — bounded by `risk` + `posture`, or by "
152
- "`posture` when `--from-risk` is supplied"),
198
+ "`posture` when `--from-risk` is supplied", analysis_class="deep"),
153
199
  CommandCache("migrate-recipe", ("parse",), "shared", False,
154
200
  "Runs the same scan as `migrate-check` and projects its findings into an "
155
201
  "OpenRewrite recipe, so it buys exactly what a warm buys `migrate-check`: "
156
202
  "the parse, not the rule pass.",
157
203
  "not measured on the battery yet — bounded by `migrate-check` on the same "
158
- "repository (openmrs-core ~2 s)"),
204
+ "repository (openmrs-core ~2 s)", analysis_class="repo-wide"),
159
205
  CommandCache("data-exposure", ("cir", "parse"), "shared", False,
160
206
  "Walks the same call reach as `impact-chain` once per declared type and "
161
207
  "reads the endpoint security surface, both over the shared CIR a warm "
162
208
  "builds. Cost scales with the number of declared types, not with the "
163
209
  "size of the label.",
164
210
  "not measured on the battery yet — one `impact-chain` traversal per "
165
- "declared seed type over a CIR the warm already paid for"),
211
+ "declared seed type over a CIR the warm already paid for", analysis_class="repo-wide"),
166
212
  CommandCache("endpoints", ("ris", "parse"), "shared", False,
167
213
  "Recomputes the endpoint surface on every run, over a parse a warm has already "
168
214
  "paid for. Until 3.7.0 the extractor parsed every file itself instead of reading "
169
215
  "the shared parse cache, and a warm measurably bought it nothing (C3-6).",
170
- "3.3 s → 1.4 s (re-measured on 3.7.0; was 2.8 s → 2.9 s)"),
216
+ "3.3 s → 1.4 s (re-measured on 3.7.0; was 2.8 s → 2.9 s)", analysis_class="repo-wide", cold_seconds=3.3, warm_seconds=1.4, field_seconds=4.2),
171
217
  CommandCache("spring-audit", ("ris", "parse"), "shared", False,
172
218
  "Recomputes every run, but over a parse a warm has already paid for.",
173
- "8.8 s → 3.7 s"),
219
+ "8.8 s → 3.7 s", analysis_class="repo-wide", cold_seconds=8.8, warm_seconds=3.7, field_seconds=408.0),
174
220
  CommandCache("migrate-check", ("cir",), "none", False,
175
221
  "Computes its own inventory and shares nothing a warm builds. Only `--blast-radius` "
176
222
  "reuses the shared CIR.",
177
- "4.8 s → 4.8 s"),
223
+ "4.8 s → 4.8 s", analysis_class="repo-wide", cold_seconds=4.8, warm_seconds=4.8, field_blocked=True),
178
224
  CommandCache("impact-chain", ("cir", "parse"), "shared", False,
179
225
  "The CIR is the expensive half — this is where a warm pays most.",
180
- "9.9 s → 1.7 s"),
181
- CommandCache("impact", ("parse",), "shared", False, "", "4.9 s → 2.8 s"),
226
+ "9.9 s → 1.7 s", analysis_class="core", cold_seconds=9.9, warm_seconds=1.7),
227
+ CommandCache("impact", ("parse",), "shared", False, "", "4.9 s → 2.8 s", analysis_class="core", cold_seconds=4.9, warm_seconds=2.8),
182
228
  CommandCache("pr-impact", ("parse",), "shared", False,
183
- "Diff-dependent: the answer itself is never stored."),
184
- CommandCache("verify", (), "none", False, "Runs the contracts against a fresh reading."),
229
+ "Diff-dependent: the answer itself is never stored.", analysis_class="core"),
230
+ CommandCache("verify", (), "none", False, "Runs the contracts against a fresh reading.", analysis_class="core"),
185
231
  CommandCache("verify-edit", ("parse",), "shared", True,
186
232
  "Built for the edit loop: the parse cache is what keeps an unchanged file out of the "
187
233
  "next run. Its own second run is faster again.",
188
- "14.6 s → 9.6 s → 5.6 s on repeat"),
234
+ "14.6 s → 9.6 s → 5.6 s on repeat", analysis_class="repo-wide", cold_seconds=14.6, warm_seconds=9.6, field_blocked=True),
189
235
  CommandCache("review-pr", ("cir",), "none", False,
190
236
  "Diff-dependent, and it reuses the CIR only if one exists. On a small diff, loading "
191
237
  "the warmed CIR costs more than the work it saves.",
192
- "1.1 s → 2.3 s (slower)"),
193
- CommandCache("plan", ("parse",), "shared", False, "", "9.3 s → 3.8 s"),
194
- CommandCache("compare", ("parse",), "shared", False, ""),
195
- CommandCache("delta", (), "none", False, "Analyses two states — two checkouts, or two refs materialised into temporary trees; neither is the tree the cache describes."),
196
- CommandCache("contract-diff", (), "none", False, "Analyses two states (checkouts or refs); neither is the tree the cache describes."),
238
+ "1.1 s → 2.3 s (slower)", analysis_class="core", cold_seconds=1.1, warm_seconds=2.3),
239
+ CommandCache("plan", ("parse",), "shared", False, "", "9.3 s → 3.8 s", analysis_class="core", cold_seconds=9.3, warm_seconds=3.8),
240
+ CommandCache("compare", ("parse",), "shared", False, "", analysis_class="core"),
241
+ CommandCache("delta", (), "none", False, "Analyses two states — two checkouts, or two refs materialised into temporary trees; neither is the tree the cache describes.", analysis_class="repo-wide"),
242
+ CommandCache("contract-diff", (), "none", False, "Analyses two states (checkouts or refs); neither is the tree the cache describes.", analysis_class="repo-wide"),
197
243
  CommandCache("fix-bug", ("task",), "none", True,
198
- "Shorthand for `prepare-context fix-bug`; caches its own answer, which a warm never runs."),
199
- CommandCache("rename-class", (), "none", False, ""),
244
+ "Shorthand for `prepare-context fix-bug`; caches its own answer, which a warm never runs.", analysis_class="core"),
245
+ CommandCache("rename-class", (), "none", False, "", analysis_class="core"),
200
246
  CommandCache("prepare-context", ("task", "ris", "cir", "parse"), "answer", True,
201
247
  "Per task: `onboard` and `explain` are served from the RIS a warm rebuilds; "
202
248
  "`refactor`, `fix-bug` and `generate-tests` cache their own answer, but a warm does "
203
249
  "not run them, so their first call pays full price; `delta` and `review-pr` are "
204
250
  "diff-dependent and never cached.",
205
- "onboard 6.5 s → 0.3 s · refactor 7.7 s → 7.7 s (0.3 s on repeat) · "
206
- "generate-tests 12.4 s → 11.0 s (0.3 s on repeat)"),
207
- CommandCache("onboard", ("task", "ris"), "answer", True, "Shorthand for `prepare-context onboard`.",
208
- "6.5 s → 0.3 s"),
251
+ "onboard 6.5 s → 0.3 s with a warm — but a second run with NO warm recomputes "
252
+ "(10.2 s → 9.4 s at 5 486 files; C1-40) · refactor 7.7 s → 7.7 s (0.3 s on "
253
+ "repeat) · generate-tests 12.4 s → 11.0 s (0.3 s on repeat)", analysis_class="repo-wide", cold_seconds=6.5, warm_seconds=0.3),
254
+ CommandCache("onboard", ("task", "ris"), "answer", False,
255
+ "Shorthand for `prepare-context onboard`. A warm stores its answer; the command "
256
+ "does NOT store its own, so a second run without a warm pays full price. C1-40: "
257
+ "the field read `repeat cached` here, ran it twice with no warm, and measured "
258
+ "56 s then 23,5 s — this row said it would be a hit.",
259
+ "with a warm: 6.5 s → 0.3 s (2 000 files) · 10.2 s → 0.7 s (5 486 files). "
260
+ "WITHOUT a warm, a second identical run: 10.2 s → 9.4 s (5 486 files) — "
261
+ "no answer hit", analysis_class="repo-wide", cold_seconds=6.5, warm_seconds=0.3),
209
262
  CommandCache("explain", ("cir",), "shared", False, "Serves from the shared CIR a warm builds.",
210
- "9.8 s → 1.6 s"),
211
- CommandCache("export", ("parse",), "shared", False, "", "8.8 s → 3.7 s"),
212
- CommandCache("repo-ir", ("parse",), "shared", False, "", "5.1 s → 2.9 s"),
213
- CommandCache("validation", ("parse",), "shared", False, "", "11.6 s → 6.4 s"),
214
- CommandCache("modernize", ("parse",), "shared", False, "", "5.2 s → 3.0 s"),
215
- CommandCache("chunk-file", (), "none", False, "Reads one file; nothing to cache."),
263
+ "9.8 s → 1.6 s", analysis_class="core", cold_seconds=9.8, warm_seconds=1.6),
264
+ CommandCache("export", ("parse",), "shared", False, "", "8.8 s → 3.7 s", analysis_class="repo-wide", cold_seconds=8.8, warm_seconds=3.7),
265
+ CommandCache("repo-ir", ("parse",), "shared", False, "", "5.1 s → 2.9 s", analysis_class="repo-wide", cold_seconds=5.1, warm_seconds=2.9, field_blocked=True),
266
+ CommandCache("validation", ("parse",), "shared", False, "", "11.6 s → 6.4 s", analysis_class="repo-wide", cold_seconds=11.6, warm_seconds=6.4),
267
+ CommandCache("modernize", ("parse",), "shared", False, "", "5.2 s → 3.0 s", analysis_class="repo-wide", cold_seconds=5.2, warm_seconds=3.0, field_blocked=True),
268
+ CommandCache("chunk-file", (), "none", False, "Reads one file; nothing to cache.", analysis_class="core"),
216
269
  CommandCache("cold-start", ("ris",), "answer", True,
217
270
  "Reads the RIS a warm rebuilds — that is all it does. Without one it answers "
218
271
  "`no_ris` instead of a snapshot.",
219
- "0.2 s either way"),
220
- CommandCache("trend", (), "none", False, "Reads stored baseline artifacts from disk; analyses no source, so no cache layer applies. Same command as `baseline trend`."),
272
+ "0.2 s either way", analysis_class="core", cold_seconds=0.2, warm_seconds=0.2),
273
+ CommandCache("trend", (), "none", False, "Reads stored baseline artifacts from disk; analyses no source, so no cache layer applies. Same command as `baseline trend`.", analysis_class="none"),
221
274
  CommandCache("baseline", ("parse",), "shared", False, "`capture`/`diff`/`trend` over architectural metrics.",
222
- "capture 8.7 s → 3.6 s"),
275
+ "capture 8.7 s → 3.6 s", analysis_class="repo-wide", cold_seconds=8.7, warm_seconds=3.6, field_seconds=11.0),
223
276
  CommandCache("retrieve", ("cir", "parse"), "shared", False,
224
- "Every query builds or reuses the shared CIR a warm builds."),
225
- CommandCache("archetype", ("parse",), "shared", False, "", "9.3 s → 5.6 s"),
226
- CommandCache("activate", (), "none", False, "Not an analysis."),
227
- CommandCache("auth", (), "none", False, "Not an analysis."),
228
- CommandCache("cache", (), "none", False, "Operates on the caches themselves."),
229
- CommandCache("config", (), "none", False, "Not an analysis."),
277
+ "Every query builds or reuses the shared CIR a warm builds.", analysis_class="core"),
278
+ CommandCache("archetype", ("parse",), "shared", False, "", "9.3 s → 5.6 s", analysis_class="repo-wide", cold_seconds=9.3, warm_seconds=5.6),
279
+ CommandCache("activate", (), "none", False, "Not an analysis.", analysis_class="none"),
280
+ CommandCache("auth", (), "none", False, "Not an analysis.", analysis_class="none"),
281
+ CommandCache("cache", (), "none", False, "Operates on the caches themselves.", analysis_class="none"),
282
+ CommandCache("config", (), "none", False, "Not an analysis.", analysis_class="none"),
230
283
  CommandCache("mcp", ("snapshot", "ris", "cir", "parse"), "shared", True,
231
- "Serves the same commands over MCP, with the same layers."),
232
- CommandCache("schema", (), "none", False, "Prints registries; reads no repository."),
233
- CommandCache("telemetry", (), "none", False, "Not an analysis."),
234
- CommandCache("version", (), "none", False, "Not an analysis."),
284
+ "Serves the same commands over MCP, with the same layers.", analysis_class="none"),
285
+ CommandCache("schema", (), "none", False, "Prints registries; reads no repository.", analysis_class="none"),
286
+ CommandCache("telemetry", (), "none", False, "Not an analysis.", analysis_class="none"),
287
+ CommandCache("version", (), "none", False, "Not an analysis.", analysis_class="none"),
235
288
  )
236
289
 
237
290
  _WARM_LABEL = {
@@ -267,6 +320,11 @@ def as_dict() -> dict:
267
320
  for lyr in LAYERS
268
321
  ],
269
322
  "reference_repository": REFERENCE_REPOSITORY,
323
+ "reference_java_files": REFERENCE_JAVA_FILES,
324
+ # Published beside the reference so no consumer reads the figures as a
325
+ # law: the same command has been measured 46× slower on a repository 1.7×
326
+ # the size, on a different platform (C4-19).
327
+ "field_anchor": FIELD_ANCHOR,
270
328
  "commands": [
271
329
  {
272
330
  "command": cmd.command,
@@ -275,6 +333,12 @@ def as_dict() -> dict:
275
333
  "repeat_run_is_cached": cmd.repeat,
276
334
  "note": cmd.note,
277
335
  "measured": cmd.measured or None,
336
+ "analysis_class": cmd.analysis_class,
337
+ "reference_seconds": (
338
+ None
339
+ if cmd.cold_seconds is None
340
+ else {"cold": cmd.cold_seconds, "warm": cmd.warm_seconds}
341
+ ),
278
342
  }
279
343
  for cmd in COMMANDS
280
344
  ],
@@ -636,6 +636,7 @@ def project_blast_radius(
636
636
  target: str,
637
637
  *,
638
638
  max_depth: int = 4,
639
+ root=None,
639
640
  ) -> dict:
640
641
  """Project blast radius from CIR.
641
642
 
@@ -645,7 +646,7 @@ def project_blast_radius(
645
646
  All endpoints in the result come from cir.endpoints (via route_surface).
646
647
  No independent endpoint reconstruction.
647
648
  """
648
- return _compute_blast_radius(cir._raw_ir, target, max_depth=max_depth)
649
+ return _compute_blast_radius(cir._raw_ir, target, max_depth=max_depth, root=root)
649
650
 
650
651
 
651
652
  # ---------------------------------------------------------------------------
sourcecode/change_plan.py CHANGED
@@ -81,10 +81,15 @@ def _node_meta(cir: "CanonicalRepositoryIR") -> dict[str, dict]:
81
81
 
82
82
 
83
83
  def build_change_plan(
84
- cir: "CanonicalRepositoryIR", target: str, *, max_depth: int = 4
84
+ cir: "CanonicalRepositoryIR", target: str, *, max_depth: int = 4, root=None
85
85
  ) -> dict:
86
- """Build the deterministic change plan for `target` over `cir`."""
87
- blast = project_blast_radius(cir, target, max_depth=max_depth)
86
+ """Build the deterministic change plan for `target` over `cir`.
87
+
88
+ `root` is optional and is used for one thing: the advice attached to an
89
+ unresolved target names a command, and whether that command can be run on
90
+ this repository is a property of the repository (C4-20).
91
+ """
92
+ blast = project_blast_radius(cir, target, max_depth=max_depth, root=root)
88
93
  resolution = blast.get("resolution", "not_found")
89
94
 
90
95
  # Unresolvable target → return a plan-shaped resolution notice (never crash).
@@ -93,17 +98,31 @@ def build_change_plan(
93
98
  from sourcecode.target_admission import is_unresolved
94
99
 
95
100
  if is_unresolved(resolution) or not blast.get("matched_fqns"):
101
+ # C1-39: this is a well-formed `change-plan-v1`, not an error envelope,
102
+ # so an agent reads its fields — and every cost used to be `0`. Nothing
103
+ # was measured, and `0` is the answer to a different question. The
104
+ # product already gets this right elsewhere (`data-exposure` answers
105
+ # `answered: false` rather than measuring nothing and publishing a
106
+ # confident zero); this is the same rule, one level up.
107
+ _not_measured = {
108
+ "answered": False,
109
+ "reason": f"target {target!r} did not resolve — nothing was measured",
110
+ }
96
111
  return {
97
112
  "schema": CHANGE_PLAN_SCHEMA,
98
113
  "target": target,
99
114
  "resolution": resolution,
100
115
  "message": blast.get("message", f"Target {target!r} not found."),
101
116
  "candidates": blast.get("candidates", []),
102
- "affected_components": {"count": 0, "components": []},
103
- "covering_tests": {"count": 0, "tests": []},
104
- "affected_endpoints": {"count": 0, "endpoints": []},
105
- "rollback_surface": {"file_count": 0, "files": []},
117
+ "affected_components": {"count": None, "components": [], **_not_measured},
118
+ "covering_tests": {"count": None, "tests": [], **_not_measured},
119
+ "affected_endpoints": {"count": None, "endpoints": [], **_not_measured},
120
+ "rollback_surface": {"file_count": None, "files": [], **_not_measured},
106
121
  "review_checklist": [],
122
+ "how_to_read": (
123
+ "Every cost here is `null`, not `0`: the target did not resolve, so "
124
+ "nothing was measured. A zero would say the change is free."
125
+ ),
107
126
  }
108
127
 
109
128
  seeds = list(blast["matched_fqns"])
@@ -148,10 +167,21 @@ def build_change_plan(
148
167
  checklist.append(
149
168
  f"Re-verify the contract of {len(ep_strings)} endpoint(s) in the blast radius."
150
169
  )
170
+ # C1-39, second half: on a repository with no test source root at all — the
171
+ # field one had 0 test files for 3 337 non-test Java sources — "0 covering
172
+ # tests" reads as *the change is safe* when it means *there are no tests*.
173
+ _has_test_root = any(
174
+ is_test_path(str(m.get("file") or "")) for m in meta.values()
175
+ )
151
176
  if tests:
152
177
  checklist.append(
153
178
  f"Run {len(tests)} covering test symbol(s) that reach the change."
154
179
  )
180
+ elif not _has_test_root:
181
+ checklist.append(
182
+ "Covering tests were not measured: this repository has no test source "
183
+ "root, so `covering_tests.count` is null rather than 0."
184
+ )
155
185
  else:
156
186
  checklist.append(
157
187
  "No covering tests reach the change — no existing test exercises this blast radius."
@@ -178,10 +208,20 @@ def build_change_plan(
178
208
  "count": len(prod),
179
209
  "components": prod[:_LIST_CAP],
180
210
  },
181
- "covering_tests": {
182
- "count": len(tests),
183
- "tests": tests[:_LIST_CAP],
184
- },
211
+ "covering_tests": (
212
+ {
213
+ "count": len(tests),
214
+ "tests": tests[:_LIST_CAP],
215
+ "answered": True,
216
+ }
217
+ if _has_test_root else
218
+ {
219
+ "count": None,
220
+ "tests": [],
221
+ "answered": False,
222
+ "reason": "no test source root in this repository — nothing to measure",
223
+ }
224
+ ),
185
225
  "affected_endpoints": {
186
226
  "count": len(ep_strings),
187
227
  "endpoints": ep_strings[:_LIST_CAP],