@descent-vtt/spec-harness 0.0.0 → 0.1.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 (161) hide show
  1. package/.claude-plugin/marketplace.json +16 -0
  2. package/.claude-plugin/plugin.json +17 -0
  3. package/.mcp.json +12 -0
  4. package/CHANGELOG.md +46 -0
  5. package/README.md +127 -9
  6. package/bin/spec-harness.js +19 -0
  7. package/dist/audit.d.ts +69 -0
  8. package/dist/audit.js +93 -0
  9. package/dist/audit.js.map +1 -0
  10. package/dist/branch.d.ts +26 -0
  11. package/dist/branch.js +100 -0
  12. package/dist/branch.js.map +1 -0
  13. package/dist/briefs.d.ts +49 -0
  14. package/dist/briefs.js +111 -0
  15. package/dist/briefs.js.map +1 -0
  16. package/dist/cli.d.ts +14 -0
  17. package/dist/cli.js +245 -0
  18. package/dist/cli.js.map +1 -0
  19. package/dist/commands.d.ts +18 -0
  20. package/dist/commands.js +224 -0
  21. package/dist/commands.js.map +1 -0
  22. package/dist/config.d.ts +57 -0
  23. package/dist/config.js +167 -0
  24. package/dist/config.js.map +1 -0
  25. package/dist/context.d.ts +56 -0
  26. package/dist/context.js +125 -0
  27. package/dist/context.js.map +1 -0
  28. package/dist/document.d.ts +33 -0
  29. package/dist/document.js +9 -0
  30. package/dist/document.js.map +1 -0
  31. package/dist/fs.d.ts +29 -0
  32. package/dist/fs.js +85 -0
  33. package/dist/fs.js.map +1 -0
  34. package/dist/git.d.ts +57 -0
  35. package/dist/git.js +129 -0
  36. package/dist/git.js.map +1 -0
  37. package/dist/guard.d.ts +43 -0
  38. package/dist/guard.js +120 -0
  39. package/dist/guard.js.map +1 -0
  40. package/dist/hooks.d.ts +50 -0
  41. package/dist/hooks.js +109 -0
  42. package/dist/hooks.js.map +1 -0
  43. package/dist/index.d.ts +31 -0
  44. package/dist/index.js +20 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/junit.d.ts +30 -0
  47. package/dist/junit.js +166 -0
  48. package/dist/junit.js.map +1 -0
  49. package/dist/manifests.d.ts +52 -0
  50. package/dist/manifests.js +346 -0
  51. package/dist/manifests.js.map +1 -0
  52. package/dist/plugin.d.ts +45 -0
  53. package/dist/plugin.js +79 -0
  54. package/dist/plugin.js.map +1 -0
  55. package/dist/premises.d.ts +16 -0
  56. package/dist/premises.js +71 -0
  57. package/dist/premises.js.map +1 -0
  58. package/dist/probe.d.ts +108 -0
  59. package/dist/probe.js +203 -0
  60. package/dist/probe.js.map +1 -0
  61. package/dist/reader.d.ts +8 -0
  62. package/dist/reader.js +105 -0
  63. package/dist/reader.js.map +1 -0
  64. package/dist/round.d.ts +68 -0
  65. package/dist/round.js +339 -0
  66. package/dist/round.js.map +1 -0
  67. package/dist/rulings.d.ts +92 -0
  68. package/dist/rulings.js +160 -0
  69. package/dist/rulings.js.map +1 -0
  70. package/dist/sandbox.d.ts +28 -0
  71. package/dist/sandbox.js +110 -0
  72. package/dist/sandbox.js.map +1 -0
  73. package/dist/server.d.ts +13 -0
  74. package/dist/server.js +212 -0
  75. package/dist/server.js.map +1 -0
  76. package/dist/setup.d.ts +32 -0
  77. package/dist/setup.js +218 -0
  78. package/dist/setup.js.map +1 -0
  79. package/dist/siblings.d.ts +66 -0
  80. package/dist/siblings.js +134 -0
  81. package/dist/siblings.js.map +1 -0
  82. package/dist/types.d.ts +38 -0
  83. package/dist/types.js +3 -0
  84. package/dist/types.js.map +1 -0
  85. package/dist/vendor/spec-core/jsonrpc/index.d.ts +2 -0
  86. package/dist/vendor/spec-core/jsonrpc/index.js +2 -0
  87. package/dist/vendor/spec-core/jsonrpc/index.js.map +1 -0
  88. package/dist/vendor/spec-core/jsonrpc/mcp.d.ts +157 -0
  89. package/dist/vendor/spec-core/jsonrpc/mcp.js +353 -0
  90. package/dist/vendor/spec-core/jsonrpc/mcp.js.map +1 -0
  91. package/dist/vendor/spec-core/markdown/frontmatter.d.ts +103 -0
  92. package/dist/vendor/spec-core/markdown/frontmatter.js +459 -0
  93. package/dist/vendor/spec-core/markdown/frontmatter.js.map +1 -0
  94. package/dist/vendor/spec-core/markdown/headings.d.ts +45 -0
  95. package/dist/vendor/spec-core/markdown/headings.js +199 -0
  96. package/dist/vendor/spec-core/markdown/headings.js.map +1 -0
  97. package/dist/vendor/spec-core/markdown/index.d.ts +5 -0
  98. package/dist/vendor/spec-core/markdown/index.js +4 -0
  99. package/dist/vendor/spec-core/markdown/index.js.map +1 -0
  100. package/dist/vendor/spec-core/markdown/layout.d.ts +39 -0
  101. package/dist/vendor/spec-core/markdown/layout.js +28 -0
  102. package/dist/vendor/spec-core/markdown/layout.js.map +1 -0
  103. package/dist/vendor/spec-core/markdown/links.d.ts +22 -0
  104. package/dist/vendor/spec-core/markdown/links.js +364 -0
  105. package/dist/vendor/spec-core/markdown/links.js.map +1 -0
  106. package/dist/vendor/spec-core/markdown/lists.d.ts +22 -0
  107. package/dist/vendor/spec-core/markdown/lists.js +127 -0
  108. package/dist/vendor/spec-core/markdown/lists.js.map +1 -0
  109. package/dist/vendor/spec-core/markdown/scan.d.ts +36 -0
  110. package/dist/vendor/spec-core/markdown/scan.js +439 -0
  111. package/dist/vendor/spec-core/markdown/scan.js.map +1 -0
  112. package/dist/vendor/spec-core/markdown/syntax.d.ts +75 -0
  113. package/dist/vendor/spec-core/markdown/syntax.js +181 -0
  114. package/dist/vendor/spec-core/markdown/syntax.js.map +1 -0
  115. package/dist/vendor/spec-core/markdown/tables.d.ts +18 -0
  116. package/dist/vendor/spec-core/markdown/tables.js +86 -0
  117. package/dist/vendor/spec-core/markdown/tables.js.map +1 -0
  118. package/dist/vendor/spec-core/markdown/types.d.ts +287 -0
  119. package/dist/vendor/spec-core/markdown/types.js +9 -0
  120. package/dist/vendor/spec-core/markdown/types.js.map +1 -0
  121. package/dist/vendor/spec-core/path/index.d.ts +1 -0
  122. package/dist/vendor/spec-core/path/index.js +2 -0
  123. package/dist/vendor/spec-core/path/index.js.map +1 -0
  124. package/dist/vendor/spec-core/path/posix.d.ts +83 -0
  125. package/dist/vendor/spec-core/path/posix.js +150 -0
  126. package/dist/vendor/spec-core/path/posix.js.map +1 -0
  127. package/dist/vendor/spec-core/pattern/automaton.d.ts +129 -0
  128. package/dist/vendor/spec-core/pattern/automaton.js +340 -0
  129. package/dist/vendor/spec-core/pattern/automaton.js.map +1 -0
  130. package/dist/vendor/spec-core/pattern/charset.d.ts +58 -0
  131. package/dist/vendor/spec-core/pattern/charset.js +116 -0
  132. package/dist/vendor/spec-core/pattern/charset.js.map +1 -0
  133. package/dist/vendor/spec-core/pattern/glob.d.ts +121 -0
  134. package/dist/vendor/spec-core/pattern/glob.js +481 -0
  135. package/dist/vendor/spec-core/pattern/glob.js.map +1 -0
  136. package/dist/vendor/spec-core/pattern/index.d.ts +6 -0
  137. package/dist/vendor/spec-core/pattern/index.js +4 -0
  138. package/dist/vendor/spec-core/pattern/index.js.map +1 -0
  139. package/dist/vendor/spec-core/pattern/regex.d.ts +67 -0
  140. package/dist/vendor/spec-core/pattern/regex.js +813 -0
  141. package/dist/vendor/spec-core/pattern/regex.js.map +1 -0
  142. package/dist/vendor/spec-core/text/index.d.ts +2 -0
  143. package/dist/vendor/spec-core/text/index.js +2 -0
  144. package/dist/vendor/spec-core/text/index.js.map +1 -0
  145. package/dist/vendor/spec-core/text/lines.d.ts +72 -0
  146. package/dist/vendor/spec-core/text/lines.js +156 -0
  147. package/dist/vendor/spec-core/text/lines.js.map +1 -0
  148. package/dist/versions.d.ts +42 -0
  149. package/dist/versions.js +57 -0
  150. package/dist/versions.js.map +1 -0
  151. package/dist/workspace.d.ts +77 -0
  152. package/dist/workspace.js +138 -0
  153. package/dist/workspace.js.map +1 -0
  154. package/hooks/hooks.json +28 -0
  155. package/package.json +86 -6
  156. package/skills/close-round/SKILL.md +38 -0
  157. package/skills/draft-brief/SKILL.md +64 -0
  158. package/skills/run-round/SKILL.md +46 -0
  159. package/skills/split-goal/SKILL.md +42 -0
  160. package/src/vendor/spec-core/LICENSE +21 -0
  161. package/src/vendor/spec-core/README.md +8 -0
