@holmes-lab/holmes-kit 0.25.1 → 0.26.1

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 (50) hide show
  1. package/CHANGELOG.md +133 -0
  2. package/README.md +10 -3
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/approve.js +7 -26
  5. package/dist/holmes/cli/doctor.js +53 -10
  6. package/dist/holmes/cli/index.js +8 -1
  7. package/dist/holmes/cli/release-docs.d.ts +27 -0
  8. package/dist/holmes/cli/release-docs.js +45 -0
  9. package/dist/holmes/config/config.d.ts +11 -0
  10. package/dist/holmes/config/config.js +11 -1
  11. package/dist/holmes/cpg/cycle-observation.d.ts +13 -0
  12. package/dist/holmes/cpg/cycle-observation.js +25 -2
  13. package/dist/holmes/cpg/cycle-report.d.ts +35 -0
  14. package/dist/holmes/cpg/cycle-report.js +74 -0
  15. package/dist/holmes/cpg/forbidden-edge-report.d.ts +20 -0
  16. package/dist/holmes/cpg/forbidden-edge-report.js +31 -0
  17. package/dist/holmes/cpg/forbidden-edges.d.ts +85 -2
  18. package/dist/holmes/cpg/forbidden-edges.js +135 -2
  19. package/dist/holmes/cpg/import-resolver.d.ts +12 -0
  20. package/dist/holmes/cpg/import-resolver.js +215 -0
  21. package/dist/holmes/cpg/language-capability.d.ts +14 -0
  22. package/dist/holmes/cpg/language-capability.js +37 -13
  23. package/dist/holmes/cpg/proposed-content.d.ts +7 -1
  24. package/dist/holmes/cpg/proposed-content.js +7 -0
  25. package/dist/holmes/governance/approval-queue.d.ts +33 -0
  26. package/dist/holmes/governance/approval-queue.js +85 -0
  27. package/dist/holmes/hooks/pre-tool-use.js +8 -1
  28. package/dist/holmes/hooks/session-start.js +39 -0
  29. package/dist/holmes/hooks/stop.d.ts +38 -2
  30. package/dist/holmes/hooks/stop.js +169 -50
  31. package/dist/holmes/mcp/handlers/entity-integration.js +5 -8
  32. package/dist/holmes/mcp/handlers/entity-renumber.js +4 -5
  33. package/dist/holmes/mcp/handlers/spec-authoring.js +9 -1
  34. package/dist/holmes/mcp/handlers/spec-lifecycle.d.ts +7 -5
  35. package/dist/holmes/mcp/handlers/spec-lifecycle.js +15 -2
  36. package/dist/holmes/mcp/handlers.d.ts +7 -5
  37. package/dist/holmes/project/report-briefing.d.ts +48 -0
  38. package/dist/holmes/project/report-briefing.js +70 -0
  39. package/dist/holmes/project/resolved-reports.d.ts +20 -0
  40. package/dist/holmes/project/resolved-reports.js +7 -0
  41. package/dist/holmes/project/root.js +11 -1
  42. package/dist/holmes/rtm/rtm-builder.js +6 -141
  43. package/dist/holmes/spec/id-collision.d.ts +98 -0
  44. package/dist/holmes/spec/id-collision.js +149 -1
  45. package/dist/holmes/spec/remote-spec-refs.d.ts +16 -0
  46. package/dist/holmes/spec/remote-spec-refs.js +148 -0
  47. package/dist/holmes/spec/renumber.d.ts +12 -2
  48. package/dist/holmes/spec/renumber.js +25 -6
  49. package/package.json +1 -1
  50. package/playbooks/publish/PLAYBOOK.md +18 -0
