@holmes-lab/holmes-kit 0.20.2 → 0.21.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 (38) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/.build-id +1 -1
  3. package/dist/holmes/governance/constitution.d.ts +11 -0
  4. package/dist/holmes/governance/constitution.js +15 -1
  5. package/dist/holmes/guardrail/impact-gate.d.ts +10 -1
  6. package/dist/holmes/guardrail/impact-gate.js +19 -0
  7. package/dist/holmes/guardrail/risk-classifier.d.ts +1 -0
  8. package/dist/holmes/guardrail/risk-classifier.js +26 -3
  9. package/dist/holmes/hooks/stop.d.ts +9 -0
  10. package/dist/holmes/hooks/stop.js +63 -1
  11. package/dist/holmes/mcp/handlers/graph-operations.d.ts +1 -0
  12. package/dist/holmes/mcp/handlers/graph-operations.js +18 -1
  13. package/dist/holmes/mcp/handlers/maintenance-evidence.d.ts +4 -0
  14. package/dist/holmes/mcp/handlers/maintenance-evidence.js +16 -1
  15. package/dist/holmes/mcp/handlers/operator-inspection.d.ts +2 -1
  16. package/dist/holmes/mcp/handlers/operator-inspection.js +25 -3
  17. package/dist/holmes/mcp/handlers/spec-approval.d.ts +1 -0
  18. package/dist/holmes/mcp/handlers/spec-approval.js +29 -1
  19. package/dist/holmes/mcp/handlers.d.ts +4 -1
  20. package/dist/holmes/mcp/handlers.js +4 -0
  21. package/dist/holmes/rtm/anchor-comment.d.ts +2 -0
  22. package/dist/holmes/rtm/anchor-comment.js +8 -0
  23. package/dist/holmes/rtm/file-anchors.d.ts +9 -0
  24. package/dist/holmes/rtm/file-anchors.js +128 -0
  25. package/dist/holmes/rtm/ftt-fulfilment.d.ts +42 -0
  26. package/dist/holmes/rtm/ftt-fulfilment.js +195 -0
  27. package/dist/holmes/rtm/known-defects.d.ts +26 -0
  28. package/dist/holmes/rtm/known-defects.js +77 -0
  29. package/dist/holmes/rtm/link-census.d.ts +61 -0
  30. package/dist/holmes/rtm/link-census.js +90 -0
  31. package/dist/holmes/rtm/trace-gaps.d.ts +20 -0
  32. package/dist/holmes/rtm/trace-gaps.js +64 -0
  33. package/dist/holmes/server/dashboard-launcher.d.ts +20 -0
  34. package/dist/holmes/server/dashboard-launcher.js +24 -1
  35. package/dist/holmes/server/dashboard.js +40 -2
  36. package/package.json +1 -1
  37. package/playbooks/author-slice/PLAYBOOK.md +11 -0
  38. package/playbooks/tdd-slice/PLAYBOOK.md +4 -0
@@ -919,6 +919,10 @@ function makeRawHandlers(store, opts) {
919
919
  projectRootOf,
920
920
  refusal: reason => new HandlerRefusal(reason),
921
921
  fileDigestOf,
922
+ // @implements A-SPEC-658 — the impact gate reads trace gaps from the same store and scan the
923
+ // graph handlers use.
924
+ listSpecs: () => store.list(),
925
+ cachedScan,
922
926
  }),
923
927
  // @implements A-SPEC-642 — placed in the one slot no public-key-order pin covers (the handler suites pin
924
928
  // spec_create..spec_upgrade, spec_retire..spec_unseal, spec_unseal+5, spec_approve+4, spec_next+3,
@@ -6,4 +6,6 @@
6
6
  * a syntax this map does not know returns null and the caller must REFUSE to write — an honest
7
7
  * refusal beats a broken file, and beats an anchor the gate cannot read.
8
8
  */
9
+ export declare const INJECTABLE_EXTENSIONS: readonly string[];
10
+ export declare const INJECTABLE_BASENAMES: readonly string[];
9
11
  export declare function anchorLineFor(filePath: string, aspecId: string): string | null;
@@ -9,6 +9,7 @@
9
9
  * refusal beats a broken file, and beats an anchor the gate cannot read.
10
10
  */
11
11
  Object.defineProperty(exports, "__esModule", { value: true });
12
+ exports.INJECTABLE_BASENAMES = exports.INJECTABLE_EXTENSIONS = void 0;
12
13
  exports.anchorLineFor = anchorLineFor;
13
14
  const SLASH = new Set(['ts', 'tsx', 'js', 'jsx', 'mjs', 'cjs', 'java', 'c', 'h', 'cc', 'cpp',
14
15
  'hpp', 'cs', 'go', 'rs', 'swift', 'kt', 'kts', 'scala', 'dart']);
