@holmes-lab/holmes-kit 0.25.1 → 0.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/CHANGELOG.md +70 -0
  2. package/README.md +8 -3
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve.js +7 -26
  5. package/dist/holmes/cli/index.js +8 -1
  6. package/dist/holmes/cpg/cycle-observation.d.ts +13 -0
  7. package/dist/holmes/cpg/cycle-observation.js +25 -2
  8. package/dist/holmes/cpg/forbidden-edge-report.d.ts +20 -0
  9. package/dist/holmes/cpg/forbidden-edge-report.js +31 -0
  10. package/dist/holmes/cpg/forbidden-edges.d.ts +85 -2
  11. package/dist/holmes/cpg/forbidden-edges.js +135 -2
  12. package/dist/holmes/cpg/import-resolver.d.ts +12 -0
  13. package/dist/holmes/cpg/import-resolver.js +215 -0
  14. package/dist/holmes/cpg/language-capability.d.ts +14 -0
  15. package/dist/holmes/cpg/language-capability.js +37 -13
  16. package/dist/holmes/cpg/proposed-content.d.ts +7 -1
  17. package/dist/holmes/cpg/proposed-content.js +7 -0
  18. package/dist/holmes/governance/approval-queue.d.ts +33 -0
  19. package/dist/holmes/governance/approval-queue.js +85 -0
  20. package/dist/holmes/hooks/pre-tool-use.js +8 -1
  21. package/dist/holmes/hooks/session-start.js +39 -0
  22. package/dist/holmes/hooks/stop.d.ts +38 -2
  23. package/dist/holmes/hooks/stop.js +153 -49
  24. package/dist/holmes/mcp/handlers/entity-integration.js +5 -8
  25. package/dist/holmes/mcp/handlers/entity-renumber.js +4 -5
  26. package/dist/holmes/mcp/handlers/spec-authoring.js +9 -1
  27. package/dist/holmes/project/report-briefing.d.ts +48 -0
  28. package/dist/holmes/project/report-briefing.js +70 -0
  29. package/dist/holmes/project/resolved-reports.d.ts +20 -0
  30. package/dist/holmes/project/resolved-reports.js +7 -0
  31. package/dist/holmes/rtm/rtm-builder.js +6 -141
  32. package/package.json +1 -1
  33. package/playbooks/publish/PLAYBOOK.md +18 -0
package/CHANGELOG.md CHANGED
@@ -5,6 +5,76 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  <!-- @implements A-SPEC-209 -->
8
+ ## [0.26.0] - 2026-09-20
9
+
10
+ Architecture you declare is now judged in every language the graph can follow, and the verdict
11
+ reaches a person. Five of these slices were authored on Windows and merged; their spec numbers
12
+ moved from REQ-674–678 to REQ-688–692 because `main` had already published those numbers.
13
+
14
+ ### Added
15
+ - **`inherits` joins the forbidden edges** (A-SPEC-691). A C-SPEC's `## Forbidden Edges` takes a
16
+ third rule kind beside `import` and `call`: `- inherits src/app -x-> ConcreteBase`. It is an EXACT
17
+ match, for the reason `call` is — a prefix would let one `Base` swallow every `Base*`. Nothing is
18
+ inferred: the rule reads the graph's `inherits` edges and never asks which class is "abstract",
19
+ which is what makes it work in Go, where interface satisfaction cannot be inferred at all. The
20
+ pre-edit gate enforces it like the other two kinds.
21
+ - **A baseline that expires** (A-SPEC-691). A project that already breaks a rule it wants to adopt
22
+ writes `- allow until 2026-09-27 : inherits src/legacy/a.ts -> LegacyBase` in the same section.
23
+ Exact on kind, file and target — never a prefix, or it becomes a way to switch the rule off. Past
24
+ its date the allowance still holds (an expiry that blocked would make the rule a barrier to
25
+ adoption) but it is reported as expired, with `(reconfirmed n since <date>)` carried along so a
26
+ twelfth renewal cannot hide. A malformed baseline line lands in `malformed`, never disappears.
27
+ - **`ARCH` lines at the end of the turn** (A-SPEC-693). The scan-wide judgement existed and nothing
28
+ in the product called it — measured: zero consumers outside the tests — so an expired baseline was
29
+ neither blocked nor seen, and a time-boxed allowance could not be told from a permanent pardon.
30
+ The Stop hook now reports, on the non-blocking `tracked` channel: violations no baseline covers,
31
+ expired baselines with their renewal count, and **which rule kind could not be judged in which
32
+ extension** (`inherits rules NOT judged in: .js, .py, .tsx`). A project that declares no rule hears
33
+ nothing and pays nothing: the judgement shares the one scan the cycle ratchet already makes
34
+ (measured here: scan 6.6 s, judgement 0.14 s).
35
+ - **A reporter hears that their defect was fixed** (A-SPEC-687). A defect filed through
36
+ `holmes-kit report` is matched, locally and once, against the list of resolved reports that ships
37
+ in the package; the session start says so. Nothing is sent anywhere.
38
+
39
+ ### Fixed
40
+ - **The cycle ratchet judged one language family out of eight** (A-SPEC-688). The Stop hook
41
+ re-implemented import resolution beside the graph builder's — relative specifiers only, four
42
+ extensions. Measured on fixtures shaped the way each language requires: 16 import edges, 2
43
+ survived; 9 planted cycles, 1 found. It cost nothing in an all-TypeScript tree (919 edges either
44
+ way), which is how it survived, and everything in a Java, Go, Python or Rust consumer. There is now
45
+ ONE resolver (`cpg/import-resolver`), consumed by both. A cycle's member list is capped per cycle
46
+ with the dropped count declared (`filesOmitted`), and its identity is computed before the cut.
47
+ - **`.cjs` relative `require()` was declared unrecoverable while being recovered** (A-SPEC-689). The
48
+ capability table under-declared, and its pin only tested the bare form, so the drift passed. Both
49
+ forms are pinned now.
50
+ - **The id a refusal printed could not be granted** (A-SPEC-690). `approve --grant <id>` resolved
51
+ references against the tracked queue alone; a shell hard-HITL refusal lives in the per-machine
52
+ refusal log, so the reference failed before the fallback that handles it could run. Id and prefix
53
+ references now resolve across both stores; a number still means only the row the operator was
54
+ shown; a prefix that collides across the stores is refused by name; an unreadable log removes
55
+ candidates rather than granting. The inbox list is unchanged.
56
+ - **The same Windows directory read as a foreign workspace** (A-SPEC-692). Three root comparisons
57
+ put one side through `realpathSync.native` and the other through the JS `realpathSync`, which
58
+ leaves an 8.3 alias (`SUNGNA~1`) spelled as it arrived. Both sides go through one canonicaliser.
59
+ - **A kind-blind `scope`** (A-SPEC-693). With an `import` rule beside an `inherits` rule, an
60
+ extension that showed only imports read as *judged*. Scope is now asked per rule kind
61
+ (`scopeByKind`), over the files a rule of that kind actually judges; the flat lists fold toward the
62
+ honest side and are unchanged when one kind is in play.
63
+ - **Lazy `require` stopped being recorded for plain JavaScript** (A-SPEC-693). Gating the edge
64
+ classifier on "does this language erase types" also switched it off for `.js/.cjs/.mjs/.jsx`.
65
+
66
+ ### Changed
67
+ - Windows test baseline (A-SPEC-692): child-boundary waits ride the measured time factor, repository
68
+ files are read CRLF-tolerantly, and permission-bit assertions run only where the filesystem keeps
69
+ the bit — reported as skipped, with a reason, where it does not (A-SPEC-693).
70
+
71
+ ### Known
72
+ - The cycle ratchet's own `tracked` line has never been emitted: the Stop hook computes the cycles
73
+ and records the observation, but does not hand them to the verdict. Found while wiring the `ARCH`
74
+ lines; not fixed here because in `strict` mode the fix introduces a new block. This repository
75
+ currently has 3 runtime cycles nobody is shown.
76
+ - Two Windows-only reds remain (`entity-renumber-transaction` race, `supervisor-liveness` EPERM).
77
+
8
78
  ## [0.25.1] - 2026-09-19
