@skill-harness/core 0.6.0 → 0.7.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.
@@ -0,0 +1,299 @@
1
+ import { createHash } from "node:crypto";
2
+ import { EXECUTION_TRACE_VERSION } from "./capture-trace-types.js";
3
+ import { redactArgs, redactText } from "./capture.js";
4
+ /**
5
+ * Parse pi's `--mode json` event stream into an `ExecutionTraceV1`.
6
+ *
7
+ * Measured against pi 0.83.0; see `docs/pi-native-capture-design-2026-08-08.md`
8
+ * and the fixtures in `packages/adapters/test/fixtures/pi-json/`. Three findings
9
+ * from that spike are baked in here and each one is a bug if removed:
10
+ *
11
+ * 1. **Line-at-a-time, never buffered.** `message_update` re-sends the entire
12
+ * accumulated message on every delta, so the stream is quadratic in output
13
+ * length — a trivial three-tool-call run emitted 52 MB wrapping 12 KB of
14
+ * terminal events. The parser consumes an iterable of lines and holds only
15
+ * what it keeps.
16
+ * 2. **The final assistant message only.** pi's print mode — what the judge has
17
+ * always been shown — emits exactly that. Concatenating every assistant text
18
+ * block would add interim narration the transcript has never contained and
19
+ * move verdicts on scenarios nobody edited.
20
+ * 3. **Correlate tool calls by `toolCallId`.** Batched calls execute
21
+ * concurrently and their `end` events arrive in completion order.
22
+ */
23
+ /** Events that carry no information a trace keeps, and are large. */
24
+ const SKIPPED = new Set(["message_update", "tool_execution_update"]);
25
+ /** `details` larger than this is dropped rather than persisted. */
26
+ const MAX_DETAILS_CHARS = 2000;
27
+ /**
28
+ * Build a trace from pi JSON lines.
29
+ *
30
+ * Malformed lines are counted, not thrown on: a single truncated line at the end
31
+ * of a killed process must not discard an otherwise complete trace. But a stream
32
+ * with NO terminal events at all is not a trace — `isComplete` says so, and the
33
+ * caller turns that into ERROR rather than a passing gate.
34
+ */
35
+ export function parseTrace(lines, meta) {
36
+ const calls = new Map();
37
+ let issueCounter = 0;
38
+ let completionCounter = 0;
39
+ let malformedLines = 0;
40
+ let sawTerminal = false;
41
+ let finalText = "";
42
+ let lastAssistantText = "";
43
+ let cost = null;
44
+ for (const line of lines) {
45
+ const trimmed = line.trim();
46
+ if (!trimmed)
47
+ continue;
48
+ let ev;
49
+ try {
50
+ ev = JSON.parse(trimmed);
51
+ }
52
+ catch {
53
+ malformedLines++;
54
+ continue;
55
+ }
56
+ const type = ev.type;
57
+ if (typeof type !== "string" || SKIPPED.has(type))
58
+ continue;
59
+ if (type === "tool_execution_start") {
60
+ const id = str(ev.toolCallId);
61
+ if (!id)
62
+ continue;
63
+ calls.set(id, {
64
+ id,
65
+ name: str(ev.toolName) ?? "(unknown)",
66
+ args: redactArgs(ev.args, meta.homeDir),
67
+ issueIndex: issueCounter++,
68
+ completionIndex: -1, // filled in on `end`; -1 means it never completed
69
+ isError: false,
70
+ result: { bytes: 0, sha256: sha256("") },
71
+ });
72
+ continue;
73
+ }
74
+ if (type === "tool_execution_end") {
75
+ const id = str(ev.toolCallId);
76
+ if (!id)
77
+ continue;
78
+ const call = calls.get(id);
79
+ if (!call)
80
+ continue; // an end with no start is not evidence of a call
81
+ call.completionIndex = completionCounter++;
82
+ call.isError = ev.isError === true;
83
+ call.result = resultMeta(ev.result, meta.homeDir);
84
+ continue;
85
+ }
86
+ if (type === "message_end") {
87
+ sawTerminal = true;
88
+ const msg = ev.message;
89
+ if (msg?.role !== "assistant")
90
+ continue;
91
+ const text = assistantText(msg);
92
+ if (text) {
93
+ lastAssistantText = text;
94
+ // `stop` marks the model's closing message; a `toolUse` message is
95
+ // mid-flight narration and is deliberately not the transcript.
96
+ if (msg.stopReason === "stop")
97
+ finalText = text;
98
+ }
99
+ const total = msg.usage?.cost?.total;
100
+ if (typeof total === "number")
101
+ cost = (cost ?? 0) + total;
102
+ continue;
103
+ }
104
+ if (type === "turn_end" || type === "agent_end" || type === "agent_settled") {
105
+ sawTerminal = true;
106
+ // Deliberately read NOTHING from these. They repeat the same assistant
107
+ // messages `message_end` already carried (`turn_end` and `agent_end` do;
108
+ // `agent_settled` carries no keys at all beyond `type`), and reading two
109
+ // sources would double the transcript and reintroduce thinking.
110
+ continue;
111
+ }
112
+ }
113
+ const trace = {
114
+ trace_version: EXECUTION_TRACE_VERSION,
115
+ pi_version: meta.piVersion,
116
+ subject: meta.subject,
117
+ scenario_id: meta.scenarioId,
118
+ mode: meta.mode,
119
+ rep: meta.rep,
120
+ turn: meta.turn,
121
+ // Fall back to the last assistant text when no message carried `stop` — a
122
+ // truncated or length-capped run still produced an answer, and losing it
123
+ // would silently turn a real reply into an empty transcript.
124
+ // Redacted: the model's own answer routinely quotes the paths it just read,
125
+ // and `smoke-real-pi.sh` asserts no `/home/` survives into a persisted trace
126
+ // — an assertion that used to pass only because the smoke model happened not
127
+ // to echo one.
128
+ final_text: redactText(finalText || lastAssistantText, meta.homeDir),
129
+ tool_calls: [...calls.values()].sort((a, b) => a.issueIndex - b.issueIndex),
130
+ // `null`, not `[]`: the stream says nothing about the filesystem. The runner
131
+ // overwrites this after observing the workspace. Defaulting to `[]` claimed
132
+ // "observed, nothing changed" for every trace ever parsed.
133
+ changed_paths: meta.changedPaths ? [...meta.changedPaths].sort() : null,
134
+ cost_usd: cost,
135
+ };
136
+ trace.trace_sha256 = traceSha256(trace);
137
+ return { trace, isComplete: sawTerminal, malformedLines };
138
+ }
139
+ /** Visible assistant text. Thinking is dropped here, and at every other reader. */
140
+ function assistantText(msg) {
141
+ return (msg.content ?? [])
142
+ .filter((b) => b.type === "text" && typeof b.text === "string")
143
+ .map((b) => b.text)
144
+ .join("\n")
145
+ .trim();
146
+ }
147
+ /**
148
+ * Bounded metadata about a tool result. The body is never kept: results carry
149
+ * file contents, command output, and absolute paths (a failing `read` embeds the
150
+ * full path in its error string).
151
+ */
152
+ function resultMeta(result, homeDir) {
153
+ const body = JSON.stringify(result?.content ?? result ?? null);
154
+ const meta = { bytes: Buffer.byteLength(body, "utf8"), sha256: sha256(body) };
155
+ const details = result?.details;
156
+ if (details && typeof details === "object" && !Array.isArray(details)) {
157
+ const encoded = JSON.stringify(details);
158
+ // Small `details` is the one channel an extension can expose deliberately.
159
+ // Large `details` is a result body wearing a different hat.
160
+ //
161
+ // Redacted like every other value that reaches disk. `TraceResultMeta` has
162
+ // always PROMISED this — "retained only when it is small and free of
163
+ // redaction hits" — while the code checked only the size, so a tool that put
164
+ // a token, a connection string or a home path in `details` wrote it verbatim
165
+ // into the trace artifact. `args` right beside it was redacted the whole time.
166
+ if (encoded.length <= MAX_DETAILS_CHARS)
167
+ meta.details = redactArgs(details, homeDir);
168
+ }
169
+ return meta;
170
+ }
171
+ function str(v) {
172
+ return typeof v === "string" && v.length > 0 ? v : undefined;
173
+ }
174
+ function sha256(text) {
175
+ return createHash("sha256").update(text, "utf8").digest("hex");
176
+ }
177
+ /**
178
+ * Deterministic hash over the trace, excluding the hash field itself.
179
+ *
180
+ * Keys are emitted in a fixed order rather than whatever insertion produced, so
181
+ * the same execution always hashes the same — a digest that depends on key order
182
+ * would make `regate` report spurious drift.
183
+ */
184
+ export function traceSha256(trace) {
185
+ const { trace_sha256: _omit, ...rest } = trace;
186
+ return sha256(stableStringify(rest));
187
+ }
188
+ function stableStringify(value) {
189
+ if (value === null || typeof value !== "object")
190
+ return JSON.stringify(value) ?? "null";
191
+ if (Array.isArray(value))
192
+ return `[${value.map(stableStringify).join(",")}]`;
193
+ const entries = Object.entries(value)
194
+ .filter(([, v]) => v !== undefined)
195
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
196
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
197
+ }
198
+ /** Serialize a trace as the JSONL artifact saved beside a transcript. */
199
+ export function serializeTrace(trace) {
200
+ return `${JSON.stringify(trace)}\n`;
201
+ }
202
+ /** Read a saved trace artifact back, for `regate`. Returns null when unusable. */
203
+ export function deserializeTrace(text) {
204
+ const line = text.split("\n").find((l) => l.trim());
205
+ if (!line)
206
+ return null;
207
+ try {
208
+ const parsed = JSON.parse(line);
209
+ // A trace from a future version is refused rather than half-read: the whole
210
+ // point of the version field is that a reader can decline.
211
+ if (parsed.trace_version !== EXECUTION_TRACE_VERSION)
212
+ return null;
213
+ return parsed;
214
+ }
215
+ catch {
216
+ return null;
217
+ }
218
+ }
219
+ /**
220
+ * Collapse a scenario's per-turn traces into one view for gate evaluation.
221
+ *
222
+ * Assertions are written about the scenario ("it delegated to `plan` at least
223
+ * once"), not about turn 3 — a multi-turn scenario would otherwise need the
224
+ * author to know which turn a tool call landed in, which is a property of the
225
+ * model's choices, not of the test.
226
+ *
227
+ * Indices are renumbered across the whole scenario so `issueIndex` stays a total
228
+ * order. `completionIndex` is renumbered within the concatenation too: turns are
229
+ * strictly sequential (each is a separate `pi` invocation), so no completion in
230
+ * turn 2 can precede one in turn 1.
231
+ *
232
+ * Returns null for an empty list — "no turns produced evidence" must not look
233
+ * like "a run in which nothing happened".
234
+ */
235
+ export function mergeTraces(traces) {
236
+ if (traces.length === 0)
237
+ return null;
238
+ if (traces.length === 1) {
239
+ // Compute the digest here rather than trusting the producer to have set it.
240
+ // `regate` identifies saved evidence by this hash, so a trace that arrived
241
+ // without one would be un-regatable — and an adapter is exactly the layer
242
+ // most likely to forget.
243
+ const only = traces[0];
244
+ return only.trace_sha256 ? only : { ...only, trace_sha256: traceSha256(only) };
245
+ }
246
+ const calls = [];
247
+ const changed = new Set();
248
+ let anyUnobserved = false;
249
+ let cost = null;
250
+ let completed = 0;
251
+ for (const t of traces) {
252
+ // Issue order and completion order are renumbered SEPARATELY. Assigning both
253
+ // from the same counter collapsed them, so every merged call got
254
+ // `completionIndex === issueIndex` — destroying the out-of-order completion
255
+ // data the parser records, and the persisted trace then described a
256
+ // concurrency ordering that did not happen.
257
+ const mergedCompletion = new Map([...t.tool_calls]
258
+ .filter((c) => c.completionIndex >= 0)
259
+ .sort((a, b) => a.completionIndex - b.completionIndex)
260
+ .map((c) => [c.id, completed++]));
261
+ for (const c of [...t.tool_calls].sort((a, b) => a.issueIndex - b.issueIndex)) {
262
+ calls.push({ ...c, issueIndex: calls.length, completionIndex: mergedCompletion.get(c.id) ?? -1 });
263
+ }
264
+ // A single unobserved turn makes the merged evidence unobserved: a scenario
265
+ // cannot claim "nothing changed" from turns it never looked at.
266
+ if (t.changed_paths === null)
267
+ anyUnobserved = true;
268
+ else
269
+ for (const p of t.changed_paths)
270
+ changed.add(p);
271
+ if (t.cost_usd !== null)
272
+ cost = (cost ?? 0) + t.cost_usd;
273
+ }
274
+ const last = traces[traces.length - 1];
275
+ const merged = {
276
+ ...last,
277
+ // The scenario's answer is its LAST turn's answer, matching how the
278
+ // transcript reads and how the judge is asked to grade it.
279
+ final_text: last.final_text,
280
+ tool_calls: calls,
281
+ changed_paths: anyUnobserved ? null : [...changed].sort(),
282
+ cost_usd: cost,
283
+ };
284
+ merged.trace_sha256 = traceSha256(merged);
285
+ return merged;
286
+ }
287
+ /** Split a raw stdout blob into lines. Prefer streaming; this is for saved blobs. */
288
+ export function* lines(text) {
289
+ let start = 0;
290
+ for (let i = 0; i < text.length; i++) {
291
+ if (text[i] === "\n") {
292
+ yield text.slice(start, i);
293
+ start = i + 1;
294
+ }
295
+ }
296
+ if (start < text.length)
297
+ yield text.slice(start);
298
+ }
299
+ //# sourceMappingURL=execution-trace.js.map
package/dist/index.d.ts CHANGED
@@ -27,3 +27,11 @@ export * from "./regate.js";
27
27
  export * from "./downgrade.js";
