@holmes-lab/holmes-kit 0.20.2 → 0.22.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 (59) hide show
  1. package/CHANGELOG.md +100 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/cli/agents.js +1 -0
  4. package/dist/holmes/cli/doctor.d.ts +1 -0
  5. package/dist/holmes/cli/doctor.js +42 -0
  6. package/dist/holmes/cli/index.js +1 -0
  7. package/dist/holmes/cli/init.js +1 -0
  8. package/dist/holmes/cpg/language-parser-walk.js +1 -0
  9. package/dist/holmes/governance/constitution.d.ts +11 -0
  10. package/dist/holmes/governance/constitution.js +15 -1
  11. package/dist/holmes/guardrail/impact-gate.d.ts +10 -1
  12. package/dist/holmes/guardrail/impact-gate.js +19 -0
  13. package/dist/holmes/guardrail/risk-classifier.d.ts +1 -0
  14. package/dist/holmes/guardrail/risk-classifier.js +26 -3
  15. package/dist/holmes/hooks/stop.d.ts +16 -0
  16. package/dist/holmes/hooks/stop.js +116 -1
  17. package/dist/holmes/mcp/handlers/graph-operations.d.ts +1 -0
  18. package/dist/holmes/mcp/handlers/graph-operations.js +18 -1
  19. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +4 -0
  20. package/dist/holmes/mcp/handlers/maintenance-evidence.js +16 -1
  21. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +28 -1
  22. package/dist/holmes/mcp/handlers/operator-inspection.js +91 -3
  23. package/dist/holmes/mcp/handlers/spec-approval.d.ts +6 -0
  24. package/dist/holmes/mcp/handlers/spec-approval.js +87 -1
  25. package/dist/holmes/mcp/handlers/test-execution.d.ts +4 -0
  26. package/dist/holmes/mcp/handlers/test-execution.js +6 -2
  27. package/dist/holmes/mcp/handlers.d.ts +34 -1
  28. package/dist/holmes/mcp/handlers.js +5 -0
  29. package/dist/holmes/mcp/maintenance-analyze.js +1 -0
  30. package/dist/holmes/mcp/tool-schemas.js +1 -0
  31. package/dist/holmes/project/ci-runs.d.ts +46 -0
  32. package/dist/holmes/project/ci-runs.js +137 -0
  33. package/dist/holmes/project/install-scripts-policy.js +1 -0
  34. package/dist/holmes/review/evaluation-metrics.js +1 -0
  35. package/dist/holmes/review/kills-check.d.ts +40 -0
  36. package/dist/holmes/review/kills-check.js +147 -0
  37. package/dist/holmes/review/manual-baseline.js +1 -0
  38. package/dist/holmes/rtm/advisory-outcomes.d.ts +137 -0
  39. package/dist/holmes/rtm/advisory-outcomes.js +314 -0
  40. package/dist/holmes/rtm/anchor-comment.d.ts +2 -0
  41. package/dist/holmes/rtm/anchor-comment.js +8 -0
  42. package/dist/holmes/rtm/file-anchors.d.ts +9 -0
  43. package/dist/holmes/rtm/file-anchors.js +128 -0
  44. package/dist/holmes/rtm/ftt-fulfilment.d.ts +42 -0
  45. package/dist/holmes/rtm/ftt-fulfilment.js +195 -0
  46. package/dist/holmes/rtm/known-defects.d.ts +26 -0
  47. package/dist/holmes/rtm/known-defects.js +77 -0
  48. package/dist/holmes/rtm/link-census.d.ts +61 -0
  49. package/dist/holmes/rtm/link-census.js +90 -0
  50. package/dist/holmes/rtm/rtm-graph.js +1 -0
  51. package/dist/holmes/rtm/taint-benchmark.js +1 -0
  52. package/dist/holmes/rtm/trace-gaps.d.ts +20 -0
  53. package/dist/holmes/rtm/trace-gaps.js +64 -0
  54. package/dist/holmes/server/dashboard-launcher.d.ts +20 -0
  55. package/dist/holmes/server/dashboard-launcher.js +24 -1
  56. package/dist/holmes/server/dashboard.js +40 -2
  57. package/package.json +1 -1
  58. package/playbooks/author-slice/PLAYBOOK.md +30 -0
  59. package/playbooks/tdd-slice/PLAYBOOK.md +4 -0