9
79
 
10
80
  Test-only. No product behaviour changed; `dist/` behaves exactly as 0.25.0.
package/README.md CHANGED
@@ -16,18 +16,23 @@
16
16
 
17
17
  ### 🛡️ Currently Supported Features (Production Features)
18
18
 
19
+ - 🧬 **Declare your architecture, in every language the graph can follow** *(new in 0.26.0)*: a C-SPEC's `## Forbidden Edges` takes a third rule kind beside `import` and `call` — `- inherits src/app -x-> ConcreteBase`, an exact match enforced at the pre-edit gate. Nothing is inferred: the rule reads the graph's `inherits` edges and never asks which class is "abstract", which is what makes it hold in Go, where interface satisfaction cannot be inferred at all. A project that already breaks the rule it wants adopts it with a **baseline that expires** — `- allow until 2026-09-27 : inherits src/legacy/a.ts -> LegacyBase`, exact on kind, file and target, never a prefix. Past its date the allowance still holds, because an expiry that blocked would turn the rule into a barrier to adoption; it is **reported** instead, carrying `(reconfirmed n since <date>)` so a twelfth renewal cannot hide.
20
+ - 🏛️ **The architecture verdict reaches the person closing the turn** *(new in 0.26.0)*: the scan-wide judgement existed and nothing in the product called it — measured, zero consumers outside the tests — so an expired baseline was neither blocked nor seen, and a time-boxed allowance could not be told from a permanent pardon. The Stop hook now prints `ARCH` lines on the non-blocking `tracked` channel: violations no baseline covers, expired baselines with their renewal count, and **which rule kind could not be judged in which extension** (`inherits rules NOT judged in: .js, .py, .tsx`) — asked per kind, over the files a rule of that kind actually judges, because a pooled answer called a language *judged* for a rule it could not see. No rule declared, nothing said and nothing paid: the judgement shares the one scan the cycle ratchet already makes (measured here: scan 6.6 s, judgement 0.14 s).
21
+ - ♻️ **The cycle ratchet judges every language it can resolve** *(new in 0.26.0)*: the Stop hook had re-implemented import resolution beside the graph builder's — relative specifiers only, four extensions. Measured on fixtures shaped the way each language requires: 16 import edges, 2 survived; 9 planted cycles, 1 found. It cost nothing in an all-TypeScript tree (919 edges either way), which is how it survived to 0.25, and everything in a Java, Go, Python or Rust consumer. There is now **one** resolver, consumed by both; a language whose specifiers do not resolve stays `unavailable` rather than reading as clean, and a wide Go package cycle is capped per cycle with the dropped members counted.
22
+ - 🔑 **The id a refusal prints can be granted** *(new in 0.26.0)*: `approve --grant <id>` resolved references against the decision inbox alone, and a shell hard-HITL refusal lives in the per-machine refusal log — so the very id the refusal printed failed before the fallback that handles it could run, and the owner had nothing to approve. Id and prefix references now resolve across both stores. A **number** still means only the row you were shown, a prefix that collides across the stores is refused by name, an unreadable log removes candidates rather than granting, and the inbox list is unchanged.
23
+ - 📬 **A reporter hears that their defect was fixed** *(new in 0.26.0)*: a defect filed through `holmes-kit report` is matched — locally, once — against the list of resolved reports that ships in the package, and the session start says so. Nothing is sent anywhere.
19
24
  - 🔦 **A semantic layer that is inert says so** *(new in 0.25.0)*: the tier line said what was CONFIGURED, never whether one lookup would succeed. Measured on this repository: the resolved tier was `cloud` and **0 of 602 scanned files** had a cached document vector under it, so every lookup returned nothing — reranking reordered nothing, alternates emitted nothing, and the output was byte-identical to a layer that examined everything and agreed. A naive check would have counted the 532 vectors sitting in the cache and called it healthy; they keyed on symbol lists that no longer existed. `doctor` now reports coverage against the files scanned NOW, through the runtime's own accessor, in four states — the two that cannot be judged say so rather than passing quietly.
