sourcecode 5.8.32__py3-none-any.whl → 5.9.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__ = "5.8.32"
7
+ __version__ = "5.9.0"
@@ -1,5 +1,5 @@
1
1
  """Generated at build time by hatch_build.py. Do not edit (AUD-592-D04)."""
2
2
 
3
- BUILD_COMMIT = '4fcf033d0261c8c67fbce36a70f42a6456f90a69'
3
+ BUILD_COMMIT = 'b4c3691c4110d863fedcd54a61a08fd2997e400c'
4
4
  CLOSURE_PROVENANCE = {'Window': {'commit': '', 'status': 'uncited'}, '`AUD-588-F05`': {'commit': '470e4a4', 'status': 'present'}, '`AUD-590-B01` / `B-01`, `F-01`': {'commit': '', 'status': 'uncited'}, '`AUD-590-B02` / `B-02`, `F-02`': {'commit': '', 'status': 'uncited'}, '`AUD-590-B03` / `B-03`, `F-03`': {'commit': '', 'status': 'uncited'}, '`AUD-590-B04` / `B-04`, `F-04`': {'commit': '', 'status': 'uncited'}, '`AUD-590-R01` / `R-01`': {'commit': '', 'status': 'uncited'}, '`AUD-591-A01` / `A-1`': {'commit': '', 'status': 'uncited'}, '`AUD-591-A02` / `A-2`, `A-4`': {'commit': '', 'status': 'uncited'}, '`AUD-591-A03` / `A-3`': {'commit': '', 'status': 'uncited'}, '`AUD-591-A05` / `A-5`': {'commit': '', 'status': 'uncited'}, '`AUD-591-A06` / `A-6`, `A-7`': {'commit': '', 'status': 'uncited'}, '`AUD-591-A08` / `A-8`': {'commit': '', 'status': 'uncited'}, '`AUD-591-A09` / `A-9`': {'commit': '', 'status': 'uncited'}, '`AUD-591-A10` / `A-10`': {'commit': '', 'status': 'uncited'}, '`AUD-592-A02` / `A-1` second half, `§12.5`': {'commit': '63f3d61', 'status': 'present'}, '`AUD-592-B01` / `B-8`': {'commit': '8329614', 'status': 'present'}, '`AUD-592-D01` / `D-1`': {'commit': '0f8e03f', 'status': 'present'}, '`AUD-592-D02` / `D-2`': {'commit': '1d3db4d', 'status': 'present'}, '`AUD-592-D03` / `D-3`': {'commit': 'f28fe40', 'status': 'present'}, '`AUD-592-R01` / `A-1`': {'commit': '216025f', 'status': 'present'}, '`AUD-592-R02` / `N-4`, reopens `AUD-513-N04`': {'commit': 'ec3050d', 'status': 'present'}, '`AUD-593-N01` / `N-01`': {'commit': 'dd6a706', 'status': 'present'}, '`AUD-593-N02` / `N-02`': {'commit': 'f539787', 'status': 'present'}, '`AUD-593-N03` / `N-03`': {'commit': '1dd0b1c', 'status': 'present'}, '`AUD-593-N05` / `N-05`': {'commit': 'aa89388', 'status': 'present'}, '`AUD-593-N06` / `N-06`': {'commit': 'aa584a6', 'status': 'present'}, '`AUD-594-N04` / `D-5`, disclosure half of `B-3`': {'commit': 'a0060b7', 'status': 'present'}, '`AUD-594-X01` / `A-1`, residual of `AUD-593-N03`': {'commit': 'dd222b9', 'status': 'present'}, '`AUD-594-X02` / `A-1` second half': {'commit': '1193937', 'status': 'present'}, '`AUD-594-X03` / `X-03`, residual of `AUD-593-N01`': {'commit': 'e89e8f0', 'status': 'present'}, '`AUD-594-X04`': {'commit': '05b8cc9', 'status': 'present'}, '`AUD-595-A02` / `N-5`, advisory half of `AUD-588-B11`': {'commit': '3e15227', 'status': 'present'}, '`AUD-595-A03` / `B-6` exit-code half': {'commit': 'ab7285f', 'status': 'present'}, '`AUD-595-A05`': {'commit': 'e0b3d10', 'status': 'present'}, '`AUD-595-A07`, narrow half of `AUD-592-A02`': {'commit': 'cc15177', 'status': 'present'}, '`AUD-595-B01` / §B': {'commit': 'b77a1ad', 'status': 'present'}, '`AUD-595-Q01`': {'commit': '91a6566', 'status': 'present'}, '`AUD-596-A01` / `ASK-DET-001`': {'commit': 'fa508b4', 'status': 'present'}, '`AUD-596-A02` / `ASK-SELF-001`': {'commit': '4dfadc8', 'status': 'present'}, '`AUD-596-A03` / `ASK-C1-001`': {'commit': 'bffa3a6', 'status': 'present'}, '`AUD-596-A04` / `ASK-UX-003` + `ASK-UX-004`': {'commit': 'f0aac3c', 'status': 'present'}, '`AUD-596-B01` / `B-01`, harder witness for `BUG-6`': {'commit': '567d09d', 'status': 'present'}, '`AUD-596-B02` / `B-02`, class residual of `R2`': {'commit': '', 'status': 'uncited'}, '`AUD-596-B03` / `B-03`': {'commit': 'a8cb814', 'status': 'present'}, '`AUD-596-B06` / `B-06`': {'commit': '8107927', 'status': 'present'}, '`AUD-596-B07` / `B-07`, class residual of `AUD-592-R02`': {'commit': 'e060485', 'status': 'present'}, '`AUD-596-B09` / `B-09`': {'commit': '5b8146c', 'status': 'present'}, '`AUD-596-B10` / `B-10`': {'commit': '5cb994d', 'status': 'present'}, '`AUD-596-B16` / `B-16`': {'commit': '1507f69', 'status': 'present'}, '`AUD-596-D01` / `B-13` + `B-14`': {'commit': '0b1bdf0', 'status': 'present'}, '`AUD-596-D02` / `B-08`, residual of `AUD-594-N04`': {'commit': 'e7f73c3', 'status': 'present'}, '`AUD-596-D03` / `ASK-AGT-001`': {'commit': '8c46967', 'status': 'present'}, '`AUD-596-D04` / `B-18`': {'commit': '040f027', 'status': 'present'}, '`AUD-596-D05` / `B-19`': {'commit': 'fd2bf68', 'status': 'present'}, '`AUD-596-D06` / `B-20`': {'commit': '784cd83', 'status': 'present'}, '`AUD-596-D07` / `B-17`, half refuted': {'commit': 'c3ff822', 'status': 'present'}, '`AUD-596-D08` / `B-22`, class residual of `AUD-591-A06`': {'commit': 'b396c6a', 'status': 'present'}, '`AUD-596-D09` / `B-23`, surviving half of `B26`': {'commit': 'fd3d63f', 'status': 'present'}, '`AUD-596-D10` / `B-29`': {'commit': '', 'status': 'uncited'}, '`AUD-596-D11` / `ASK-UX-005`': {'commit': 'f4b4f77', 'status': 'present'}, '`AUD-596-D12` — six hints that send the reader back to the error': {'commit': '498df44', 'status': 'present'}, '`AUD-596-D13` / `B-28`': {'commit': '1014c72', 'status': 'present'}, '`AUD-596-D14` / `B-15`, narrow half of `B20`': {'commit': '10f2827', 'status': 'present'}, '`AUD-596-R01` / `B-05`': {'commit': '', 'status': 'uncited'}, '`AUD-596-X01` / `ASK-GATE-001`, gating half of `B24`': {'commit': 'e59c655', 'status': 'present'}, '`AUD-596-X02` / `ASK-UX-001` + `ASK-UX-002` + `B-21`': {'commit': 'f6ebf50', 'status': 'present'}, '`AUD-597-A01` / `ASK-UX-001` + `N-01`': {'commit': 'f8cf061', 'status': 'present'}, '`AUD-597-A02` / `ASK-UX-008` + `ASK-DOC-002`': {'commit': '9d47a4c', 'status': 'present'}, '`AUD-597-A03` / `ASK-DET-001`': {'commit': '4c7622a', 'status': 'present'}, '`AUD-597-A04` / `ASK-LEDGER-001`': {'commit': '46fb870', 'status': 'present'}, '`AUD-597-D01` / `B-04`': {'commit': '1094e6a', 'status': 'present'}, '`AUD-597-D02` / `ASK-DOC-001`': {'commit': 'ecb1bb7', 'status': 'present'}, '`AUD-597-D03` / `N-06`': {'commit': '9784333', 'status': 'present'}, '`AUD-597-D04` / `N-05`': {'commit': 'e51c087', 'status': 'present'}, '`AUD-597-D05` / `N-04`, fourth appearance of `C4-27`': {'commit': '9cccc15', 'status': 'present'}, '`AUD-597-D06` / `ASK-CLI-001`': {'commit': '178aa6d', 'status': 'present'}, '`AUD-597-D07` / `N-03`': {'commit': '9245e35', 'status': 'present'}, '`AUD-597-R01` / `ASK-PERF-001`': {'commit': '6910054', 'status': 'present'}, '`AUD-597-R02` / `ASK-ENC-001`': {'commit': 'ce6dac1', 'status': 'present'}, '`AUD-597-R03` / `ASK-UX-002`': {'commit': 'c57e50b', 'status': 'present'}, '`AUD-597-X01` / `ASK-BUILD-001` + `ASK-META-001` / `B-12`': {'commit': '354338b', 'status': 'present'}, '`AUD-598-B01` / `ASK-META-001`': {'commit': 'b2f7c65', 'status': 'present'}, '`AUD-598-B02` / `ASK-UX-002`': {'commit': '56a4bf7', 'status': 'present'}, '`AUD-598-B03` / `B-11`': {'commit': 'c63cb24', 'status': 'present'}, '`AUD-598-B04` / `N-02`': {'commit': '187937f', 'status': 'present'}, '`AUD-598-R01` / `ASK-PERF-002`': {'commit': '', 'status': 'uncited'}, '`AUD-598-X01` / `N-01` + `B-13` + `B-14` + `B-29` + `N-06`': {'commit': '6f2f5a4', 'status': 'present'}, '`AUD-600-B01` / `P-01`': {'commit': '366ffed', 'status': 'present'}, '`AUD-600-B02` / `P-02` + `ASK-PACK-002`': {'commit': '2a814c3', 'status': 'present'}, '`AUD-600-B03` / `ASK-PACK-001`': {'commit': '452f2a0', 'status': 'present'}, '`AUD-600-D01`': {'commit': '1224e1b', 'status': 'present'}, '`AUD-600-D02`': {'commit': 'eab8172', 'status': 'present'}, '`AUD-600-R01` / `R-01`': {'commit': '88add64', 'status': 'present'}, '`AUD-601-B01` / `N-02`, 5th round': {'commit': '', 'status': 'uncited'}, '`AUD-601-B02` / `ASK-PACK-004`': {'commit': '', 'status': 'uncited'}, '`AUD-601-D01` / `N-01`, `ASK-UX-001`, 5th round': {'commit': '', 'status': 'uncited'}, '`AUD-601-D02` / `B-13`, `B-14`, 5th round': {'commit': '', 'status': 'uncited'}, '`AUD-601-M01` / `B-26`, `B-27` retracted': {'commit': '', 'status': 'uncited'}, '`AUD-601-R01` / `R-02`': {'commit': '', 'status': 'uncited'}, '`B-3`': {'commit': '773268d', 'status': 'present'}, '`B-6`': {'commit': '17c6e06', 'status': 'present'}, '`B-8`': {'commit': '8329614', 'status': 'present'}, '`BUG-3` / `443b345`': {'commit': '', 'status': 'uncited'}, '`BUG-4a` / `AUD-588-B11` residual': {'commit': '24b4185', 'status': 'present'}, '`BUG-4b` / `AUD-588-B11` residual': {'commit': '0376350', 'status': 'present'}, '`E-39`, class residual of `AUD-593-N06`': {'commit': '25fc11f', 'status': 'present'}, '`N-5`': {'commit': 'c68083f', 'status': 'present'}, '`N-7`': {'commit': '082e9de', 'status': 'present'}, '`R-1`': {'commit': '424fb1c', 'status': 'present'}, '`R-2`': {'commit': '5158b92', 'status': 'present'}, '`R-3`': {'commit': '88aec04', 'status': 'present'}, '`R-4`': {'commit': '04fd04f', 'status': 'present'}}