@@ -0,0 +1,195 @@
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.fttItems = fttItems;
37
+ exports.fttFulfilment = fttFulfilment;
38
+ exports.locateByBasename = locateByBasename;
39
+ exports.appendFttFulfilment = appendFttFulfilment;
40
+ exports.readFttFulfilments = readFttFulfilments;
41
+ // @implements A-SPEC-656
42
+ /**
43
+ * Files-to-Touch fulfilment — the paths a sealed A-SPEC promised and never made.
44
+ *
45
+ * `declaredImpactGap` says what the declaration MISSED (from the call graph); this says what the
46
+ * declaration PROMISED and the tree does not hold (from the filesystem). Measured 2026-09-16 on
47
+ * this repository: four promised paths across three approved specs never existed, and nothing
48
+ * named them — every gate reads the other direction (code → declaration). The same measurement
49
+ * found the three false-positive shapes a naive existence check would raise, so each has its own
50
+ * bucket: an item written as an alternative ("또는" / "or"), a basename that lives elsewhere
51
+ * (location drift), and prose that is not an item at all (already not a token under REQ-654).
52
+ *
53
+ * ADVISORY, NEVER VERDICT: nothing here can change ok/refuse/grant. NO GUESSING: a failed read
54
+ * propagates to the caller, which drops the field rather than inventing a finding.
55
+ */
56
+ const fs = __importStar(require("node:fs"));
57
+ const path = __importStar(require("node:path"));
58
+ const scope_judgment_1 = require("../guardrail/scope-judgment");
59
+ const replica_id_1 = require("../governance/replica-id");
60
+ const ITEM_RE = /^\s*[-*]\s+(.*)$/;
61
+ const NEW_RE = /\((신규|new)\)/i;
62
+ const ALT_RE = /또는|\bor\b/;
63
+ const SLASHED = /^[\w@.-]+(?:\/[\w@.-]+)+$/;
64
+ const HAS_EXT = /\.[A-Za-z][A-Za-z0-9]*$/;
65
+ /**
66
+ * List items only, first word only — the REQ-654 token rule (a slashed path or an item-shaped
67
+ * root filename; prose is nothing). Globs and extension-less paths (directories) are not items:
68
+ * their fulfilment is not a single file's existence.
69
+ */
70
+ function fttItems(fttText) {
71
+ const out = [];
72
+ for (const raw of String(fttText ?? '').replace(/\\/g, '/').split('\n')) {
73
+ const m = ITEM_RE.exec(raw);
74
+ if (!m)
75
+ continue;
76
+ const line = m[1].trim();
77
+ const word = (line.split(/\s+/)[0] ?? '').replace(/^[`'"]+|[`'"]+$/g, '').replace(/^`?([^`]*?)`?\(/, '$1(');
78
+ const token = word.replace(/\(.*$/, ''); // `src/b.ts`(new) → src/b.ts
79
+ if (!token || token.includes('*') || !HAS_EXT.test(token))
80
+ continue;
81
+ if (!(SLASHED.test(token) || (0, scope_judgment_1.isRootFileToken)(token)))
82
+ continue;
83
+ out.push({ path: token, line, isNew: NEW_RE.test(line), alternative: ALT_RE.test(line) });
84
+ }
85
+ return out;
86
+ }
87
+ function fttFulfilment(fttText, exists, locate) {
88
+ const items = fttItems(fttText);
89
+ if (items.length === 0)
90
+ return null;
91
+ const missing = [];
92
+ const moved = [];
93
+ const alternatives = [];
94
+ for (const it of items) {
95
+ if (it.alternative) {
96
+ alternatives.push({ path: it.path, line: it.line });
97
+ continue;
98
+ }
99
+ if (exists(it.path))
100
+ continue;
101
+ const base = it.path.slice(it.path.lastIndexOf('/') + 1);
102
+ const foundAt = [...new Set(locate(base))].filter((p) => p !== it.path).sort();
103
+ if (foundAt.length > 0)
104
+ moved.push({ path: it.path, foundAt });
105
+ else
106
+ missing.push({ path: it.path, isNew: it.isNew });
107
+ }
108
+ if (missing.length === 0 && moved.length === 0 && alternatives.length === 0)
109
+ return null;
110
+ return { declared: items.length, missing, moved, alternatives };
111
+ }
112
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.ax']);
113
+ /** One walk, then O(1) lookups. Unreadable directories are skipped, never reported as empty. */
114
+ function locateByBasename(root) {
115
+ const byBase = new Map();
116
+ const walk = (dir) => {
117
+ let entries;
118
+ try {
119
+ entries = fs.readdirSync(dir, { withFileTypes: true });
120
+ }
121
+ catch {
122
+ return;
123
+ }
124
+ for (const e of entries) {
125
+ if (SKIP_DIRS.has(e.name))
126
+ continue;
127
+ const abs = path.join(dir, e.name);
128
+ if (e.isDirectory()) {
129
+ walk(abs);
130
+ continue;
131
+ }
132
+ if (!e.isFile())
133
+ continue;
134
+ const rel = path.relative(root, abs).split(path.sep).join('/');
135
+ const list = byBase.get(e.name) ?? [];
136
+ list.push(rel);
137
+ byBase.set(e.name, list);
138
+ }
139
+ };
140
+ walk(root);
141
+ return (basename) => [...(byBase.get(basename) ?? [])].sort();
142
+ }
143
+ const LEDGER_RE = /^ftt-fulfilment\.([^.]+)\.jsonl$/;
144
+ /** The observation ledger — repo-relative paths, a spec id, integers and a timestamp; nothing else. */
145
+ function appendFttFulfilment(root, rec) {
146
+ try {
147
+ if (!fs.existsSync(path.join(root, '.ax')))
148
+ return false;
149
+ let replica = 'local';
150
+ try {
151
+ replica = (0, replica_id_1.resolveReplicaId)(root) || 'local';
152
+ }
153
+ catch { /* keep the fallback */ }
154
+ const file = path.join(root, '.ax', 'ledger', `ftt-fulfilment.${replica}.jsonl`);
155
+ fs.mkdirSync(path.dirname(file), { recursive: true });
156
+ const line = { aspec: rec.aspec, declared: rec.declared, missing: [...rec.missing], moved: [...rec.moved], ts: rec.ts, replica };
157
+ fs.appendFileSync(file, `${JSON.stringify(line)}\n`);
158
+ return true;
159
+ }
160
+ catch {
161
+ return false;
162
+ }
163
+ }
164
+ function readFttFulfilments(root) {
165
+ const dir = path.join(root, '.ax', 'ledger');
166
+ let names;
167
+ try {
168
+ names = fs.readdirSync(dir).filter((n) => LEDGER_RE.test(n)).sort();
169
+ }
170
+ catch {
171
+ return [];
172
+ }
173
+ const out = [];
174
+ for (const name of names) {
175
+ let text;
176
+ try {
177
+ text = fs.readFileSync(path.join(dir, name), 'utf8');
178
+ }
179
+ catch {
180
+ continue;
181
+ }
182
+ for (const raw of text.split('\n')) {
183
+ const s = raw.trim();
184
+ if (!s)
185
+ continue;
186
+ try {
187
+ const r = JSON.parse(s);
188
+ if (r && typeof r.aspec === 'string' && Array.isArray(r.missing) && Array.isArray(r.moved) && typeof r.ts === 'string')
189
+ out.push(r);
190
+ }
191
+ catch { /* a corrupt line never breaks the read */ }
192
+ }
193
+ }
194
+ return out;
195
+ }
@@ -0,0 +1,26 @@
1
+ export interface KnownDefect {
2
+ file: string;
3
+ line: number;
4
+ reason: string;
5
+ expires: string;
6
+ }
7
+ export interface MalformedMarker {
8
+ file: string;
9
+ line: number;
10
+ text: string;
11
+ why: 'no-reason' | 'no-expires' | 'bad-date';
12
+ }
13
+ export interface KnownDefectJudgement {
14
+ unexpired: KnownDefect[];
15
+ expired: KnownDefect[];
16
+ malformed: MalformedMarker[];
17
+ }
18
+ export declare function knownDefectsIn(source: string, file: string): {
19
+ markers: KnownDefect[];
20
+ malformed: MalformedMarker[];
21
+ };
22
+ /** Expired when the expiry day's UTC midnight is at or before `now` — the day itself counts as expired. */
23
+ export declare function judgeKnownDefects(found: {
24
+ markers: KnownDefect[];
25
+ malformed: MalformedMarker[];
26
+ }, now: Date): KnownDefectJudgement;
@@ -0,0 +1,77 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.knownDefectsIn = knownDefectsIn;
4
+ exports.judgeKnownDefects = judgeKnownDefects;
5
+ // @implements A-SPEC-660
6
+ /**
7
+ * `@known-defect(<reason>, expires=YYYY-MM-DD)` — the machine-readable marker for an assertion that
8
+ * pins a KNOWN DEFECT as the expected value.
9
+ *
10
+ * jarvis 2026-09-08: a query that could only ever return 0 was worked around instead of fixed, the
11
+ * workaround was pinned by a test (`assert raw == []`), and to every gate that test was
12
+ * indistinguishable from a healthy one — green, anchored, covered. The docstring said "trap"; only
13
+ * people read docstrings, and the next person repeated the misjudgement. Three precedents make the
14
+ * shape: posixOnly(reason) refuses a reason-less skip, countTestCases folds a marker head into
15
+ * ART-4's evidence, sessionAutonomyActive judges `expires` fail-closed.
16
+ *
17
+ * Pure. Markers are read from COMMENT lines only, after string literals are removed — the ART-4
18
+ * counter's lesson, so a fixture string never becomes a marker. A marker that cannot be read is
19
+ * returned as `malformed`, never dropped: a typo that parses to nothing reads as "the rule passed".
20
+ * The clock is injected; nothing here reads process time.
21
+ */
22
+ const anchor_ids_1 = require("./anchor-ids");
23
+ // A comment line (`//`, `/*`, `*`, `#`) carrying the marker; the body runs to the closing paren.
24
+ const MARKER_LINE_RE = /^[ \t]*(?:\/\/|\/\*|\*|#)[^\n]*?@known-defect\(([^)]*)\)/;
25
+ const DATE_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
26
+ /** The calendar date's UTC midnight, or null when the string is not a real date. */
27
+ function utcMidnightOf(date) {
28
+ const m = DATE_RE.exec(date);
29
+ if (!m)
30
+ return null;
31
+ const [y, mo, d] = [Number(m[1]), Number(m[2]), Number(m[3])];
32
+ const t = Date.UTC(y, mo - 1, d);
33
+ const back = new Date(t);
34
+ return back.getUTCFullYear() === y && back.getUTCMonth() === mo - 1 && back.getUTCDate() === d ? t : null;
35
+ }
36
+ function knownDefectsIn(source, file) {
37
+ const markers = [];
38
+ const malformed = [];
39
+ const lines = (0, anchor_ids_1.stripStringLiterals)(String(source ?? '')).split('\n');
40
+ for (let i = 0; i < lines.length; i++) {
41
+ const m = MARKER_LINE_RE.exec(lines[i]);
42
+ if (!m)
43
+ continue;
44
+ const body = m[1];
45
+ const text = `@known-defect(${body})`;
46
+ const at = body.lastIndexOf(', expires=');
47
+ if (at === -1) {
48
+ malformed.push({ file, line: i + 1, text, why: 'no-expires' });
49
+ continue;
50
+ }
51
+ const reason = body.slice(0, at).trim();
52
+ const expires = body.slice(at + ', expires='.length).trim();
53
+ if (reason === '') {
54
+ malformed.push({ file, line: i + 1, text, why: 'no-reason' });
55
+ continue;
56
+ }
57
+ if (utcMidnightOf(expires) === null) {
58
+ malformed.push({ file, line: i + 1, text, why: 'bad-date' });
59
+ continue;
60
+ }
61
+ markers.push({ file, line: i + 1, reason, expires });
62
+ }
63
+ return { markers, malformed };
64
+ }
65
+ /** Expired when the expiry day's UTC midnight is at or before `now` — the day itself counts as expired. */
66
+ function judgeKnownDefects(found, now) {
67
+ const unexpired = [];
68
+ const expired = [];
69
+ for (const k of found.markers) {
70
+ const t = utcMidnightOf(k.expires);
71
+ if (t === null || t <= now.getTime())
72
+ expired.push(k);
73
+ else
74
+ unexpired.push(k);
75
+ }
76
+ return { unexpired, expired, malformed: [...found.malformed] };
77
+ }
@@ -0,0 +1,61 @@
1
+ /**
2
+ * RTM link census — which approved A-SPECs the code graph cannot see, and why.
3
+ *
4
+ * Measured 2026-09-16 on this repository: the dashboard census said coveragePct 100 while only
5
+ * 558/630 approved A-SPECs (88.6%) carried an `implements` edge; the other 72 were anchored only
6
+ * in test files the scanner deliberately excludes. "Unknown" and "absent" were the same number.
7
+ * The census here keeps the graph's contracts (tests and non-AST files stay OUT of the graph —
8
+ * cpg-scanner.ts:141-147 is a sealed decision) and classifies the gap instead:
9
+ *
10
+ * test-only anchored only in test files (a characterization / docs / CI slice — legitimate)
11
+ * file-anchor anchored only in files the injector can write but the indexer never parses
12
+ * test-and-file both of the above
13
+ * weak-anchor anchored nowhere, but a file inside the spec's own Files to Touch names the id
14
+ * none no trace at all
15
+ *
16
+ * The weak-anchor predicate is deliberately NARROW. The backlog card that proposed it ("any
17
+ * `A-SPEC-N` mention in a comment without a matching @implements") scores 817 false positives and
18
+ * 0 true positives on this repository; the narrowed form scores 0 and 0. Pure: every reader is
19
+ * injected, so the classifier is testable without a filesystem.
20
+ */
21
+ export type UnlinkedReason = 'test-only' | 'file-anchor' | 'test-and-file' | 'weak-anchor' | 'none';
22
+ export interface UnlinkedSpec {
23
+ id: string;
24
+ reason: UnlinkedReason;
25
+ /** Test and file-anchor paths that carry the id, sorted. Empty for weak-anchor / none. */
26
+ anchoredIn: string[];
27
+ /** Files-to-Touch paths whose text names the id — weak-anchor only. */
28
+ mentionedIn: string[];
29
+ }
30
+ export interface LinkCensus {
31
+ total: number;
32
+ codeLinked: number;
33
+ /** One decimal, never NaN. */
34
+ codeLinkedPct: number;
35
+ /** Sorted by id. */
36
+ unlinked: UnlinkedSpec[];
37
+ /** Always carries all five keys. */
38
+ byReason: Record<UnlinkedReason, number>;
39
+ }
40
+ export interface LinkCensusInput {
41
+ approvedIds: string[];
42
+ /** Ids anchored by production (scanned) source — the ids that own an `implements` edge. */
43
+ implemented: Iterable<string>;
44
+ /** `scanTestAnchors` shape: repo-relative POSIX path → ids. */
45
+ testAnchors: Record<string, string[]>;
46
+ /** `scanFileAnchors` shape: repo-relative POSIX path → ids. */
47
+ fileAnchors: Record<string, string[]>;
48
+ /** id → Files-to-Touch paths whose text names it (see `weakMentionsFor`). */
49
+ weakMentions: Record<string, string[]>;
50
+ }
51
+ export declare const UNLINKED_REASONS: readonly UnlinkedReason[];
52
+ export declare function emptyByReason(): Record<UnlinkedReason, number>;
53
+ /** Percentage with one decimal; a zero denominator reads as 0, never NaN. */
54
+ export declare function pct1(num: number, den: number): number;
55
+ /**
56
+ * Files-to-Touch paths whose text names EXACTLY `id`. `A-SPEC-12` does not match `A-SPEC-120` or
57
+ * `A-SPEC-12.1`. A reader returning null (unreadable, absent) contributes nothing — an unreadable
58
+ * file is neither a mention nor evidence of its absence.
59
+ */
60
+ export declare function weakMentionsFor(id: string, fttFiles: string[], readText: (rel: string) => string | null): string[];
61
+ export declare function linkCensus(input: LinkCensusInput): LinkCensus;
@@ -0,0 +1,90 @@
1
+ "use strict";
2
+ // @implements A-SPEC-655
3
+ /**
4
+ * RTM link census — which approved A-SPECs the code graph cannot see, and why.
5
+ *
6
+ * Measured 2026-09-16 on this repository: the dashboard census said coveragePct 100 while only
7
+ * 558/630 approved A-SPECs (88.6%) carried an `implements` edge; the other 72 were anchored only
8
+ * in test files the scanner deliberately excludes. "Unknown" and "absent" were the same number.
9
+ * The census here keeps the graph's contracts (tests and non-AST files stay OUT of the graph —
10
+ * cpg-scanner.ts:141-147 is a sealed decision) and classifies the gap instead:
11
+ *
12
+ * test-only anchored only in test files (a characterization / docs / CI slice — legitimate)
13
+ * file-anchor anchored only in files the injector can write but the indexer never parses
14
+ * test-and-file both of the above
15
+ * weak-anchor anchored nowhere, but a file inside the spec's own Files to Touch names the id
16
+ * none no trace at all
17
+ *
18
+ * The weak-anchor predicate is deliberately NARROW. The backlog card that proposed it ("any
19
+ * `A-SPEC-N` mention in a comment without a matching @implements") scores 817 false positives and
20
+ * 0 true positives on this repository; the narrowed form scores 0 and 0. Pure: every reader is
21
+ * injected, so the classifier is testable without a filesystem.
22
+ */
23
+ Object.defineProperty(exports, "__esModule", { value: true });
24
+ exports.UNLINKED_REASONS = void 0;
25
+ exports.emptyByReason = emptyByReason;
26
+ exports.pct1 = pct1;
27
+ exports.weakMentionsFor = weakMentionsFor;
28
+ exports.linkCensus = linkCensus;
29
+ exports.UNLINKED_REASONS = ['test-only', 'file-anchor', 'test-and-file', 'weak-anchor', 'none'];
30
+ function emptyByReason() {
31
+ return { 'test-only': 0, 'file-anchor': 0, 'test-and-file': 0, 'weak-anchor': 0, none: 0 };
32
+ }
33
+ /** Percentage with one decimal; a zero denominator reads as 0, never NaN. */
34
+ function pct1(num, den) {
35
+ return den > 0 ? Math.round((num / den) * 1000) / 10 : 0;
36
+ }
37
+ const escapeRe = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
38
+ /**
39
+ * Files-to-Touch paths whose text names EXACTLY `id`. `A-SPEC-12` does not match `A-SPEC-120` or
40
+ * `A-SPEC-12.1`. A reader returning null (unreadable, absent) contributes nothing — an unreadable
41
+ * file is neither a mention nor evidence of its absence.
42
+ */
43
+ function weakMentionsFor(id, fttFiles, readText) {
44
+ const re = new RegExp(String.raw `(?<![\w.-])${escapeRe(id)}(?!\w|\.\d)`);
45
+ const out = [];
46
+ for (const rel of fttFiles) {
47
+ const text = readText(rel);
48
+ if (text !== null && text !== undefined && re.test(text))
49
+ out.push(rel);
50
+ }
51
+ return [...new Set(out)].sort();
52
+ }
53
+ function invert(map) {
54
+ const byId = new Map();
55
+ for (const [file, ids] of Object.entries(map ?? {})) {
56
+ for (const id of ids ?? []) {
57
+ const list = byId.get(id) ?? [];
58
+ if (!list.includes(file))
59
+ list.push(file);
60
+ byId.set(id, list);
61
+ }
62
+ }
63
+ return byId;
64
+ }
65
+ function linkCensus(input) {
66
+ const implemented = new Set(input.implemented);
67
+ const tests = invert(input.testAnchors);
68
+ const files = invert(input.fileAnchors);
69
+ const byReason = emptyByReason();
70
+ const unlinked = [];
71
+ const ids = [...new Set(input.approvedIds)].sort();
72
+ let codeLinked = 0;
73
+ for (const id of ids) {
74
+ if (implemented.has(id)) {
75
+ codeLinked++;
76
+ continue;
77
+ }
78
+ const t = tests.get(id) ?? [];
79
+ const f = files.get(id) ?? [];
80
+ const mentions = t.length === 0 && f.length === 0 ? [...(input.weakMentions?.[id] ?? [])].sort() : [];
81
+ const reason = t.length && f.length ? 'test-and-file'
82
+ : t.length ? 'test-only'
83
+ : f.length ? 'file-anchor'
84
+ : mentions.length ? 'weak-anchor'
85
+ : 'none';
86
+ byReason[reason]++;
87
+ unlinked.push({ id, reason, anchoredIn: [...t, ...f].sort(), mentionedIn: mentions });
88
+ }
89
+ return { total: ids.length, codeLinked, codeLinkedPct: pct1(codeLinked, ids.length), unlinked, byReason };
90
+ }
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.RtmGraph = void 0;
7
+ // @implements A-SPEC-588
7
8
  // @implements A-SPEC-293
8
9
  // @implements A-SPEC-644
9
10
  // @implements A-SPEC-289
@@ -38,6 +38,7 @@ exports.runTaintBenchmarkAsync = runTaintBenchmarkAsync;
38
38
  exports.compareEngines = compareEngines;
39
39
  exports.runTaintBenchmark = runTaintBenchmark;
40
40
  exports.taintOnRealCorpus = taintOnRealCorpus;
41
+ // @implements A-SPEC-355
41
42
  // @implements A-SPEC-354
42
43
  const language_parser_1 = require("../cpg/language-parser");
43
44
  const dataflow_taint_1 = require("./dataflow-taint");
@@ -0,0 +1,20 @@
1
+ export interface UnlinkedSpec {
2
+ id: string;
3
+ ftt: string;
4
+ }
5
+ export interface TraceGap {
6
+ id: string;
7
+ file: string;
8
+ summary?: string | null;
9
+ }
10
+ /** Approved A-SPECs no scanned (production) file anchors, with their Files-to-Touch text; sorted by id. */
11
+ export declare function unlinkedApproved(specs: ReadonlyArray<{
12
+ id: string;
13
+ type: string;
14
+ status: string;
15
+ sections?: Record<string, string>;
16
+ }>, scanned: ReadonlyArray<{
17
+ implementsSpecs?: string[];
18
+ }>): UnlinkedSpec[];
19
+ /** (id, file) for every changed file an unlinked spec declares; sorted by id then file; no duplicates. */
20
+ export declare function traceGaps(changedFiles: string[], unlinked: UnlinkedSpec[], summaryOf?: (id: string) => string | null): TraceGap[];
@@ -0,0 +1,64 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.unlinkedApproved = unlinkedApproved;
4
+ exports.traceGaps = traceGaps;
5
+ // @implements A-SPEC-658
6
+ /**
7
+ * Trace gaps — the approved A-SPECs the graph cannot see when a file they declare changes.
8
+ *
9
+ * `rtm_impact` walks `implements` edges, and an approved A-SPEC that no production source anchors
10
+ * has none: it can never appear in any impact set, however directly the change concerns it. That
11
+ * is not "no impact", it is "cannot see" — and until now the two had the same shape (silence).
12
+ * Measured 2026-09-17 on this repository: 24 of the 72 unlinked approved specs (REQ-655's census)
13
+ * declare 34 scanned production files in their Files to Touch — change `hooks/pre-tool-use.ts`
14
+ * today and A-SPEC-202 / A-SPEC-250 stay unmentioned. The jarvis incident was the same shape
15
+ * (`_fmt_at` changed, REQ-347 unreported).
16
+ *
17
+ * Pure: the population is the same one REQ-655 counts (approved A-SPEC minus scanned anchors), the
18
+ * declared files come from REQ-656's item parser (REQ-654's token rule, globs excluded), and the
19
+ * result is an intersection. Information for `rtm_impact`, a `widen` reason for the impact gate;
20
+ * never a verdict on its own.
21
+ */
22
+ const ftt_fulfilment_1 = require("./ftt-fulfilment");
23
+ /** Approved A-SPECs no scanned (production) file anchors, with their Files-to-Touch text; sorted by id. */
24
+ function unlinkedApproved(specs, scanned) {
25
+ const anchored = new Set();
26
+ for (const f of scanned)
27
+ for (const id of f.implementsSpecs ?? [])
28
+ anchored.add(id);
29
+ return specs
30
+ .filter((s) => s.type === 'A-SPEC' && s.status === 'approved' && !anchored.has(s.id))
31
+ .map((s) => ({ id: s.id, ftt: String(s.sections?.['Files to Touch'] ?? '') }))
32
+ .sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
33
+ }
34
+ /** (id, file) for every changed file an unlinked spec declares; sorted by id then file; no duplicates. */
35
+ function traceGaps(changedFiles, unlinked, summaryOf) {
36
+ const files = new Set(changedFiles.map((p) => p.replace(/\\/g, '/')));
37
+ if (files.size === 0)
38
+ return [];
39
+ const out = [];
40
+ const seen = new Set();
41
+ for (const u of unlinked) {
42
+ for (const item of (0, ftt_fulfilment_1.fttItems)(u.ftt)) {
43
+ if (!files.has(item.path))
44
+ continue;
45
+ const key = `${u.id} ${item.path}`;
46
+ if (seen.has(key))
47
+ continue;
48
+ seen.add(key);
49
+ const gap = { id: u.id, file: item.path };
50
+ if (summaryOf) {
51
+ let s = null;
52
+ try {
53
+ s = summaryOf(`SPEC:${u.id}`);
54
+ }
55
+ catch {
56
+ s = null;
57
+ }
58
+ gap.summary = s;
59
+ }
60
+ out.push(gap);
61
+ }
62
+ }
63
+ return out.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : a.file < b.file ? -1 : a.file > b.file ? 1 : 0));
64
+ }
@@ -1,3 +1,4 @@
1
+ import { UnlinkedReason } from '../rtm/link-census';
1
2
  /** Honesty summary of what a launched dashboard shows — derived from the endpoints it serves. */
2
3
  export interface DashboardCensus {
3
4
  reqCount: number;
@@ -13,6 +14,25 @@ export interface DashboardCensus {
13
14
  * coveragePct never divides by zero.
14
15
  */
15
16
  export declare function dashboardCensus(rtm: any, heatmap: any): DashboardCensus;
17
+ /** The honesty census plus the code-link axis. `dashboardCensus` above stays byte-identical (pinned). */
18
+ export interface DashboardCensusExtended extends DashboardCensus {
19
+ codeLinkedPct: number;
20
+ codeLinkedCount: number;
21
+ unlinkedCount: number;
22
+ unlinkedByReason: Record<UnlinkedReason, number>;
23
+ excluded: {
24
+ total: number;
25
+ retired: number;
26
+ unmapped: number;
27
+ nonSpec: number;
28
+ };
29
+ }
30
+ /**
31
+ * Pure: the pinned census plus the code-link axis from `/api/rtm`'s `linkCensus`, `coveragePct`
32
+ * with one decimal, and the documents the axes exclude (retired / unmapped / non-spec) counted
33
+ * with their reasons. A payload without `linkCensus` (an older server) folds to zeros, never throws.
34
+ */
35
+ export declare function dashboardCensusExtended(rtm: any, heatmap: any): DashboardCensusExtended;
16
36
  export interface LaunchResult {
17
37
  url: string;
18
38
  port: number;
@@ -1,10 +1,12 @@
1
1
  "use strict";
2
- // @implements A-SPEC-545.3
3
2
  Object.defineProperty(exports, "__esModule", { value: true });
4
3
  exports.dashboardCensus = dashboardCensus;
4
+ exports.dashboardCensusExtended = dashboardCensusExtended;
5
5
  exports.ensureDashboard = ensureDashboard;
6
6
  exports._resetLauncher = _resetLauncher;
7
7
  exports._stopAll = _stopAll;
8
+ // @implements A-SPEC-545.3
9
+ const link_census_1 = require("../rtm/link-census");
8
10
  /**
9
11
  * @implements A-SPEC-545.3
10
12
  * Pure: fold the /api/rtm and /api/rtm/heatmap payloads into a census. Missing fields read as 0/false;
@@ -24,6 +26,27 @@ function dashboardCensus(rtm, heatmap) {
24
26
  findingsScanned: heatmap?.findingsScanned === true,
25
27
  };
26
28
  }
29
+ /**
30
+ * Pure: the pinned census plus the code-link axis from `/api/rtm`'s `linkCensus`, `coveragePct`
31
+ * with one decimal, and the documents the axes exclude (retired / unmapped / non-spec) counted
32
+ * with their reasons. A payload without `linkCensus` (an older server) folds to zeros, never throws.
33
+ */
34
+ function dashboardCensusExtended(rtm, heatmap) {
35
+ const base = dashboardCensus(rtm, heatmap);
36
+ const lc = rtm?.linkCensus;
37
+ const retired = rtm?.retired?.count ?? 0;
38
+ const unmapped = rtm?.unmapped?.count ?? 0;
39
+ const nonSpec = rtm?.nonSpec?.count ?? 0;
40
+ return {
41
+ ...base,
42
+ coveragePct: (0, link_census_1.pct1)(heatmap?.completeCount ?? 0, heatmap?.pipelineCount ?? 0),
43
+ codeLinkedPct: typeof lc?.codeLinkedPct === 'number' ? lc.codeLinkedPct : 0,
44
+ codeLinkedCount: typeof lc?.codeLinked === 'number' ? lc.codeLinked : 0,
45
+ unlinkedCount: Array.isArray(lc?.unlinked) ? lc.unlinked.length : 0,
46
+ unlinkedByReason: { ...(0, link_census_1.emptyByReason)(), ...(lc?.byReason ?? {}) },
47
+ excluded: { total: retired + unmapped + nonSpec, retired, unmapped, nonSpec },
48
+ };
49
+ }
27
50
  const live = new Map();
28
51
  /**
29
52
  * @implements A-SPEC-545.3