@miroshnik/elves 0.0.0 → 2026.1018.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 (68) hide show
  1. package/AGENTS.md +310 -0
  2. package/README.md +344 -0
  3. package/bin/ai-dev.ts +7 -0
  4. package/bin/elves.ts +2847 -0
  5. package/bin/shims/timeout +113 -0
  6. package/claude/CLAUDE.md +71 -0
  7. package/dist/github/scripts/github.js +3428 -0
  8. package/dist/spec/scripts/architecture.js +628 -0
  9. package/dist/spec/scripts/eslint-config.js +54 -0
  10. package/dist/spec/scripts/harness-qzb932c8.js +959 -0
  11. package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
  12. package/dist/spec/scripts/harness.js +53 -0
  13. package/dist/spec/scripts/knip-hints.cjs +34 -0
  14. package/docs/ci-concurrency.md +82 -0
  15. package/docs/cloud-sessions.md +203 -0
  16. package/docs/judgment.md +81 -0
  17. package/docs/parallel-checkouts.md +220 -0
  18. package/docs/pr-checks.md +117 -0
  19. package/docs/testing.md +304 -0
  20. package/package.json +70 -1
  21. package/skills/ci-wait/SKILL.md +180 -0
  22. package/skills/ci-wait/scripts/wait-ci.sh +377 -0
  23. package/skills/dashboard/SKILL.md +212 -0
  24. package/skills/dashboard/scripts/dashboard.ts +912 -0
  25. package/skills/dashboard/scripts/sessions.ts +772 -0
  26. package/skills/est/SKILL.md +221 -0
  27. package/skills/est/reference.md +381 -0
  28. package/skills/est/scripts/est.ts +4854 -0
  29. package/skills/github/SKILL.md +206 -0
  30. package/skills/github/locales/en.json +449 -0
  31. package/skills/github/locales/ru.json +449 -0
  32. package/skills/github/reference.md +1073 -0
  33. package/skills/github/scripts/github.ts +4390 -0
  34. package/skills/github/scripts/locale.ts +495 -0
  35. package/skills/github/scripts/package.json +3 -0
  36. package/skills/slot/SKILL.md +145 -0
  37. package/skills/slot/scripts/package.json +3 -0
  38. package/skills/slot/scripts/slot.ts +279 -0
  39. package/skills/spec/SKILL.md +247 -0
  40. package/skills/spec/canon.md +219 -0
  41. package/skills/spec/checks.md +709 -0
  42. package/skills/spec/ci.md +378 -0
  43. package/skills/spec/migration.md +125 -0
  44. package/skills/spec/reference.md +889 -0
  45. package/skills/spec/scripts/architecture.ts +839 -0
  46. package/skills/spec/scripts/c4.ts +184 -0
  47. package/skills/spec/scripts/eslint-config.ts +82 -0
  48. package/skills/spec/scripts/harness.ts +1483 -0
  49. package/skills/spec/scripts/knip-hints.cjs +34 -0
  50. package/skills/spec/scripts/knip-hints.d.cts +17 -0
  51. package/skills/spec/scripts/package-run.ts +22 -0
  52. package/skills/spec/scripts/package.json +3 -0
  53. package/skills/spec/scripts/spec-break.ts +232 -0
  54. package/skills/spec/scripts/spec-claims.ts +198 -0
  55. package/skills/spec/scripts/spec-diff.ts +1111 -0
  56. package/skills/spec/scripts/spec-doc.ts +744 -0
  57. package/skills/spec/scripts/spec-exceptions.ts +233 -0
  58. package/skills/spec/scripts/spec-publish.ts +214 -0
  59. package/skills/spec/scripts/spec-run.ts +235 -0
  60. package/skills/spec/scripts/spec-shard.ts +267 -0
  61. package/skills/spec/scripts/speclib.ts +826 -0
  62. package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
  63. package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
  64. package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
  65. package/skills/task-env/SKILL.md +231 -0
  66. package/skills/task-env/proxy/compose.yaml +51 -0
  67. package/skills/task-env/reference.md +254 -0
  68. package/skills/task-env/scripts/task-env.ts +1017 -0