20
25
  - 🔁 **Vectors refresh on the channel that keeps the graph fresh** *(new in 0.25.0)*: the graph self-heals through the Stop hook's detached child; the vectors did not, because warming was reachable only from an explicit `rtm_reindex`. The cache key hashes the file's path plus its symbol names, so every edit that renames a symbol invalidates that file's vector — twelve days of work had taken coverage to zero. The refresh now warms too, and the Stop hook reports the verdict the child recorded rather than scanning (tree-sitter wasm must not ride into a gate process); **a missing verdict reads as "not run", never as a pass**. Warming under the cloud tier is egress, so it is automatic AND observed, with `HOLMES_NO_SEMANTIC_WARM` to switch the transfer off — an owner who switches it off is shown a switch, not a fault. The verdict records the commit it was taken at, so work merged from another machine reports **needs re-measuring** instead of yesterday's numbers as today's truth.
21
26
  - 🎣 **The graph can introduce a candidate your words never named** *(new in 0.25.0)*: `issue_localize`'s semantic rerank could only reorder what lexical matching already found, so a question phrased in intent vocabulary never reached the file that answers it. Measured with the tier live and the vectors warm: asking *who opens a URL in the user's browser* ranked an unrelated file first on the shared prose word "repository", at 3.3× the second score, and never returned `open-url.ts` — which the graph held the whole time. The same need in mechanism vocabulary found its answer at rank 4. `semanticAlternates` now carries the top cached-vector matches among files the emission missed, and that file comes back at cosine 0.754. **Pure addition on its own field**: the ranked hits are untouched in set, order and score, because admitting candidates INTO a ranked set is where this project measured precision being lost.
22
27
  - 🪧 **The semantic ladder reaches you without running `doctor`** *(new in 0.25.0)*: measured here on 305 traceability cases, recall goes 0.486 lexical → 0.667 local → **0.887 cloud**, and on requests lexical search misses entirely, recovery goes 0% → 52% → **92%** — and a consumer never learned any of it, because only `doctor` said so. `init` now names the ladder with those numbers and the command for each, and the session banner says it **once** per workspace on a machine and then never again. `local` is named before `cloud` on purpose: `none` is the default because egress needs consent, not because nobody got to it, and the cloud line states what leaves the machine. A consumer who has already chosen hears nothing.
23
28
  - 📮 **`holmes-kit report` — a defect can reach the maintainers** *(new in 0.24.0)*: until now a consumer's holmes-kit defect had no way back to us; the one we learned about arrived because someone pasted a transcript, and it had been reproducing for every consumer on every slice. The command writes a **redacted** report to `.ax/reports/<fingerprint>.md` and prints a prefilled GitHub issue link — title, assignee, body — plus a search link for the same fingerprint so you can see whether it is already known. `--open` opens it; on a headless box or over SSH the printed link is the whole of it. **No token, no API, nothing sent automatically**: you press Submit on GitHub's own page with the body in front of you and editable. Redaction is an allowlist rather than a scrubber — this project's own remote carries a token before the `@`, its replica ids carry a person's name, and its spec titles are unreleased product intent, so a path, a credential or a machine identifier withholds the field and the report says which. A spec id passes by shape; a spec title does not. And what holmes-kit cannot know, it says: no ledger keeps its own refusal text, so a report with no description states that rather than pretending.
24
29
  - 🧭 **A skipped graph analysis is visible** *(new in 0.24.0)*: `AGENTS.md` asks for `maintenance_analyze` before editing source, and nothing checked. Measured on this repository, the step had been skipped for seventeen consecutive commits — and the run that followed named child-process precedents a name search had missed completely, because the question was "who opens a browser" while the answer lived under "who spawns a child". The Stop hook now reports source changed with no analysis standing open: non-blocking, judged by commit rather than by clock, and silent in a workspace that never adopted the habit.
25
- - 🧱 **A stale build is told, not discovered** *(new in 0.23.3)*: thirty suites in this project load `dist/` while they run, and nothing asserted that it still represented the source — only the release gate compared the build id to HEAD, and only at publish time. A stale build does not go red; it verifies old code and returns green. The Stop hook now reports it on the non-blocking `tracked` channel, judged by the **build id and never by mtime**: measured here, `.build-id` had a newer mtime than every source file while naming a commit nine behind HEAD, with two changed sources missing from `dist` entirely. A workspace that does not build hears nothing, `fresh` says nothing, and the two states that cannot be judged say **that** rather than passing quietly.
26
- - 📐 **Declarations are read as written** *(new in 0.23.2)*: `Files to Touch` is where a spec declares the files it will touch, and three things read it — fulfilment advisories, the declaration census and the approval impact note. The parser took only the **first word of a list item**, so measured over 678 approved specs here, **88 specs and 172 paths were declared and never read**; 24 of them parsed to zero while naming files plainly. Several paths on one line, an indented continuation, a Korean first word, a prose paragraph — all invisible. They are read now, wherever they sit, and a bare `name.ext` keeps its old position rule so a property access like `module.exports` is still not a file. A declared path that does not exist but is the suffix of exactly one repository file is reported as an abbreviation rather than a defect; two candidates stays an ambiguity and nothing is guessed. Cost, measured across the whole corpus: **zero** new `missing` findings.
27
- - 📄 **The publish gate reads the docs** *(new in 0.23.2)*: the publish playbook has demanded "bring README and CHANGELOG up to this release" since 0.16.0 and only prose enforced it, so it failed four times — including 0.21.0, 0.22.0 and 0.23.0, which each shipped with a feature list frozen at 0.20.0. The release gate now refuses two things it can decide: a missing CHANGELOG entry for the version being published, and an entry with `### Added` while `README.md` has not changed since the previous release. Replayed over eight releases it refuses exactly the three that were stale and passes the other five. What needs judgement — is the old wording still true? — stays with the person and is **reported**, never faked; a check that could not run says so instead of reading as a pass.
28
30
 