@@ -0,0 +1,74 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.CYCLE_LINE_MEMBERS = exports.CYCLE_LINE_CAP = void 0;
4
+ exports.ignoredCycleKeys = ignoredCycleKeys;
5
+ exports.cycleStatusDetails = cycleStatusDetails;
6
+ // @implements A-SPEC-694
7
+ /**
8
+ * The two ends around a judgement that was already right.
9
+ *
10
+ * `cycleRatchetViolations(current, allowed)` has honoured a named exception since A-SPEC-574.4, and
11
+ * the Stop hook has supplied `allowed: []` since the same day — there was nowhere to name one. And
12
+ * what the judgement found had no bounded way to be said: `trackedLines` joins every detail of an
13
+ * article into ONE line, and measured 2026-09-20 this repository carries a 46-member cycle inside a
14
+ * vendored tree. Wiring the evidence without these two would have printed thousands of characters a
15
+ * turn about code nobody here can change — "a rule that blocks adoption is not a rule, it is a
16
+ * barrier" (REQ-574), in its observational form.
17
+ *
18
+ * Pure, and it judges nothing: one function fills the list the judgement reads, the other renders
19
+ * what the judgement returned. The caps bound RENDERING only — no verdict reads them (REQ-569 S5).
20
+ */
21
+ const cycle_detect_1 = require("./cycle-detect");
22
+ /** How many cycles one ART-2 line names before it counts the rest. */
23
+ exports.CYCLE_LINE_CAP = 5;
24
+ /** How many members of one cycle are named before the rest are counted. The ledger keeps them all. */
25
+ exports.CYCLE_LINE_MEMBERS = 5;
26
+ /**
27
+ * A prefix as the scanner spells paths: forward slashes, no leading `./`, no trailing `/`.
28
+ *
29
+ * A prefix that normalises to nothing (`''`, `.`, `/`, `./`) means "everything" and is DROPPED. One
30
+ * line that switches the whole ratchet off is not an exception, it is a switch, and a switch is a
31
+ * different decision from the one this field records.
32
+ */
33
+ function normalisePrefix(raw) {
34
+ if (typeof raw !== 'string')
35
+ return null;
36
+ const p = raw.replace(/\\/g, '/').replace(/^(\.\/)+/, '').replace(/\/+$/, '').replace(/^\/+/, '');
37
+ return p === '' || p === '.' ? null : p;
38
+ }
39
+ /** At a path boundary only — `reference` must not pardon `reference-impl/` (shape is not location). */
40
+ const under = (file, prefix) => file === prefix || file.startsWith(`${prefix}/`);
41
+ /**
42
+ * The keys of the cycles that lie ENTIRELY inside ignored areas — what the hook supplies as `allowed`.
43
+ *
44
+ * Entirely, because a cycle that straddles project code and a vendored tree is the project's to
45
+ * fix: if one ignored member were enough, the declaration would become a way to hide the project's
46
+ * own cycles behind a neighbour.
47
+ */
48
+ function ignoredCycleKeys(cycles, prefixes) {
49
+ const areas = (Array.isArray(prefixes) ? prefixes : []).map(normalisePrefix).filter((p) => p !== null);
50
+ if (areas.length === 0)
51
+ return [];
52
+ return cycles
53
+ .filter((c) => c.files.length > 0 && c.files.every((f) => areas.some((a) => under(f, a))))
54
+ .map((c) => (0, cycle_detect_1.cycleKey)(c.files));
55
+ }
56
+ /**
57
+ * The ratchet's violations as `tracked` details of bounded length.
58
+ *
59
+ * The wording is A-SPEC-574.4's (`code import cycle: a -> b -> a`), kept so nothing that reads it
60
+ * has to change. What is folded is COUNTED, per cycle and across cycles: a truncation that does not
61
+ * announce itself reads as the whole list. The full membership is in the observation ledger.
62
+ */
63
+ function cycleStatusDetails(violations, scopeSuffix) {
64
+ const details = violations.slice(0, exports.CYCLE_LINE_CAP).map((v) => {
65
+ const shown = v.files.slice(0, exports.CYCLE_LINE_MEMBERS);
66
+ const folded = v.files.length - shown.length;
67
+ const more = folded > 0 ? ` -> (+${folded} more)` : '';
68
+ return `code import cycle: ${shown.join(' -> ')}${more} -> ${v.files[0]}${scopeSuffix}`;
69
+ });
70
+ const foldedCycles = violations.length - exports.CYCLE_LINE_CAP;
71
+ if (foldedCycles > 0)
72
+ details.push(`… and ${foldedCycles} more import cycle(s)${scopeSuffix}`);
73
+ return details;
74
+ }
@@ -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;
@@ -0,0 +1,215 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.createImportResolver = createImportResolver;
37
+ // @implements A-SPEC-688
38
+ /**
39
+ * The ONE place an import specifier becomes a scanned file path.
40
+ *
41
+ * It used to be two. `rtm/addImportEdges` grew the per-language rules (see A-SPEC-289, A-SPEC-406,
42
+ * A-SPEC-525.1, A-SPEC-527.1) while the Stop hook's cycle ratchet re-implemented a crippled
43
+ * version beside it — relative specifiers only, four extensions. Measured 2026-09-18 on fixtures
44
+ * shaped the way each language's rules require: this resolver reached 18 of 20 specifiers and
45
+ * found all 9 planted cycles; the ratchet's copy reached 2 and found 1. The defect cost nothing in
46
+ * this repository (all TypeScript, where the two agree exactly: 919 edges each), which is why it
47
+ * survived to v0.23.2 — and everything in a consumer written in Java, Go, Python or Rust.
48
+ *
49
+ * Extracted here rather than left in `rtm/` because resolution is a fact about CODE, not about the
50
+ * requirement graph, and because `rtm/` and `hooks/` both already import `cpg/` — so this placement
51
+ * adds no layer edge. Choosing the direction at design time is the cheap moment; that is the whole
52
+ * subject of the slice this module belongs to.
53
+ *
54
+ * The founding rule is unchanged and is what makes every branch safe: resolve ONLY to a file the
55
+ * scan actually contains. An unresolvable specifier emits nothing rather than a guess.
56
+ *
57
+ * The rules below are MOVED, not rewritten. Their originating specs are cited by number rather
58
+ * than anchored, because the authority for this file is A-SPEC-688 alone.
59
+ */
60
+ const fs = __importStar(require("node:fs"));
61
+ const path = __importStar(require("node:path"));
62
+ function fsReadGoMod(p) {
63
+ try {
64
+ return fs.readFileSync(p, 'utf8').match(/^module\s+(\S+)/m)?.[1] ?? null;
65
+ }
66
+ catch {
67
+ return null;
68
+ }
69
+ }
70
+ function createImportResolver(scanned, opts) {
71
+ const known = new Set(scanned.map((f) => f.sourcePath));
72
+ const EXTENSIONS = ['', '.ts', '.tsx', '.mts', '.cts', '.js', '.mjs', '.cjs', '.jsx', '.py',
73
+ '/index.ts', '/index.js', '/__init__.py'];
74
+ const firstKnown = (base) => EXTENSIONS.map((ext) => `${base}${ext}`).find((candidate) => known.has(candidate)) ?? null;
75
+ // A-SPEC-525.1 — the five languages' resolution rules, selected by the IMPORTING file's
76
+ // extension because resolution IS per-language semantics. The founding rule is unchanged
77
+ // everywhere: resolve only to files the scan actually contains; the ambiguous resolve to none.
78
+ const knownList = [...known];
79
+ const uniqueSuffix = (suffix) => {
80
+ const hits = knownList.filter((k) => k === suffix || k.endsWith(`/${suffix}`));
81
+ return hits.length === 1 ? hits[0] : null; // two files may not become a guess
82
+ };
83
+ // go.mod discovery: injected via opts when the caller knows it; otherwise walked up from the
84
+ // first Go file's ABSOLUTE path (bounded), so production builds resolve without extra wiring.
85
+ let goModCache;
86
+ const goModuleOf = () => {
87
+ if (opts?.goModule)
88
+ return opts.goModule;
89
+ if (goModCache !== undefined)
90
+ return goModCache ?? undefined;
91
+ goModCache = null;
92
+ const anyGo = scanned.find((f) => /\.go$/.test(f.sourcePath) && f.path);
93
+ if (anyGo) {
94
+ let dir = path.dirname(anyGo.path);
95
+ for (let hops = 0; hops < 12; hops++) {
96
+ try {
97
+ const txt = fsReadGoMod(path.join(dir, 'go.mod'));
98
+ if (txt !== null) {
99
+ goModCache = txt;
100
+ break;
101
+ }
102
+ }
103
+ catch { /* keep walking */ }
104
+ const up = path.dirname(dir);
105
+ if (up === dir)
106
+ break;
107
+ dir = up;
108
+ }
109
+ }
110
+ return goModCache ?? undefined;
111
+ };
112
+ const goPackage = (spec) => {
113
+ const mod = goModuleOf();
114
+ if (!mod || !(spec === mod || spec.startsWith(`${mod}/`)))
115
+ return []; // external, honestly
116
+ const dir = spec === mod ? '' : spec.slice(mod.length + 1);
117
+ const prefix = dir === '' ? '' : `${dir}/`;
118
+ return knownList.filter((k) => k.startsWith(prefix) && k.endsWith('.go')
119
+ && !k.endsWith('_test.go') && !k.slice(prefix.length).includes('/'));
120
+ };
121
+ const rustResolve = (fromFile, spec) => {
122
+ const segs = spec.split('::');
123
+ const head = segs.shift();
124
+ let baseDir;
125
+ if (head === 'crate') {
126
+ // the crate root is the src/ directory nearest above the importing file
127
+ const m = fromFile.match(/^(.*?src)\//);
128
+ baseDir = m ? m[1] : 'src';
129
+ }
130
+ else if (head === 'super') {
131
+ baseDir = path.posix.dirname(path.posix.dirname(fromFile));
132
+ while (segs[0] === 'super') {
133
+ // A-SPEC-527.1 — super:: above the crate root is an rustc error; resolving it pinned '.'
134
+ // and produced edges to repo-root files (adversarial sweep). Refuse.
135
+ if (baseDir === '.' || baseDir === '/')
136
+ return null;
137
+ segs.shift();
138
+ baseDir = path.posix.dirname(baseDir);
139
+ }
140
+ }
141
+ else if (head === 'self') {
142
+ baseDir = path.posix.dirname(fromFile);
143
+ }
144
+ else {
145
+ return null; // external crate
146
+ }
147
+ const tryPath = (parts) => {
148
+ if (parts.length === 0)
149
+ return null;
150
+ const base = path.posix.normalize(path.posix.join(baseDir, ...parts));
151
+ if (known.has(`${base}.rs`))
152
+ return `${base}.rs`;
153
+ if (known.has(`${base}/mod.rs`))
154
+ return `${base}/mod.rs`;
155
+ return null;
156
+ };
157
+ // the last segment may be an ITEM, not a module — drop it once and retry
158
+ return tryPath(segs) ?? tryPath(segs.slice(0, -1));
159
+ };
160
+ const resolve = (fromFile, spec) => {
161
+ // A-SPEC-525.1 — language branches BEFORE the JS/Python-shaped fallthrough.
162
+ if (/\.go$/.test(fromFile))
163
+ return null; // Go fans out separately (a package is its files)
164
+ if (/\.rs$/.test(fromFile))
165
+ return rustResolve(fromFile, spec);
166
+ if (/\.java$/.test(fromFile))
167
+ return uniqueSuffix(`${spec.split('.').join('/')}.java`);
168
+ if (/\.cs$/.test(fromFile))
169
+ return uniqueSuffix(`${spec.split('.').join('/')}.cs`);
170
+ if (/\.(cpp|cc|cxx|hpp|h)$/.test(fromFile)) {
171
+ // A-SPEC-527.1 — an ABSOLUTE include is not a repository coordinate: joining it into the
172
+ // repo frame let `#include "/etc/passwd"` match a coincidentally-shaped scanned file
173
+ // (adversarial sweep). Absolute means absolute; it resolves to nothing here.
174
+ if (spec.startsWith('/'))
175
+ return null;
176
+ // extension preserved: the dotted split below would butcher `util/env.h`
177
+ const relative = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), spec));
178
+ if (known.has(relative))
179
+ return relative;
180
+ return known.has(spec) ? spec : null; // repo-root-relative include
181
+ }
182
+ // A-SPEC-406
183
+ // A specifier without a leading dot used to be refused outright, on the ground that it names an
184
+ // external package. That holds for `node:fs` and `js-yaml`; it does NOT hold for Python, which
185
+ // writes intra-repository modules absolutely — `from src.core.memory_audit import X`. Measured
186
+ // on the jarvis corpus: 1060 specifiers, 0 relative, 542 naming a file the scan already had, and
187
+ // 0 import edges in the graph. The whole import layer was missing for that language because the
188
+ // resolver's shape was JS's.
189
+ //
190
+ // The rule the original was written under is unchanged and is what makes this safe: resolve
191
+ // ONLY to a file the scan actually contains. `os.path` becomes `os/path.py`, which is in no
192
+ // scan, so it still emits nothing. Nothing is invented; the dotted form is simply also read.
193
+ if (!spec.startsWith('.')) {
194
+ if (spec.includes(':'))
195
+ return null; // `node:fs` and friends are never a repository path
196
+ return firstKnown(path.posix.normalize(spec.split('.').filter(Boolean).join('/')));
197
+ }
198
+ // A-SPEC-289
199
+ // Python writes a relative import as `.b` / `..pkg.mod`, where the leading dots are LEVELS and
200
+ // the remaining dots are path separators — not the `./b` form JS uses. Treating `.b` as a path
201
+ // yields the literal name `.b`, which matches nothing. Detected by the absence of a slash: a
202
+ // specifier that already contains one is a JS-style path and is left alone.
203
+ let relative = spec;
204
+ if (!spec.includes('/')) {
205
+ const dots = spec.match(/^\.+/)[0].length;
206
+ const rest = spec.slice(dots).split('.').filter(Boolean).join('/');
207
+ relative = `${'../'.repeat(Math.max(0, dots - 1)) || './'}${rest}`;
208
+ }
209
+ return firstKnown(path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), relative)));
210
+ };
211
+ return {
212
+ resolve,
213
+ fanOut: (fromFile, spec) => (/\.go$/.test(fromFile) ? goPackage(spec) : []),
214
+ };
215
+ }
@@ -54,6 +54,20 @@ export interface LanguageGap {
54
54
  * meet would bury the ones this answer actually rests on. A language with nothing missing drops out
55
55
  * entirely, so the report shrinks to nothing as extraction catches up rather than becoming noise.
56
56
  */
57
+ /**
58
+ * @implements A-SPEC-689
59
+ * The gap shape for ONE capability, as a pure function of it.
60
+ *
61
+ * Split out so the concept can be pinned on a constructed capability instead of on whichever real
62
+ * language happens to lag. That was already the stated intent of the sibling test, but not its
63
+ * practice: the pin was moved from `.java` to `.cjs` when Java graduated, and `.cjs` has now
64
+ * graduated too, which would have moved it a third time — to nothing, because no advertised
65
+ * extension lags any more. A test that must be rewritten every time the product improves is not
66
+ * pinning the product; it is following it.
67
+ *
68
+ * Returns null when the capability has no gap at all.
69
+ */
70
+ export declare function gapOf(ext: string, cap: LanguageCapability): LanguageGap | null;
57
71
  export declare function capabilityGapsFor(sourcePaths: readonly string[]): LanguageGap[];
58
72
  /** Every advertised extension must have an entry; a blank would be a silent overclaim. */
59
73
  export declare const ADVERTISED_EXTENSIONS: readonly string[];