5
5
  CLOSURE_COVERAGE = {'closed_rows': 113, 'present': 88, 'absent': 0, 'unresolved': 0, 'uncited': 25, 'unknown': 0}
@@ -16,6 +16,76 @@ The facts these rows are keyed to are published: `ask schema facts-v1` prints th
16
16
 
17
17
  ## Current Synchronization
18
18
 
19
+ **External `5.8.32` audit received, 2026-08-26 — `AUD-607`.** Two independent
20
+ reports ([`AUDIT-2026-08-26-EXTERNAL-5.8.32.md`](AUDIT-2026-08-26-EXTERNAL-5.8.32.md)):
21
+ a reverification round (9.7/10) and a comparison round against `5.8.31` (8/10).
22
+ Six previously closed findings hold on their third or fourth consecutive
23
+ re-check, and **no regression is reported by either round**.
24
+
25
+ One new defect is confirmed and closed in `5.9.0`: `AUD-607-C01`
26
+ (P1), where `chunk-file` emitted chunk content that did not match its own
27
+ declared `start_line`/`end_line`. Controlled reproduction found the mechanism
28
+ **wider than reported** — two defects, not one: `_flush_chunk` serialised the
29
+ untruncated pending buffer while declaring a truncated end line, and the
30
+ annotation buffer's clearing guard was **unreachable**, so a field's
31
+ `@Autowired` was carried onto the following method while the field's own
32
+ declaration line dropped out of that chunk. The report's "deliberate undeclared
33
+ overlap" hypothesis is refuted; no overlap contract is published. Its bounding
34
+ battery now asserts content against the declared range, which the previous
35
+ `include_content=False` coverage structurally could not see.
36
+
37
+ `AUD-607-E01` (P2, `explain` returned "No stereotype detected" while listing that
38
+ same class's endpoints) and `AUD-607-I01` (P3, `--files -` asserted "stdin is a
39
+ terminal" for a Git Bash/MSYS redirect it cannot verify) are also closed in
40
+ `5.9.0`. The first is fixed vendor-agnostically by reading the endpoint
41
+ evidence the answer already computed, never by adding JAX-RS to a Spring
42
+ annotation list; the second names the outcome instead of an unverifiable cause,
43
+ the same rule `_confirm` follows for C3-106.
44
+
45
+ **`AUD-607-O01` is not reproduced — second independent refutation, and it
46
+ supersedes `AUD-606-O01`.** The two reports disagreed; measurement resolved it.
47
+ The reverification round re-ran the command itself and confirmed the vendor
48
+ position, and controlled reproduction on the current checkout finds no list
49
+ exceeding the cap on `spring-petclinic` or `BroadleafCommerce`, with the
50
+ `total`/`shown`/`omitted` envelope published on both sides of the diff. The
51
+ `estimated_tokens` figure the second report cites is **not emitted by this
52
+ command's payload**; it belongs to the output-ceiling spill path and describes a
53
+ run that overflowed the ceiling. This row must not be reopened from that figure
54
+ alone.
55
+
56
+ `AUD-606-P01` is closed: the cold-cache improvement held for a second
57
+ consecutive release (`137.8s` then `130s`), so it is no longer a single sample.
58
+ `AUD-607-P01` (`pack gate --since` timing) is inconclusive for the fourth round
59
+ under confirmed process contamination and stays bound to the existing
60
+ measurement protocol — a contaminated sample is evidence of neither a regression
61
+ nor its absence.
62
+
63
+ **Feature intake `AUD-607-X01`…`X09` is CLOSED, one commit per row.** All nine
64
+ are released in `5.9.0` with bounding batteries and full registry entries
65
+ (`b7df333`, `182d95e`, `da3e2da`, `d35b7f8`, `3e42c2c`, `0fc3a15`, `55b93c2`,
66
+ `6463fdf`, `f1fa913`): `cve`, `intent`, `remediate`, `pr-risk`,
67
+ `export --c4 --render`, `watch`, `baseline --kind findings`, `drift` and
68
+ `self-audit`. Six schemas were registered and published alongside them.
69
+ ⚠**Three defects were found by building these, not by the audit:** the C4
70
+ renderer's first version drew report metadata (`summary`, `coverage_note`) as
71
+ system architecture; the watch loop stat-ed repository-relative paths against
72
+ the process CWD and so reported "nothing changed" forever; and `self-audit`
73
+ found its own schema identifier unregistered on its first run. Each is covered
74
+ by an assertion now. `X09` ships with its "not recommended" verdict inside the
75
+ payload rather than dropped: the semantic engine does not apply to this
76
+ product's own Python source, and those figures are declared absent rather than
77
+ reported as zeros.
78
+
79
+ **Current `5.9.0` queue:** `AUD-607-U01` (P3, new commands do not name their
80
+ applicable bounding flags in `--help`) and `AUD-607-U02` (P2, unconfirmed:
81
+ `cache warm` reported a component `exit 143` under a global exit `0`). Feature
82
+ intake `AUD-607-X01`…`X09` is recorded in the audit note and the development
83
+ roadmap; **two proposed features were withdrawn on inspection** because they
84
+ already ship as `spring-audit --clusters` (CODEOWNERS ownership) and
85
+ `spring-audit --since --fail-on-new` (new-versus-historical findings). The
86
+ existing `AUD-604-R01` and `AUD-605` release-artifact verification items remain
87
+ unchanged.
88
+
19
89
  **External `5.8.31` audit received, 2026-08-26 - `AUD-606`.**
20
90
  [`AUDIT-2026-08-26-EXTERNAL-5.8.31.md`](AUDIT-2026-08-26-EXTERNAL-5.8.31.md)
21
91
  confirms the new progress and shared-cache controls, and records one actionable
@@ -46,13 +46,13 @@ CLI commands — impact, endpoints, spring-audit, explain, … each a pro
46
46
  The key idea: the extraction is **content-addressed**. Commands reuse the parse cache and,
47
47
  where their analysed scope matches, the shared Canonical IR; `ask cache model` names what a
48
48
  warm buys for each command rather than implying that every projection costs the same. In
49
- 5.8.32, `validation` enters through that shared CIR and `data-exposure` reuses one semantic
49
+ 5.9.0, `validation` enters through that shared CIR and `data-exposure` reuses one semantic
50
50
  model across all declared label seeds. (The extraction and consumption contract is fixed in
51
51
  the architecture ADRs 0001–0004 under `docs/architecture/`.)
52
52
 
53
53
  The current external pack audit intake is tracked in
54
54
  [`AUDIT-2026-08-26-EXTERNAL-5.8.28-PACK.md`](AUDIT-2026-08-26-EXTERNAL-5.8.28-PACK.md);
55
- the two pack fixes are included in release 5.8.32, with release-artifact verification
55
+ the two pack fixes are included in release 5.9.0, with release-artifact verification
56
56
  remaining for detached execution and pack evidence.
57
57
 
58
58
  ---
@@ -206,7 +206,7 @@ pipx install sourcecode # isolated install, no venv needed
206
206
 
207
207
  # Verify
208
208
  ask version
209
- # ask 5.8.32
209
+ # ask 5.9.0
210
210
  ```
211
211
 
212
212
  Requires Python 3.9+.
@@ -251,7 +251,7 @@ estimate scaled by file count would be wrong in the direction that costs you a s
251
251
  and an anchor printed without its release goes on recommending a nightly job for a command
252
252
  that has come to finish in seconds (C3-97).
253
253
 
254
- 41 commands and seven command groups exist. Four of them carry most of the measured
254
+ 48 commands and seven command groups exist. Four of them carry most of the measured
255
255
  value in field use, and they are the ones to learn first:
256
256
 
257
257
  | Start with | Because |
@@ -1298,14 +1298,22 @@ explaining. The rest do one thing; `ask <command> --help` is the whole story.
1298
1298
  | Command | Tier | What it does |
1299
1299
  |---|---|---|
1300
1300
  | `ask migrate-check` | core | Spring Boot 2→3 readiness plus Java LTS/licensing inventory: blockers, effort range, target JDK evidence and explicit licensing-review signals. Full reference: [migrate-check.md](migrate-check.md) |
1301
+ | `ask drift` | supported | What changed in the effective access — since the last capture (`--capture` records; the default compares) or between two profile sets (`--against`). One arithmetic for both axes. Separates `newly_unmatched` from `newly_matched`, and a `removed` route is NOT reported as having become safe. |
1302
+ | `ask watch` | supported | Warm the index once, then answer what each save affected — one JSON line per tick. `--once` is the pre-commit shape; `--ticks`/`--for` bound the loop. A file added since the warm is reported `unresolved`, never `unreachable`, and a failed analysis is an `error` tick rather than an empty change list. |
1303
+ | `ask pr-risk` | supported | One verdict for one diff — does this change make things worse? Joins `pr-impact` reach, endpoint access, the shared risk model and CODEOWNERS. `--since` differences route inventories so `new_endpoints` is a fact rather than a guess. An unresolved changed file caps the verdict at `UNKNOWN`, which fails every gate. |
1304
+ | `ask remediate` | supported | One loop from finding to verified change: audit → recipe → apply in a dedicated worktree → `verify-edit`. Dry by default; `--write` applies. Publishes `mechanisable` against `manual_remainder` with the reason each family cannot be rewritten, and reports `unverified` — never `applied` — when the result could not be checked. |
1305
+ | `ask cve` | supported | Cross the dependency inventory against a **local** OSV advisory feed (`--feed`; no network is taken), then report whether a route can reach the vulnerable package: `reachable`, `imported_not_exposed`, `not_observed` or `unknown`. `not_observed` is not a statement of safety, and nothing here answers "not reachable". |
1301
1306
  | `ask explain <Class>` | supported | Human-readable architectural summary for one class (text by design, not JSON) |
1302
- | `ask export` | supported | Tool-agnostic views for downstream tooling: `--c4`, `--module-graph`, `--by-directory`, `--integrations` |
1307
+ | `ask export` | supported | Tool-agnostic views for downstream tooling: `--c4`, `--module-graph`, `--by-directory`, `--integrations`. `--c4 --render mermaid\|svg` draws the same model instead of emitting it — presentation over the existing payload, with the node cap written on the diagram. |
1303
1308
  | `ask repo-ir` | supported | Deterministic symbol-level IR for the whole repository |
1304
1309
  | `ask cold-start` | supported | Repository Intelligence Snapshot bootstrap context, returned from the persisted RIS |
1310
+ | `ask baseline … --kind findings` | supported | The defect population as a persisted series, in the same store as the architectural baseline: `capture` records it, `diff` reports new/resolved/persisting against the last capture, and `trend` says how long each still-open defect has been open. `first_seen` is the earliest STORED capture — not the day it was introduced. |
1305
1311
  | `ask prepare-context <task>` | supported | Task-shaped context: `onboard`, `delta`, `fix-bug`, `generate-tests`. See [Typical workflows](#typical-workflows) |
1306
1312
  | `ask chunk-file <file>` | supported | Split a large Java file into semantic chunks an agent can consume |
1313
+ | `ask intent "<question>"` | supported | Resolve a plain-language question to the typed `retrieve` query that answers it (scored lexical match against a declared vocabulary — no model, no generation). Prints the query; `--run` executes it. A question fitting two intents too closely is refused with both named. |
1307
1314
  | `ask rename-class` | supported | Word-boundary Java rename across the repository |
1308
1315
  | `ask schema <name>` | supported | Print a published JSON Schema for this tool's output |
1316
+ | `ask self-audit` | supported | Check the SHIPPED build's own coherence — cache model, format contract, schema registry and output affordances. Declares explicitly that the semantic engine does not apply to this product's own source (ASK analyses Java; this build is Python), rather than reporting zeros. `ask selftest` is the different question: the defect ledger run against a real repository. |
1309
1317
  | `ask archetype` | experimental | Evidence-based architectural archetype across four dimensions |
1310
1318
  | `ask retrieve` | parked | Typed knowledge queries over the model — kept working, no longer developed. Symbol-oriented queries accept `--target/-t`; `--module/-m`, `--subsystem/-s` and `--endpoint/-e` remain compatible domain aliases. |
1311
1319
  | `ask baseline capture\|diff\|trend` | supported | Versioned architectural metrics over time; see [`ask trend`](#ask-trend-dir--how-the-architecture-moved) |
@@ -1382,6 +1390,51 @@ ask endpoints /repo --compact # the census without the rows: coun
1382
1390
  ask endpoints /repo --output endpoints.json
1383
1391
  ```
1384
1392
 
1393
+ ### Closing the loop: find it, judge it, fix it, prove it
1394
+
1395
+ The seven newest commands are joins of pieces that already shipped, and they share one
1396
+ rule: where the answer cannot be decided, they say so rather than return a reassuring zero.
1397
+
1398
+ ```bash
1399
+ # 1. Which advisories a route can actually REACH — not which are on the classpath.
1400
+ # The feed is a local file; no network is taken during analysis, so a CI gate can
1401
+ # never pass because a fetch timed out.
1402
+ ask cve . --feed ./osv-maven.json --reachable-only --fail-on reachable
1403
+
1404
+ # 2. Ask in your own words. It SELECTS one of retrieve's typed queries and prints it;
1405
+ # --run executes it. It never invents an answer.
1406
+ ask intent "what breaks if I change OrderService?"
1407
+
1408
+ # 3. Judge the change, not the repository. UNKNOWN outranks PASS and fails the gate.
1409
+ git diff --name-only main | ask pr-risk . --files - --since main --fail-on block
1410
+
1411
+ # 4. Fix the mechanical part, and prove the tree still holds.
1412
+ ask remediate . # the plan; writes nothing
1413
+ ask remediate . --write --allow-network
1414
+
1415
+ # 5. Keep the receipts, so "how long has this been open?" has an answer.
1416
+ ask baseline capture . --kind findings
1417
+ ask baseline trend . --kind findings
1418
+ ```
1419
+
1420
+ Two of them run on a clock rather than on demand:
1421
+
1422
+ ```bash
1423
+ # In CI, after each deploy: what opened up since the last capture?
1424
+ ask drift . --profile prod --capture
1425
+ ask drift . --profile prod --fail-on-exposure
1426
+
1427
+ # In the editor: what did this save affect?
1428
+ ask watch . --once
1429
+ ```
1430
+
1431
+ And two answer about the tool rather than the repository:
1432
+
1433
+ ```bash
1434
+ ask self-audit --fail-on fail # does the INSTALLED build's registry agree with itself?
1435
+ ask selftest . # the published defect ledger, run against your repo
1436
+ ```
1437
+
1385
1438
  ### MCP server (AI agent integration)
1386
1439
 
1387
1440
  ```bash
@@ -0,0 +1,232 @@
1
+ """access_drift.py — what changed in the effective access, and when.
2
+
3
+ `AUD-607-X08`. `posture --diff dev:prod` already answers *"do these two
4
+ environments expose different things?"*, and `baseline trend` already keeps a
5
+ series. Neither answers the question an operator actually gets paged about:
6
+ *what changed in prod's effective access since the last deploy?* — because the
7
+ first is a comparison of two profile sets at one instant, and nobody was
8
+ recording the instants.
9
+
10
+ One diff serves both axes. A snapshot is `route → access class` for a profile
11
+ set, so comparing two profile sets and comparing one profile set across time are
12
+ the same operation on different pairs, and the two answers can never disagree
13
+ about what "newly exposed" means.
14
+
15
+ **Direction is not symmetry.** A route that moved from a matched rule to
16
+ `no_rule_matched` is not the same event as the reverse, and collapsing both into
17
+ "changed" is how an alert that matters gets read as noise. The payload separates
18
+ `newly_unmatched` from `newly_matched`.
19
+
20
+ **A route that vanished did not become safe.** It may have been deleted, renamed
21
+ or moved behind a profile that is no longer active, and this analysis cannot tell
22
+ those apart — so `removed` is its own bucket and says so.
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import json
28
+ from dataclasses import dataclass
29
+ from datetime import datetime, timezone
30
+ from pathlib import Path
31
+ from typing import Any, Optional
32
+
33
+ SCHEMA_VERSION = "1.0"
34
+ SCHEMA_ID = "access-drift-v1"
35
+
36
+ #: Stored snapshots share the baseline directory; the prefix keeps the three
37
+ #: series (architecture, findings, access) from reading each other's documents.
38
+ FILENAME_PREFIX = "access-"
39
+
40
+ #: The access class published when the posture found no rule deciding a route.
41
+ UNMATCHED = "no_rule_matched"
42
+
43
+ #: Keys of `effective_access` that are not access classes.
44
+ _NOT_A_CLASS = frozenset({"chains", "summary"})
45
+
46
+
47
+ def _utc_now() -> str:
48
+ return datetime.now(timezone.utc).replace(microsecond=0).isoformat()
49
+
50
+
51
+ def routes_from_posture(posture: "dict") -> "dict[str, str]":
52
+ """`"VERB path" → access class`, from a posture payload.
53
+
54
+ The lists under `effective_access` are keyed by access class and hold
55
+ `VERB:path:controller:handler` strings. Only the verb and path identify the
56
+ route across two runs: a handler that moved class is the same route to a
57
+ caller, and keying on the handler would report a refactor as a new exposure.
58
+ """
59
+ access = ((posture or {}).get("endpoints") or {}).get("effective_access") or {}
60
+ routes: "dict[str, str]" = {}
61
+ for access_class, value in access.items():
62
+ if access_class in _NOT_A_CLASS or not isinstance(value, list):
63
+ continue
64
+ for entry in value:
65
+ parts = str(entry).split(":")
66
+ if len(parts) < 2:
67
+ continue
68
+ verb, path = parts[0].strip().upper(), parts[1].strip()
69
+ if not path:
70
+ continue
71
+ routes[f"{verb} {path}"] = access_class
72
+ return routes
73
+
74
+
75
+ def build_snapshot(
76
+ root: Path,
77
+ profiles: "set[str]",
78
+ *,
79
+ posture: "Optional[dict]" = None,
80
+ properties: "Optional[dict]" = None,
81
+ commit: "Optional[str]" = None,
82
+ ) -> dict:
83
+ """One capture of the effective access under a profile set."""
84
+ from sourcecode.architectural_baseline import git_commit
85
+
86
+ if posture is None:
87
+ from sourcecode.posture import build_posture
88
+
89
+ posture = build_posture(Path(root), set(profiles or ()), properties)
90
+
91
+ routes = routes_from_posture(posture)
92
+ by_class: "dict[str, int]" = {}
93
+ for access_class in routes.values():
94
+ by_class[access_class] = by_class.get(access_class, 0) + 1
95
+
96
+ return {
97
+ "schema_version": SCHEMA_VERSION,
98
+ "schema": SCHEMA_ID,
99
+ "kind": "access",
100
+ "captured_at": _utc_now(),
101
+ "commit": commit if commit is not None else git_commit(Path(root)),
102
+ "repo": Path(root).name,
103
+ "profiles": sorted(profiles or ()),
104
+ "counts": {
105
+ "routes": len(routes),
106
+ "by_access_class": dict(sorted(by_class.items())),
107
+ },
108
+ "routes": dict(sorted(routes.items())),
109
+ }
110
+
111
+
112
+ def snapshot_filename(snapshot: "dict") -> str:
113
+ profiles = "-".join(snapshot.get("profiles") or ()) or "default"
114
+ stem = snapshot.get("commit") or snapshot.get("captured_at", "capture")
115
+ safe = "".join(c if (c.isalnum() or c in "-_.") else "-" for c in f"{profiles}-{stem}")
116
+ return f"{FILENAME_PREFIX}{safe}.json"
117
+
118
+
119
+ def write_snapshot(snapshot: "dict", out_dir: Path) -> Path:
120
+ from sourcecode import readonly as _readonly
121
+ from sourcecode.architectural_baseline import protect_ask_dir
122
+
123
+ out_dir = Path(out_dir)
124
+ _readonly.guard(out_dir, what="access history directory")
125
+ out_dir.mkdir(parents=True, exist_ok=True)
126
+ protect_ask_dir(out_dir)
127
+ path = out_dir / snapshot_filename(snapshot)
128
+ path.write_text(
129
+ json.dumps(snapshot, indent=2, sort_keys=True, ensure_ascii=False) + "\n",
130
+ encoding="utf-8",
131
+ )
132
+ return path
133
+
134
+
135
+ def load_series(out_dir: Path, *, profiles: "Optional[set]" = None) -> "list[dict]":
136
+ """Stored access snapshots, oldest first, optionally for one profile set."""
137
+ out_dir = Path(out_dir)
138
+ if not out_dir.is_dir():
139
+ return []
140
+ series: "list[dict]" = []
141
+ wanted = sorted(profiles) if profiles is not None else None
142
+ for path in sorted(out_dir.glob(f"{FILENAME_PREFIX}*.json")):
143
+ try:
144
+ payload = json.loads(path.read_text(encoding="utf-8"))
145
+ except (OSError, ValueError):
146
+ continue
147
+ if not isinstance(payload, dict) or payload.get("kind") != "access":
148
+ continue
149
+ if wanted is not None and list(payload.get("profiles") or ()) != wanted:
150
+ continue
151
+ payload["_source_file"] = path.name
152
+ series.append(payload)
153
+ series.sort(key=lambda s: str(s.get("captured_at") or ""))
154
+ return series
155
+
156
+
157
+ def diff_snapshots(base: "dict", head: "dict", *, axis: str = "time") -> dict:
158
+ """What moved between two access snapshots.
159
+
160
+ `axis` only labels the answer: `time` for one profile set across captures,
161
+ `environment` for two profile sets at one instant. The arithmetic is identical,
162
+ which is the point — the two can never disagree about "newly exposed".
163
+ """
164
+ base_routes: "dict[str, str]" = dict(base.get("routes") or {})
165
+ head_routes: "dict[str, str]" = dict(head.get("routes") or {})
166
+
167
+ added = sorted(set(head_routes) - set(base_routes))
168
+ removed = sorted(set(base_routes) - set(head_routes))
169
+ shared = sorted(set(base_routes) & set(head_routes))
170
+
171
+ newly_unmatched: "list[dict]" = []
172
+ newly_matched: "list[dict]" = []
173
+ reclassified: "list[dict]" = []
174
+ for route in shared:
175
+ before, after = base_routes[route], head_routes[route]
176
+ if before == after:
177
+ continue
178
+ row = {"route": route, "from": before, "to": after}
179
+ if after == UNMATCHED:
180
+ newly_unmatched.append(row)
181
+ elif before == UNMATCHED:
182
+ newly_matched.append(row)
183
+ else:
184
+ reclassified.append(row)
185
+
186
+ added_rows = [{"route": r, "access": head_routes[r]} for r in added]
187
+ added_unmatched = [r for r in added_rows if r["access"] == UNMATCHED]
188
+
189
+ changed = (
190
+ len(added) + len(removed) + len(newly_unmatched)
191
+ + len(newly_matched) + len(reclassified)
192
+ )
193
+
194
+ return {
195
+ "schema_version": SCHEMA_VERSION,
196
+ "schema": SCHEMA_ID,
197
+ "axis": axis,
198
+ "base": {
199
+ "captured_at": base.get("captured_at"),
200
+ "commit": base.get("commit"),
201
+ "profiles": list(base.get("profiles") or ()),
202
+ "routes": len(base_routes),
203
+ },
204
+ "head": {
205
+ "captured_at": head.get("captured_at"),
206
+ "commit": head.get("commit"),
207
+ "profiles": list(head.get("profiles") or ()),
208
+ "routes": len(head_routes),
209
+ },
210
+ "counts": {
211
+ "added": len(added),
212
+ "added_without_a_matching_rule": len(added_unmatched),
213
+ "removed": len(removed),
214
+ "newly_unmatched": len(newly_unmatched),
215
+ "newly_matched": len(newly_matched),
216
+ "reclassified": len(reclassified),
217
+ "changed_total": changed,
218
+ },
219
+ "drifted": bool(changed),
220
+ "added": added_rows,
221
+ "removed": [{"route": r, "access": base_routes[r]} for r in removed],
222
+ "newly_unmatched": newly_unmatched,
223
+ "newly_matched": newly_matched,
224
+ "reclassified": reclassified,
225
+ "non_coverage": [
226
+ "a route in `removed` did not become safe: it may have been deleted, "
227
+ "renamed, or moved behind a profile that is no longer active, and this "
228
+ "comparison cannot tell those apart",
229
+ "routes are identified by verb and path, so a handler that moved class "
230
+ "is the same route — a refactor is not reported as a new exposure",
231
+ ],
232
+ }