@@ -0,0 +1,826 @@
1
+ /**
2
+ * speclib — shared by spec-doc, spec-diff, spec-publish, spec-exceptions, spec-shard and the harness: the tests/ tree,
3
+ * the test model, the publication's `Source:`, parsing of runner reports (Vitest/Jest JSON, Playwright JSON, JUnit XML
4
+ * from `bun test`) and test files' durations from them, parsing of test sources with a parser (describe/it/test for
5
+ * TS/JS: names and prose from JSDoc), the rule for a decision folder's exception files (`exceptionFile`) and the "a
6
+ * name is a statement" rule (`isStatement`).
7
+ *
8
+ * The scripts run under Bun (`bun script.ts`); the harness imports this module too, and a project's test loads the
9
+ * harness under the project's runner, Node included — so only `node:` APIs and erasable TypeScript
10
+ * (tests/standards/harness-load). Without dependencies (the parser is the skill's file `vendor/babel-parser.cjs`) and
11
+ * without per-repository configuration: the tests/ tree from the rule "Specification — decisions"
12
+ * (skills/spec/canon.md) and the standard report formats.
13
+ */
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import path from "node:path";
17
+
18
+ import locale from "../../github/scripts/locale.ts";
19
+ import * as babel from "./vendor/babel-parser.cjs";
20
+
21
+ const posix = path.posix;
22
+
23
+ export const TESTS = "tests";
24
+
25
+ /**
26
+ * Source SHA from a publication commit message (`Source: <sha>`, written by spec-publish): 40 chars — SHA-1, 64 —
27
+ * SHA-256. One expression for spec-publish and spec-diff: if they diverge, one script stops recognizing the other's
28
+ * publications.
29
+ */
30
+ export const sourceOf = (message: string): string | undefined => /^Source: ([0-9a-f]{40,64})$/m.exec(message)?.[1];
31
+ export type Kind = "capability" | "architecture" | "standard" | "lib" | "out";
32
+ export type Status = "passed" | "failed" | "skipped" | "todo";
33
+
34
+ export interface Test {
35
+ path: string; // file path relative to the root, posix
36
+ describes: string[]; // describe chain
37
+ name: string; // it / test
38
+ status: Status;
39
+ reason: string; // skip reason, if the report knows it
40
+ variants: number; // identical records from several reports (Playwright projects) are folded
41
+ body?: string; // call arguments after the name without whitespace — only from source parsing (the test body)
42
+ }
43
+
44
+ export function makeTest(
45
+ file: string,
46
+ describes: string[],
47
+ name: string,
48
+ status: Status = "passed",
49
+ reason = "",
50
+ variants = 1,
51
+ ): Test {
52
+ return { path: file, describes, name, status, reason, variants };
53
+ }
54
+
55
+ export function folderOf(t: Test): string {
56
+ return posix.dirname(t.path);
57
+ }
58
+
59
+ /** Requirement identity: folder path + describe + it (the file inside the folder doesn't matter). */
60
+ export function keyOf(t: Test): string {
61
+ return JSON.stringify([folderOf(t), t.describes, t.name]);
62
+ }
63
+
64
+ export function titleOf(t: Test): string {
65
+ return [...t.describes, t.name].map(mdText).join(" › ");
66
+ }
67
+
68
+ /**
69
+ * A test or describe name for markdown: `<` outside a code span is escaped — otherwise GitHub takes `<type>` or
70
+ * `<!-- … -->` for HTML and silently drops it. The rest of the markup is as the author wrote it.
71
+ */
72
+ export function mdText(s: string): string {
73
+ return s
74
+ .split(/(`+[^`]*`+)/)
75
+ .map((part, i) => (i % 2 ? part : part.replace(/</g, "\\<")))
76
+ .join("");
77
+ }
78
+
79
+ /**
80
+ * "1 test", "2 tests", "5 tests" — in language `lang`: the word form from the github skill's dictionary by the
81
+ * language's rules.
82
+ */
83
+ export const testsWord = (lang: string, n: number): string => locale.count(lang, locale.names(lang).spec.tests, n);
84
+
85
+ // Test files: *.test.ts / *.spec.ts / *.e2e.ts (and js/jsx/tsx/mjs/cjs…)
86
+ const TEST_FILE = /\.(test|spec|e2e)\.[cm]?[jt]sx?$/;
87
+
88
+ export function isTestFile(p: string): boolean {
89
+ return TEST_FILE.test(p);
90
+ }
91
+
92
+ /**
93
+ * (kind, name) for a path relative to the root: capability / architecture rule / standard with the folder name,
94
+ * lib — not a spec, out — outside the tree (including a file directly in tests/capabilities/ or tests/architecture/).
95
+ */
96
+ export function classify(p: string): [Kind, string | null] {
97
+ const parts = p.split("/");
98
+ if (parts.length >= 4 && parts[0] === TESTS) {
99
+ if (parts[1] === "capabilities") return ["capability", parts[2]!];
100
+ if (parts[1] === "architecture") return ["architecture", parts[2]!];
101
+ if (parts[1] === "standards") return ["standard", parts[2]!];
102
+ }
103
+ if (parts.length >= 3 && parts[0] === TESTS && parts[1] === "lib") return ["lib", null];
104
+ return ["out", null];
105
+ }
106
+
107
+ /** A test's decision folder — `tests/<kind>/<name>`; a test outside the decision tree — null. */
108
+ export function decisionFolder(file: string): string | null {
109
+ const [kind] = classify(file);
110
+ return kind === "capability" || kind === "architecture" || kind === "standard" ? file.split("/").slice(0, 3).join("/") : null;
111
+ }
112
+
113
+ /** Decision folder exceptions — the `exceptions/` directory, a file per harness registry element (`exceptionsIn`). */
114
+ export const EXCEPTIONS_DIR = "exceptions";
115
+
116
+ // the old format — an array in one file: the harness's exceptions.ts
117
+ const LEGACY_EXCEPTIONS = /^exceptions\.(ts|mts|js|mjs|json)$/;
118
+
119
+ /** Whose exceptions file it is and in what form. */
120
+ export interface ExceptionFile {
121
+ /** The decision folder `tests/<kind>/<name>` these exceptions belong to. */
122
+ folder: string;
123
+ /** The folder's exceptions directory. */
124
+ dir: typeof EXCEPTIONS_DIR;
125
+ /**
126
+ * The old format — an array in one file of the folder or a subfolder: spec-exceptions moves it into the folder's
127
+ * `dir` directory.
128
+ */
129
+ legacy: boolean;
130
+ /** Misplaced or of the wrong kind — what's wrong; such a file isn't read as an exception. */
131
+ error?: string;
132
+ }
133
+
134
+ /**
135
+ * Exceptions file of which decision folder? One rule for the harness, spec-doc, spec-diff and spec-exceptions; a copy
136
+ * in the github skill (`pr labels`: a mechanical edit) is checked against it by the standard
137
+ * `tests/standards/exception-files`.
138
+ *
139
+ * - `tests/<kind>/<name>/exceptions/<element>.json` — the folder's exception;
140
+ * - another file in the directory (not `.json`, a nested directory) — an error: an exception is a JSON file;
141
+ * - the directory in a subfolder of the decision folder — an error: there's one directory per folder, `exceptionsIn()`
142
+ * of a test in any subfolder reads it;
143
+ * - `exceptions.{ts,mts,js,mjs,json}` in the folder or a subfolder — the folder's old exceptions format;
144
+ * - outside decision folders, and hidden files — not exceptions (null).
145
+ */
146
+ export function exceptionFile(p: string): ExceptionFile | null {
147
+ const folder = decisionFolder(p);
148
+ if (folder === null) return null;
149
+ const inner = p.split("/").slice(3);
150
+ if (inner.some((s) => s.startsWith("."))) return null;
151
+ const at = inner.findIndex((s, i) => (i < inner.length - 1 ? s === EXCEPTIONS_DIR : LEGACY_EXCEPTIONS.test(s)));
152
+ if (at < 0) return null;
153
+ const dir = EXCEPTIONS_DIR;
154
+ if (at === inner.length - 1) return { folder, dir, legacy: true };
155
+ if (at > 0) return { folder, dir, legacy: false, error: `exceptions — only in ${folder}/${dir}/ of the decision folder, not in a subfolder` };
156
+ if (inner.length > 2 || !p.endsWith(".json")) return { folder, dir, legacy: false, error: "an exception is an <element>.json file with { item, issue, reason }" };
157
+ return { folder, dir, legacy: false };
158
+ }
159
+
160
+ // a word: letters, the capital — only the first or all (an abbreviation), parts — joined by an apostrophe or a hyphen
161
+ // ("user's", "e-mail"); letters of a caseless script (ideographs, kana) — a whole word, it has no spaces between words
162
+ const PART = String.raw`(?:\p{Lo}+|\p{Lu}+|\p{L}[\p{Ll}\p{M}]*)`;
163
+ const WORD = new RegExp(String.raw`^${PART}(?:['’-]${PART})*$`, "u");
164
+ // marks around a word in a sentence: quotes and brackets, at the end — also punctuation
165
+ const EDGES = /^["'«“‘([{]+|["'»”’)\]}.,;:!?…]+$/gu;
166
+
167
+ /**
168
+ * A name is a statement in the project language; code in backticks doesn't count. `ru` — it has Cyrillic words. Another
169
+ * language — a phrase: two words or more, or one word (of two letters or more, without a hyphen) as a whole. An
170
+ * identifier (`parseScenarios`, `foo_bar`, `fooBar`, `foo-bar`), a function or file name, a path, a word with a number
171
+ * (`returns 201`) isn't a statement: the spec reader won't learn from it what the system does. A rule without an exit
172
+ * code: spec-doc warns, spec-diff counts the PR's new ones.
173
+ */
174
+ export function isStatement(name: string, lang: string): boolean {
175
+ const text = name.replace(/`[^`]*`/g, "");
176
+ if (lang === "ru") return /\p{Script=Cyrillic}/u.test(text);
177
+ const tokens = text.split(/\s+/).map((t) => t.replace(EDGES, "")).filter(Boolean);
178
+ const words = tokens.filter((t) => WORD.test(t));
179
+ if (words.length >= 2) return true;
180
+ const [only] = words;
181
+ return tokens.length === 1 && only !== undefined && [...only].length >= 2 && !/['’-]/.test(only);
182
+ }
183
+
184
+ /** Suite status: any failed → failed; otherwise any passed → passed; otherwise todo/skipped. */
185
+ export function combineStatus(a: Status, b: Status): Status {
186
+ const order: Record<Status, number> = { failed: 3, passed: 2, todo: 1, skipped: 0 };
187
+ return order[a] >= order[b] ? a : b;
188
+ }
189
+
190
+ /** Identical records from several reports — into one, in order of first appearance. */
191
+ export function mergeTests(tests: Test[]): Test[] {
192
+ const byKey = new Map<string, Test>();
193
+ for (const t of tests) {
194
+ const k = t.path + "\0" + keyOf(t);
195
+ const m = byKey.get(k);
196
+ if (m) {
197
+ m.status = combineStatus(m.status, t.status);
198
+ m.reason = m.reason || t.reason;
199
+ m.variants += t.variants;
200
+ } else {
201
+ byKey.set(k, { ...t, describes: [...t.describes] });
202
+ }
203
+ }
204
+ return [...byKey.values()];
205
+ }
206
+
207
+ // ---------- paths ----------
208
+
209
+ const DRIVE = /^[A-Za-z]:\//;
210
+
211
+ /**
212
+ * A path from a report → relative to the root. An absolute path from another machine (CI) is mapped by the /tests/
213
+ * segment; otherwise — as is, without the leading slash.
214
+ */
215
+ export function toRepoPath(p: string, root: string): string {
216
+ p = (p ?? "").replace(/\\/g, "/");
217
+ if (p.startsWith("/") || DRIVE.test(p)) {
218
+ if (root) {
219
+ const rel = path.relative(root, p).replace(/\\/g, "/");
220
+ if (rel && !rel.startsWith("..") && !path.isAbsolute(rel)) return rel;
221
+ }
222
+ const idx = p.indexOf("/" + TESTS + "/");
223
+ if (idx >= 0) return p.slice(idx + 1);
224
+ return p.replace(/^\/+/, "");
225
+ }
226
+ return p ? posix.normalize(p) : p;
227
+ }
228
+
229
+ // ---------- reports ----------
230
+
231
+ type Report = { kind: "junit"; text: string } | { kind: "vitest" | "playwright"; data: Json };
232
+
233
+ /** A report's format — by the content, not the extension: XML — JUnit, JSON — Vitest/Jest or Playwright. */
234
+ function readReport(file: string): Report {
235
+ const text = readFileSync(file, "utf8");
236
+ if (text.trimStart().startsWith("<")) return { kind: "junit", text };
237
+ let data: unknown;
238
+ try {
239
+ data = JSON.parse(text);
240
+ } catch (e) {
241
+ throw new Error(`${file}: neither JSON nor XML (${(e as Error).message})`);
242
+ }
243
+ if (data && typeof data === "object") {
244
+ const d = data as Json;
245
+ if ("testResults" in d) return { kind: "vitest", data: d };
246
+ if ("suites" in d && "config" in d) return { kind: "playwright", data: d };
247
+ }
248
+ throw new Error(`${file}: unknown report format (expected Vitest/Jest JSON, Playwright JSON or JUnit XML from bun test)`);
249
+ }
250
+
251
+ export function loadReport(file: string, root: string): Test[] {
252
+ const r = readReport(file);
253
+ if (r.kind === "junit") return parseJunit(r.text, root);
254
+ return r.kind === "vitest" ? parseVitest(r.data, root) : parsePlaywright(r.data, root);
255
+ }
256
+
257
+ /**
258
+ * Test files' durations in a report, ms, by the file path as the report gives it (an absolute path from another
259
+ * machine included): spec-shard lays out CI shards by them. Vitest/Jest — from the first test's start to the last one's
260
+ * end (`endTime - startTime`), without them — the sum of the tests; Playwright — the sum of all results of the file's
261
+ * tests: each project and retry takes a worker's time; JUnit from `bun test` — the `time` of the file's suite. A file
262
+ * met twice in one report (two runner projects) — the sum.
263
+ */
264
+ export function loadDurations(file: string): Map<string, number> {
265
+ const r = readReport(file);
266
+ const out = new Map<string, number>();
267
+ const add = (p: string, ms: number): void => {
268
+ p = p.replace(/\\/g, "/");
269
+ if (p && Number.isFinite(ms) && ms >= 0) out.set(p, (out.get(p) ?? 0) + ms);
270
+ };
271
+ const num = (v: unknown): number => (typeof v === "number" ? v : 0);
272
+ if (r.kind === "junit") {
273
+ // the file's suite is a testsuite at the top level (<testsuites> isn't one), describe — nested ones
274
+ let depth = 0;
275
+ for (const m of r.text.matchAll(/<!\[CDATA\[[\s\S]*?\]\]>|<!--[\s\S]*?-->|<(\/?)testsuite\b([^>]*?)(\/?)>/g)) {
276
+ if (m[0][1] === "!") continue;
277
+ if (m[1] === "/") {
278
+ depth--;
279
+ continue;
280
+ }
281
+ if (depth === 0) {
282
+ const a = xmlAttrs(m[2] ?? "");
283
+ add(a.file || a.name || "", Number(a.time) * 1000);
284
+ }
285
+ if (m[3] !== "/") depth++;
286
+ }
287
+ } else if (r.kind === "vitest") {
288
+ for (const tr of arr(r.data.testResults)) {
289
+ const [start, end] = [tr.startTime, tr.endTime];
290
+ const ms = typeof start === "number" && typeof end === "number" && end >= start ? end - start : arr(tr.assertionResults).reduce((s, a) => s + num(a.duration), 0);
291
+ add(str(tr.name), ms);
292
+ }
293
+ } else {
294
+ const rootDir = str(((r.data.config ?? {}) as Json).rootDir).replace(/\\/g, "/");
295
+ const total = (suite: Json): number =>
296
+ arr(suite.specs).reduce((s, spec) => s + arr(spec.tests).reduce((t, test) => t + arr(test.results).reduce((u, res) => u + num(res.duration), 0), 0), 0) +
297
+ arr(suite.suites).reduce((s, sub) => s + total(sub), 0);
298
+ for (const fileSuite of arr(r.data.suites)) {
299
+ const f = (str(fileSuite.file) || str(fileSuite.title)).replace(/\\/g, "/");
300
+ add(rootDir ? posix.join(rootDir, f) : f, total(fileSuite));
301
+ }
302
+ }
303
+ return out;
304
+ }
305
+
306
+ const VITEST_STATUS: Record<string, Status> = {
307
+ passed: "passed",
308
+ failed: "failed",
309
+ skipped: "skipped",
310
+ pending: "skipped",
311
+ disabled: "skipped",
312
+ todo: "todo",
313
+ };
314
+
315
+ type Json = Record<string, unknown>;
316
+ const arr = (v: unknown): Json[] => (Array.isArray(v) ? (v as Json[]) : []);
317
+ const str = (v: unknown): string => (typeof v === "string" ? v : "");
318
+
319
+ /** Vitest JSON (`--reporter=json`) — also the Jest format: testResults[].assertionResults[]. */
320
+ export function parseVitest(data: Json, root: string): Test[] {
321
+ const tests: Test[] = [];
322
+ for (const tr of arr(data.testResults)) {
323
+ const file = toRepoPath(str(tr.name), root);
324
+ for (const a of arr(tr.assertionResults)) {
325
+ const status = VITEST_STATUS[str(a.status)] ?? "failed";
326
+ const describes = arr(a.ancestorTitles).map((x) => String(x));
327
+ tests.push(makeTest(file, describes, str(a.title) || str(a.fullName), status));
328
+ }
329
+ }
330
+ return tests;
331
+ }
332
+
333
+ const PW_STATUS: Record<string, Status> = { expected: "passed", flaky: "passed", unexpected: "failed", skipped: "skipped" };
334
+
335
+ /**
336
+ * Playwright JSON (`--reporter=json`): suites[] per file, nested suites — describe, specs[].tests[] — one per project
337
+ * (chromium, firefox…) — are folded into one row.
338
+ */
339
+ export function parsePlaywright(data: Json, root: string): Test[] {
340
+ const config = (data.config ?? {}) as Json;
341
+ const rootDir = str(config.rootDir).replace(/\\/g, "/");
342
+ const tests: Test[] = [];
343
+
344
+ const specToTest = (spec: Json, describes: string[], file: string): Test => {
345
+ const statuses: Status[] = [];
346
+ const reasons: string[] = [];
347
+ for (const t of arr(spec.tests)) {
348
+ statuses.push(PW_STATUS[str(t.status)] ?? "failed");
349
+ for (const ann of arr(t.annotations)) {
350
+ const type = str(ann.type);
351
+ if ((type === "skip" || type === "fixme") && str(ann.description)) reasons.push(str(ann.description));
352
+ }
353
+ }
354
+ let status: Status = "passed";
355
+ for (const s of statuses) if (s !== "skipped") status = combineStatus(status, s);
356
+ if (statuses.length && statuses.every((s) => s === "skipped")) status = "skipped";
357
+ return makeTest(file, describes, str(spec.title), status, reasons[0] ?? "");
358
+ };
359
+
360
+ const walk = (suite: Json, describes: string[], file: string): void => {
361
+ for (const spec of arr(suite.specs)) tests.push(specToTest(spec, describes, file));
362
+ for (const sub of arr(suite.suites)) walk(sub, [...describes, str(sub.title)], file);
363
+ };
364
+
365
+ for (const fileSuite of arr(data.suites)) {
366
+ const f = (str(fileSuite.file) || str(fileSuite.title)).replace(/\\/g, "/");
367
+ const file = toRepoPath(rootDir ? posix.join(rootDir, f) : f, root);
368
+ walk(fileSuite, [], file);
369
+ }
370
+ return tests;
371
+ }
372
+
373
+ const XML_ENTITY = /&(#x[0-9a-fA-F]+|#\d+|amp|lt|gt|quot|apos);/g;
374
+ function xmlUnescape(s: string): string {
375
+ return s.replace(XML_ENTITY, (_, e: string) => {
376
+ if (e[0] === "#") return String.fromCodePoint(e[1] === "x" ? parseInt(e.slice(2), 16) : parseInt(e.slice(1), 10));
377
+ return { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'" }[e] ?? "";
378
+ });
379
+ }
380
+
381
+ function xmlAttrs(s: string): Record<string, string> {
382
+ const out: Record<string, string> = {};
383
+ for (const m of s.matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g)) out[m[1]!] = xmlUnescape(m[2] ?? m[3] ?? "");
384
+ return out;
385
+ }
386
+
387
+ type Suite = { name: string; file: string };
388
+
389
+ /**
390
+ * JUnit XML from `bun test --reporter=junit`: a file is a testsuite with name = file, describe — nested testsuites with
391
+ * the file attribute, a test — a testcase (file, name). Parsing with regular expressions and a suite stack: there's no
392
+ * XML parser without dependencies, and the format is simple. `<skipped/>` — skipped, `<skipped message="TODO"/>` —
393
+ * todo, failure/error — failed. A hook (`beforeAll`, `afterAll`) of a skipped describe bun writes as a skipped testcase
394
+ * `(unnamed)` without `line` — it isn't a test and is dropped; a failed hook stays: its failure is visible.
395
+ */
396
+ export function parseJunit(text: string, root: string): Test[] {
397
+ const tests: Test[] = [];
398
+ const stack: Suite[] = [];
399
+ const TOKEN = /<!\[CDATA\[[\s\S]*?\]\]>|<!--[\s\S]*?-->|<(\/?)(testsuite|testcase)\b([^>]*?)(\/?)>/g;
400
+ let caseAttrs: Record<string, string> | null = null;
401
+ let caseStart = 0;
402
+ for (const m of text.matchAll(TOKEN)) {
403
+ if (!m[2]) continue; // CDATA or a comment
404
+ const closing = m[1] === "/";
405
+ const selfClosing = m[4] === "/";
406
+ if (m[2] === "testsuite") {
407
+ if (closing) stack.pop();
408
+ else if (!selfClosing) {
409
+ const a = xmlAttrs(m[3] ?? "");
410
+ stack.push({ name: a.name ?? "", file: (a.file ?? "").replace(/\\/g, "/") });
411
+ }
412
+ continue;
413
+ }
414
+ if (!closing) {
415
+ const a = xmlAttrs(m[3] ?? "");
416
+ if (selfClosing) tests.push(junitCase(a, "", stack, root));
417
+ else {
418
+ caseAttrs = a;
419
+ caseStart = m.index + m[0].length;
420
+ }
421
+ } else if (caseAttrs) {
422
+ const body = text.slice(caseStart, m.index);
423
+ if (!skippedHook(caseAttrs, body)) tests.push(junitCase(caseAttrs, body, stack, root));
424
+ caseAttrs = null;
425
+ }
426
+ }
427
+ return mergeTests(tests);
428
+ }
429
+
430
+ /** A skipped describe's hook: a test always has `line`, a hook — the name `(unnamed)` and no `line` (bun 1.4). */
431
+ function skippedHook(a: Record<string, string>, body: string): boolean {
432
+ return a.name === "(unnamed)" && a.line === undefined && /<skipped\b/.test(body);
433
+ }
434
+
435
+ function junitCase(a: Record<string, string>, body: string, stack: Suite[], root: string): Test {
436
+ // describe — nested suites with file whose name isn't the file (the file's own suite isn't a describe)
437
+ const describes = stack.filter((s) => s.file && s.name !== s.file).map((s) => s.name);
438
+ const suiteFile = [...stack].reverse().find((s) => s.file)?.file ?? "";
439
+ const file = toRepoPath((a.file ?? "").replace(/\\/g, "/") || suiteFile, root);
440
+ let status: Status = "passed";
441
+ let reason = "";
442
+ const sk = /<skipped\b([^>]*)/.exec(body);
443
+ if (sk) {
444
+ const msg = (xmlAttrs(sk[1] ?? "").message ?? "").trim();
445
+ if (msg === "TODO") status = "todo";
446
+ else {
447
+ status = "skipped";
448
+ reason = msg;
449
+ }
450
+ } else if (/<(failure|error)\b/.test(body)) {
451
+ status = "failed";
452
+ }
453
+ return makeTest(file, describes, a.name ?? "", status, reason);
454
+ }
455
+
456
+ // ---------- source parsing ----------
457
+
458
+ /** A Babel AST node: the type, bounds in the source and the fields of its type. */
459
+ interface AstNode {
460
+ type: string;
461
+ start: number;
462
+ end: number;
463
+ loc: { start: { line: number; column: number } };
464
+ [field: string]: unknown;
465
+ }
466
+
467
+ /** A comment from parsing: block or line, bounds in the source. */
468
+ interface AstComment {
469
+ type: "CommentBlock" | "CommentLine";
470
+ start: number;
471
+ end: number;
472
+ }
473
+
474
+ interface Parsed {
475
+ program: AstNode & { interpreter?: AstNode | null };
476
+ comments: AstComment[];
477
+ }
478
+
479
+ /*
480
+ * The parser is `@babel/parser` 7.29.9 as a file in the skill: `vendor/babel-parser.cjs` is the package's
481
+ * `lib/index.js` (a single file without `require`) without the last line `//# sourceMappingURL` — there's no map next
482
+ * to it, Vitest looks for it and prints an error; types — `vendor/babel-parser.d.cts`, the license next to it. The
483
+ * skill's scripts have no dependencies, the `spec-diff` job in the project's CI installs no packages. To update —
484
+ * `npm pack @babel/parser`: its `lib/index.js` and `LICENSE` go here, the version — into this comment. Our own lexer
485
+ * guessed where a regex, a template, a comment and a type argument were, and silently lost tests (#267, #269, #271,
486
+ * #275); TypeScript syntax is wider than any guess (#277).
487
+ *
488
+ * The import is static, at the top of the file: `createRequire(import.meta.url)` broke Playwright and Jest in a project
489
+ * without `"type": "module"` — they load TS as CommonJS, where `import.meta` is a syntax error (#303,
490
+ * tests/standards/harness-load).
491
+ */
492
+
493
+ const OPTIONS = {
494
+ sourceType: "module",
495
+ // errors after which there's still an AST (strict mode, a parameter decorator) don't get in the way of parsing
496
+ errorRecovery: true,
497
+ allowReturnOutsideFunction: true,
498
+ allowAwaitOutsideFunction: true,
499
+ allowImportExportEverywhere: true,
500
+ allowUndeclaredExports: true,
501
+ allowNewTargetOutsideFunction: true,
502
+ allowSuperOutsideMethod: true,
503
+ };
504
+ // decorators — both in the position after export and on parameters (TS experimentalDecorators — an error with recovery)
505
+ const DECORATORS = ["decorators", "decoratorAutoAccessors"];
506
+
507
+ /**
508
+ * Plugin sets by file extension, in order of attempts: `.ts` — without JSX (`<T>x` is a type cast), `.tsx` — with JSX,
509
+ * JS — with JSX; an unknown file — TypeScript, then TSX.
510
+ */
511
+ function pluginSets(file?: string): unknown[][] {
512
+ const ts = ["typescript", ...DECORATORS];
513
+ if (file && /\.d\.[cm]?ts$/.test(file)) return [[["typescript", { dts: true }], ...DECORATORS]];
514
+ if (file && /\.[cm]?ts$/.test(file)) return [ts];
515
+ if (file && /\.tsx$/.test(file)) return [[...ts, "jsx"]];
516
+ if (file && /\.[cm]?jsx?$/.test(file)) return [["jsx", ...DECORATORS]];
517
+ return [ts, [...ts, "jsx"]];
518
+ }
519
+
520
+ /** The AST of a TS/JS source; can't parse — an error with the location (`Unexpected token (3:7)`). */
521
+ function parseCode(source: string, file?: string): Parsed {
522
+ let first: Error | undefined;
523
+ for (const plugins of pluginSets(file)) {
524
+ try {
525
+ return babel.parse(source, { ...OPTIONS, plugins }) as Parsed;
526
+ } catch (e) {
527
+ first ??= e as Error;
528
+ }
529
+ }
530
+ throw new Error(`can't parse: ${first!.message}`);
531
+ }
532
+
533
+ const isNode = (v: unknown): v is AstNode => !!v && typeof v === "object" && typeof (v as AstNode).type === "string" && typeof (v as AstNode).start === "number";
534
+
535
+ /** Child nodes in source order; comments attached to a node aren't nodes. */
536
+ function children(node: AstNode): AstNode[] {
537
+ const out: AstNode[] = [];
538
+ for (const [k, v] of Object.entries(node)) {
539
+ if (k.endsWith("Comments")) continue;
540
+ if (Array.isArray(v)) out.push(...v.filter(isNode));
541
+ else if (isNode(v)) out.push(v);
542
+ }
543
+ return out.sort((a, b) => a.start - b.start);
544
+ }
545
+
546
+ /** AST walk in source order: visit returned false — don't go into the node. */
547
+ function walkAst(node: AstNode, visit: (n: AstNode) => boolean | void): void {
548
+ if (visit(node) === false) return;
549
+ for (const c of children(node)) walkAst(c, visit);
550
+ }
551
+
552
+ /** The source's comments from parsing — [start, text]; a shebang is a comment too. */
553
+ export function commentsOf(source: string, file?: string): [number, string][] {
554
+ const ast = parseCode(source, file);
555
+ const out: [number, string][] = ast.comments.map((c) => [c.start, source.slice(c.start, c.end)]);
556
+ const bang = ast.program.interpreter;
557
+ if (bang) out.unshift([bang.start, source.slice(bang.start, bang.end)]);
558
+ return out;
559
+ }
560
+
561
+ /**
562
+ * The source's strings as written — literals and templates with their `${…}` — `[start, end]`: what the code prints,
563
+ * without comments and regular expressions; a template inside a template is inside its span.
564
+ */
565
+ export function stringSpans(source: string, file?: string): [number, number][] {
566
+ const out: [number, number][] = [];
567
+ walkAst(parseCode(source, file).program, (n) => {
568
+ if (n.type !== "StringLiteral" && n.type !== "TemplateLiteral") return;
569
+ out.push([n.start, n.end]);
570
+ return false;
571
+ });
572
+ return out;
573
+ }
574
+
575
+ /** The source's strings known without running: literals and templates without substitutions. */
576
+ export function stringsOf(source: string, file?: string): string[] {
577
+ const out: string[] = [];
578
+ walkAst(parseCode(source, file).program, (n) => {
579
+ const s = staticString(n);
580
+ if (s !== null && n.type !== "BinaryExpression") out.push(s);
581
+ });
582
+ return out;
583
+ }
584
+
585
+ /** The callee's name: `f(…)` and `x.f(…)` — `f`; computed — null. */
586
+ function calleeName(call: AstNode): string | null {
587
+ const callee = call.callee as AstNode;
588
+ const id = callee.type === "MemberExpression" && !callee.computed ? (callee.property as AstNode) : callee;
589
+ return id.type === "Identifier" ? (id.name as string) : null;
590
+ }
591
+
592
+ /**
593
+ * Values of option `key` in object literals among the call's arguments (the key is a name or a string):
594
+ * `invariant(it, { registry: … })`. An object from a variable or `...spec()` can't be computed without running.
595
+ */
596
+ function optionValues(call: AstNode, key: string): AstNode[] {
597
+ const out: AstNode[] = [];
598
+ for (const a of call.arguments as AstNode[]) {
599
+ if (a.type !== "ObjectExpression") continue;
600
+ for (const p of a.properties as AstNode[]) {
601
+ if (p.type !== "ObjectProperty" || p.computed) continue;
602
+ const k = p.key as AstNode;
603
+ if ((k.type === "Identifier" ? k.name : k.type === "StringLiteral" ? k.value : null) === key) out.push(p.value as AstNode);
604
+ }
605
+ }
606
+ return out;
607
+ }
608
+
609
+ /** Values of option `key` — strings known without running. */
610
+ function optionsOf(call: AstNode, key: string): string[] {
611
+ return optionValues(call, key).flatMap((v) => staticString(v) ?? []);
612
+ }
613
+
614
+ /**
615
+ * Harness calls and the check name from the call's options: for invariant — the registry (its `rule` is the folder's
616
+ * convention, not a name), for examples — the linter rule, for deadCode — the run's `rule` (`production: true` without
617
+ * it — "production"), architecture has no name — there's one model. null — no name, or it can't be computed without
618
+ * running.
619
+ */
620
+ const HARNESS_NAMES: Record<string, (call: AstNode) => string | null> = {
621
+ invariant: (n) => optionsOf(n, "registry")[0] ?? null,
622
+ examples: (n) => optionsOf(n, "rule")[0] ?? null,
623
+ deadCode: (n) => optionsOf(n, "rule")[0] ?? (optionValues(n, "production").some((v) => v.type === "BooleanLiteral" && v.value === true) ? "production" : null),
624
+ architecture: () => null,
625
+ };
626
+
627
+ /** Harness calls: a describe in which any of them is called is a standard's scenario (`spec-diff --scenarios`). */
628
+ export const HARNESS_CALLS: readonly string[] = Object.keys(HARNESS_NAMES);
629
+
630
+ /**
631
+ * A harness check in the source: the call, the describe chain at its place and the registry or rule (null — can't be
632
+ * computed without running).
633
+ */
634
+ export interface HarnessCall {
635
+ call: string;
636
+ describes: string[];
637
+ value: string | null;
638
+ }
639
+
640
+ /** A string known without running: a literal, a substitution-free template or their `+` concatenation; else null. */
641
+ function staticString(n: AstNode | undefined): string | null {
642
+ if (!n) return null;
643
+ if (n.type === "StringLiteral") return n.value as string;
644
+ if (n.type === "TemplateLiteral") {
645
+ const quasis = n.quasis as { value: { cooked: string | null } }[];
646
+ return quasis.length === 1 ? quasis[0]!.value.cooked : null;
647
+ }
648
+ if (n.type === "BinaryExpression" && n.operator === "+") {
649
+ const a = staticString(n.left as AstNode);
650
+ const b = a === null ? null : staticString(n.right as AstNode);
651
+ return b === null ? null : a + b;
652
+ }
653
+ return null;
654
+ }
655
+
656
+ // describe / it / test / suite / context (+ Jest's x/f variants, Playwright's test.describe), modifiers only from the
657
+ // list: test.step, test.use, test.beforeEach etc. aren't tests
658
+ const KIND = /^[xf]?(describe|it|test|suite|context)$/;
659
+ const DESCRIBE_KINDS = new Set(["describe", "suite", "context"]);
660
+ const MODS_OK = new Set([
661
+ "skip", "only", "todo", "each", "for", "concurrent", "sequential", "fails",
662
+ "runIf", "skipIf", "serial", "parallel", "fixme", "fail", "shuffle",
663
+ ]);
664
+ // modifiers with two calls: `it.each(table)(name, …)`, `it.each\`table\`(name, …)`, `it.skipIf(condition)(name, …)`
665
+ const CURRIED = ["each", "for", "runIf", "skipIf"];
666
+
667
+ /** The test call's kind by the name chain (`test.describe.serial` → describe); not a test — null. */
668
+ function testCallee(n: unknown): { kind: string; curried: boolean } | null {
669
+ const names: string[] = [];
670
+ let c = n as AstNode;
671
+ while (isNode(c) && c.type === "MemberExpression" && !c.computed && (c.property as AstNode).type === "Identifier") {
672
+ names.unshift((c.property as AstNode).name as string);
673
+ c = c.object as AstNode;
674
+ }
675
+ if (!isNode(c) || c.type !== "Identifier") return null;
676
+ names.unshift(c.name as string);
677
+ const at = names[0] === "test" && names.length > 1 && KIND.test(names[1]!) ? 1 : 0;
678
+ const kind = KIND.exec(names[at]!)?.[1];
679
+ const mods = names.slice(at + 1);
680
+ if (!kind || !mods.every((x) => MODS_OK.has(x))) return null;
681
+ return { kind, curried: mods.some((x) => CURRIED.includes(x)) };
682
+ }
683
+
684
+ /** A test or describe call — the kind and the arguments, name first; the `.each(…)` table and any other call — null. */
685
+ function testCall(call: AstNode): { kind: string; args: AstNode[] } | null {
686
+ const args = call.arguments as AstNode[];
687
+ const direct = testCallee(call.callee);
688
+ if (direct) return direct.curried ? null : { kind: direct.kind, args };
689
+ const callee = call.callee as AstNode;
690
+ const inner = callee.type === "CallExpression" ? callee.callee : callee.type === "TaggedTemplateExpression" ? callee.tag : null;
691
+ const curried = testCallee(inner);
692
+ return curried?.curried ? { kind: curried.kind, args } : null;
693
+ }
694
+
695
+ const isFunction = (n: AstNode): boolean => n.type === "ArrowFunctionExpression" || n.type === "FunctionExpression";
696
+
697
+ /**
698
+ * Prose from the source's JSDoc — what isn't visible from the names. Keys are the JSON of the name chain, as for a
699
+ * test: describe — [describes…], it — [describes…, name].
700
+ */
701
+ export interface Docs {
702
+ file: string; // JSDoc at the start of the file, before code: what this capability or standard is
703
+ describes: Map<string, string>; // JSDoc right before describe
704
+ tests: Map<string, string>; // JSDoc right before it / test
705
+ }
706
+
707
+ /** A test or describe call whose name can't be computed without running (a template with substitution, a variable). */
708
+ export interface Unnamed {
709
+ line: number;
710
+ kind: string;
711
+ /** The name expression from the source, shortened. */
712
+ name: string;
713
+ /** JSDoc right before the call — prose with nothing to attach to. */
714
+ doc: string;
715
+ }
716
+
717
+ /**
718
+ * JSDoc text without the comment brackets and leading `*`; lines are kept (markdown), empty ones at the edges and
719
+ * repeated ones are removed. Block tags (@see, @param…) aren't prose: everything from the first tag on is dropped.
720
+ */
721
+ export function jsdocText(comment: string): string {
722
+ const lines = comment
723
+ .slice(3, -2)
724
+ .split("\n")
725
+ .map((l) => l.replace(/^\s*(?:\*(?= |$))? ?/, "").trimEnd());
726
+ const tag = lines.findIndex((l) => /^@\w/.test(l));
727
+ const out: string[] = [];
728
+ for (const l of tag >= 0 ? lines.slice(0, tag) : lines) if (l || out[out.length - 1]) out.push(l);
729
+ while (out.length && !out[out.length - 1]) out.pop();
730
+ return out.join("\n");
731
+ }
732
+
733
+ /**
734
+ * JSDoc by code position: the prose is the last JSDoc before the code, comments between them don't interfere.
735
+ * Consecutive comments with only whitespace between them form one group; the key is the start of the code after the
736
+ * group. The first JSDoc of a group before any code is a candidate for the file's prose. `//` and a block comment with
737
+ * a single asterisk aren't prose.
738
+ */
739
+ function jsdocs(source: string, ast: Parsed): { at: Map<number, string>; head: { at: number; text: string } | null } {
740
+ const at = new Map<number, string>();
741
+ let head: { at: number; text: string } | null = null;
742
+ const blank = (from: number, to: number) => !source.slice(from, to).trim();
743
+ const isDoc = (c: AstComment) => source.startsWith("/**", c.start) && !source.startsWith("/**/", c.start);
744
+ const comments = ast.comments;
745
+ const codeFrom = ast.program.interpreter?.end ?? 0;
746
+ for (let i = 0; i < comments.length; ) {
747
+ let j = i;
748
+ while (j + 1 < comments.length && blank(comments[j]!.end, comments[j + 1]!.start)) j++;
749
+ const group = comments.slice(i, j + 1);
750
+ let after = comments[j]!.end;
751
+ while (after < source.length && /\s/.test(source[after]!)) after++;
752
+ const docs = group.filter(isDoc).map((c) => jsdocText(source.slice(c.start, c.end)));
753
+ if (docs.length) {
754
+ at.set(after, docs[docs.length - 1]!);
755
+ if (i === 0 && blank(codeFrom, group[0]!.start)) head = { at: docs.length > 1 ? -1 : after, text: docs[0]! };
756
+ }
757
+ i = j + 1;
758
+ }
759
+ return { at, head };
760
+ }
761
+
762
+ /** Tests from a TS/JS source. */
763
+ export function parseJs(file: string, source: string): Test[] {
764
+ return scanJs(file, source).tests;
765
+ }
766
+
767
+ /**
768
+ * Tests and prose of a source by AST. The name is a string known without running (a literal, a template without
769
+ * substitutions), the call's first argument (for .each / .for / .runIf / .skipIf — of the second call), with a type
770
+ * argument `<…>` allowed anywhere; describe opens nesting for all of its arguments. A name that can't be computed goes
771
+ * to `unnamed`, and the call isn't parsed further: the chain of its tests is unknown. JSDoc right before a call is its
772
+ * prose; the file's first JSDoc followed by something other than a call (usually imports) is the file's prose. Harness
773
+ * calls (`HARNESS_CALLS`) go to `checks` with the describe chain: their tests aren't named in the source. Can't parse —
774
+ * an error.
775
+ */
776
+ export function scanJs(file: string, source: string): { tests: Test[]; docs: Docs; unnamed: Unnamed[]; checks: HarnessCall[] } {
777
+ const ast = parseCode(source, file);
778
+ const doc = jsdocs(source, ast);
779
+ const out: Test[] = [];
780
+ const unnamed: Unnamed[] = [];
781
+ const checks: HarnessCall[] = [];
782
+ const docs: Docs = { file: "", describes: new Map(), tests: new Map() };
783
+ const calls = new Set<number>(); // starts of test calls: JSDoc before them isn't the file's prose
784
+
785
+ const addDoc = (m: Map<string, string>, chain: string[], text: string | undefined): void => {
786
+ if (!text) return;
787
+ const k = JSON.stringify(chain);
788
+ const prev = m.get(k);
789
+ m.set(k, prev && prev !== text ? prev + "\n\n" + text : text);
790
+ };
791
+
792
+ const visit = (chain: string[]) => (n: AstNode): boolean => {
793
+ if (n.type !== "CallExpression") return true;
794
+ const call = testCall(n);
795
+ if (!call) {
796
+ const name = calleeName(n);
797
+ if (name !== null && Object.hasOwn(HARNESS_NAMES, name)) checks.push({ call: name, describes: chain, value: HARNESS_NAMES[name]!(n) });
798
+ return true;
799
+ }
800
+ calls.add(n.start);
801
+ const [first, ...rest] = call.args;
802
+ const name = staticString(first);
803
+ if (name === null) {
804
+ // Playwright's `test.fail()`, `test.skip(condition, "reason")` aren't tests: there's no callback
805
+ if (!first || !rest.some(isFunction)) return true;
806
+ const text = source.slice(first.start, first.end).replace(/\s+/g, " ");
807
+ unnamed.push({ line: first.loc.start.line, kind: call.kind, name: text.length > 60 ? text.slice(0, 59) + "…" : text, doc: doc.at.get(n.start) ?? "" });
808
+ return false;
809
+ }
810
+ const inner = [...chain, name];
811
+ if (DESCRIBE_KINDS.has(call.kind)) {
812
+ addDoc(docs.describes, inner, doc.at.get(n.start));
813
+ } else {
814
+ // the body is the rest of the call's arguments: a renamed test with the same check is recognized by it
815
+ const t = makeTest(file, chain, name);
816
+ t.body = source.slice(first!.end, n.end - 1).replace(/\s+/g, "");
817
+ out.push(t);
818
+ addDoc(docs.tests, inner, doc.at.get(n.start));
819
+ }
820
+ for (const a of rest) walkAst(a, visit(DESCRIBE_KINDS.has(call.kind) ? inner : chain));
821
+ return false;
822
+ };
823
+ walkAst(ast.program, visit([]));
824
+ if (doc.head && !calls.has(doc.head.at)) docs.file = doc.head.text;
825
+ return { tests: out, docs, unnamed, checks };
826
+ }