29
31
  **Earlier releases** — condensed to one line each; every release's full account lives in [CHANGELOG.md](CHANGELOG.md).
30
32
 
33
+ - 🧱 **A stale build is told, not discovered** *(0.23.3)*: the Stop hook reports a `dist/` that no longer represents the source, judged by the **build id and never by mtime** — a stale build does not go red, it verifies old code and returns green; the two states that cannot be judged say that rather than passing quietly.
34
+ - 📐 **Declarations are read as written** *(0.23.2)*: `Files to Touch` is read wherever a path sits — several on one line, an indented continuation, a Korean first word — after 88 specs and 172 declared paths were measured as never read; an unambiguous abbreviation is reported as one, and nothing is guessed.
35
+ - 📄 **The publish gate reads the docs** *(0.23.2)*: the release gate refuses a missing CHANGELOG entry for the version being published, and an `### Added` entry while `README.md` has not changed since the previous release; what needs judgement stays with the person and is reported, never faked.
31
36
  - 🧩 **Your config files survive a re-wire** *(0.23.0)*: `init --agent antigravity|codex` refreshes only the holmes-kit entry instead of replacing `.agents/mcp_config.json`, `hooks.json` and `marketplace.json` whole — a neighbour server, hook, plugin and your own `disabled` flag all survive, and an unreadable JSON file is refused with a reason rather than overwritten.
32
37
  - 🫀 **The MCP supervisor notices a child that died** *(0.23.0)*: a crashed child used to leave the server **permanently deaf**; outstanding requests now get a JSON-RPC error first, then the supervisor resets, respawns and replays the opening exchange — with a restart budget spent only by a child that never answered.
33
38
  - 🧮 **Coverage you can explain** *(0.23.0, corrected in 0.23.2)*: the RTM census says *what each unlinked spec declared* (`scanned-source`, `file-anchor-target`, `test-target`, `unreachable-target`, `no-declaration`). The "zero" 0.23.0 published here was the Files-to-Touch parser's, not the corpus's — the corrected census reads three, with five real trace gaps behind it.
package/dist/.build-id CHANGED
@@ -1 +1 @@
1
- 6277a6d6-mu7vpg1b
1
+ a53bfcd1-mu95v98e
@@ -100,32 +100,13 @@ const findPending = (root, id) => {
100
100
  // — exactly the "grant is for what the human saw" contract. A decided id never promotes: the
101
101
  // granted/denied event in the queue closes the request, same single-use convention as pending.
102
102
  try {
103
- const { readRefusals } = require('../governance/approval-queue');
104
- let mine = readRefusals(root).filter((r) => r.id === id);
105
- // A decided id closes the request UNLESS the gate refused it again afterwards. The fold has
106
- // the same rule for pending (a later `requested` re-inserts after granted/denied): a human may
107
- // change their mind, and the retry is what re-opens the question.
108
- const decided = state.decisions[id];
109
- if (decided) {
110
- // Clock-free reopening: the decision recorded HOW MANY occurrences it covered, so a retry is
111
- // simply everything past that count — millisecond ties (measured flaky under the full suite)
112
- // cannot lose it. Legacy decisions without the count fall back to the timestamp rule.
113
- if (typeof decided.covered === 'number')
114
- mine = mine.slice(decided.covered);
115
- else
116
- mine = mine.filter((r) => r.ts > decided.ts);
117
- }
118
- if (mine.length > 0) {
119
- const latest = mine[mine.length - 1];
120
- return {
121
- entry: {
122
- id, kind: latest.kind, target: latest.target, why: latest.why,
123
- count: mine.length, firstTs: mine[0].ts, lastTs: latest.ts,
124
- ...(latest.replica ? { replica: latest.replica } : {}),
125
- },
126
- state,
127
- };
128
- }
103
+ // @implements A-SPEC-690 the reopening rule and the queue-shape conversion live in ONE place
104
+ // now (approval-queue). They used to be spelled out here as well, and a second copy of a
105
+ // judgement is how two paths quietly disagree about whether a request is open.
106
+ const { readRefusals, openOccurrences, asPendingRequest } = require('../governance/approval-queue');
107
+ const mine = openOccurrences(readRefusals(root).filter((r) => r.id === id), state.decisions[id]);
108
+ if (mine.length > 0)
109
+ return { entry: asPendingRequest(id, mine), state };
129
110
  }
130
111
  catch { /* a broken refusal log must not break the decision surface */ }
131
112
  return { entry: undefined, state };
@@ -821,8 +821,15 @@ async function main(argv) {
821
821
  // the same filtered list or `--grant 3` would act on a row nobody saw. An ID or prefix is the
822
822
  // operator naming a specific request — including a `shell` refusal they approved out of band
823
823
  // (A-SPEC-563.2), which the inbox filter hides from the list but must never make ungrantable.
824
+ // @implements A-SPEC-690 — `includeAllKinds` only unfilters KINDS inside the tracked queue;
825
+ // a shell refusal is not in that file at all (A-SPEC-563.1 routes it to this machine's
826
+ // refusal log). So the id/prefix path failed here, before approve.ts's refusal-log fallback
827
+ // could ever run — making the very id the refusal printed ungrantable, which the comment
828
+ // above says must never happen. The NUMBER path is untouched: an index must mean the row the
829
+ // operator was shown.
824
830
  const byIndex = /^\d+$/.test(ref.trim());
825
- const r = resolveRequestRef(readQueue(root, byIndex ? undefined : { includeAllKinds: true }).pending, ref);
831
+ const { grantableRequests } = require('../governance/approval-queue');
832
+ const r = resolveRequestRef(byIndex ? readQueue(root).pending : grantableRequests(root), ref);
826
833
  return r.ok ? { id: r.entry.id, subject: (prefix) => decisionSubject(r.entry, prefix), detail: (tail) => decisionDetail(r.entry, tail) } : { refusal: r.reason };
827
834
  };
828
835
  const root = typeof flags.target === 'string' ? path.resolve(flags.target) : process.cwd();
@@ -7,6 +7,17 @@ import { type Cycle } from './cycle-detect';
7
7
  * that makes it unreadable. What is dropped is COUNTED, never silently cut.
8
8
  */
9
9
  export declare const CYCLE_LIST_CAP = 50;
10
+ /**
11
+ * @implements A-SPEC-688
12
+ * The same rule applied one level down: a single cycle must not grow with the tree either.
13
+ *
14
+ * `CYCLE_LIST_CAP` bounded how many cycles a record holds; nothing bounded how many FILES one cycle
15
+ * holds. That was harmless while only TypeScript was judged, because a TS cycle is a handful of
16
+ * files. Judging Go activates it — a package import names EVERY file of the package, so one
17
+ * strongly connected component can carry a package's worth of paths into every turn's record.
18
+ * What is dropped is counted here too, per cycle, in `filesOmitted`.
19
+ */
20
+ export declare const CYCLE_FILES_CAP = 25;
10
21
  /**
11
22
  * One turn's observation — paths, integers and enums only.
12
23
  *
@@ -20,6 +31,8 @@ export interface CycleObservationRecord {
20
31
  cycles: Array<{
21
32
  key: string;
22
33
  files: string[];
34
+ /** Members beyond CYCLE_FILES_CAP: counted, so a wide cycle is never mistaken for a narrow one. */
35
+ filesOmitted: number;
23
36
  runtime: boolean;
24
37
  edges: number;
25
38
  }>;
