@holmes-lab/holmes-kit 0.1.9 → 0.1.10

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 (51) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +48 -4
  3. package/dist/.build-id +1 -1
  4. package/dist/holmes/cli/test-platform.d.ts +25 -0
  5. package/dist/holmes/cli/test-platform.js +38 -0
  6. package/dist/holmes/cpg/cpg-scanner.d.ts +41 -0
  7. package/dist/holmes/cpg/cpg-scanner.js +53 -1
  8. package/dist/holmes/cpg/forbidden-edges.d.ts +73 -0
  9. package/dist/holmes/cpg/forbidden-edges.js +140 -0
  10. package/dist/holmes/cpg/hash-cache.js +13 -5
  11. package/dist/holmes/cpg/language-parser-walk.js +70 -4
  12. package/dist/holmes/cpg/proposed-content.d.ts +51 -0
  13. package/dist/holmes/cpg/proposed-content.js +72 -0
  14. package/dist/holmes/cpg/required-calls.d.ts +62 -0
  15. package/dist/holmes/cpg/required-calls.js +93 -0
  16. package/dist/holmes/guardrail/cspec-change.d.ts +23 -0
  17. package/dist/holmes/guardrail/cspec-change.js +70 -0
  18. package/dist/holmes/guardrail/risk-classifier.js +122 -0
  19. package/dist/holmes/guardrail/write-target.d.ts +42 -0
  20. package/dist/holmes/guardrail/write-target.js +69 -18
  21. package/dist/holmes/hooks/pre-tool-use.js +90 -5
  22. package/dist/holmes/hooks/stop.d.ts +17 -0
  23. package/dist/holmes/hooks/stop.js +39 -2
  24. package/dist/holmes/mcp/handlers.d.ts +41 -0
  25. package/dist/holmes/mcp/handlers.js +173 -3
  26. package/dist/holmes/mcp/tool-schemas.js +12 -0
  27. package/dist/holmes/project/dependencies.d.ts +15 -0
  28. package/dist/holmes/project/dependencies.js +58 -0
  29. package/dist/holmes/project/json-state.d.ts +24 -0
  30. package/dist/holmes/project/json-state.js +30 -0
  31. package/dist/holmes/reverse/scan.js +8 -1
  32. package/dist/holmes/review/scope.d.ts +29 -0
  33. package/dist/holmes/review/scope.js +44 -0
  34. package/dist/holmes/rtm/test-scope.d.ts +44 -0
  35. package/dist/holmes/rtm/test-scope.js +92 -2
  36. package/dist/holmes/server/dashboard.d.ts +22 -0
  37. package/dist/holmes/server/dashboard.js +114 -24
  38. package/dist/holmes/spec/approval-blockers.d.ts +21 -5
  39. package/dist/holmes/spec/approval-blockers.js +49 -6
  40. package/dist/holmes/spec/legacy-format.d.ts +14 -0
  41. package/dist/holmes/spec/legacy-format.js +15 -1
  42. package/dist/holmes/spec/nonfunctional.d.ts +70 -0
  43. package/dist/holmes/spec/nonfunctional.js +119 -0
  44. package/dist/holmes/spec/spec-parser.d.ts +25 -0
  45. package/dist/holmes/spec/spec-parser.js +46 -2
  46. package/dist/holmes/spec/spec-types.d.ts +4 -1
  47. package/dist/holmes/spec/spec-types.js +13 -1
  48. package/dist/holmes/testing/effects.d.ts +54 -0
  49. package/dist/holmes/testing/effects.js +107 -0
  50. package/package.json +3 -2
  51. package/playbooks/promote-slice/PLAYBOOK.md +20 -0