28
28
  export * from "./canary.js";
29
29
  export * from "./stability.js";
30
+ export * from "./capture-trace-types.js";
31
+ export * from "./spec-write.js";
32
+ export * from "./capture.js";
33
+ export * from "./execution-trace.js";
34
+ export * from "./trace-gates.js";
35
+ export * from "./instruction-coverage.js";
36
+ export * from "./affected.js";
37
+ export * from "./adjudication.js";
package/dist/index.js CHANGED
@@ -27,4 +27,12 @@ export * from "./regate.js";
27
27
  export * from "./downgrade.js";
28
28
  export * from "./canary.js";
29
29
  export * from "./stability.js";
30
+ export * from "./capture-trace-types.js";
31
+ export * from "./spec-write.js";
32
+ export * from "./capture.js";
33
+ export * from "./execution-trace.js";
34
+ export * from "./trace-gates.js";
35
+ export * from "./instruction-coverage.js";
36
+ export * from "./affected.js";
37
+ export * from "./adjudication.js";
30
38
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,106 @@
1
+ import type { Scenario } from "./spec.js";
2
+ /**
3
+ * Which instructions have a test pointing at them.
4
+ *
5
+ * The unit is a Markdown heading section, because that is the unit skill authors
6
+ * already write in — no new syntax to learn, and no annotation step that gets
7
+ * skipped. The cost is that renaming a heading breaks a reference; that is
8
+ * reported as a broken reference rather than silently dropped, which is the only
9
+ * honest option (a silently-dropped reference reads as "not covered" and sends
10
+ * the author to write a test that already exists).
11
+ *
12
+ * **This measures declared linkage, not proof.** A section with a scenario
13
+ * pointing at it has *a test somebody associated with it* — not a guarantee the
14
+ * behavior is tested, still less tested well. Every surface here says "declared"
15
+ * for that reason. Overselling this number would make it worse than absent: an
16
+ * author who believes 100% coverage means 100% tested will stop looking.
17
+ */
18
+ export interface Section {
19
+ /** GitHub-style slug, disambiguated on collision (`name`, `name-1`, …). */
20
+ slug: string;
21
+ /** Heading text as written. */
22
+ title: string;
23
+ /** Heading depth: 1 for `#`, 2 for `##`. Setext `===`/`---` map to 1/2. */
24
+ depth: number;
25
+ /** 1-based line of the heading itself. */
26
+ startLine: number;
27
+ /** 1-based last line of the section, inclusive — the line before the next heading. */
28
+ endLine: number;
29
+ }
30
+ /**
31
+ * GitHub-style anchor slug: lowercase, drop punctuation, spaces to hyphens.
32
+ *
33
+ * Matching GitHub matters because the reference an author writes
34
+ * (`SKILL.md#core-principle`) is the anchor they would use in a link, so it is
35
+ * the one they will guess.
36
+ */
37
+ export declare function slugify(title: string): string;
38
+ /**
39
+ * Extract heading sections from Markdown.
40
+ *
41
+ * Fenced code blocks are skipped: a shell comment (`# rebuild the bundle`) inside
42
+ * an example is not a section, and treating it as one both invents coverage
43
+ * targets and shifts every subsequent section's line range — which would then
44
+ * mis-map git hunks to the wrong section, silently selecting the wrong tests.
45
+ */
46
+ export declare function parseSections(markdown: string): Section[];
47
+ /** Section containing a 1-based line, or undefined for content before the first heading. */
48
+ export declare function sectionAtLine(sections: Section[], line: number): Section | undefined;
49
+ export interface CoversRef {
50
+ /** Raw reference as written in the spec. */
51
+ raw: string;
52
+ /** Path portion, relative to the spec dir. */
53
+ file: string;
54
+ /** Slug portion; undefined when the reference names a whole file. */
55
+ slug?: string;
56
+ }
57
+ /** Parse `SKILL.md#core-principle` / `../../agents/plan.md` into its parts. */
58
+ export declare function parseCoversRef(raw: string): CoversRef;
59
+ export interface SectionCoverage {
60
+ file: string;
61
+ section: Section;
62
+ /** Scenario ids that declare a reference to this section. */
63
+ scenarios: string[];
64
+ /** Capture ids parked against this section but not yet promoted. */
65
+ pendingCaptures: string[];
66
+ }
67
+ export interface BrokenRef {
68
+ scenarioId: string;
69
+ raw: string;
70
+ reason: "file-missing" | "section-missing";
71
+ /** Nearest slugs in that file, to make a rename obvious. */
72
+ didYouMean: string[];
73
+ }
74
+ export interface CoverageReport {
75
+ /** Every section of every referenced instruction file, covered or not. */
76
+ sections: SectionCoverage[];
77
+ covered: SectionCoverage[];
78
+ uncovered: SectionCoverage[];
79
+ broken: BrokenRef[];
80
+ /** Scenarios that declare no `covers` at all. */
81
+ unmapped: string[];
82
+ pct: number;
83
+ }
84
+ export interface CoverageOptions {
85
+ /** Dir that `covers` paths resolve against — the spec's own directory. */
86
+ specDir: string;
87
+ scenarios: Scenario[];
88
+ /** Instruction files to report on even if nothing references them. */
89
+ baseFiles?: string[];
90
+ /** capture id → covers refs, for parking a pending case against a section. */
91
+ pendingCaptures?: {
92
+ id: string;
93
+ covers: string[];
94
+ }[];
95
+ }
96
+ /**
97
+ * Build the coverage report.
98
+ *
99
+ * Free and offline — it reads Markdown and the spec, nothing else. That is
100
+ * deliberate: a coverage command that spends tokens would be run once.
101
+ */
102
+ export declare function computeCoverage(opts: CoverageOptions): CoverageReport;
103
+ /** Render the report for a terminal. `declared` wording is deliberate throughout. */
104
+ export declare function formatCoverage(report: CoverageReport, skill: string): string;
105
+ /** Path of `file` relative to `specDir`, normalized for comparison with `covers`. */
106
+ export declare function relativeToSpec(specDir: string, file: string): string;
@@ -0,0 +1,253 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { resolve, dirname, relative, isAbsolute } from "node:path";
3
+ const FENCE = /^\s{0,3}(`{3,}|~{3,})/;
4
+ const ATX = /^(#{1,6})\s+(.*?)\s*#*\s*$/;
5
+ const SETEXT_H1 = /^\s{0,3}=+\s*$/;
6
+ const SETEXT_H2 = /^\s{0,3}-+\s*$/;
7
+ /**
8
+ * GitHub-style anchor slug: lowercase, drop punctuation, spaces to hyphens.
9
+ *
10
+ * Matching GitHub matters because the reference an author writes
11
+ * (`SKILL.md#core-principle`) is the anchor they would use in a link, so it is
12
+ * the one they will guess.
13
+ */
14
+ export function slugify(title) {
15
+ return title
16
+ .toLowerCase()
17
+ .replace(/[`*_~[\]()]/g, "")
18
+ .replace(/[^\p{L}\p{N}\s-]/gu, "")
19
+ .trim()
20
+ .replace(/\s+/g, "-");
21
+ }
22
+ /**
23
+ * Extract heading sections from Markdown.
24
+ *
25
+ * Fenced code blocks are skipped: a shell comment (`# rebuild the bundle`) inside
26
+ * an example is not a section, and treating it as one both invents coverage
27
+ * targets and shifts every subsequent section's line range — which would then
28
+ * mis-map git hunks to the wrong section, silently selecting the wrong tests.
29
+ */
30
+ export function parseSections(markdown) {
31
+ const lines = markdown.split("\n");
32
+ const found = [];
33
+ const seen = new Map();
34
+ let fence = null;
35
+ // Skip YAML frontmatter. Every SKILL.md opens with it, and its closing `---`
36
+ // makes the line above look exactly like a Setext h2 underline — so without
37
+ // this, every skill gains a phantom section named after its own `description:`
38
+ // line, and it lands at the top where it is most likely to be "covered" by a
39
+ // careless whole-file reference.
40
+ const start = frontmatterEnd(lines);
41
+ const push = (title, depth, startLine) => {
42
+ const base = slugify(title);
43
+ if (base === "")
44
+ return; // an empty heading is not addressable
45
+ const n = seen.get(base) ?? 0;
46
+ seen.set(base, n + 1);
47
+ found.push({ slug: n === 0 ? base : `${base}-${n}`, title, depth, startLine });
48
+ };
49
+ for (let i = start; i < lines.length; i++) {
50
+ const line = lines[i];
51
+ const fenceMatch = FENCE.exec(line);
52
+ if (fenceMatch) {
53
+ const marker = fenceMatch[1][0];
54
+ if (fence === null)
55
+ fence = marker;
56
+ else if (fence === marker)
57
+ fence = null;
58
+ continue;
59
+ }
60
+ if (fence !== null)
61
+ continue;
62
+ const atx = ATX.exec(line);
63
+ if (atx) {
64
+ push(atx[2], atx[1].length, i + 1);
65
+ continue;
66
+ }
67
+ // Setext: the UNDERLINE marks the heading, whose text is the line above.
68
+ const prev = i > start ? lines[i - 1] : "";
69
+ if (prev.trim() !== "" && !ATX.test(prev)) {
70
+ if (SETEXT_H1.test(line))
71
+ push(prev.trim(), 1, i);
72
+ else if (SETEXT_H2.test(line) && /[^-\s]/.test(prev))
73
+ push(prev.trim(), 2, i);
74
+ }
75
+ }
76
+ return found.map((s, i) => ({
77
+ ...s,
78
+ endLine: i + 1 < found.length ? found[i + 1].startLine - 1 : lines.length,
79
+ }));
80
+ }
81
+ /**
82
+ * Index of the first line after YAML frontmatter, or 0 when there is none.
83
+ *
84
+ * Only a `---` on line 1 opens frontmatter — a `---` further down is a horizontal
85
+ * rule, and treating it as a delimiter would swallow the document.
86
+ */
87
+ function frontmatterEnd(lines) {
88
+ if (lines[0]?.trim() !== "---")
89
+ return 0;
90
+ for (let i = 1; i < lines.length; i++) {
91
+ if (lines[i].trim() === "---")
92
+ return i + 1;
93
+ }
94
+ return 0; // unterminated: treat the whole file as content rather than losing it
95
+ }
96
+ /** Section containing a 1-based line, or undefined for content before the first heading. */
97
+ export function sectionAtLine(sections, line) {
98
+ return sections.find((s) => line >= s.startLine && line <= s.endLine);
99
+ }
100
+ /** Parse `SKILL.md#core-principle` / `../../agents/plan.md` into its parts. */
101
+ export function parseCoversRef(raw) {
102
+ const hash = raw.indexOf("#");
103
+ if (hash < 0)
104
+ return { raw, file: raw.trim() };
105
+ return { raw, file: raw.slice(0, hash).trim(), slug: raw.slice(hash + 1).trim() || undefined };
106
+ }
107
+ /**
108
+ * Build the coverage report.
109
+ *
110
+ * Free and offline — it reads Markdown and the spec, nothing else. That is
111
+ * deliberate: a coverage command that spends tokens would be run once.
112
+ */
113
+ export function computeCoverage(opts) {
114
+ const fileSections = new Map();
115
+ const readSections = (file) => {
116
+ if (fileSections.has(file))
117
+ return fileSections.get(file);
118
+ const abs = isAbsolute(file) ? file : resolve(opts.specDir, file);
119
+ if (!existsSync(abs))
120
+ return null;
121
+ const sections = parseSections(readFileSync(abs, "utf8"));
122
+ fileSections.set(file, sections);
123
+ return sections;
124
+ };
125
+ for (const f of opts.baseFiles ?? [])
126
+ readSections(f);
127
+ const bySection = new Map();
128
+ const key = (file, slug) => `${file}#${slug}`;
129
+ const ensure = (file, section) => {
130
+ const k = key(file, section.slug);
131
+ let entry = bySection.get(k);
132
+ if (!entry) {
133
+ entry = { file, section, scenarios: [], pendingCaptures: [] };
134
+ bySection.set(k, entry);
135
+ }
136
+ return entry;
137
+ };
138
+ // Seed every section of every known file, so "uncovered" is a real list rather
139
+ // than only what someone happened to reference.
140
+ for (const [file, sections] of fileSections)
141
+ for (const s of sections)
142
+ ensure(file, s);
143
+ const broken = [];
144
+ const unmapped = [];
145
+ const attach = (id, refs, into) => {
146
+ for (const raw of refs) {
147
+ const ref = parseCoversRef(raw);
148
+ const sections = readSections(ref.file);
149
+ if (sections === null) {
150
+ broken.push({ scenarioId: id, raw, reason: "file-missing", didYouMean: [] });
151
+ continue;
152
+ }
153
+ for (const s of sections)
154
+ ensure(ref.file, s);
155
+ if (ref.slug === undefined) {
156
+ // A whole-file reference covers every section in it.
157
+ for (const s of sections)
158
+ ensure(ref.file, s)[into].push(id);
159
+ continue;
160
+ }
161
+ const match = sections.find((s) => s.slug === ref.slug);
162
+ if (!match) {
163
+ broken.push({
164
+ scenarioId: id,
165
+ raw,
166
+ reason: "section-missing",
167
+ didYouMean: nearest(ref.slug, sections.map((s) => s.slug)),
168
+ });
169
+ continue;
170
+ }
171
+ ensure(ref.file, match)[into].push(id);
172
+ }
173
+ };
174
+ for (const s of opts.scenarios) {
175
+ if (!s.covers || s.covers.length === 0) {
176
+ unmapped.push(s.id);
177
+ continue;
178
+ }
179
+ attach(s.id, s.covers, "scenarios");
180
+ }
181
+ for (const c of opts.pendingCaptures ?? [])
182
+ attach(c.id, c.covers, "pendingCaptures");
183
+ const sections = [...bySection.values()].sort((a, b) => a.file.localeCompare(b.file) || a.section.startLine - b.section.startLine);
184
+ const covered = sections.filter((s) => s.scenarios.length > 0);
185
+ const uncovered = sections.filter((s) => s.scenarios.length === 0);
186
+ return {
187
+ sections,
188
+ covered,
189
+ uncovered,
190
+ broken,
191
+ unmapped,
192
+ pct: sections.length === 0 ? 0 : Math.round((covered.length / sections.length) * 100),
193
+ };
194
+ }
195
+ /**
196
+ * Closest existing slugs, so a broken reference names the likely rename.
197
+ *
198
+ * A renamed heading is the common cause of a broken reference, and
199
+ * "section-missing: core-principle" without a suggestion sends the author
200
+ * hunting through a file they just edited.
201
+ */
202
+ function nearest(target, candidates, limit = 3) {
203
+ return candidates
204
+ .map((c) => ({ c, d: distance(target, c) }))
205
+ .filter(({ c, d }) => d <= Math.max(3, Math.floor(c.length / 2)))
206
+ .sort((a, b) => a.d - b.d)
207
+ .slice(0, limit)
208
+ .map(({ c }) => c);
209
+ }
210
+ function distance(a, b) {
211
+ const prev = Array.from({ length: b.length + 1 }, (_, i) => i);
212
+ for (let i = 1; i <= a.length; i++) {
213
+ let last = prev[0];
214
+ prev[0] = i;
215
+ for (let j = 1; j <= b.length; j++) {
216
+ const tmp = prev[j];
217
+ prev[j] = Math.min(prev[j] + 1, prev[j - 1] + 1, last + (a[i - 1] === b[j - 1] ? 0 : 1));
218
+ last = tmp;
219
+ }
220
+ }
221
+ return prev[b.length];
222
+ }
223
+ /** Render the report for a terminal. `declared` wording is deliberate throughout. */
224
+ export function formatCoverage(report, skill) {
225
+ const out = [];
226
+ out.push(`${skill}: ${report.covered.length}/${report.sections.length} sections have a declared test (${report.pct}%)`);
227
+ out.push("");
228
+ if (report.uncovered.length) {
229
+ out.push(" no test declares coverage of:");
230
+ for (const s of report.uncovered)
231
+ out.push(` ${s.file}#${s.section.slug} (${s.section.title})`);
232
+ out.push("");
233
+ }
234
+ if (report.broken.length) {
235
+ out.push(" broken references:");
236
+ for (const b of report.broken) {
237
+ const hint = b.didYouMean.length ? ` — did you mean ${b.didYouMean.map((s) => `#${s}`).join(", ")}?` : "";
238
+ out.push(` ${b.scenarioId}: ${b.raw} (${b.reason})${hint}`);
239
+ }
240
+ out.push("");
241
+ }
242
+ if (report.unmapped.length) {
243
+ out.push(` scenarios with no \`covers\`: ${report.unmapped.join(", ")}`);
244
+ out.push("");
245
+ }
246
+ out.push(" `covers` records a declared link, not proof the behaviour is tested.");
247
+ return out.join("\n");
248
+ }
249
+ /** Path of `file` relative to `specDir`, normalized for comparison with `covers`. */
250
+ export function relativeToSpec(specDir, file) {
251
+ return relative(specDir, isAbsolute(file) ? file : resolve(dirname(specDir), file)).split("\\").join("/");
252
+ }
253
+ //# sourceMappingURL=instruction-coverage.js.map