@@ -33,7 +33,7 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
- exports.CYCLE_LIST_CAP = void 0;
36
+ exports.CYCLE_FILES_CAP = exports.CYCLE_LIST_CAP = void 0;
37
37
  exports.buildCycleObservation = buildCycleObservation;
38
38
  exports.appendCycleObservation = appendCycleObservation;
39
39
  exports.readCycleObservations = readCycleObservations;
@@ -61,6 +61,17 @@ const cycle_detect_1 = require("./cycle-detect");
61
61
  * that makes it unreadable. What is dropped is COUNTED, never silently cut.
62
62
  */
63
63
  exports.CYCLE_LIST_CAP = 50;
64
+ /**
65
+ * @implements A-SPEC-688
66
+ * The same rule applied one level down: a single cycle must not grow with the tree either.
67
+ *
68
+ * `CYCLE_LIST_CAP` bounded how many cycles a record holds; nothing bounded how many FILES one cycle
69
+ * holds. That was harmless while only TypeScript was judged, because a TS cycle is a handful of
70
+ * files. Judging Go activates it — a package import names EVERY file of the package, so one
71
+ * strongly connected component can carry a package's worth of paths into every turn's record.
72
+ * What is dropped is counted here too, per cycle, in `filesOmitted`.
73
+ */
74
+ exports.CYCLE_FILES_CAP = 25;
64
75
  /**
65
76
  * Build the record. PURE — the clock is an argument, so a test can pin it and two callers cannot
66
77
  * disagree about what "now" was.
@@ -77,7 +88,19 @@ function buildCycleObservation(ev, ts) {
77
88
  return {
78
89
  ts,
79
90
  mode: ev.mode,
80
- cycles: listed.map((c) => ({ key: (0, cycle_detect_1.cycleKey)(c.files), files: [...c.files].sort(), runtime: c.runtime, edges: c.edges.length })),
91
+ cycles: listed.map((c) => {
92
+ const all = [...c.files].sort();
93
+ return {
94
+ // Identity comes from the FULL membership. Keying off the truncated list would make one
95
+ // wide cycle look like a different cycle whenever a member outside the window changed —
96
+ // and the ledger's whole job is to say whether this is the same finding as last turn.
97
+ key: (0, cycle_detect_1.cycleKey)(c.files),
98
+ files: all.slice(0, exports.CYCLE_FILES_CAP),
99
+ filesOmitted: Math.max(0, all.length - exports.CYCLE_FILES_CAP),
100
+ runtime: c.runtime,
101
+ edges: c.edges.length,
102
+ };
103
+ }),
81
104
  cyclesOmitted: Math.max(0, ev.current.length - listed.length),
82
105
  violations,
83
106
  allowed: ev.allowed.length,
@@ -0,0 +1,20 @@
1
+ /**
2
+ * The forbidden-edge judgement, as lines a person closing a turn can act on.
3
+ *
4
+ * `judgeForbiddenEdges` (A-SPEC-691) computed an honest answer — what is violated, which allowance
5
+ * has run out and how many times it was renewed, which languages could not be judged — and nothing
6
+ * in the product read it. Measured 2026-09-20: zero consumers outside the tests. An expired baseline
7
+ * does not block (owner decision, 2026-09-19), so while it was also invisible a time-boxed baseline
8
+ * was indistinguishable from a permanent pardon — the decay A-SPEC-691 was written to prevent.
9
+ *
10
+ * Pure: a judgement in, strings out. A line is spent only on something the reader can do something
11
+ * about, so a baseline inside its window and a kind that was judged everywhere say nothing.
12
+ */
13
+ import type { judgeForbiddenEdges } from './forbidden-edges';
14
+ /**
15
+ * How many violation LINES are printed. It bounds the rendering and nothing else — no verdict reads
16
+ * it (REQ-569 S5, REQ-691: counts never enter a judgement). What is folded is counted, never dropped:
17
+ * a truncation that does not announce itself reads as the whole list.
18
+ */
19
+ export declare const FORBIDDEN_EDGE_LINE_CAP = 10;
20
+ export declare function forbiddenEdgeStatusLines(j: ReturnType<typeof judgeForbiddenEdges>): string[];
@@ -0,0 +1,31 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.FORBIDDEN_EDGE_LINE_CAP = void 0;
4
+ exports.forbiddenEdgeStatusLines = forbiddenEdgeStatusLines;
5
+ /**
6
+ * How many violation LINES are printed. It bounds the rendering and nothing else — no verdict reads
7
+ * it (REQ-569 S5, REQ-691: counts never enter a judgement). What is folded is counted, never dropped:
8
+ * a truncation that does not announce itself reads as the whole list.
9
+ */
10
+ exports.FORBIDDEN_EDGE_LINE_CAP = 10;
11
+ function forbiddenEdgeStatusLines(j) {
12
+ const lines = [];
13
+ for (const v of j.violations.slice(0, exports.FORBIDDEN_EDGE_LINE_CAP)) {
14
+ lines.push(`forbidden edge: ${v.sourcePath}: ${v.rule} (${v.kind} → ${v.to})`);
15
+ }
16
+ const folded = j.violations.length - exports.FORBIDDEN_EDGE_LINE_CAP;
17
+ if (folded > 0)
18
+ lines.push(`… and ${folded} more forbidden edge violation(s)`);
19
+ for (const b of j.baselines) {
20
+ if (!b.expired)
21
+ continue;
22
+ const since = b.since ? ` since ${b.since}` : '';
23
+ lines.push(`baseline expired ${b.until}: ${b.kind} ${b.sourcePath} -> ${b.target} (reconfirmed ${b.reconfirmed}${since})`);
24
+ }
25
+ for (const kind of Object.keys(j.scopeByKind).sort()) {
26
+ const missed = j.scopeByKind[kind]?.unavailable ?? [];
27
+ if (missed.length > 0)
28
+ lines.push(`${kind} rules NOT judged in: ${missed.join(', ')}`);
29
+ }
30
+ return lines;
31
+ }
@@ -25,7 +25,15 @@ export declare function splitCorpus(prefix: string): {
25
25
  * to remove.
26
26
  */
27
27
  export interface ForbiddenEdgeRule {
28
- kind: 'import' | 'call';
28
+ /**
29
+ * @implements A-SPEC-691
30
+ * `inherits` joined the two originals because the sentence above stopped being true. It named
31
+ * the kinds "the two things the D-CPG can actually see", measured 2026-08-22 — when `inherits`
32
+ * reached the graph from NO language at all. It now resolves in eight families (A-SPEC-286,
33
+ * A-SPEC-288, A-SPEC-511.1). Counting again and widening is what that rule asks for; offering a
34
+ * shape for something still invisible is what it forbids.
35
+ */
36
+ kind: 'import' | 'call' | 'inherits';
29
37
  /** Which corpus this rule judges. @implements A-SPEC-229 */
30
38
  corpus: RuleCorpus;
31
39
  /** Repo-relative prefix of the files the rule judges. */
@@ -41,7 +49,33 @@ export interface ForbiddenEdgeViolation {
41
49
  /** The enclosing definition the edge leaves from — `<module>` for a top-level import. */
42
50
  from: string;
43
51
  to: string;
44
- kind: 'import' | 'call';
52
+ kind: 'import' | 'call' | 'inherits';
53
+ }
54
+ /**
55
+ * @implements A-SPEC-691
56
+ * One grandfathered violation — how a project adopts a rule it already breaks.
57
+ *
58
+ * Its whole worth is that it EXPIRES. An expiry that blocked would make the rule a barrier to
59
+ * adoption, which REQ-574 named the failure mode ("a rule that blocks adoption is not a rule, it is
60
+ * a barrier"), so expiry SHOWS instead. That only works if the showing is real: `reconfirmed` and
61
+ * `since` travel with the entry so a twelfth renewal cannot hide. Not blocking and not being seen
62
+ * are different things.
63
+ */
64
+ export interface BaselineEntry {
65
+ kind: 'import' | 'call' | 'inherits';
66
+ /** The exact file this allowance covers — never a prefix. */
67
+ sourcePath: string;
68
+ /** The exact edge target this allowance covers. */
69
+ target: string;
70
+ /** ISO date the allowance runs to. */
71
+ until: string;
72
+ /** How many times the author has renewed it. */
73
+ reconfirmed: number;
74
+ /** When the allowance was first taken, if the author recorded it. */
75
+ since?: string;
76
+ /** Past `until` at judgement time. Reported, never enforced. */
77
+ expired: boolean;
78
+ text: string;
45
79
  }
46
80
  /**
47
81
  * Read the rules out of a `## Forbidden Edges` section.
@@ -56,6 +90,8 @@ export interface ForbiddenEdgeViolation {
56
90
  */
57
91
  export declare function parseForbiddenEdges(section: string): {
58
92
  rules: ForbiddenEdgeRule[];
93
+ /** @implements A-SPEC-691 — grandfathered violations, living in the same section on purpose. */
94
+ baselines: BaselineEntry[];
59
95
  malformed: string[];
60
96
  };
61
97
  /**
@@ -71,3 +107,50 @@ export declare function parseForbiddenEdges(section: string): {
71
107
  * would make `list` catch `listFiles`.
72
108
  */
73
109
  export declare function findForbiddenEdgeViolations(files: ScannedFile[], rules: ForbiddenEdgeRule[]): ForbiddenEdgeViolation[];
110
+ /**
111
+ * @implements A-SPEC-691
112
+ * Is this violation one the project already declared it carries?
113
+ *
114
+ * EXACT on all three fields. A prefix here would turn "these violations" into "this whole area",
115
+ * which is a way to switch the rule off rather than a way to carry legacy forward.
116
+ *
117
+ * Exported because BOTH judgement paths need it — the scan-wide one and the pre-edit gate — and a
118
+ * second copy of this predicate is how a baseline comes to mean one thing in the report and another
119
+ * at the gate.
120
+ */
121
+ export declare function isBaselined(v: {
122
+ kind: string;
123
+ sourcePath: string;
124
+ to: string;
125
+ }, baselines: readonly BaselineEntry[]): boolean;
126
+ /**
127
+ * @implements A-SPEC-691
128
+ * The judgement with its own honesty attached: what was found, what was grandfathered, and which
129
+ * languages could be judged at all.
130
+ *
131
+ * `findForbiddenEdgeViolations` keeps its array return, so every existing caller and pin is
132
+ * untouched — this WRAPS it rather than replacing it, because a second copy of the matching rule is
133
+ * how two paths quietly disagree.
134
+ *
135
+ * `scope` follows the distinction REQ-688 established for cycles. An extension that produced edges
136
+ * but NONE of the kind a rule reads is `unavailable`: it was looked at and could not be judged. An
137
+ * extension that produced nothing at all is in neither list, because "there was nothing to see" and
138
+ * "we could not see" are different facts, and reporting them as one lets an empty scan read as a
139
+ * clean one.
140
+ */
141
+ export declare function judgeForbiddenEdges(files: ScannedFile[], rules: ForbiddenEdgeRule[], opts?: {
142
+ baselines?: BaselineEntry[];
143
+ now?: Date;
144
+ }): {
145
+ violations: ForbiddenEdgeViolation[];
146
+ baselines: BaselineEntry[];
147
+ scope: {
148
+ judged: string[];
149
+ unavailable: string[];
150
+ };
151
+ /** @implements A-SPEC-693 — the same question asked once per rule kind in play. */
152
+ scopeByKind: Partial<Record<ForbiddenEdgeRule['kind'], {
153
+ judged: string[];
154
+ unavailable: string[];
155
+ }>>;
156
+ };
@@ -36,6 +36,8 @@ Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.splitCorpus = splitCorpus;
37
37
  exports.parseForbiddenEdges = parseForbiddenEdges;
38
38
  exports.findForbiddenEdgeViolations = findForbiddenEdgeViolations;
39
+ exports.isBaselined = isBaselined;
40
+ exports.judgeForbiddenEdges = judgeForbiddenEdges;
39
41
  // @implements A-SPEC-224
40
42
  const path = __importStar(require("node:path"));
41
43
  const test_files_1 = require("./test-files");
@@ -45,7 +47,15 @@ function splitCorpus(prefix) {
45
47
  ? { corpus: 'tests', sourcePrefix: prefix.slice('tests:'.length) }
46
48
  : { corpus: 'production', sourcePrefix: prefix };
47
49
  }
48
- const RULE = /^(import|call)\s+(\S+)\s+-x->\s+(\S+)$/;
50
+ const RULE = /^(import|call|inherits)\s+(\S+)\s+-x->\s+(\S+)$/;
51
+ /**
52
+ * @implements A-SPEC-691
53
+ * `allow until <date> [(reconfirmed <n> since <date>)] : <kind> <path> -> <target>`
54
+ *
55
+ * The arrow differs from a rule's `-x->` on purpose: the two shapes cannot be confused for each
56
+ * other, and a baseline written wrong falls through to `malformed` rather than disappearing.
57
+ */
58
+ const BASELINE = /^allow until (\d{4}-\d{2}-\d{2})(?:\s*\(reconfirmed (\d+)(?: since (\d{4}-\d{2}-\d{2}))?\))?\s*:\s*(import|call|inherits)\s+(\S+)\s+->\s+(\S+)$/;
49
59
  /**
50
60
  * Read the rules out of a `## Forbidden Edges` section.
51
61
  *
@@ -59,6 +69,7 @@ const RULE = /^(import|call)\s+(\S+)\s+-x->\s+(\S+)$/;
59
69
  */
60
70
  function parseForbiddenEdges(section) {
61
71
  const rules = [];
72
+ const baselines = [];
62
73
  const malformed = [];
63
74
  for (const raw of String(section ?? '').split('\n')) {
64
75
  const line = raw.trim();
@@ -67,6 +78,26 @@ function parseForbiddenEdges(section) {
67
78
  // Strip a trailing comment and the backticks a Markdown author naturally reaches for, so the
68
79
  // rule reads the same whether or not it was written for a human eye.
69
80
  const body = line.slice(2).replace(/\s+#.*$/, '').replace(/`/g, '').trim().replace(/\s+/g, ' ');
81
+ // @implements A-SPEC-691 — a baseline is tried first; its `allow until` opening cannot be a
82
+ // rule, so trying the rule shape first would only ever produce a confusing `malformed`.
83
+ if (body.startsWith('allow until')) {
84
+ const b = BASELINE.exec(body);
85
+ if (!b) {
86
+ malformed.push(raw.trim());
87
+ continue;
88
+ }
89
+ baselines.push({
90
+ kind: b[4],
91
+ sourcePath: b[5],
92
+ target: b[6],
93
+ until: b[1],
94
+ reconfirmed: b[2] ? Number(b[2]) : 0,
95
+ ...(b[3] ? { since: b[3] } : {}),
96
+ expired: false, // decided at judgement time, against that call's clock
97
+ text: body,
98
+ });
99
+ continue;
100
+ }
70
101
  const m = RULE.exec(body);
71
102
  if (!m) {
72
103
  malformed.push(raw.trim());
@@ -75,7 +106,7 @@ function parseForbiddenEdges(section) {
75
106
  const { corpus, sourcePrefix } = splitCorpus(m[2]);
76
107
  rules.push({ kind: m[1], corpus, sourcePrefix, target: m[3], text: body });
77
108
  }
78
- return { rules, malformed };
109
+ return { rules, baselines, malformed };
79
110
  }
80
111
  /**
81
112
  * Where an import specifier lands, as a repo-relative path.
@@ -126,6 +157,14 @@ function findForbiddenEdgeViolations(files, rules) {
126
157
  if (!importTargetOf(sourcePath, e.to).startsWith(r.target))
127
158
  continue;
128
159
  }
160
+ else if (r.kind === 'inherits') {
161
+ // @implements A-SPEC-691 — EXACT, for the reason `call` is exact: a prefix would let one
162
+ // `Base` rule swallow every `Base*` and the rule would stop meaning anything.
163
+ if (e.rel !== 'inherits')
164
+ continue;
165
+ if (e.to !== r.target)
166
+ continue;
167
+ }
129
168
  else {
130
169
  if (e.rel !== 'calls')
131
170
  continue;
@@ -138,3 +177,97 @@ function findForbiddenEdgeViolations(files, rules) {
138
177
  }
139
178
  return out;
140
179
  }
180
+ /**
181
+ * @implements A-SPEC-691
182
+ * Is this violation one the project already declared it carries?
183
+ *
184
+ * EXACT on all three fields. A prefix here would turn "these violations" into "this whole area",
185
+ * which is a way to switch the rule off rather than a way to carry legacy forward.
186
+ *
187
+ * Exported because BOTH judgement paths need it — the scan-wide one and the pre-edit gate — and a
188
+ * second copy of this predicate is how a baseline comes to mean one thing in the report and another
189
+ * at the gate.
190
+ */
191
+ function isBaselined(v, baselines) {
192
+ return baselines.some((b) => b.kind === v.kind && b.sourcePath === v.sourcePath && b.target === v.to);
193
+ }
194
+ /** @implements A-SPEC-691 — which edge relation each rule kind reads. */
195
+ const REL_OF = {
196
+ import: 'imports', call: 'calls', inherits: 'inherits',
197
+ };
198
+ /**
199
+ * @implements A-SPEC-691
200
+ * The judgement with its own honesty attached: what was found, what was grandfathered, and which
201
+ * languages could be judged at all.
202
+ *
203
+ * `findForbiddenEdgeViolations` keeps its array return, so every existing caller and pin is
204
+ * untouched — this WRAPS it rather than replacing it, because a second copy of the matching rule is
205
+ * how two paths quietly disagree.
206
+ *
207
+ * `scope` follows the distinction REQ-688 established for cycles. An extension that produced edges
208
+ * but NONE of the kind a rule reads is `unavailable`: it was looked at and could not be judged. An
209
+ * extension that produced nothing at all is in neither list, because "there was nothing to see" and
210
+ * "we could not see" are different facts, and reporting them as one lets an empty scan read as a
211
+ * clean one.
212
+ */
213
+ function judgeForbiddenEdges(files, rules, opts) {
214
+ const today = (opts?.now ?? new Date()).toISOString().slice(0, 10);
215
+ const baselines = (opts?.baselines ?? []).map((b) => ({ ...b, expired: b.until < today }));
216
+ const all = findForbiddenEdgeViolations(files, rules);
217
+ // @implements A-SPEC-693 — asked ONCE PER KIND. The first version pooled the kinds in play, so an
218
+ // extension that showed imports read as `judged` beside an `inherits` rule it could not be judged
219
+ // for (measured 2026-09-20 on exactly the mix C-SPEC-224 declares). Which relations an extension
220
+ // produced is recorded per extension; each kind then reads its own relation out of that.
221
+ //
222
+ // A kind's scope counts only the files a rule OF THAT KIND actually judges — the same corpus and
223
+ // prefix test the matcher applies. Measured on this repository: counted over the whole scan, an
224
+ // import rule scoped to `src/holmes/cpg/` reported fixture extensions from elsewhere as "not
225
+ // judged", every turn, about files no rule speaks of. True, and nothing anyone can act on.
226
+ const kindsInPlay = [...new Set(rules.map((r) => r.kind))].sort();
227
+ const relsByKindExt = new Map();
228
+ const seenExt = new Set();
229
+ for (const f of files) {
230
+ const sourcePath = f?.sourcePath;
231
+ if (typeof sourcePath !== 'string' || (f.edges ?? []).length === 0)
232
+ continue;
233
+ const corpus = (0, test_files_1.isTestFile)(sourcePath) ? 'tests' : 'production';
234
+ const ext = sourcePath.slice(sourcePath.lastIndexOf('.'));
235
+ for (const kind of kindsInPlay) {
236
+ if (!rules.some((r) => r.kind === kind && r.corpus === corpus && sourcePath.startsWith(r.sourcePrefix)))
237
+ continue;
238
+ seenExt.add(ext);
239
+ const byExt = relsByKindExt.get(kind) ?? new Map();
240
+ relsByKindExt.set(kind, byExt);
241
+ const rels = byExt.get(ext) ?? new Set();
242
+ byExt.set(ext, rels);
243
+ for (const e of f.edges ?? [])
244
+ rels.add(e.rel);
245
+ }
246
+ }
247
+ const scopeByKind = {};
248
+ const unavailable = new Set();
249
+ for (const kind of kindsInPlay) {
250
+ const judgedHere = [];
251
+ const unavailableHere = [];
252
+ for (const [ext, rels] of relsByKindExt.get(kind) ?? []) {
253
+ if (rels.has(REL_OF[kind]))
254
+ judgedHere.push(ext);
255
+ else {
256
+ unavailableHere.push(ext);
257
+ unavailable.add(ext);
258
+ }
259
+ }
260
+ scopeByKind[kind] = { judged: judgedHere.sort(), unavailable: unavailableHere.sort() };
261
+ }
262
+ return {
263
+ violations: all.filter((v) => !isBaselined(v, baselines)),
264
+ baselines,
265
+ // The flat lists fold toward the honest side: unjudged for ANY kind in play is unavailable, and
266
+ // only an extension judged for EVERY kind is judged. With one kind this is what it always was.
267
+ scope: {
268
+ judged: [...seenExt].filter((e) => !unavailable.has(e)).sort(),
269
+ unavailable: [...unavailable].sort(),
270
+ },
271
+ scopeByKind,
272
+ };
273
+ }
@@ -0,0 +1,12 @@
1
+ import { ScannedFile } from './cpg-scanner';
2
+ export interface ImportResolver {
3
+ /** One specifier → a scanned file path, or null when it names nothing the scan holds. */
4
+ resolve(fromFile: string, spec: string): string | null;
5
+ /** Go only: a package specifier → every scanned file of that package. Empty elsewhere. */
6
+ fanOut(fromFile: string, spec: string): string[];
7
+ }
8
+ export interface ImportResolverOptions {
9
+ /** Injected go.mod module path; when absent it is walked up from the first Go file. */
10
+ goModule?: string;
11
+ }
12
+ export declare function createImportResolver(scanned: readonly ScannedFile[], opts?: ImportResolverOptions): ImportResolver;