@holmes-lab/holmes-kit 0.1.8 → 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 +77 -0
  37. package/dist/holmes/server/dashboard.js +703 -183
  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
@@ -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[];
@@ -0,0 +1,119 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.parseNonFunctional = parseNonFunctional;
4
+ exports.caseObligationTags = caseObligationTags;
5
+ exports.unmetObligations = unmetObligations;
6
+ // @implements A-SPEC-234
7
+ const spec_types_1 = require("./spec-types");
8
+ /**
9
+ * `- [tag] text` — the whole grammar.
10
+ *
11
+ * WHY THIS SPELLING. Measured 2026-08-22 across the approved corpus: a leading `- [tag]` appears in
12
+ * 0 of the 174 `## Non-Functional` lines and 0 of the 1,511 T-SPEC case lines. The obvious
13
+ * alternative, `- name: text`, already appears 21 times (13%) — adopting it would have invented 21
14
+ * false obligations on the day it shipped, breaking the rule this repository keeps: the first rule
15
+ * applied must be one that is ALREADY TRUE.
16
+ *
17
+ * The tag is only recognised at the START of a line. Eleven approved T-SPEC case lines contain
18
+ * brackets mid-prose (`[f1]`, `[V]`, `[REQ-007]`) and every one is a code fragment; counting those
19
+ * would invent coverage nobody wrote.
20
+ */
21
+ const OBLIGATION = /^\[([A-Za-z][A-Za-z0-9-]*)\]\s+(\S.*)$/;
22
+ /** A line that reaches for the grammar: `- [` … and then gets it wrong. */
23
+ const IMITATES = /^\[/;
24
+ const clean = (s) => s.replace(/`/g, '').trim().replace(/\s+/g, ' ');
25
+ /**
26
+ * Read the obligations out of a `## Non-Functional` section.
27
+ *
28
+ * @implements A-SPEC-234
29
+ * A malformed list item is RETURNED, never dropped. A typo that silently parses to nothing reads as
30
+ * "the rule passed" — the shape this repository has paid for in A-SPEC-191 §17, §29 and again in
31
+ * A-SPEC-222.2. Prose and blank lines are neither: a section is allowed to explain itself.
32
+ *
33
+ * THE TAG VOCABULARY IS NOT FIXED, AND THAT IS THE POINT. A closed set of allowed tags becomes a
34
+ * thing to maintain and then falls behind — the enumeration-chasing this repository already rejected
35
+ * for tool names (`write-target.ts`). Whatever the author writes IS the vocabulary; the machine
36
+ * judges CORRESPONDENCE between an H-SPEC tag and a T-SPEC tag, never meaning.
37
+ *
38
+ * WHAT THIS DOES NOT SEE. Whether the obligation is a good one. Whether the case tagged with it
39
+ * actually exercises it — a case reading `- [perf] Given nothing When nothing Then nothing` counts
40
+ * as met. Obligations written as prose without a tag (the other 105 documents). Those are human
41
+ * judgements, and simulating them would recreate the "documented but unenforced" state this slice
42
+ * exists to remove.
43
+ */
44
+ function parseNonFunctional(section) {
45
+ const obligations = [];
46
+ const malformed = [];
47
+ if (typeof section !== 'string')
48
+ return { obligations, malformed };
49
+ for (const raw of section.split('\n')) {
50
+ const line = raw.trim();
51
+ if (!line.startsWith('- '))
52
+ continue; // prose, blank, or a non-list line
53
+ const body = clean(line.slice(2));
54
+ const m = OBLIGATION.exec(body);
55
+ if (m) {
56
+ obligations.push({ tag: m[1].toLowerCase(), text: m[2].trim(), line });
57
+ continue;
58
+ }
59
+ // Only lines that REACH for the grammar are errors. Every other list item is ordinary prose and
60
+ // reporting it would bury the real typos in noise.
61
+ if (IMITATES.test(body))
62
+ malformed.push(line);
63
+ }
64
+ return { obligations, malformed };
65
+ }
66
+ /**
67
+ * The obligation tags a T-SPEC's CASES carry.
68
+ *
69
+ * @implements A-SPEC-234
70
+ * Only the four case sections count, and the section names are read from the type table rather than
71
+ * spelled here — a second spelling of a location is a second thing to keep in step, and this
72
+ * repository has already paid for that twice (`isTestFile` drifted into two copies; a restated
73
+ * section-splitting regex returned an empty section while the check using it went green).
74
+ *
75
+ * A tag anywhere else in the document states an INTENTION. Only a case is evidence that something
76
+ * was tested.
77
+ */
78
+ function caseObligationTags(sections) {
79
+ const tags = new Set();
80
+ if (!sections || typeof sections !== 'object')
81
+ return tags;
82
+ const map = sections;
83
+ for (const name of spec_types_1.SPEC_TYPES['T-SPEC'].requiredSections) {
84
+ const body = map[name];
85
+ if (typeof body !== 'string')
86
+ continue;
87
+ for (const raw of body.split('\n')) {
88
+ const line = raw.trim();
89
+ if (!line.startsWith('- '))
90
+ continue;
91
+ const m = OBLIGATION.exec(clean(line.slice(2)));
92
+ if (m)
93
+ tags.add(m[1].toLowerCase());
94
+ }
95
+ }
96
+ return tags;
97
+ }
98
+ /**
99
+ * Obligations an H-SPEC declares that no child T-SPEC case answers.
100
+ *
101
+ * @implements A-SPEC-234
102
+ * Matching is EXACT after lower-casing. A prefix match would let `perf` be satisfied by
103
+ * `performance`, and a false "met" is a gate switched off that says nothing — the same asymmetry
104
+ * that made `call` targets exact in `forbidden-edges.ts`.
105
+ *
106
+ * `malformed` lines are NOT reported here. Flagging syntax errors and judging fulfilment are
107
+ * different questions; mixed together, one typo invents an obligation nobody declared.
108
+ */
109
+ function unmetObligations(nonFunctional, tspecSections) {
110
+ const { obligations } = parseNonFunctional(nonFunctional);
111
+ if (obligations.length === 0)
112
+ return [];
113
+ const covered = new Set();
114
+ for (const s of Array.isArray(tspecSections) ? tspecSections : []) {
115
+ for (const t of caseObligationTags(s))
116
+ covered.add(t);
117
+ }
118
+ return obligations.filter((o) => !covered.has(o.tag));
119
+ }
@@ -7,6 +7,31 @@ export interface Spec {
7
7
  dependsOn: string[];
8
8
  frontmatter: Record<string, unknown>;
9
9
  sections: Record<string, string>;
10
+ /**
11
+ * Body text before the first `##`, or `''`.
12
+ *
13
+ * @implements A-SPEC-221
14
+ * `splitSections` keys on `## ` because the heading IS the key, which is right — but serialization
15
+ * rebuilt the body from those sections alone, so a legacy document's opening `# 개요` section was
16
+ * dropped on every write. Captured separately so it can be put back without changing what a
17
+ * section means.
18
+ *
19
+ * Optional so the four call sites that BUILD a Spec (reverse drafts, the conformance fixture,
20
+ * spec_create) need no change: they have no preamble, and serialization treats absent and empty
21
+ * identically. `parseSpec` always sets it.
22
+ */
23
+ preamble?: string;
10
24
  }
25
+ /**
26
+ * Read `depends_on` in every spelling the corpus contains, keeping what cannot be represented.
27
+ *
28
+ * @implements A-SPEC-221
29
+ * Three forms exist: the current array, a bare string, and the legacy `ID:digest` string. Only the
30
+ * first was understood; the other two silently became no parents at all.
31
+ */
32
+ export declare function parseDependsOn(raw: unknown): {
33
+ ids: string[];
34
+ legacy: string | null;
35
+ };
11
36
  export declare function parseSpec(input: string): Spec;
12
37
  export declare function serializeSpec(spec: Spec): string;
@@ -33,9 +33,38 @@ var __importStar = (this && this.__importStar) || (function () {
33
33
  };
34
34
  })();
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
+ exports.parseDependsOn = parseDependsOn;
36
37
  exports.parseSpec = parseSpec;
37
38
  exports.serializeSpec = serializeSpec;
38
39
  const yaml = __importStar(require("js-yaml"));
40
+ const legacy_format_1 = require("./legacy-format");
41
+ /**
42
+ * Read `depends_on` in every spelling the corpus contains, keeping what cannot be represented.
43
+ *
44
+ * @implements A-SPEC-221
45
+ * Three forms exist: the current array, a bare string, and the legacy `ID:digest` string. Only the
46
+ * first was understood; the other two silently became no parents at all.
47
+ */
48
+ function parseDependsOn(raw) {
49
+ if (Array.isArray(raw))
50
+ return { ids: raw, legacy: null };
51
+ if (typeof raw !== 'string' || raw.length === 0)
52
+ return { ids: [], legacy: null };
53
+ const colon = raw.indexOf(':');
54
+ // The id ends at the FIRST colon — `REQ-001:sha256:abcd` names REQ-001, not `REQ-001:sha256`.
55
+ const id = colon === -1 ? raw : raw.slice(0, colon);
56
+ if (id.length === 0)
57
+ return { ids: [], legacy: null };
58
+ return { ids: [id], legacy: colon === -1 ? null : raw };
59
+ }
60
+ function splitPreamble(body) {
61
+ const idx = body.search(/^##\s+/m);
62
+ const head = idx === -1 ? body : body.slice(0, idx);
63
+ // Whitespace is not a preamble. The frontmatter delimiter is followed by a blank line, so nearly
64
+ // every document has a stray newline here; treating it as content would add one blank line to the
65
+ // file on every single write, forever.
66
+ return head.trim().length === 0 ? '' : head;
67
+ }
39
68
  function splitSections(body) {
40
69
  const out = {};
41
70
  const lines = body.split('\n');
@@ -89,18 +118,33 @@ function parseSpec(input) {
89
118
  const m = /^---\n([\s\S]*?)\n---\n?([\s\S]*)$/.exec(raw);
90
119
  const fm = ((m ? yaml.load(m[1]) : {}) ?? {});
91
120
  const body = m ? m[2] : raw;
121
+ const deps = parseDependsOn(fm.depends_on);
122
+ // @implements A-SPEC-221 — never overwrite an existing preservation: a second round trip must not
123
+ // chew up what the first one saved, the same rule `legacy_status` follows.
124
+ if (deps.legacy !== null && fm[legacy_format_1.LEGACY_DEPENDS_FIELD] === undefined) {
125
+ fm[legacy_format_1.LEGACY_DEPENDS_FIELD] = deps.legacy;
126
+ }
92
127
  return {
93
128
  id: String(fm.id ?? ''),
94
129
  type: fm.type,
95
130
  title: String(fm.title ?? ''),
96
131
  status: (fm.status ?? 'draft'),
97
- dependsOn: Array.isArray(fm.depends_on) ? fm.depends_on : [],
132
+ dependsOn: deps.ids,
98
133
  frontmatter: fm,
99
134
  sections: splitSections(body),
135
+ preamble: splitPreamble(body),
100
136
  };
101
137
  }
102
138
  function serializeSpec(spec) {
103
139
  const fm = { ...spec.frontmatter, id: spec.id, type: spec.type, title: spec.title, status: spec.status, depends_on: spec.dependsOn };
104
140
  const body = Object.entries(spec.sections).map(([h, t]) => `## ${h}\n${t}\n`).join('\n');
105
- return `---\n${yaml.dump(fm)}---\n\n${body}`;
141
+ // @implements A-SPEC-221 — an empty preamble must contribute NOTHING, and a non-empty one must
142
+ // contribute the SAME thing every time. The delimiter below already supplies the blank line, so
143
+ // emitting the preamble's own leading newlines on top of it grew the file by one line on every
144
+ // write — measured: 2 → 3 → 4 → 5 blank lines over three round trips, unbounded. Trimming the
145
+ // leading whitespace makes serialization idempotent; the trailing blank line is restored so the
146
+ // preamble stays separated from the first section.
147
+ const head = (spec.preamble ?? '').replace(/^\s+/, '');
148
+ const preamble = head ? `${head.replace(/\s+$/, '')}\n\n` : '';
149
+ return `---\n${yaml.dump(fm)}---\n\n${preamble}${body}`;
106
150
  }
@@ -97,7 +97,8 @@ export declare const SPEC_TYPES: Record<SpecType, SpecTypeDef>;
97
97
  * The GOVERNED set is defined by the modern spec format: a spec is governed
98
98
  * iff it carries a `type` field naming a known SpecType. This deliberately
99
99
  * excludes (a) pre-`type` legacy specs retained only as archived history, and
100
- * (b) the `06_job/JOB-*` runtime ledger, which is not a governed spec type.
100
+ * (b) any document that is not a governed spec type. `06_job/JOB-*` was the historical case and
101
+ * was removed under REQ-223; `spec-store-boundary.test.ts` now fails the build if another appears.
101
102
  * Validation and RTM integrity checks operate on the governed set — validating
102
103
  * archived legacy or runtime-ledger entries is a category error (audit C1).
103
104
  * Generic over `{ type?: SpecType }` to stay free of an import cycle with the
@@ -113,9 +114,11 @@ export declare const SPEC_TYPES: Record<SpecType, SpecTypeDef>;
113
114
  export declare function specTypeDef(type: string | undefined | null): SpecTypeDef | undefined;
114
115
  export declare function isGoverned(spec: {
115
116
  type?: SpecType;
117
+ status?: SpecStatus;
116
118
  }): boolean;
117
119
  export declare function filterGoverned<T extends {
118
120
  type?: SpecType;
121
+ status?: SpecStatus;
119
122
  }>(specs: T[]): T[];
120
123
  /**
121
124
  * @implements A-SPEC-174
@@ -106,7 +106,8 @@ exports.SPEC_TYPES = {
106
106
  * The GOVERNED set is defined by the modern spec format: a spec is governed
107
107
  * iff it carries a `type` field naming a known SpecType. This deliberately
108
108
  * excludes (a) pre-`type` legacy specs retained only as archived history, and
109
- * (b) the `06_job/JOB-*` runtime ledger, which is not a governed spec type.
109
+ * (b) any document that is not a governed spec type. `06_job/JOB-*` was the historical case and
110
+ * was removed under REQ-223; `spec-store-boundary.test.ts` now fails the build if another appears.
110
111
  * Validation and RTM integrity checks operate on the governed set — validating
111
112
  * archived legacy or runtime-ledger entries is a category error (audit C1).
112
113
  * Generic over `{ type?: SpecType }` to stay free of an import cycle with the
@@ -127,6 +128,17 @@ function specTypeDef(type) {
127
128
  : undefined;
128
129
  }
129
130
  function isGoverned(spec) {
131
+ // @implements A-SPEC-222.1 — a retired document is the canonical form of "archived", which the
132
+ // comment above already excludes as a category error. The rule was expressed as `type` alone only
133
+ // because no document had ever carried `outdated`: it was in SPEC_STATUSES with nothing able to
134
+ // write it. Measured the day retirement shipped — moving 23 legacy REQs to `outdated` surfaced 24
135
+ // constitution violations, among them `missing section: ## Success Criteria` and
136
+ // `root-has-parent`. Demanding those of a retired document is demanding that history be rewritten,
137
+ // and `root-has-parent` was not a violation at all under the 2026-04 model the document was
138
+ // written in. Retirement has to mean removal from the ACTIVE graph, or it means nothing beyond a
139
+ // changed string.
140
+ if (spec.status === 'outdated')
141
+ return false;
130
142
  return specTypeDef(spec.type) !== undefined;
131
143
  }
132
144
  function filterGoverned(specs) {
@@ -0,0 +1,54 @@
1
+ /**
2
+ * What a function actually touched while it ran.
3
+ *
4
+ * @implements A-SPEC-238
5
+ * WHY OBSERVATION RATHER THAN SOURCE READING. Sixteen approved H-SPECs claim "순수", "I/O 없음",
6
+ * "파일시스템 접근 0". Measured 2026-08-23, every one of those claims is TRUE — but nothing held
7
+ * them true, and the `[purity]` cases written earlier in this session were weak in a specific way:
8
+ *
9
+ * expect(fn.toString()).not.toMatch(/readFileSync/) // reads the function's TEXT
10
+ * expect(input).toEqual(copy) // proves only that input is unchanged
11
+ *
12
+ * Both ask how the function is WRITTEN, not what it DOES. One helper call into another module and
13
+ * the first passes; a file-reading function that mutates nothing passes the second. This session has
14
+ * corrected that same shape twice already — ADR-016's off-switch check guessed at flag names and
15
+ * missed its mutation, and A-SPEC-237's `[symmetry]` check grepped for `try` and passed before the
16
+ * fix existed.
17
+ */
18
+ export interface ObservedEffects {
19
+ /** Names of the filesystem methods called, in order. */
20
+ fs: string[];
21
+ /** How many times the clock was read. */
22
+ clock: number;
23
+ /** How many times randomness was drawn. */
24
+ random: number;
25
+ /** What the body threw, if anything — returned rather than propagated. */
26
+ threw?: unknown;
27
+ /**
28
+ * Read methods that could NOT be wrapped, so the caller knows the observation has holes.
29
+ *
30
+ * Silence about what a tool could not watch is the same defect as a gate that passes because it
31
+ * saw nothing. Under normal Node this is empty; it is reported rather than assumed.
32
+ */
33
+ unwatched: string[];
34
+ }
35
+ /**
36
+ * Run `fn` and report what it touched.
37
+ *
38
+ * @implements A-SPEC-238
39
+ * The body's exception is RETURNED in `threw`, not re-thrown. "Why did it fail" and "what did it
40
+ * touch" are different pieces of information and a caller usually needs both; re-throwing would
41
+ * restore the globals but discard the observation.
42
+ *
43
+ * WHAT THIS DOES NOT SEE. Network calls and child processes — no claim in this repository's
44
+ * `## Non-Functional` sections mentions them, and offering coverage would recreate the
45
+ * "documented but unenforced" state. Asynchronous work that lands after `fn` returns: every function
46
+ * making a purity claim here is synchronous. Reads through a handle obtained before observation
47
+ * began. And a grammar loaded once per process — measured 2026-08-23, `extractSymbols` performs 26
48
+ * filesystem calls on the FIRST call in a process and zero on every call after, including the first
49
+ * call for a different language. That is one-time lazy loading, not per-call I/O, and this tool
50
+ * cannot tell them apart: warm the callee first.
51
+ */
52
+ export declare function observeEffects(fn: () => unknown): ObservedEffects;
53
+ /** Assert that `fn` touched nothing, naming what it touched when it did. */
54
+ export declare function expectPure(fn: () => unknown): void;