@@ -34,6 +34,7 @@ var __importStar = (this && this.__importStar) || (function () {
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.SUPPORTED_TEST_FRAMEWORKS = void 0;
37
+ exports.architectureGoverningFiles = architectureGoverningFiles;
37
38
  exports.accountChangedFiles = accountChangedFiles;
38
39
  exports.countTestCases = countTestCases;
39
40
  exports.extractAnchors = extractAnchors;
@@ -42,8 +43,50 @@ exports.computeTestScope = computeTestScope;
42
43
  // @implements A-SPEC-121.2
43
44
  const fs = __importStar(require("node:fs"));
44
45
  const path = __importStar(require("node:path"));
46
+ const spec_types_1 = require("../spec/spec-types");
45
47
  const rtm_check_1 = require("./rtm-check");
46
48
  const DEFAULTS = { partialBlast: 3, fullBlast: 12 };
49
+ /**
50
+ * Architecture-governing documents, picked out of a change set.
51
+ *
52
+ * @implements A-SPEC-233
53
+ * WHY THIS EXISTS. `assessRisk`'s architecture axis already returns `hard-hitl` for exactly these
54
+ * changes — "supersedes accepted ADR(s)" and "C-SPEC Forbidden Edges / Layer Rules changed". Nothing
55
+ * on the test side consumed that verdict. Measured 2026-08-22 on the built dist: a change impacting
56
+ * A-SPEC-224 (the structural-constraint slice itself) got tier `unit`, ONE test file, while
57
+ * `assessRisk` returned `hard-hitl`. The most dangerous change received the narrowest test run.
58
+ *
59
+ * WHY THIS PREDICATE IS COARSER THAN THE RISK AXIS, AND WHY THAT IS RIGHT. The risk axis asks "did
60
+ * the CONSTRAINTS change?" and answers precisely, via `cspecConstraintChanged(current, proposed)`.
61
+ * That needs before-and-after content, which is not available here: the change source is not always
62
+ * git — `change-source.ts` also diffs against a baseline hash tree, where no "before" exists to
63
+ * read. So this asks the weaker question it can actually answer: "was an architecture-governing
64
+ * document touched?" The two are allowed to differ because their cost asymmetries are opposite. A
65
+ * false positive on the risk axis interrupts a human for nothing; a false positive here runs more
66
+ * tests. Widening is this module's standing answer to uncertainty everywhere else, and it is the
67
+ * answer here too.
68
+ *
69
+ * WHAT IT DOES NOT SEE. Whether the edit changed a rule or only its prose. Deletions (`changedFiles`
70
+ * carries added/modified/renamed; `.ax/specs`/`.ax/decisions` deletion is gated separately). Any
71
+ * architecture decision recorded somewhere other than these two locations.
72
+ */
73
+ function architectureGoverningFiles(changedFiles) {
74
+ if (!Array.isArray(changedFiles))
75
+ return [];
76
+ // The C-SPEC folder is READ from the type table, never spelled here. A second spelling of a
77
+ // location is a second thing to keep in step, and this repository has already paid for that
78
+ // (`isTestFile` drifted into two copies; `parseSpec`'s section split was restated and returned an
79
+ // empty section while the check that used it went green).
80
+ const cspecDir = `.ax/specs/${spec_types_1.SPEC_TYPES['C-SPEC'].folder}/`;
81
+ const out = new Set();
82
+ for (const f of changedFiles) {
83
+ if (typeof f !== 'string' || !f.endsWith('.md'))
84
+ continue;
85
+ if (f.startsWith('.ax/decisions/') || f.startsWith(cspecDir))
86
+ out.add(f);
87
+ }
88
+ return [...out].sort();
89
+ }
47
90
  const stripSpecPrefix = (id) => (id.startsWith('SPEC:') ? id.slice('SPEC:'.length) : id);
48
91
  // "Is this a test file?" must be ONE predicate. Two copies had already drifted: the scanner excluded
49
92
  // JVM/.NET/Rust/C++ conventions from the production graph while this side still only recognized
@@ -279,15 +322,62 @@ function computeTestScope(impactedSpecsRaw, testAnchors, specs, coverageGaps = [
279
322
  // incomplete graph is not evidence of no impact — same epistemic state as C3 below, same answer.
280
323
  // Checked FIRST so a contradictory input (empty impact + unresolved present) resolves toward
281
324
  // full, never toward "nothing to re-test".
325
+ const architectureTouched = [...new Set(extras?.architectureTouched ?? [])].sort();
326
+ const obligationGaps = [...new Set(extras?.obligationGaps ?? [])].sort();
282
327
  if (unresolvedFiles.length > 0) {
283
328
  const shown = unresolvedFiles.slice(0, 5).join(', ');
284
329
  const suffix = unresolvedFiles.length > 5 ? ` (${unresolvedFiles.length} unresolved total)` : '';
285
330
  return {
286
331
  tier: 'full', impactedSpecs, impactedTestFiles: selectedTestFiles, blastRadius: 0,
287
- hitsFoundational: false, coverageGaps: [...coverageGaps], unresolvedFiles,
332
+ hitsFoundational: false, coverageGaps: [...coverageGaps], unresolvedFiles, architectureTouched, obligationGaps,
288
333
  reason: `${unresolvedFiles.length} changed source file(s) resolved to no spec — cannot narrow safely, full regression: ${shown}${suffix}`,
289
334
  };
290
335
  }
336
+ // @implements A-SPEC-233
337
+ // This branch RETURNS. That, not its position, is what makes it beat the branches below, which
338
+ // only assign `tier`/`reason` and fall through to the shared return.
339
+ //
340
+ // The first cut of this comment claimed the placement was the contract, and gave a reason that is
341
+ // false: that below `impactedSpecs.length === 0` the signal would vanish. Measured — moving this
342
+ // block to just above the final return left all twelve cases green, because that branch does not
343
+ // return either. A comment asserting a guarantee the code does not make is the exact state this
344
+ // slice's own REQ exists to remove, so it is written as it actually is.
345
+ //
346
+ // The one real ordering relation is with the OTHER early return, `unresolvedFiles`. Both answer
347
+ // `full`, so the tier is never in question between them; what the order decides is which reason
348
+ // is reported, and `unresolvedFiles` wins because it names the files to anchor — an operator can
349
+ // act on that.
350
+ //
351
+ // What carries the weight instead of placement: an architecture document having changed is true
352
+ // regardless of blast, and true even when `impactedSpecs` is empty — the shape of a change that
353
+ // edits ONLY a C-SPEC, which resolves to no impacted spec at all.
354
+ if (architectureTouched.length > 0) {
355
+ const shown = architectureTouched.slice(0, 5).join(', ');
356
+ const suffix = architectureTouched.length > 5 ? ` (${architectureTouched.length} total)` : '';
357
+ return {
358
+ tier: 'full', impactedSpecs, impactedTestFiles: selectedTestFiles, blastRadius: 0,
359
+ hitsFoundational: false, coverageGaps: [...coverageGaps], unresolvedFiles: [], architectureTouched, obligationGaps,
360
+ reason: `architecture-governing document(s) changed — full regression: ${shown}${suffix}`,
361
+ };
362
+ }
363
+ // @implements A-SPEC-235
364
+ // Another early return, so it wins over the assigning branches below for the same reason the last
365
+ // one does. Its position among the OTHER early returns decides only which reason is reported —
366
+ // all three answer `full`. It comes last of the three because the two above signal larger
367
+ // uncertainty: a graph that cannot see a changed file, and an architecture document that moved.
368
+ //
369
+ // The reason names the TAG, not just the spec id. "coverage gap in A-SPEC-235" sends the reader
370
+ // to grep; "A-SPEC-235 [purity]" tells them the case to write.
371
+ if (obligationGaps.length > 0) {
372
+ const shown = obligationGaps.slice(0, 5).join(', ');
373
+ const suffix = obligationGaps.length > 5 ? ` (${obligationGaps.length} total)` : '';
374
+ return {
375
+ tier: 'full', impactedSpecs, impactedTestFiles: selectedTestFiles, blastRadius: 0,
376
+ hitsFoundational: false, coverageGaps: [...coverageGaps], unresolvedFiles: [],
377
+ architectureTouched, obligationGaps,
378
+ reason: `declared obligation(s) with no test — full regression: ${shown}${suffix}`,
379
+ };
380
+ }
291
381
  // Tier on the DIRECTLY-impacted A-SPECs (what the changed code implements), NOT the whole impacted
292
382
  // set — impactedBy always returns the ancestor chain up to the REQ root, so keying on it made every
293
383
  // change "reach a foundational REQ" → always full (verification H4). Downstream blast of the direct
@@ -332,5 +422,5 @@ function computeTestScope(impactedSpecsRaw, testAnchors, specs, coverageGaps = [
332
422
  tier = 'unit';
333
423
  reason = `localized impact (blast=${blastRadius}) — run the ${impactedTestFiles.length} impacted test file(s)`;
334
424
  }
335
- return { tier, impactedSpecs, impactedTestFiles: selectedTestFiles, blastRadius, hitsFoundational, coverageGaps: [...coverageGaps], reason, unresolvedFiles: [] };
425
+ return { tier, impactedSpecs, impactedTestFiles: selectedTestFiles, blastRadius, hitsFoundational, coverageGaps: [...coverageGaps], reason, unresolvedFiles: [], architectureTouched, obligationGaps };
336
426
  }
@@ -70,6 +70,28 @@ export interface PipelineRow {
70
70
  mutationScoreMeasured: false;
71
71
  }
72
72
  export declare function isCanonicalStatus(status: unknown): boolean;
73
+ /**
74
+ * The kinds that are governed specs at all.
75
+ *
76
+ * @implements A-SPEC-219.1
77
+ * Held here rather than imported from SPEC_TYPES because REQ is a spec kind that SPEC_TYPES does
78
+ * list — the distinction this set draws is "document in the spec store that the pipeline governs"
79
+ * versus "document that lives there but never will", and JOB is the whole of the second group.
80
+ */
81
+ export declare const SPEC_KINDS: Set<string>;
82
+ /**
83
+ * Drop retired documents before anything reads the corpus.
84
+ *
85
+ * @implements A-SPEC-222.2
86
+ * A-SPEC-222.1 excluded `outdated` from `isGoverned` and claimed every other check would follow.
87
+ * `tspec-mirror.test.ts` refuted that, and the spec was corrected to name one known site. Measured
88
+ * 2026-08-22 it was three: /api/rtm exposed 132 retired ids, the heatmap 108, the graph 132 — a
89
+ * screen still pointing at documents retired the day before. Stated once here so the three sites
90
+ * cannot drift, with the suite enumerating the endpoints so a fourth cannot be added silently.
91
+ */
92
+ export declare function activeSpecs<T extends {
93
+ status?: unknown;
94
+ }>(specs: T[]): T[];
73
95
  /** `A-SPEC-219.1` -> `A-SPEC`, `REQ-021` -> `REQ`. */
74
96
  export declare function specKindOf(id: string): string;
75
97
  /**
@@ -33,12 +33,15 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.SPEC_KINDS = void 0;
36
37
  exports.startDashboardServer = startDashboardServer;
37
38
  exports.isCanonicalStatus = isCanonicalStatus;
39
+ exports.activeSpecs = activeSpecs;
38
40
  exports.specKindOf = specKindOf;
39
41
  exports.buildPipelineRows = buildPipelineRows;
40
42
  // @implements A-SPEC-215
41
43
  // @implements A-SPEC-219
44
+ // @implements A-SPEC-222.2
42
45
  const fs = __importStar(require("node:fs"));
43
46
  const http = __importStar(require("node:http"));
44
47
  const path = __importStar(require("node:path"));
@@ -77,7 +80,9 @@ async function startDashboardServer(options) {
77
80
  }
78
81
  if (parsedUrl === '/api/rtm') {
79
82
  try {
80
- const specs = await store.list();
83
+ const allSpecs = await store.list();
84
+ const retiredSpecs = allSpecs.filter((s) => s.status === 'outdated');
85
+ const specs = activeSpecs(allSpecs);
81
86
  const files = scanner.scan(root);
82
87
  const implementedSpecIds = new Set();
83
88
  for (const f of files) {
@@ -93,16 +98,29 @@ async function startDashboardServer(options) {
93
98
  // anchored to it) answer different questions, and a spec whose status is outside the
94
99
  // canonical vocabulary answers neither — it is excluded and counted separately instead of
95
100
  // being silently scored 0, which read as "47 uncovered specs" when it meant "47 unmapped".
96
- const canonical = enrichedSpecs.filter((s) => !s.legacyStatus);
101
+ const canonical = enrichedSpecs.filter((s) => !s.legacyStatus && exports.SPEC_KINDS.has(specKindOf(s.id)));
97
102
  const approvedCount = canonical.filter((s) => s.status === 'approved').length;
98
103
  const anchorable = enrichedSpecs.filter((s) => s.id.startsWith('A-SPEC') && !s.legacyStatus);
99
104
  const anchoredCount = anchorable.filter((s) => s.covered).length;
100
- const legacySpecs = enrichedSpecs.filter((s) => s.legacyStatus);
101
- const legacyByKind = {};
102
- for (const s of legacySpecs) {
103
- const kind = specKindOf(s.id);
104
- legacyByKind[kind] = (legacyByKind[kind] || 0) + 1;
105
- }
105
+ // @implements A-SPEC-219.1
106
+ // Two populations sit outside the axes, and merging them into one number misleads: a reader
107
+ // of "legacy 49" concludes 49 documents need cleaning up, when 23 of them will never become
108
+ // specs no matter how long anyone waits. The population that prompted this split was
109
+ // `06_job/JOB-*`, work orders carrying an `assignee`; those were deleted under REQ-223
110
+ // once measurement showed all 23 pointed at retired REQs and at 46 source paths of which
111
+ // none still existed. The split stays because it is the guard: the next stray document
112
+ // lands here instead of in the cleanup backlog.
113
+ const outside = enrichedSpecs.filter((s) => s.legacyStatus || !exports.SPEC_KINDS.has(specKindOf(s.id)));
114
+ const unmappedSpecs = outside.filter((s) => exports.SPEC_KINDS.has(specKindOf(s.id)));
115
+ const nonSpecDocs = outside.filter((s) => !exports.SPEC_KINDS.has(specKindOf(s.id)));
116
+ const tally = (rows) => {
117
+ const out = {};
118
+ for (const s of rows) {
119
+ const kind = specKindOf(s.id);
120
+ out[kind] = (out[kind] || 0) + 1;
121
+ }
122
+ return out;
123
+ };
106
124
  const approval = {
107
125
  total: canonical.length,
108
126
  approved: approvedCount,
@@ -113,7 +131,18 @@ async function startDashboardServer(options) {
113
131
  anchored: anchoredCount,
114
132
  pct: anchorable.length > 0 ? Math.round((anchoredCount / anchorable.length) * 100) : 0,
115
133
  };
116
- const legacy = { count: legacySpecs.length, byKind: legacyByKind };
134
+ // Reported, never silently dropped: without this the 132 excluded documents vanish and the
135
+ // screen cannot say where they went.
136
+ const retiredByKind = {};
137
+ for (const s of retiredSpecs) {
138
+ const kind = specKindOf(s.id);
139
+ retiredByKind[kind] = (retiredByKind[kind] || 0) + 1;
140
+ }
141
+ // Count and kinds only. Shipping the ids would put retired documents back into the very
142
+ // response this slice removes them from, and nothing on the screen renders them.
143
+ const retired = { count: retiredSpecs.length, byKind: retiredByKind };
144
+ const unmapped = { count: unmappedSpecs.length, byKind: tally(unmappedSpecs) };
145
+ const nonSpec = { count: nonSpecDocs.length, byKind: tally(nonSpecDocs) };
117
146
  const coveredCount = enrichedSpecs.filter((s) => s.covered).length;
118
147
  const totalCount = enrichedSpecs.length;
119
148
  const body = JSON.stringify({
@@ -122,7 +151,9 @@ async function startDashboardServer(options) {
122
151
  coveredCount,
123
152
  approval,
124
153
  implementation,
125
- legacy,
154
+ unmapped,
155
+ nonSpec,
156
+ retired,
126
157
  specs: enrichedSpecs,
127
158
  files,
128
159
  });
@@ -140,7 +171,8 @@ async function startDashboardServer(options) {
140
171
  // @implements A-SPEC-219
141
172
  if (parsedUrl === '/api/rtm/heatmap') {
142
173
  try {
143
- const specs = await store.list();
174
+ // @implements A-SPEC-222.2 retired documents never reach a rendered view.
175
+ const specs = activeSpecs(await store.list());
144
176
  const files = scanner.scan(root);
145
177
  // FindingsLedger.list() returns [] for a missing file, which is indistinguishable from a
146
178
  // clean scan. The dashboard therefore asks the filesystem directly: an audit lens that
@@ -188,7 +220,8 @@ async function startDashboardServer(options) {
188
220
  // @implements A-SPEC-219
189
221
  if (parsedUrl === '/api/graph') {
190
222
  try {
191
- const specs = await store.list();
223
+ // @implements A-SPEC-222.2 retired documents never reach a rendered view.
224
+ const specs = activeSpecs(await store.list());
192
225
  const files = scanner.scan(root);
193
226
  const nodes = [];
194
227
  const edges = [];
@@ -472,6 +505,8 @@ function renderDashboardHtml() {
472
505
  .axis-stat { display: inline-flex; align-items: center; gap: 6px; }
473
506
  .legacy-chip { cursor: pointer; border: 1px dashed #64748b; border-radius: 999px; padding: 4px 10px; }
474
507
  .legacy-chip:hover { border-color: #f59e0b; color: #f59e0b; }
508
+ .nonspec-chip { cursor: pointer; border: 1px solid #334155; border-radius: 999px; padding: 4px 10px; color: #64748b; }
509
+ .nonspec-chip:hover { border-color: #94a3b8; color: #94a3b8; }
475
510
  .scope-notice { font-size: 12px; color: #94a3b8; background: #0f172a; border: 1px solid #334155; border-radius: 8px; padding: 8px 12px; margin-bottom: 10px; }
476
511
  .scope-notice-truncated { color: #fbbf24; border-color: #b45309; border-style: dashed; }
477
512
  .stage-missing { color: #64748b; font-style: italic; cursor: default; background: repeating-linear-gradient(45deg, rgba(100,116,139,0.06) 0 6px, transparent 6px 12px); }
@@ -500,7 +535,8 @@ function renderDashboardHtml() {
500
535
  <div class="axis-stats">
501
536
  <span class="axis-stat" title="Specs signed off, over specs using the canonical status vocabulary">📋 Approval <span id="approvalStat" class="stat-badge">--%</span></span>
502
537
  <span class="axis-stat" title="A-SPECs with code anchored to them, over canonical A-SPECs">⚙️ Implementation <span id="implStat" class="stat-badge">--%</span></span>
503
- <span class="axis-stat legacy-chip" id="legacyChip" title="Specs whose status is outside draft|review|approved|outdated — excluded from both axes, not scored zero" onclick="switchTab('legacy')">🏷️ <span id="legacyStat">--</span></span>
538
+ <span class="axis-stat legacy-chip" id="unmappedChip" title="Spec documents whose status is outside draft|review|approved|outdated — these are the cleanup backlog" onclick="switchTab('legacy')">🏷️ <span id="unmappedStat">--</span></span>
539
+ <span class="axis-stat nonspec-chip" id="nonSpecChip" title="Documents in the spec store that are not a governed spec kind — never a cleanup target" onclick="switchTab('legacy')">📋 <span id="nonSpecStat">--</span></span>
504
540
  </div>
505
541
  </div>
506
542
 
@@ -520,10 +556,17 @@ function renderDashboardHtml() {
520
556
  </div>
521
557
 
522
558
  <div id="legacyTab" style="display: none;">
523
- <div class="ux-hint-banner" style="border-left-color: #64748b;">
524
- <span>🏷️ <strong>Unmapped Specs:</strong> these documents use a status vocabulary outside <code>draft | review | approved | outdated</code>, so no governance gate has scored them. They are <strong>excluded</strong> from both headline axes — an unmapped vocabulary is not the same as an uncovered spec.</span>
559
+ <div class="ux-hint-banner" style="border-left-color: #f59e0b;">
560
+ <span>🏷️ <strong>Cleanup backlog:</strong> spec documents whose <code>status</code> falls outside <code>draft | review | approved | outdated</code>, so no governance gate has scored them. Each is waiting for <code>spec_upgrade</code> → <code>spec_retire</code>, or a rewrite into the current format. Excluded from both headline axes — an unmapped vocabulary is not an uncovered spec.</span>
561
+ </div>
562
+ <div id="unmappedGrid" class="rtm-container">Loading…</div>
563
+
564
+ <div id="nonSpecSection" style="display: none;">
565
+ <div class="ux-hint-banner" style="border-left-color: #334155; margin-top: 28px;">
566
+ <span>📋 <strong>Not specifications:</strong> documents that live in the spec store but are not a governed spec kind. They are <strong>not</strong> a cleanup target and never will be, and they have always sat outside both axes. Nothing should normally appear here — <code>spec-store-boundary.test.ts</code> fails the build when it does.</span>
567
+ </div>
568
+ <div id="nonSpecGrid" class="rtm-container"></div>
525
569
  </div>
526
- <div id="legacyGrid" class="rtm-container">Loading unmapped specifications...</div>
527
570
  </div>
528
571
 
529
572
  <div id="heatmapTab" style="display: none;">
@@ -682,12 +725,32 @@ function renderDashboardHtml() {
682
725
 
683
726
  // Specs excluded from both axes get their own surface. Previously 47 documents were invisible
684
727
  // on every tab while still sitting in the headline denominator, which read as 0% coverage.
685
- function renderLegacySpecGrid(specs) {
686
- const container = document.getElementById('legacyGrid');
728
+ // @implements A-SPEC-219.1 — two populations, two destinations. Summing them is what misled:
729
+ // "49" read as "49 to clean up" while 23 of them will never become specs.
730
+ const SPEC_KINDS_UI = ['REQ', 'H-SPEC', 'A-SPEC', 'C-SPEC', 'T-SPEC'];
731
+ const kindOfId = (id) => (String(id).match(/^([A-Za-z-]+?)-\d/) || [null, 'UNKNOWN'])[1];
732
+
733
+ function renderUnmappedGrid(specs) {
734
+ const outside = specs.filter(s => s.legacyStatus || SPEC_KINDS_UI.indexOf(kindOfId(s.id)) === -1);
735
+ renderDocGroup(
736
+ document.getElementById('unmappedGrid'),
737
+ outside.filter(s => SPEC_KINDS_UI.indexOf(kindOfId(s.id)) !== -1),
738
+ 'Every spec uses the canonical status vocabulary.',
739
+ );
740
+ // @implements A-SPEC-223 — an absent category is not explained. T-SPEC-219.1 asked for this
741
+ // as a boundary case and it was never implemented; the store is now empty of such documents,
742
+ // so the banner would describe something that does not exist.
743
+ const nonSpec = outside.filter(s => SPEC_KINDS_UI.indexOf(kindOfId(s.id)) === -1);
744
+ const section = document.getElementById('nonSpecSection');
745
+ if (section) section.style.display = nonSpec.length > 0 ? 'block' : 'none';
746
+ renderDocGroup(document.getElementById('nonSpecGrid'), nonSpec, '');
747
+ }
748
+
749
+ function renderDocGroup(container, rows, emptyMessage) {
687
750
  if (!container) return;
688
- const legacy = specs.filter(s => s.legacyStatus);
751
+ const legacy = rows;
689
752
  if (legacy.length === 0) {
690
- container.innerHTML = '<div style="color:#94a3b8; font-size:14px;">Every spec uses the canonical status vocabulary.</div>';
753
+ container.innerHTML = '<div style="color:#94a3b8; font-size:14px;">' + emptyMessage + '</div>';
691
754
  return;
692
755
  }
693
756
  const byKind = new Map();
@@ -744,12 +807,17 @@ function renderDashboardHtml() {
744
807
  allFiles = data.files || [];
745
808
  const ap = data.approval || { pct: 0, approved: 0, total: 0 };
746
809
  const im = data.implementation || { pct: 0, anchored: 0, total: 0 };
747
- const lg = data.legacy || { count: 0, byKind: {} };
810
+ const um = data.unmapped || { count: 0, byKind: {} };
811
+ const ns = data.nonSpec || { count: 0, byKind: {} };
748
812
  document.getElementById('approvalStat').innerText = ap.pct + '% (' + ap.approved + '/' + ap.total + ')';
749
813
  document.getElementById('implStat').innerText = im.pct + '% (' + im.anchored + '/' + im.total + ')';
750
- const kinds = Object.keys(lg.byKind || {}).map(k => k + ' ' + lg.byKind[k]).join(' · ');
751
- document.getElementById('legacyStat').innerText = lg.count + ' unmapped' + (kinds ? ' (' + kinds + ')' : '');
752
- renderLegacySpecGrid(data.specs || []);
814
+ const umKinds = Object.keys(um.byKind || {}).map(k => k + ' ' + um.byKind[k]).join(' · ');
815
+ const nsKinds = Object.keys(ns.byKind || {}).map(k => k + ' ' + ns.byKind[k]).join(' · ');
816
+ document.getElementById('unmappedStat').innerText = um.count + ' to clean up' + (umKinds ? ' (' + umKinds + ')' : '');
817
+ const nsChip = document.getElementById('nonSpecChip');
818
+ if (nsChip) nsChip.style.display = ns.count > 0 ? 'inline-flex' : 'none';
819
+ document.getElementById('nonSpecStat').innerText = ns.count + ' not specs' + (nsKinds ? ' (' + nsKinds + ')' : '');
820
+ renderUnmappedGrid(data.specs || []);
753
821
  renderReqQuantitativeGrid(allSpecs, allFiles);
754
822
  }
755
823
  } catch (err) {
@@ -2057,6 +2125,28 @@ const CANONICAL_STATUSES = new Set(['draft', 'review', 'approved', 'outdated']);
2057
2125
  function isCanonicalStatus(status) {
2058
2126
  return typeof status === 'string' && CANONICAL_STATUSES.has(status);
2059
2127
  }
2128
+ /**
2129
+ * The kinds that are governed specs at all.
2130
+ *
2131
+ * @implements A-SPEC-219.1
2132
+ * Held here rather than imported from SPEC_TYPES because REQ is a spec kind that SPEC_TYPES does
2133
+ * list — the distinction this set draws is "document in the spec store that the pipeline governs"
2134
+ * versus "document that lives there but never will", and JOB is the whole of the second group.
2135
+ */
2136
+ exports.SPEC_KINDS = new Set(['REQ', 'H-SPEC', 'A-SPEC', 'C-SPEC', 'T-SPEC']);
2137
+ /**
2138
+ * Drop retired documents before anything reads the corpus.
2139
+ *
2140
+ * @implements A-SPEC-222.2
2141
+ * A-SPEC-222.1 excluded `outdated` from `isGoverned` and claimed every other check would follow.
2142
+ * `tspec-mirror.test.ts` refuted that, and the spec was corrected to name one known site. Measured
2143
+ * 2026-08-22 it was three: /api/rtm exposed 132 retired ids, the heatmap 108, the graph 132 — a
2144
+ * screen still pointing at documents retired the day before. Stated once here so the three sites
2145
+ * cannot drift, with the suite enumerating the endpoints so a fourth cannot be added silently.
2146
+ */
2147
+ function activeSpecs(specs) {
2148
+ return specs.filter((s) => s.status !== 'outdated');
2149
+ }
2060
2150
  /** `A-SPEC-219.1` -> `A-SPEC`, `REQ-021` -> `REQ`. */
2061
2151
  function specKindOf(id) {
2062
2152
  const m = /^([A-Za-z-]+?)-\d/.exec(String(id ?? ''));
@@ -2,12 +2,28 @@ import { Spec } from './spec-parser';
2
2
  /**
3
3
  * Sections that still hold nothing but a generated placeholder.
4
4
  *
5
- * Two generators plant placeholders and both are judged here:
6
- * - `reverse_draft` writes `TODO — <prompt>` plus the INFERRED marker comment. Both are
7
- * required together: 11 of this repository's 257 approved specs use the word "TODO" in
8
- * legitimate prose ("남아 있는 TODO 주석을 제거한다"), and a word search refuses every one.
5
+ * Three shapes are judged, and the last two are a UNION rather than a replacement:
9
6
  * - `spec_create` writes the bare literal `TODO` (`FIELD_PLACEHOLDER`) into each required section.
10
- * That one is unambiguous on its own — an entire section equal to the string is never prose.
7
+ * Unambiguous on its own — an entire section equal to that string is never prose.
8
+ * - `reverse_draft` writes `TODO — <prompt>` plus the INFERRED marker. Requiring both together is
9
+ * what lets a section that is FILLED with scan evidence and still carries a TODO count as
10
+ * unfinished (`evidenceThenTodo`, the main brownfield shape REQ-181 created).
11
+ * - A `TODO` line with nothing but furniture around it, marker or no marker.
12
+ *
13
+ * @implements A-SPEC-240
14
+ * WHY THE THIRD SHAPE EXISTS. Measured 2026-08-23 against the installed package: an H-SPEC whose
15
+ * eight prose sections all read `TODO — 사람이 쓸 것` — title and owner placeholders too — received
16
+ * `status: approved` and an `approved_digest`. The marker was absent, so nothing looked. `INFERRED`
17
+ * is an HTML comment: invisible when rendered, and easy to strip while editing the section around
18
+ * it. Keying the judgement on it means the protection can vanish silently.
19
+ *
20
+ * WHY IT IS A UNION AND NOT A REPLACEMENT. The first cut replaced the marker rule with the emptiness
21
+ * rule, and `evidenceThenTodo` walked through it — a file list reads as substance. Measured across
22
+ * 482 approved specs the two rules disagree on nothing, so the union costs no false refusals while
23
+ * each covers what the other cannot.
24
+ *
25
+ * The word "TODO" in ordinary prose is still safe: 37 approved sections use it that way
26
+ * ("남아 있는 TODO 주석을 제거한다"), and every one carries real content beside it.
11
27
  */
12
28
  export declare function placeholderSections(spec: Spec): string[];
13
29
  /**
@@ -79,15 +79,56 @@ const stripFences = (s) => {
79
79
  * emptiness matters most.
80
80
  */
81
81
  const TODO_LINE = /^TODO\s*[—-]/m;
82
+ /**
83
+ * Furniture a generator writes around the prose a human still owes.
84
+ *
85
+ * @implements A-SPEC-240
86
+ * Deliberately a closed, short list: an HTML comment (the markers), the reverse-engineering note,
87
+ * and the prompt line itself. Lengthening it widens what counts as "empty", which is a decision to
88
+ * refuse more documents — not one to make without measuring.
89
+ */
90
+ const FURNITURE = [/^TODO\s*[—-]/, /^NOTE: this spec was REVERSE-ENGINEERED/];
91
+ /**
92
+ * Is there anything left once the generator's furniture is removed?
93
+ *
94
+ * @implements A-SPEC-240
95
+ * A `TODO` line is the precondition for asking at all — see `placeholderSections`. What counts as
96
+ * adequate prose is a question a machine cannot answer, so a short but genuine section is left
97
+ * alone; only a section that is nothing BUT scaffolding is judged empty.
98
+ */
99
+ function hasNoSubstance(text) {
100
+ const rest = stripFences(text)
101
+ .replace(/<!--[\s\S]*?-->/g, '')
102
+ .split('\n')
103
+ .map((l) => l.trim())
104
+ .filter((l) => l !== '' && !FURNITURE.some((re) => re.test(l)));
105
+ return rest.length === 0;
106
+ }
82
107
  /**
83
108
  * Sections that still hold nothing but a generated placeholder.
84
109
  *
85
- * Two generators plant placeholders and both are judged here:
86
- * - `reverse_draft` writes `TODO — <prompt>` plus the INFERRED marker comment. Both are
87
- * required together: 11 of this repository's 257 approved specs use the word "TODO" in
88
- * legitimate prose ("남아 있는 TODO 주석을 제거한다"), and a word search refuses every one.
110
+ * Three shapes are judged, and the last two are a UNION rather than a replacement:
89
111
  * - `spec_create` writes the bare literal `TODO` (`FIELD_PLACEHOLDER`) into each required section.
90
- * That one is unambiguous on its own — an entire section equal to the string is never prose.
112
+ * Unambiguous on its own — an entire section equal to that string is never prose.
113
+ * - `reverse_draft` writes `TODO — <prompt>` plus the INFERRED marker. Requiring both together is
114
+ * what lets a section that is FILLED with scan evidence and still carries a TODO count as
115
+ * unfinished (`evidenceThenTodo`, the main brownfield shape REQ-181 created).
116
+ * - A `TODO` line with nothing but furniture around it, marker or no marker.
117
+ *
118
+ * @implements A-SPEC-240
119
+ * WHY THE THIRD SHAPE EXISTS. Measured 2026-08-23 against the installed package: an H-SPEC whose
120
+ * eight prose sections all read `TODO — 사람이 쓸 것` — title and owner placeholders too — received
121
+ * `status: approved` and an `approved_digest`. The marker was absent, so nothing looked. `INFERRED`
122
+ * is an HTML comment: invisible when rendered, and easy to strip while editing the section around
123
+ * it. Keying the judgement on it means the protection can vanish silently.
124
+ *
125
+ * WHY IT IS A UNION AND NOT A REPLACEMENT. The first cut replaced the marker rule with the emptiness
126
+ * rule, and `evidenceThenTodo` walked through it — a file list reads as substance. Measured across
127
+ * 482 approved specs the two rules disagree on nothing, so the union costs no false refusals while
128
+ * each covers what the other cannot.
129
+ *
130
+ * The word "TODO" in ordinary prose is still safe: 37 approved sections use it that way
131
+ * ("남아 있는 TODO 주석을 제거한다"), and every one carries real content beside it.
91
132
  */
92
133
  function placeholderSections(spec) {
93
134
  const out = [];
@@ -98,7 +139,9 @@ function placeholderSections(spec) {
98
139
  continue;
99
140
  }
100
141
  const prose = stripFences(text);
101
- if (TODO_LINE.test(prose) && prose.includes(draft_1.INFERRED))
142
+ if (!TODO_LINE.test(prose))
143
+ continue;
144
+ if (prose.includes(draft_1.INFERRED) || hasNoSubstance(text))
102
145
  out.push(name);
103
146
  }
104
147
  return out;
@@ -35,6 +35,20 @@ export type FormatState =
35
35
  };
36
36
  /** Frontmatter key that preserves the pre-upgrade status. Named once so writer and reader agree. */
37
37
  export declare const LEGACY_STATUS_FIELD = "legacy_status";
38
+ /**
39
+ * Where a legacy `depends_on` string is kept verbatim.
40
+ *
41
+ * @implements A-SPEC-221
42
+ * The old format wrote the parent as `REQ-001:<hash>`. The parser only understood arrays, so the
43
+ * whole value became `[]` — measured on H-SPEC-001, the upgrade destroyed the parent edge and then
44
+ * reported `orphan: H-SPEC requires a parent of type REQ` about the edge it had just removed.
45
+ *
46
+ * The digest is preserved rather than promoted. Writing it into `parent_digests` would assert that
47
+ * a `version_token`-era value is an approval digest, and there is no evidence the two are the same
48
+ * computation — asserting it anyway is the fabrication this repository keeps removing. Verbatim
49
+ * preservation leaves the judgement to a person who can actually make it.
50
+ */
51
+ export declare const LEGACY_DEPENDS_FIELD = "legacy_depends_on";
38
52
  /**
39
53
  * Classify a document by FORMAT, not by validity.
40
54
  *
@@ -1,6 +1,6 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.LEGACY_STATUS_FIELD = void 0;
3
+ exports.LEGACY_DEPENDS_FIELD = exports.LEGACY_STATUS_FIELD = void 0;
4
4
  exports.formatStateOf = formatStateOf;
5
5
  exports.legacyMessage = legacyMessage;
6
6
  exports.upgradePlan = upgradePlan;
@@ -8,6 +8,20 @@ exports.upgradedFrontmatter = upgradedFrontmatter;
8
8
  const spec_types_1 = require("./spec-types");
9
9
  /** Frontmatter key that preserves the pre-upgrade status. Named once so writer and reader agree. */
10
10
  exports.LEGACY_STATUS_FIELD = 'legacy_status';
11
+ /**
12
+ * Where a legacy `depends_on` string is kept verbatim.
13
+ *
14
+ * @implements A-SPEC-221
15
+ * The old format wrote the parent as `REQ-001:<hash>`. The parser only understood arrays, so the
16
+ * whole value became `[]` — measured on H-SPEC-001, the upgrade destroyed the parent edge and then
17
+ * reported `orphan: H-SPEC requires a parent of type REQ` about the edge it had just removed.
18
+ *
19
+ * The digest is preserved rather than promoted. Writing it into `parent_digests` would assert that
20
+ * a `version_token`-era value is an approval digest, and there is no evidence the two are the same
21
+ * computation — asserting it anyway is the fabrication this repository keeps removing. Verbatim
22
+ * preservation leaves the judgement to a person who can actually make it.
23
+ */
24
+ exports.LEGACY_DEPENDS_FIELD = 'legacy_depends_on';
11
25
  /**
12
26
  * Classify a document by FORMAT, not by validity.
13
27
  *
@@ -0,0 +1,70 @@
1
+ /**
2
+ * One non-functional obligation, read from an H-SPEC's `## Non-Functional`.
3
+ *
4
+ * @implements A-SPEC-234
5
+ * WHY THIS EXISTS. `## Non-Functional` is a REQUIRED section — measured 2026-08-22, all 105 approved
6
+ * H-SPECs in this repository have one filled in. Nothing read it. The T-SPEC approval gate asks only
7
+ * for the four input quadrants (Normal/Corner/Negative/Boundary), which are an input-selection
8
+ * taxonomy: equivalence partitioning, boundary values, negative cases. Concurrency, resource
9
+ * exhaustion and partial failure are not kinds of INPUT but kinds of EXECUTION CONDITION, so they
10
+ * never belonged to that question and no gate ever asked about them.
11
+ *
12
+ * The cost is on record in this session: an H-SPEC declared a cost obligation, its T-SPEC was green
13
+ * on all four quadrants, and 10,558ms shipped for a 2KB file.
14
+ */
15
+ export interface NonFunctionalObligation {
16
+ /** Author-chosen, lower-cased. The vocabulary is NOT fixed — see `parseNonFunctional`. */
17
+ tag: string;
18
+ /** The obligation as written, minus the tag. */
19
+ text: string;
20
+ /** The line as written, so a report can quote it. */
21
+ line: string;
22
+ }
23
+ /**
24
+ * Read the obligations out of a `## Non-Functional` section.
25
+ *
26
+ * @implements A-SPEC-234
27
+ * A malformed list item is RETURNED, never dropped. A typo that silently parses to nothing reads as
28
+ * "the rule passed" — the shape this repository has paid for in A-SPEC-191 §17, §29 and again in
29
+ * A-SPEC-222.2. Prose and blank lines are neither: a section is allowed to explain itself.
30
+ *
31
+ * THE TAG VOCABULARY IS NOT FIXED, AND THAT IS THE POINT. A closed set of allowed tags becomes a
32
+ * thing to maintain and then falls behind — the enumeration-chasing this repository already rejected
33
+ * for tool names (`write-target.ts`). Whatever the author writes IS the vocabulary; the machine
34
+ * judges CORRESPONDENCE between an H-SPEC tag and a T-SPEC tag, never meaning.
35
+ *
36
+ * WHAT THIS DOES NOT SEE. Whether the obligation is a good one. Whether the case tagged with it
37
+ * actually exercises it — a case reading `- [perf] Given nothing When nothing Then nothing` counts
38
+ * as met. Obligations written as prose without a tag (the other 105 documents). Those are human
39
+ * judgements, and simulating them would recreate the "documented but unenforced" state this slice
40
+ * exists to remove.
41
+ */
42
+ export declare function parseNonFunctional(section: unknown): {
43
+ obligations: NonFunctionalObligation[];
44
+ malformed: string[];
45
+ };
46
+ /**
47
+ * The obligation tags a T-SPEC's CASES carry.
48
+ *
49
+ * @implements A-SPEC-234
50
+ * Only the four case sections count, and the section names are read from the type table rather than
51
+ * spelled here — a second spelling of a location is a second thing to keep in step, and this
52
+ * repository has already paid for that twice (`isTestFile` drifted into two copies; a restated
53
+ * section-splitting regex returned an empty section while the check using it went green).
54
+ *
55
+ * A tag anywhere else in the document states an INTENTION. Only a case is evidence that something
56
+ * was tested.
57
+ */
58
+ export declare function caseObligationTags(sections: unknown): Set<string>;
59
+ /**
60
+ * Obligations an H-SPEC declares that no child T-SPEC case answers.
61
+ *
62
+ * @implements A-SPEC-234
63
+ * Matching is EXACT after lower-casing. A prefix match would let `perf` be satisfied by
64
+ * `performance`, and a false "met" is a gate switched off that says nothing — the same asymmetry
65
+ * that made `call` targets exact in `forbidden-edges.ts`.
66
+ *
67
+ * `malformed` lines are NOT reported here. Flagging syntax errors and judging fulfilment are
68
+ * different questions; mixed together, one typo invents an obligation nobody declared.
69
+ */
70
+ export declare function unmetObligations(nonFunctional: unknown, tspecSections: unknown[]): NonFunctionalObligation[];