@@ -0,0 +1,71 @@
1
+ /**
2
+ * `spec-harness premises`: is every live brief still about something true?
3
+ *
4
+ * A brief written against a defect states the defect as a premise - an
5
+ * assertion under a section such as "The Defect, Measured" that holds while
6
+ * the defect does. When the premise stops holding before the round has run,
7
+ * the brief is stale: someone fixed the defect another way, or the code it
8
+ * describes is gone. A repository that keeps its premises executable can say
9
+ * so on every build, and ask for the brief to be archived or rewritten before
10
+ * an agent is sent to fix what is already fixed.
11
+ *
12
+ * Goals are the audit's (`audit`), not this: a goal fails until its round is
13
+ * done, and a build that failed for that would fail for every brief in flight.
14
+ */
15
+ import { isPremise } from './audit.js';
16
+ import { createReader } from './reader.js';
17
+ import { briefText } from './round.js';
18
+ import { EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, openWorkspace } from './workspace.js';
19
+ export async function premisesCommand(options, io) {
20
+ const workspace = await openWorkspace(options, io);
21
+ const reader = createReader();
22
+ const live = (await workspace.siblings.briefs()).filter((brief) => brief.phase === 'live');
23
+ if (live.length === 0) {
24
+ io.stdout.write(options.format === 'json' ? json('premises', { ok: true, checked: 0, findings: [] }) : 'no live brief\n');
25
+ return EXIT_OK;
26
+ }
27
+ const answer = await workspace.siblings.json('spec-guard', [...live.map((brief) => brief.file), '--ignore-status', '--json']);
28
+ if ('absent' in answer) {
29
+ io.stderr.write(`spec-harness: premises cannot be checked: ${answer.absent}\n`);
30
+ return EXIT_ERROR;
31
+ }
32
+ if (answer.code === 2) {
33
+ io.stderr.write('spec-harness: spec-guard could not run the briefs\' assertions\n');
34
+ return EXIT_ERROR;
35
+ }
36
+ const results = (answer.document.results ?? []).filter((result) => result.spec !== undefined);
37
+ const findings = [];
38
+ let premises = 0;
39
+ for (const brief of live) {
40
+ const own = results.filter((result) => result.spec?.file.replace(/\\/g, '/') === brief.file);
41
+ if (own.length === 0)
42
+ continue;
43
+ const text = await briefText(workspace, brief);
44
+ for (const result of own) {
45
+ const line = result.spec?.line ?? 0;
46
+ if (!isPremise(reader.sectionsAt(text, line), workspace.config.assertions.premises))
47
+ continue;
48
+ premises += 1;
49
+ if (result.ok)
50
+ continue;
51
+ findings.push({
52
+ rule: 'stale-premise',
53
+ severity: 'error',
54
+ message: `brief ${brief.id}'s premise no longer holds: ${result.description}: ${result.message}`,
55
+ hint: 'what the brief was written against has changed; archive the brief if its work is done, or rewrite its premise before a round is run on it',
56
+ file: brief.file,
57
+ line,
58
+ });
59
+ }
60
+ }
61
+ if (options.format === 'json') {
62
+ io.stdout.write(json('premises', { ok: findings.length === 0, briefs: live.length, premises, findings }));
63
+ }
64
+ else {
65
+ for (const finding of findings)
66
+ io.stdout.write(`error ${finding.file}:${finding.line} ${finding.message}\n ${finding.hint}\n`);
67
+ io.stdout.write(`${premises} premise(s) in ${live.length} live brief(s), ${findings.length} no longer hold\n`);
68
+ }
69
+ return findings.length > 0 ? EXIT_FAILED : EXIT_OK;
70
+ }
71
+ //# sourceMappingURL=premises.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"premises.js","sourceRoot":"","sources":["../src/premises.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * `spec-harness premises`: is every live brief still about something true?\n *\n * A brief written against a defect states the defect as a premise - an\n * assertion under a section such as \"The Defect, Measured\" that holds while\n * the defect does. When the premise stops holding before the round has run,\n * the brief is stale: someone fixed the defect another way, or the code it\n * describes is gone. A repository that keeps its premises executable can say\n * so on every build, and ask for the brief to be archived or rewritten before\n * an agent is sent to fix what is already fixed.\n *\n * Goals are the audit's (`audit`), not this: a goal fails until its round is\n * done, and a build that failed for that would fail for every brief in flight.\n */\n\nimport { isPremise } from './audit.js';\nimport { createReader } from './reader.js';\nimport { briefText } from './round.js';\nimport type { Finding } from './types.js';\nimport { EXIT_ERROR, EXIT_FAILED, EXIT_OK, json, openWorkspace, type CliIO, type Options } from './workspace.js';\n\ninterface GuardResult {\n readonly ok: boolean;\n readonly description: string;\n readonly message: string;\n readonly spec?: { readonly file: string; readonly line: number };\n}\n\nexport async function premisesCommand(options: Options, io: CliIO): Promise<number> {\n const workspace = await openWorkspace(options, io);\n const reader = createReader();\n const live = (await workspace.siblings.briefs()).filter((brief) => brief.phase === 'live');\n if (live.length === 0) {\n io.stdout.write(options.format === 'json' ? json('premises', { ok: true, checked: 0, findings: [] }) : 'no live brief\\n');\n return EXIT_OK;\n }\n const answer = await workspace.siblings.json('spec-guard', [...live.map((brief) => brief.file), '--ignore-status', '--json']);\n if ('absent' in answer) {\n io.stderr.write(`spec-harness: premises cannot be checked: ${answer.absent}\\n`);\n return EXIT_ERROR;\n }\n if (answer.code === 2) {\n io.stderr.write('spec-harness: spec-guard could not run the briefs\\' assertions\\n');\n return EXIT_ERROR;\n }\n const results = ((answer.document as { results?: GuardResult[] }).results ?? []).filter((result) => result.spec !== undefined);\n const findings: Finding[] = [];\n let premises = 0;\n for (const brief of live) {\n const own = results.filter((result) => result.spec?.file.replace(/\\\\/g, '/') === brief.file);\n if (own.length === 0) continue;\n const text = await briefText(workspace, brief);\n for (const result of own) {\n const line = result.spec?.line ?? 0;\n if (!isPremise(reader.sectionsAt(text, line), workspace.config.assertions.premises)) continue;\n premises += 1;\n if (result.ok) continue;\n findings.push({\n rule: 'stale-premise',\n severity: 'error',\n message: `brief ${brief.id}'s premise no longer holds: ${result.description}: ${result.message}`,\n hint: 'what the brief was written against has changed; archive the brief if its work is done, or rewrite its premise before a round is run on it',\n file: brief.file,\n line,\n });\n }\n }\n if (options.format === 'json') {\n io.stdout.write(json('premises', { ok: findings.length === 0, briefs: live.length, premises, findings }));\n } else {\n for (const finding of findings) io.stdout.write(`error ${finding.file}:${finding.line} ${finding.message}\\n ${finding.hint}\\n`);\n io.stdout.write(`${premises} premise(s) in ${live.length} live brief(s), ${findings.length} no longer hold\\n`);\n }\n return findings.length > 0 ? EXIT_FAILED : EXIT_OK;\n}\n"]}
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Probes: a defect, measured before anyone is sent to fix it.
3
+ *
4
+ * A brief that claims a defect proves it with a probe - a test that fails at
5
+ * the base commit, for the reason the brief states, every time it runs - and
6
+ * the round is done when the same probe passes at the head. The probe lives
7
+ * in the brief, as fenced blocks, so the evidence and the claim are one
8
+ * record:
9
+ *
10
+ * ````markdown
11
+ * ```probe
12
+ * id: old-token-still-accepted
13
+ * run: npm test -- tests/probes/rotate.test.ts
14
+ * signature: expected 401, got 200
15
+ * ```
16
+ *
17
+ * ```probe-file tests/probes/rotate.test.ts
18
+ * ...the test...
19
+ * ```
20
+ * ````
21
+ *
22
+ * What "for the reason the brief states" means is declared, never inferred
23
+ * from prose: a `signature` the failing output must contain, or a `test` name
24
+ * the JUnit report must show failing (ADR-0007). Without one, a probe could be
25
+ * red because it did not compile, and a red that proves nothing is refused.
26
+ */
27
+ import type { JUnitRead } from './junit.js';
28
+ export interface ProbeSpec {
29
+ readonly id: string;
30
+ /** The command, as a line a shell would run. */
31
+ readonly run: string;
32
+ /** A command to prepare a fresh checkout, such as `npm ci`. */
33
+ readonly setup: string | null;
34
+ /** Text the failing output, or the failing test's message, must contain. */
35
+ readonly signature: string | null;
36
+ /** A JUnit report the command writes, relative to the checkout. */
37
+ readonly junit: string | null;
38
+ /** The failing test's name, or part of it, in the JUnit report. */
39
+ readonly test: string | null;
40
+ readonly runs: number | null;
41
+ readonly timeout: number | null;
42
+ /** 1-based line of the probe block in the brief. */
43
+ readonly line: number;
44
+ }
45
+ export interface ProbeFile {
46
+ readonly path: string;
47
+ readonly content: string;
48
+ readonly line: number;
49
+ }
50
+ /** A fenced block as the scanner found it. */
51
+ export interface CodeBlock {
52
+ readonly info: string;
53
+ readonly content: string;
54
+ /** 1-based line of the opening fence. */
55
+ readonly line: number;
56
+ }
57
+ export interface ProbeSet {
58
+ readonly probes: readonly ProbeSpec[];
59
+ readonly files: readonly ProbeFile[];
60
+ readonly problems: readonly {
61
+ readonly line: number;
62
+ readonly message: string;
63
+ }[];
64
+ /** SHA-256 over every probe block and probe file, in order: what the evidence was measured with. */
65
+ readonly hash: string;
66
+ }
67
+ /** Reads the probes and probe files among a brief's fenced blocks. */
68
+ export declare function readProbes(blocks: readonly CodeBlock[]): ProbeSet;
69
+ /** What one run of a probe did. `exitCode` is `null` when it was stopped at its timeout. */
70
+ export interface ProbeRun {
71
+ readonly exitCode: number | null;
72
+ readonly output: string;
73
+ /** The report when the probe names one: read, unreadable, or `null` when the file was not written. */
74
+ readonly junit: JUnitRead | null;
75
+ }
76
+ export type RunOutcome = 'red' | 'green' | 'wrong-failure' | 'timeout' | 'no-report';
77
+ export interface Classified {
78
+ readonly outcome: RunOutcome;
79
+ /** The evidence: the line or the message that matched, or what was missing. */
80
+ readonly detail: string;
81
+ }
82
+ /** What one run showed, against what the probe declares. */
83
+ export declare function classify(spec: ProbeSpec, run: ProbeRun): Classified;
84
+ export type Verdict =
85
+ /** Red at every run where red was expected: the defect is measured. */
86
+ 'measured'
87
+ /** Green at every run where green was expected: the defect is gone. */
88
+ | 'fixed'
89
+ /** Green where red was expected: the probe finds no defect. */
90
+ | 'vacuous'
91
+ /** Red where green was expected: the round did not fix it. */
92
+ | 'still-failing'
93
+ /** Runs disagreed with each other. */
94
+ | 'flaky'
95
+ /** It failed for another reason, timed out, or wrote no report: it proves nothing. */
96
+ | 'invalid';
97
+ /** The verdict over every run of one probe, at the base (`red` expected) or the head (`green`). */
98
+ export declare function verdictOf(expected: 'red' | 'green', runs: readonly Classified[]): Verdict;
99
+ export interface ProbeResult {
100
+ readonly probe: ProbeSpec;
101
+ readonly at: string;
102
+ readonly commit: string;
103
+ readonly expected: 'red' | 'green';
104
+ readonly runs: readonly Classified[];
105
+ readonly verdict: Verdict;
106
+ }
107
+ /** The evidence a brief records: a table a reader can check, and what it was measured with. */
108
+ export declare function renderEvidence(results: readonly ProbeResult[], hash: string, date: string): string;
package/dist/probe.js ADDED
@@ -0,0 +1,203 @@
1
+ /**
2
+ * Probes: a defect, measured before anyone is sent to fix it.
3
+ *
4
+ * A brief that claims a defect proves it with a probe - a test that fails at
5
+ * the base commit, for the reason the brief states, every time it runs - and
6
+ * the round is done when the same probe passes at the head. The probe lives
7
+ * in the brief, as fenced blocks, so the evidence and the claim are one
8
+ * record:
9
+ *
10
+ * ````markdown
11
+ * ```probe
12
+ * id: old-token-still-accepted
13
+ * run: npm test -- tests/probes/rotate.test.ts
14
+ * signature: expected 401, got 200
15
+ * ```
16
+ *
17
+ * ```probe-file tests/probes/rotate.test.ts
18
+ * ...the test...
19
+ * ```
20
+ * ````
21
+ *
22
+ * What "for the reason the brief states" means is declared, never inferred
23
+ * from prose: a `signature` the failing output must contain, or a `test` name
24
+ * the JUnit report must show failing (ADR-0007). Without one, a probe could be
25
+ * red because it did not compile, and a red that proves nothing is refused.
26
+ */
27
+ import { createHash } from 'node:crypto';
28
+ const KEYS = ['id', 'run', 'setup', 'signature', 'junit', 'test', 'runs', 'timeout'];
29
+ function unquote(value) {
30
+ const trimmed = value.trim();
31
+ if (trimmed.length >= 2 && (trimmed.startsWith('"') || trimmed.startsWith("'")) && trimmed.endsWith(trimmed.charAt(0))) {
32
+ return trimmed.slice(1, -1);
33
+ }
34
+ return trimmed;
35
+ }
36
+ /**
37
+ * A probe line without its trailing `# comment`. A `#` counts only after
38
+ * whitespace and outside quotes, so `run: npm test -- -t "issue #12"` keeps
39
+ * its argument; a quote counts only where a word starts, so the apostrophe in
40
+ * `it's` opens nothing.
41
+ */
42
+ function withoutComment(raw) {
43
+ let quote = null;
44
+ for (let i = 0; i < raw.length; i += 1) {
45
+ const ch = raw.charAt(i);
46
+ const after = i === 0 ? ' ' : raw.charAt(i - 1);
47
+ if (quote !== null) {
48
+ if (ch === quote)
49
+ quote = null;
50
+ }
51
+ else if ((ch === '"' || ch === "'") && /[\s:=]/.test(after)) {
52
+ quote = ch;
53
+ }
54
+ else if (ch === '#' && /\s/.test(after)) {
55
+ return raw.slice(0, i);
56
+ }
57
+ }
58
+ return raw;
59
+ }
60
+ /** Reads the probes and probe files among a brief's fenced blocks. */
61
+ export function readProbes(blocks) {
62
+ const probes = [];
63
+ const files = [];
64
+ const problems = [];
65
+ const hash = createHash('sha256');
66
+ for (const block of blocks) {
67
+ const [word, ...rest] = block.info.trim().split(/\s+/);
68
+ if (word === 'probe-file') {
69
+ const path = rest.join(' ').trim();
70
+ // A backslash is a separator where the probe is written to disk on Windows.
71
+ if (path === '' || /^[\\/]/.test(path) || path.split(/[\\/]/).includes('..') || /^[A-Za-z]:/.test(path)) {
72
+ problems.push({ line: block.line, message: `a probe-file names "${path}"; it must be a path inside the repository` });
73
+ continue;
74
+ }
75
+ files.push({ path, content: block.content, line: block.line });
76
+ hash.update(`probe-file ${path}\n${block.content}\n`);
77
+ continue;
78
+ }
79
+ if (word !== 'probe')
80
+ continue;
81
+ hash.update(`probe\n${block.content}\n`);
82
+ const fields = new Map();
83
+ let bad = false;
84
+ for (const raw of block.content.split(/\r?\n/)) {
85
+ const line = withoutComment(raw).trim();
86
+ if (line === '' || line.startsWith('#'))
87
+ continue;
88
+ const colon = line.indexOf(':');
89
+ const key = colon < 0 ? '' : line.slice(0, colon).trim();
90
+ if (!KEYS.includes(key)) {
91
+ problems.push({ line: block.line, message: `a probe says "${line}"; its keys are ${KEYS.join(', ')}` });
92
+ bad = true;
93
+ continue;
94
+ }
95
+ fields.set(key, unquote(line.slice(colon + 1)));
96
+ }
97
+ if (bad)
98
+ continue;
99
+ const id = fields.get('id') ?? '';
100
+ const run = fields.get('run') ?? '';
101
+ if (id === '' || run === '') {
102
+ problems.push({ line: block.line, message: 'a probe needs an id and a run command' });
103
+ continue;
104
+ }
105
+ const signature = fields.get('signature') || null;
106
+ const test = fields.get('test') || null;
107
+ const junit = fields.get('junit') || null;
108
+ if (signature === null && test === null) {
109
+ problems.push({
110
+ line: block.line,
111
+ message: `probe ${id} does not say how it fails; give a signature, or a JUnit test name, so that a failure to compile is not taken for the defect`,
112
+ });
113
+ continue;
114
+ }
115
+ if (test !== null && junit === null) {
116
+ problems.push({ line: block.line, message: `probe ${id} names a test but no junit report to find it in` });
117
+ continue;
118
+ }
119
+ const count = (key) => {
120
+ const value = fields.get(key);
121
+ if (value === undefined)
122
+ return null;
123
+ const n = Number(value);
124
+ return Number.isInteger(n) && n >= 1 ? n : `probe ${id}: "${key}" must be a whole number of at least 1`;
125
+ };
126
+ const runs = count('runs');
127
+ const timeout = count('timeout');
128
+ if (typeof runs === 'string' || typeof timeout === 'string') {
129
+ problems.push({ line: block.line, message: (typeof runs === 'string' ? runs : timeout) });
130
+ continue;
131
+ }
132
+ if (probes.some((probe) => probe.id === id)) {
133
+ problems.push({ line: block.line, message: `probe ${id} is declared twice` });
134
+ continue;
135
+ }
136
+ probes.push({ id, run, setup: fields.get('setup') || null, signature, junit, test, runs, timeout, line: block.line });
137
+ }
138
+ return { probes, files, problems, hash: `sha256-${hash.digest('hex')}` };
139
+ }
140
+ function firstLineWith(text, needle) {
141
+ const line = text.split(/\r?\n/).find((candidate) => candidate.includes(needle));
142
+ return (line ?? needle).trim().slice(0, 200);
143
+ }
144
+ /** What one run showed, against what the probe declares. */
145
+ export function classify(spec, run) {
146
+ if (run.exitCode === null)
147
+ return { outcome: 'timeout', detail: `stopped after ${spec.timeout ?? 'the configured'} seconds` };
148
+ if (spec.junit !== null) {
149
+ if (run.junit === null) {
150
+ return run.exitCode === 0
151
+ ? { outcome: 'no-report', detail: `the command passed and wrote no ${spec.junit}` }
152
+ : { outcome: 'wrong-failure', detail: `the command failed before writing ${spec.junit}` };
153
+ }
154
+ if (!run.junit.ok)
155
+ return { outcome: 'no-report', detail: `${spec.junit} cannot be read: ${run.junit.error}` };
156
+ const failing = run.junit.cases.filter((c) => c.outcome === 'failed' || c.outcome === 'errored');
157
+ const matching = failing.filter((c) => (spec.test === null || `${c.classname} ${c.name}`.includes(spec.test)) &&
158
+ (spec.signature === null || c.message.includes(spec.signature) || c.name.includes(spec.signature)));
159
+ const first = matching[0];
160
+ if (first !== undefined) {
161
+ return { outcome: 'red', detail: `${first.name} failed${spec.signature === null ? '' : `: ${firstLineWith(first.message, spec.signature)}`}` };
162
+ }
163
+ if (failing.length === 0 && run.exitCode === 0)
164
+ return { outcome: 'green', detail: 'every test passed' };
165
+ const other = failing[0];
166
+ return {
167
+ outcome: 'wrong-failure',
168
+ detail: other === undefined ? `the command exited ${run.exitCode} with no failing test` : `${other.name} failed, which is not what the probe declares`,
169
+ };
170
+ }
171
+ if (run.exitCode === 0)
172
+ return { outcome: 'green', detail: 'the command passed' };
173
+ const signature = spec.signature;
174
+ return run.output.includes(signature)
175
+ ? { outcome: 'red', detail: firstLineWith(run.output, signature) }
176
+ : { outcome: 'wrong-failure', detail: `the command exited ${run.exitCode} and its output does not contain "${signature}"` };
177
+ }
178
+ /** The verdict over every run of one probe, at the base (`red` expected) or the head (`green`). */
179
+ export function verdictOf(expected, runs) {
180
+ const outcomes = new Set(runs.map((run) => run.outcome));
181
+ if (outcomes.size > 1)
182
+ return 'flaky';
183
+ const only = runs[0]?.outcome;
184
+ if (only === 'red')
185
+ return expected === 'red' ? 'measured' : 'still-failing';
186
+ if (only === 'green')
187
+ return expected === 'green' ? 'fixed' : 'vacuous';
188
+ return 'invalid';
189
+ }
190
+ function cell(text) {
191
+ return text.replace(/\|/g, '\\|').replace(/\r?\n/g, ' ').trim();
192
+ }
193
+ /** The evidence a brief records: a table a reader can check, and what it was measured with. */
194
+ export function renderEvidence(results, hash, date) {
195
+ const lines = ['| Probe | At | Runs | Verdict | Evidence |', '| --- | --- | ---: | --- | --- |'];
196
+ for (const result of results) {
197
+ const agreeing = result.runs.filter((run) => run.outcome === result.runs[0]?.outcome).length;
198
+ lines.push(`| ${cell(result.probe.id)} | ${result.at} \`${result.commit.slice(0, 12)}\` | ${agreeing}/${result.runs.length} | ${result.verdict} | ${cell(result.runs[0]?.detail ?? '')} |`);
199
+ }
200
+ lines.push('', `Measured ${date} by spec-harness with probes \`${hash}\`.`);
201
+ return lines.join('\n');
202
+ }
203
+ //# sourceMappingURL=probe.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"probe.js","sourceRoot":"","sources":["../src/probe.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * Probes: a defect, measured before anyone is sent to fix it.\n *\n * A brief that claims a defect proves it with a probe - a test that fails at\n * the base commit, for the reason the brief states, every time it runs - and\n * the round is done when the same probe passes at the head. The probe lives\n * in the brief, as fenced blocks, so the evidence and the claim are one\n * record:\n *\n * ````markdown\n * ```probe\n * id: old-token-still-accepted\n * run: npm test -- tests/probes/rotate.test.ts\n * signature: expected 401, got 200\n * ```\n *\n * ```probe-file tests/probes/rotate.test.ts\n * ...the test...\n * ```\n * ````\n *\n * What \"for the reason the brief states\" means is declared, never inferred\n * from prose: a `signature` the failing output must contain, or a `test` name\n * the JUnit report must show failing (ADR-0007). Without one, a probe could be\n * red because it did not compile, and a red that proves nothing is refused.\n */\n\nimport { createHash } from 'node:crypto';\n\nimport type { JUnitRead } from './junit.js';\n\nexport interface ProbeSpec {\n readonly id: string;\n /** The command, as a line a shell would run. */\n readonly run: string;\n /** A command to prepare a fresh checkout, such as `npm ci`. */\n readonly setup: string | null;\n /** Text the failing output, or the failing test's message, must contain. */\n readonly signature: string | null;\n /** A JUnit report the command writes, relative to the checkout. */\n readonly junit: string | null;\n /** The failing test's name, or part of it, in the JUnit report. */\n readonly test: string | null;\n readonly runs: number | null;\n readonly timeout: number | null;\n /** 1-based line of the probe block in the brief. */\n readonly line: number;\n}\n\nexport interface ProbeFile {\n readonly path: string;\n readonly content: string;\n readonly line: number;\n}\n\n/** A fenced block as the scanner found it. */\nexport interface CodeBlock {\n readonly info: string;\n readonly content: string;\n /** 1-based line of the opening fence. */\n readonly line: number;\n}\n\nexport interface ProbeSet {\n readonly probes: readonly ProbeSpec[];\n readonly files: readonly ProbeFile[];\n readonly problems: readonly { readonly line: number; readonly message: string }[];\n /** SHA-256 over every probe block and probe file, in order: what the evidence was measured with. */\n readonly hash: string;\n}\n\nconst KEYS = ['id', 'run', 'setup', 'signature', 'junit', 'test', 'runs', 'timeout'];\n\nfunction unquote(value: string): string {\n const trimmed = value.trim();\n if (trimmed.length >= 2 && (trimmed.startsWith('\"') || trimmed.startsWith(\"'\")) && trimmed.endsWith(trimmed.charAt(0))) {\n return trimmed.slice(1, -1);\n }\n return trimmed;\n}\n\n/**\n * A probe line without its trailing `# comment`. A `#` counts only after\n * whitespace and outside quotes, so `run: npm test -- -t \"issue #12\"` keeps\n * its argument; a quote counts only where a word starts, so the apostrophe in\n * `it's` opens nothing.\n */\nfunction withoutComment(raw: string): string {\n let quote: string | null = null;\n for (let i = 0; i < raw.length; i += 1) {\n const ch = raw.charAt(i);\n const after = i === 0 ? ' ' : raw.charAt(i - 1);\n if (quote !== null) {\n if (ch === quote) quote = null;\n } else if ((ch === '\"' || ch === \"'\") && /[\\s:=]/.test(after)) {\n quote = ch;\n } else if (ch === '#' && /\\s/.test(after)) {\n return raw.slice(0, i);\n }\n }\n return raw;\n}\n\n/** Reads the probes and probe files among a brief's fenced blocks. */\nexport function readProbes(blocks: readonly CodeBlock[]): ProbeSet {\n const probes: ProbeSpec[] = [];\n const files: ProbeFile[] = [];\n const problems: { line: number; message: string }[] = [];\n const hash = createHash('sha256');\n for (const block of blocks) {\n const [word, ...rest] = block.info.trim().split(/\\s+/);\n if (word === 'probe-file') {\n const path = rest.join(' ').trim();\n // A backslash is a separator where the probe is written to disk on Windows.\n if (path === '' || /^[\\\\/]/.test(path) || path.split(/[\\\\/]/).includes('..') || /^[A-Za-z]:/.test(path)) {\n problems.push({ line: block.line, message: `a probe-file names \"${path}\"; it must be a path inside the repository` });\n continue;\n }\n files.push({ path, content: block.content, line: block.line });\n hash.update(`probe-file ${path}\\n${block.content}\\n`);\n continue;\n }\n if (word !== 'probe') continue;\n hash.update(`probe\\n${block.content}\\n`);\n const fields = new Map<string, string>();\n let bad = false;\n for (const raw of block.content.split(/\\r?\\n/)) {\n const line = withoutComment(raw).trim();\n if (line === '' || line.startsWith('#')) continue;\n const colon = line.indexOf(':');\n const key = colon < 0 ? '' : line.slice(0, colon).trim();\n if (!KEYS.includes(key)) {\n problems.push({ line: block.line, message: `a probe says \"${line}\"; its keys are ${KEYS.join(', ')}` });\n bad = true;\n continue;\n }\n fields.set(key, unquote(line.slice(colon + 1)));\n }\n if (bad) continue;\n const id = fields.get('id') ?? '';\n const run = fields.get('run') ?? '';\n if (id === '' || run === '') {\n problems.push({ line: block.line, message: 'a probe needs an id and a run command' });\n continue;\n }\n const signature = fields.get('signature') || null;\n const test = fields.get('test') || null;\n const junit = fields.get('junit') || null;\n if (signature === null && test === null) {\n problems.push({\n line: block.line,\n message: `probe ${id} does not say how it fails; give a signature, or a JUnit test name, so that a failure to compile is not taken for the defect`,\n });\n continue;\n }\n if (test !== null && junit === null) {\n problems.push({ line: block.line, message: `probe ${id} names a test but no junit report to find it in` });\n continue;\n }\n const count = (key: string): number | null | string => {\n const value = fields.get(key);\n if (value === undefined) return null;\n const n = Number(value);\n return Number.isInteger(n) && n >= 1 ? n : `probe ${id}: \"${key}\" must be a whole number of at least 1`;\n };\n const runs = count('runs');\n const timeout = count('timeout');\n if (typeof runs === 'string' || typeof timeout === 'string') {\n problems.push({ line: block.line, message: (typeof runs === 'string' ? runs : timeout) as string });\n continue;\n }\n if (probes.some((probe) => probe.id === id)) {\n problems.push({ line: block.line, message: `probe ${id} is declared twice` });\n continue;\n }\n probes.push({ id, run, setup: fields.get('setup') || null, signature, junit, test, runs, timeout, line: block.line });\n }\n return { probes, files, problems, hash: `sha256-${hash.digest('hex')}` };\n}\n\n/** What one run of a probe did. `exitCode` is `null` when it was stopped at its timeout. */\nexport interface ProbeRun {\n readonly exitCode: number | null;\n readonly output: string;\n /** The report when the probe names one: read, unreadable, or `null` when the file was not written. */\n readonly junit: JUnitRead | null;\n}\n\nexport type RunOutcome = 'red' | 'green' | 'wrong-failure' | 'timeout' | 'no-report';\n\nexport interface Classified {\n readonly outcome: RunOutcome;\n /** The evidence: the line or the message that matched, or what was missing. */\n readonly detail: string;\n}\n\nfunction firstLineWith(text: string, needle: string): string {\n const line = text.split(/\\r?\\n/).find((candidate) => candidate.includes(needle));\n return (line ?? needle).trim().slice(0, 200);\n}\n\n/** What one run showed, against what the probe declares. */\nexport function classify(spec: ProbeSpec, run: ProbeRun): Classified {\n if (run.exitCode === null) return { outcome: 'timeout', detail: `stopped after ${spec.timeout ?? 'the configured'} seconds` };\n if (spec.junit !== null) {\n if (run.junit === null) {\n return run.exitCode === 0\n ? { outcome: 'no-report', detail: `the command passed and wrote no ${spec.junit}` }\n : { outcome: 'wrong-failure', detail: `the command failed before writing ${spec.junit}` };\n }\n if (!run.junit.ok) return { outcome: 'no-report', detail: `${spec.junit} cannot be read: ${run.junit.error}` };\n const failing = run.junit.cases.filter((c) => c.outcome === 'failed' || c.outcome === 'errored');\n const matching = failing.filter(\n (c) =>\n (spec.test === null || `${c.classname} ${c.name}`.includes(spec.test)) &&\n (spec.signature === null || c.message.includes(spec.signature) || c.name.includes(spec.signature)),\n );\n const first = matching[0];\n if (first !== undefined) {\n return { outcome: 'red', detail: `${first.name} failed${spec.signature === null ? '' : `: ${firstLineWith(first.message, spec.signature)}`}` };\n }\n if (failing.length === 0 && run.exitCode === 0) return { outcome: 'green', detail: 'every test passed' };\n const other = failing[0];\n return {\n outcome: 'wrong-failure',\n detail: other === undefined ? `the command exited ${run.exitCode} with no failing test` : `${other.name} failed, which is not what the probe declares`,\n };\n }\n if (run.exitCode === 0) return { outcome: 'green', detail: 'the command passed' };\n const signature = spec.signature as string;\n return run.output.includes(signature)\n ? { outcome: 'red', detail: firstLineWith(run.output, signature) }\n : { outcome: 'wrong-failure', detail: `the command exited ${run.exitCode} and its output does not contain \"${signature}\"` };\n}\n\nexport type Verdict =\n /** Red at every run where red was expected: the defect is measured. */\n | 'measured'\n /** Green at every run where green was expected: the defect is gone. */\n | 'fixed'\n /** Green where red was expected: the probe finds no defect. */\n | 'vacuous'\n /** Red where green was expected: the round did not fix it. */\n | 'still-failing'\n /** Runs disagreed with each other. */\n | 'flaky'\n /** It failed for another reason, timed out, or wrote no report: it proves nothing. */\n | 'invalid';\n\n/** The verdict over every run of one probe, at the base (`red` expected) or the head (`green`). */\nexport function verdictOf(expected: 'red' | 'green', runs: readonly Classified[]): Verdict {\n const outcomes = new Set(runs.map((run) => run.outcome));\n if (outcomes.size > 1) return 'flaky';\n const only = runs[0]?.outcome;\n if (only === 'red') return expected === 'red' ? 'measured' : 'still-failing';\n if (only === 'green') return expected === 'green' ? 'fixed' : 'vacuous';\n return 'invalid';\n}\n\nexport interface ProbeResult {\n readonly probe: ProbeSpec;\n readonly at: string;\n readonly commit: string;\n readonly expected: 'red' | 'green';\n readonly runs: readonly Classified[];\n readonly verdict: Verdict;\n}\n\nfunction cell(text: string): string {\n return text.replace(/\\|/g, '\\\\|').replace(/\\r?\\n/g, ' ').trim();\n}\n\n/** The evidence a brief records: a table a reader can check, and what it was measured with. */\nexport function renderEvidence(results: readonly ProbeResult[], hash: string, date: string): string {\n const lines = ['| Probe | At | Runs | Verdict | Evidence |', '| --- | --- | ---: | --- | --- |'];\n for (const result of results) {\n const agreeing = result.runs.filter((run) => run.outcome === result.runs[0]?.outcome).length;\n lines.push(\n `| ${cell(result.probe.id)} | ${result.at} \\`${result.commit.slice(0, 12)}\\` | ${agreeing}/${result.runs.length} | ${result.verdict} | ${cell(result.runs[0]?.detail ?? '')} |`,\n );\n }\n lines.push('', `Measured ${date} by spec-harness with probes \\`${hash}\\`.`);\n return lines.join('\\n');\n}\n"]}
@@ -0,0 +1,8 @@
1
+ /**
2
+ * The document reader, on spec-core's Markdown scanner: nothing inside code
3
+ * or a comment is ever taken for a section, a table, a link or a probe.
4
+ */
5
+ import type { DocumentReader } from './document.js';
6
+ /** Section names compare without case, emphasis, a leading number or a trailing colon. */
7
+ export declare function sameSection(heading: string, name: string): boolean;
8
+ export declare function createReader(): DocumentReader;
package/dist/reader.js ADDED
@@ -0,0 +1,105 @@
1
+ /**
2
+ * The document reader, on spec-core's Markdown scanner: nothing inside code
3
+ * or a comment is ever taken for a section, a table, a link or a probe.
4
+ */
5
+ import { readFrontMatter, scanMarkdown } from './vendor/spec-core/markdown/index.js';
6
+ /** Section names compare without case, emphasis, a leading number or a trailing colon. */
7
+ export function sameSection(heading, name) {
8
+ const normal = (text) => text
9
+ .replace(/[*_`]/g, '')
10
+ .replace(/^\s*\d+[.)]?\s+/, '')
11
+ .replace(/[:\s]+$/, '')
12
+ .trim()
13
+ .toLowerCase();
14
+ return normal(heading) === normal(name);
15
+ }
16
+ function scanOf(text, cache) {
17
+ return cache !== null && cache.text === text ? cache.scan : scanMarkdown(text);
18
+ }
19
+ export function createReader() {
20
+ // Commands read the same brief several times in a row; one scan serves them.
21
+ let last = null;
22
+ const scan = (text) => {
23
+ const result = scanOf(text, last);
24
+ last = { text, scan: result };
25
+ return result;
26
+ };
27
+ return {
28
+ codeBlocks(text) {
29
+ const scanned = scan(text);
30
+ const spaces = (line) => /^ */.exec(line)[0].length;
31
+ return scanned.blocks
32
+ .filter((block) => block.kind === 'fenced')
33
+ .map((block) => {
34
+ const first = block.line + 1;
35
+ const last = block.closed ? block.endLine - 1 : block.endLine;
36
+ // A fence in a block quote or a list item: its content is the code
37
+ // without the quote markers and without the fence's own indent, as
38
+ // CommonMark reads it, so a probe file is written as it was meant.
39
+ const indent = spaces(scanned.lines[block.line - 1]?.content ?? '');
40
+ const lines = [];
41
+ for (let line = first; line <= last; line += 1) {
42
+ const content = scanned.lines[line - 1]?.content ?? '';
43
+ lines.push(content.slice(Math.min(indent, spaces(content))));
44
+ }
45
+ return { info: block.info, content: lines.join('\n'), line: block.line };
46
+ });
47
+ },
48
+ sectionAt(text, line) {
49
+ let found = null;
50
+ for (const heading of scan(text).headings) {
51
+ if (heading.line > line)
52
+ break;
53
+ if (heading.level >= 2)
54
+ found = heading.text;
55
+ }
56
+ return found;
57
+ },
58
+ sectionsAt(text, line) {
59
+ const open = [];
60
+ for (const heading of scan(text).headings) {
61
+ if (heading.line > line)
62
+ break;
63
+ if (heading.level < 2)
64
+ continue;
65
+ while (open.length > 0 && open[open.length - 1].level >= heading.level)
66
+ open.pop();
67
+ open.push({ level: heading.level, text: heading.text });
68
+ }
69
+ return open.map((section) => section.text);
70
+ },
71
+ sectionTables(text, section) {
72
+ const scanned = scan(text);
73
+ const headings = scanned.headings;
74
+ const index = headings.findIndex((heading) => heading.level >= 2 && sameSection(heading.text, section));
75
+ if (index < 0)
76
+ return { tables: [], headingLine: null, tableEndLine: null };
77
+ const heading = headings[index];
78
+ const next = headings.slice(index + 1).find((candidate) => candidate.level <= heading.level);
79
+ const endLine = next === undefined ? scanned.index.lineCount + 1 : next.line;
80
+ const tables = scanned.tables.filter((table) => table.line > heading.endLine && table.line < endLine);
81
+ const views = tables.map((table) => ({
82
+ headers: table.headers.map((cell) => cell.text),
83
+ rows: table.rows.map((row) => ({ cells: row.cells.map((cell) => cell.text), line: row.line })),
84
+ }));
85
+ const lastTable = tables[tables.length - 1];
86
+ return { tables: views, headingLine: heading.endLine, tableEndLine: lastTable === undefined ? null : lastTable.endLine };
87
+ },
88
+ citations(text) {
89
+ return scan(text)
90
+ .links.filter((link) => !link.image && link.form !== 'autolink' && link.form !== 'definition')
91
+ .map((link) => ({ target: link.target, line: link.line }));
92
+ },
93
+ titleAndStatus(text) {
94
+ const scanned = scan(text);
95
+ const title = scanned.headings.find((heading) => heading.level === 1)?.text ?? null;
96
+ const front = readFrontMatter(text);
97
+ let status = null;
98
+ const entry = front?.entries.find((candidate) => candidate.name === 'status');
99
+ if (entry !== undefined && entry.value.kind === 'scalar')
100
+ status = entry.value.scalar.text || null;
101
+ return { title, status };
102
+ },
103
+ };
104
+ }
105
+ //# sourceMappingURL=reader.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"reader.js","sourceRoot":"","sources":["../src/reader.ts"],"names":[],"mappings":"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","sourcesContent":["/**\n * The document reader, on spec-core's Markdown scanner: nothing inside code\n * or a comment is ever taken for a section, a table, a link or a probe.\n */\n\nimport type { Citation, DocumentReader, SectionTables } from './document.js';\nimport type { CodeBlock } from './probe.js';\nimport type { TableView } from './rulings.js';\nimport { readFrontMatter, scanMarkdown, type MarkdownScan } from './vendor/spec-core/markdown/index.js';\n\n/** Section names compare without case, emphasis, a leading number or a trailing colon. */\nexport function sameSection(heading: string, name: string): boolean {\n const normal = (text: string): string =>\n text\n .replace(/[*_`]/g, '')\n .replace(/^\\s*\\d+[.)]?\\s+/, '')\n .replace(/[:\\s]+$/, '')\n .trim()\n .toLowerCase();\n return normal(heading) === normal(name);\n}\n\nfunction scanOf(text: string, cache: { text: string; scan: MarkdownScan } | null): MarkdownScan {\n return cache !== null && cache.text === text ? cache.scan : scanMarkdown(text);\n}\n\nexport function createReader(): DocumentReader {\n // Commands read the same brief several times in a row; one scan serves them.\n let last: { text: string; scan: MarkdownScan } | null = null;\n const scan = (text: string): MarkdownScan => {\n const result = scanOf(text, last);\n last = { text, scan: result };\n return result;\n };\n\n return {\n codeBlocks(text: string): CodeBlock[] {\n const scanned = scan(text);\n const spaces = (line: string): number => (/^ */.exec(line) as RegExpExecArray)[0].length;\n return scanned.blocks\n .filter((block) => block.kind === 'fenced')\n .map((block) => {\n const first = block.line + 1;\n const last = block.closed ? block.endLine - 1 : block.endLine;\n // A fence in a block quote or a list item: its content is the code\n // without the quote markers and without the fence's own indent, as\n // CommonMark reads it, so a probe file is written as it was meant.\n const indent = spaces(scanned.lines[block.line - 1]?.content ?? '');\n const lines: string[] = [];\n for (let line = first; line <= last; line += 1) {\n const content = scanned.lines[line - 1]?.content ?? '';\n lines.push(content.slice(Math.min(indent, spaces(content))));\n }\n return { info: block.info, content: lines.join('\\n'), line: block.line };\n });\n },\n\n sectionAt(text: string, line: number): string | null {\n let found: string | null = null;\n for (const heading of scan(text).headings) {\n if (heading.line > line) break;\n if (heading.level >= 2) found = heading.text;\n }\n return found;\n },\n\n sectionsAt(text: string, line: number): string[] {\n const open: { level: number; text: string }[] = [];\n for (const heading of scan(text).headings) {\n if (heading.line > line) break;\n if (heading.level < 2) continue;\n while (open.length > 0 && (open[open.length - 1] as { level: number }).level >= heading.level) open.pop();\n open.push({ level: heading.level, text: heading.text });\n }\n return open.map((section) => section.text);\n },\n\n sectionTables(text: string, section: string): SectionTables {\n const scanned = scan(text);\n const headings = scanned.headings;\n const index = headings.findIndex((heading) => heading.level >= 2 && sameSection(heading.text, section));\n if (index < 0) return { tables: [], headingLine: null, tableEndLine: null };\n const heading = headings[index] as (typeof headings)[number];\n const next = headings.slice(index + 1).find((candidate) => candidate.level <= heading.level);\n const endLine = next === undefined ? scanned.index.lineCount + 1 : next.line;\n const tables = scanned.tables.filter((table) => table.line > heading.endLine && table.line < endLine);\n const views: TableView[] = tables.map((table) => ({\n headers: table.headers.map((cell) => cell.text),\n rows: table.rows.map((row) => ({ cells: row.cells.map((cell) => cell.text), line: row.line })),\n }));\n const lastTable = tables[tables.length - 1];\n return { tables: views, headingLine: heading.endLine, tableEndLine: lastTable === undefined ? null : lastTable.endLine };\n },\n\n citations(text: string): Citation[] {\n return scan(text)\n .links.filter((link) => !link.image && link.form !== 'autolink' && link.form !== 'definition')\n .map((link) => ({ target: link.target, line: link.line }));\n },\n\n titleAndStatus(text: string): { title: string | null; status: string | null } {\n const scanned = scan(text);\n const title = scanned.headings.find((heading) => heading.level === 1)?.text ?? null;\n const front = readFrontMatter(text);\n let status: string | null = null;\n const entry = front?.entries.find((candidate) => candidate.name === 'status');\n if (entry !== undefined && entry.value.kind === 'scalar') status = entry.value.scalar.text || null;\n return { title, status };\n },\n };\n}\n"]}