@@ -16,6 +17,13 @@ const HASH = new Set(['py', 'rb', 'sh', 'bash', 'zsh', 'fish', 'yml', 'yaml', 't
16
17
  'r', 'jl', 'cmake', 'mk']);
17
18
  const CSS = new Set(['css', 'scss', 'less']);
18
19
  const HASH_BASENAMES = new Set(['Makefile', 'Dockerfile', 'Rakefile', 'Gemfile']);
20
+ // @implements A-SPEC-655 — the injector's syntax set, exported so the census can read every file
21
+ // the injector may have written. Measured 2026-09-16: this map knew `.sh/.yml/.toml/...` and the
22
+ // indexer's SCANNABLE_EXTENSIONS did not, so `spec_remediate` planted anchors the graph could
23
+ // never see (scripts/ci-local.sh → A-SPEC-530.1). `rtm/file-anchors.ts` reads the difference and
24
+ // link-census.test.ts pins INJECTABLE ⊆ SCANNABLE ∪ FILE_ANCHOR over the whole set.
25
+ exports.INJECTABLE_EXTENSIONS = [...new Set([...SLASH, ...HASH, ...CSS])].sort();
26
+ exports.INJECTABLE_BASENAMES = [...HASH_BASENAMES].sort();
19
27
  function anchorLineFor(filePath, aspecId) {
20
28
  const basename = filePath.slice(filePath.lastIndexOf('/') + 1);
21
29
  if (HASH_BASENAMES.has(basename))
@@ -0,0 +1,9 @@
1
+ /** Extensions the injector writes that the indexer does not parse — the census reads these. */
2
+ export declare const FILE_ANCHOR_EXTENSIONS: readonly string[];
3
+ /** True for a PROJECT-RELATIVE POSIX path the file-anchor scanner reads. */
4
+ export declare function isFileAnchorPath(rel: string): boolean;
5
+ /**
6
+ * Repo-relative POSIX path → A-SPEC ids, for every file `isFileAnchorPath` admits. Same walk and
7
+ * resilience as `scanTestAnchors`: unreadable files are skipped, never reported as anchor-less.
8
+ */
9
+ export declare function scanFileAnchors(root: string): Record<string, string[]>;
@@ -0,0 +1,128 @@
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.FILE_ANCHOR_EXTENSIONS = void 0;
37
+ exports.isFileAnchorPath = isFileAnchorPath;
38
+ exports.scanFileAnchors = scanFileAnchors;
39
+ // @implements A-SPEC-655
40
+ /**
41
+ * Anchors in files the injector can write but the indexer never parses.
42
+ *
43
+ * `anchor-comment.ts` knows the comment syntax of `.sh/.yml/.toml/.rb/...` and `spec_remediate`
44
+ * injects `# @implements` there; `cpg-scanner.ts` ingests 19 AST extensions and none of those.
45
+ * Measured 2026-09-16: `scripts/ci-local.sh` carries a hash-comment anchor line naming
46
+ * A-SPEC-530.1 that no graph edge will ever reflect. (The id is deliberately not written here in
47
+ * anchor form: the scanner reads the marker anywhere in a file, and this module's first attempt
48
+ * quoted the line verbatim — which made THIS file the implementer of A-SPEC-530.1.) This scanner reads exactly the injector's set minus the indexer's set —
49
+ * off-graph, like `scanTestAnchors` — so the census can say "anchored in a file the graph does
50
+ * not parse" instead of "no trace". The set difference is computed, not copied: when either list
51
+ * moves, `FILE_ANCHOR_EXTENSIONS` follows, and link-census.test.ts pins the inclusion invariant
52
+ * INJECTABLE ⊆ SCANNABLE ∪ FILE_ANCHOR over the whole set.
53
+ *
54
+ * NOT a graph input. Test files stay out (isTestFile), `.ax/` stays out (spec documents mention
55
+ * the marker in prose), and nothing here creates a node or an edge.
56
+ */
57
+ const fs = __importStar(require("node:fs"));
58
+ const path = __importStar(require("node:path"));
59
+ const anchor_comment_1 = require("./anchor-comment");
60
+ const cpg_scanner_1 = require("../cpg/cpg-scanner");
61
+ const test_scope_1 = require("./test-scope");
62
+ const SCANNABLE = new Set(cpg_scanner_1.SCANNABLE_EXTENSIONS.map((e) => e.replace(/^\./, '')));
63
+ /** Extensions the injector writes that the indexer does not parse — the census reads these. */
64
+ exports.FILE_ANCHOR_EXTENSIONS = anchor_comment_1.INJECTABLE_EXTENSIONS.filter((e) => !SCANNABLE.has(e));
65
+ const FILE_ANCHOR = new Set(exports.FILE_ANCHOR_EXTENSIONS);
66
+ const BASENAMES = new Set(anchor_comment_1.INJECTABLE_BASENAMES);
67
+ const SKIP_DIRS = new Set(['node_modules', '.git', 'dist', '.ax']);
68
+ // `isTestFile` is language-scoped on purpose (an unscoped `tests/` rule once swallowed
69
+ // `src/holmes/spec/**`); a shell script or workflow under a test directory has no such hazard and is
70
+ // test scaffolding, so the directory rule applies here in full.
71
+ const TEST_DIRS = new Set(['test', 'tests', '__tests__']);
72
+ /** True for a PROJECT-RELATIVE POSIX path the file-anchor scanner reads. */
73
+ function isFileAnchorPath(rel) {
74
+ const segs = rel.split('/');
75
+ if (rel.startsWith('.ax/') || segs.some((seg) => SKIP_DIRS.has(seg)))
76
+ return false;
77
+ if ((0, cpg_scanner_1.isTestFile)(rel) || segs.slice(0, -1).some((seg) => TEST_DIRS.has(seg)))
78
+ return false;
79
+ const base = rel.slice(rel.lastIndexOf('/') + 1);
80
+ if (BASENAMES.has(base))
81
+ return true;
82
+ const dot = base.lastIndexOf('.');
83
+ if (dot <= 0)
84
+ return false;
85
+ return FILE_ANCHOR.has(base.slice(dot + 1).toLowerCase());
86
+ }
87
+ /**
88
+ * Repo-relative POSIX path → A-SPEC ids, for every file `isFileAnchorPath` admits. Same walk and
89
+ * resilience as `scanTestAnchors`: unreadable files are skipped, never reported as anchor-less.
90
+ */
91
+ function scanFileAnchors(root) {
92
+ const out = {};
93
+ const walk = (dir) => {
94
+ let entries;
95
+ try {
96
+ entries = fs.readdirSync(dir, { withFileTypes: true });
97
+ }
98
+ catch {
99
+ return;
100
+ }
101
+ for (const e of entries) {
102
+ if (SKIP_DIRS.has(e.name))
103
+ continue;
104
+ const abs = path.join(dir, e.name);
105
+ if (e.isDirectory()) {
106
+ walk(abs);
107
+ continue;
108
+ }
109
+ if (!e.isFile())
110
+ continue;
111
+ const rel = path.relative(root, abs).split(path.sep).join('/');
112
+ if (!isFileAnchorPath(rel))
113
+ continue;
114
+ let text;
115
+ try {
116
+ text = fs.readFileSync(abs, 'utf8');
117
+ }
118
+ catch {
119
+ continue;
120
+ }
121
+ const ids = (0, test_scope_1.extractAnchors)(text);
122
+ if (ids.length)
123
+ out[rel] = ids;
124
+ }
125
+ };
126
+ walk(root);
127
+ return out;
128
+ }
@@ -0,0 +1,42 @@
1
+ export interface FttItem {
2
+ path: string;
3
+ line: string;
4
+ isNew: boolean;
5
+ alternative: boolean;
6
+ }
7
+ export interface FttFulfilment {
8
+ /** Items examined — so "no finding" can be told from "nothing to examine". */
9
+ declared: number;
10
+ missing: Array<{
11
+ path: string;
12
+ isNew: boolean;
13
+ }>;
14
+ moved: Array<{
15
+ path: string;
16
+ foundAt: string[];
17
+ }>;
18
+ alternatives: Array<{
19
+ path: string;
20
+ line: string;
21
+ }>;
22
+ }
23
+ export interface FulfilmentRecord {
24
+ aspec: string;
25
+ declared: number;
26
+ missing: string[];
27
+ moved: string[];
28
+ ts: string;
29
+ replica?: string;
30
+ }
31
+ /**
32
+ * List items only, first word only — the REQ-654 token rule (a slashed path or an item-shaped
33
+ * root filename; prose is nothing). Globs and extension-less paths (directories) are not items:
34
+ * their fulfilment is not a single file's existence.
35
+ */
36
+ export declare function fttItems(fttText: string): FttItem[];
37
+ export declare function fttFulfilment(fttText: string, exists: (rel: string) => boolean, locate: (basename: string) => string[]): FttFulfilment | null;
38
+ /** One walk, then O(1) lookups. Unreadable directories are skipped, never reported as empty. */
39
+ export declare function locateByBasename(root: string): (basename: string) => string[];
40
+ /** The observation ledger — repo-relative paths, a spec id, integers and a timestamp; nothing else. */
41
+ export declare function appendFttFulfilment(root: string, rec: FulfilmentRecord): boolean;
42
+ export declare function readFttFulfilments(root: string): FulfilmentRecord[];
@@ -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;