@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,744 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * spec-doc — documentation from runner reports (Vitest/Jest JSON, Playwright JSON, JUnit XML from bun test): a section
4
+ * per folder tests/capabilities/<name> ("What the system does"), tests/architecture/<name> ("What it's made of") and
5
+ * tests/standards/<name> ("Which rules the code obeys"). A page is a story: the description <folder>.md next to the
6
+ * tests under the heading (the first paragraph goes to the index, the md headings — under the folder heading), the main
7
+ * file <name>.test.ts for all kinds (<name>.e2e.ts next to it — right after it; in a monorepo — also
8
+ * <name>/<package>/<name>.test.ts; rule.test.ts — a hint to rename), a describe → a section with prose from its own
9
+ * JSDoc, the section's tests collapsed into <details> with a counter. Sections go in the order of the main file, then
10
+ * of the packages' main files by package name, the other files follow by path. The header of another file of the
11
+ * folder doesn't go into the documentation — the script names the file, --strict gives code 1. No source — no prose.
12
+ * tests/lib is skipped; tests outside the tree go to the "Outside the tree" section — that's a signal. A describe or it
13
+ * name that isn't a statement in the project language — a warning line in stderr, without an exit code.
14
+ *
15
+ * bun spec-doc.ts report.json [report2.xml …] [--root DIR] [--out docs/spec | --stdout] [--strict]
16
+ * [--meta .spec-meta]
17
+ *
18
+ * Harness run metadata (`.spec-meta/` in the root or --meta) — the example code and exception reasons under the test
19
+ * line. The architecture model (`tests/architecture/model.ts`) — the architecture.md page: C4 diagrams and the module
20
+ * table (`c4.ts`); the `<!-- spec: c4-… -->` mark in `<folder>.md` is replaced with the same diagram. A scenario trace
21
+ * (the harness `sequence`) — a sequence diagram under its test or where `<!-- spec: sequence-<id> -->` stands in
22
+ * `<folder>.md`.
23
+ *
24
+ * Output by default — docs/spec/: README.md (index), architecture.md, capabilities/<name>.md, architecture/<name>.md,
25
+ * standards/<name>.md; it deletes its own outdated files (with the marker in any dictionary language). The
26
+ * documentation text is in the root's project language (`language` in `.agents/elves.json`, the github skill's
27
+ * dictionary). Runs under Bun, no dependencies.
28
+ */
29
+
30
+ import { execFileSync } from "node:child_process";
31
+ import { existsSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
32
+ import path from "node:path";
33
+ import { pathToFileURL } from "node:url";
34
+ import { parseArgs } from "node:util";
35
+
36
+ import locale from "../../github/scripts/locale.ts";
37
+ import type { Names, Plural } from "../../github/scripts/locale.ts";
38
+ import type { Model } from "./architecture.ts";
39
+ import * as C4 from "./c4.ts";
40
+ import type { Call } from "./harness.ts";
41
+ import * as L from "./speclib.ts";
42
+ import type { Kind, Test } from "./speclib.ts";
43
+
44
+ // the project language and its names: everything that goes into docs/spec is in it; main sets it from the root
45
+ let LANG = locale.DEFAULT_LANGUAGE;
46
+ let N: Names = locale.names(LANG);
47
+
48
+ const ICON: Record<string, string> = { passed: "✅", failed: "❌", skipped: "⏭️", todo: "📝" };
49
+ // section order — the reader's questions: what it does, what it's made of, which rules the code obeys
50
+ const KINDS: Kind[] = ["capability", "architecture", "standard"];
51
+ const SUBDIR: Record<string, string> = { capability: "capabilities", architecture: "architecture", standard: "standards" };
52
+
53
+ /** A table of contents section heading — the reader's question about the tree root. */
54
+ function sectionTitle(kind: Kind): string {
55
+ if (kind === "capability") return N.spec.doc.capabilities;
56
+ if (kind === "architecture") return N.spec.doc.architecture;
57
+ return N.spec.doc.standards;
58
+ }
59
+
60
+ // doc — prose paragraphs from JSDoc: for a group — the main file's header, for a describe from several files — in
61
+ // file order (the main one first), a repeat once
62
+ type Node = { tests: Test[]; children: Map<string, Node>; doc: string[] };
63
+ type Group = { kind: Kind; name: string; tests: Test[]; tree: Node; doc: string[]; testDocs: Map<string, string[]> };
64
+
65
+ /** A number and the word form in the project language: "2 tests". */
66
+ const count = (forms: Plural, n: number): string => locale.count(LANG, forms, n);
67
+ const testsWord = (n: number): string => L.testsWord(LANG, n);
68
+
69
+ /** Skipped and failing — what's worth noticing; empty if everything is green. */
70
+ function problemsLine(tests: Test[]): string {
71
+ const parts: string[] = [];
72
+ const sk = tests.filter((t) => t.status === "skipped" || t.status === "todo").length;
73
+ const fl = tests.filter((t) => t.status === "failed").length;
74
+ if (sk) parts.push(count(N.spec.doc.skippedCount, sk));
75
+ if (fl) parts.push(count(N.spec.doc.failingCount, fl));
76
+ return parts.join(", ");
77
+ }
78
+
79
+ function countsLine(tests: Test[]): string {
80
+ return [testsWord(tests.length), problemsLine(tests)].filter(Boolean).join(", ");
81
+ }
82
+
83
+ /** A number and a word in English — for stderr messages (they aren't in the project language): "1 file", "2 files". */
84
+ const enCount = (n: number, word: string): string => `${n} ${word}${n === 1 ? "" : "s"}`;
85
+
86
+ /** Test counters for stderr: "12 tests, 1 skipped, 1 failing". */
87
+ function enCountsLine(tests: Test[]): string {
88
+ const sk = tests.filter((t) => t.status === "skipped" || t.status === "todo").length;
89
+ const fl = tests.filter((t) => t.status === "failed").length;
90
+ return [enCount(tests.length, "test"), sk ? `${sk} skipped` : "", fl ? `${fl} failing` : ""].filter(Boolean).join(", ");
91
+ }
92
+
93
+ function findRoot(root?: string): string {
94
+ if (root) return path.resolve(root);
95
+ try {
96
+ const top = execFileSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim();
97
+ if (top) return top;
98
+ } catch {
99
+ /* not a git repository — the current directory */
100
+ }
101
+ return process.cwd();
102
+ }
103
+
104
+ const newNode = (): Node => ({ tests: [], children: new Map(), doc: [] });
105
+ const testKey = (t: Test): string => JSON.stringify([...t.describes, t.name]);
106
+
107
+ /**
108
+ * The folder's main file (`<name>.test.ts`, `<name>.e2e.ts`…) — it holds the story, the page starts with it. Its name
109
+ * is the folder name for all kinds: one rule instead of a special `rule.test.ts` for a standard. It lies in the folder
110
+ * root or in a monorepo package subfolder (`<name>/<package>/<name>.test.ts`: a package has its own runner, but the
111
+ * decision is one); not deeper.
112
+ * → the package ("" — the folder root) and whether these are unit statements (`.test.*`); not the main one — null.
113
+ */
114
+ function mainOf(file: string): { pkg: string; unit: boolean } | null {
115
+ const [kind, name] = L.classify(file);
116
+ if (!KINDS.includes(kind) || name === null) return null;
117
+ const inner = file.split("/").slice(3);
118
+ const base = inner.at(-1)!.split(".");
119
+ if (inner.length > 2 || base[0] !== name) return null;
120
+ return { pkg: inner.length === 2 ? inner[0]! : "", unit: base[1] === "test" };
121
+ }
122
+
123
+ const isMain = (file: string): boolean => mainOf(file) !== null;
124
+
125
+ /**
126
+ * A file's place in the folder's story — the sort key: the main file in the folder root, then the subfolders' main
127
+ * files by package name, then the rest; for the main one — first `<name>.test.*` (unit statements), then another
128
+ * `<name>.*` (`.e2e.ts`): alphabetically e2e would come first. Within a place — by path.
129
+ */
130
+ function mainRank(file: string): string {
131
+ const m = mainOf(file);
132
+ return (m ? "0" + m.pkg + "\0" + (m.unit ? "0" : "1") : "1") + "\0" + file;
133
+ }
134
+
135
+ /** The former main file of a standard, `rule.*` right in the folder: no longer the main one — it must be renamed. */
136
+ function isLegacyMain(file: string): boolean {
137
+ const [kind, name] = L.classify(file);
138
+ const parts = file.split("/");
139
+ return (kind === "standard" || kind === "architecture") && name !== "rule" && parts.length === 4 && parts[3]!.split(".")[0] === "rule";
140
+ }
141
+
142
+ /** → groups by (kind, name), tests outside the tree by file, tests/lib files. */
143
+ export function build(tests: Test[]): { groups: Map<string, Group>; out: Map<string, Test[]>; libFiles: Set<string> } {
144
+ const groups = new Map<string, Group>();
145
+ const out = new Map<string, Test[]>();
146
+ const libFiles = new Set<string>();
147
+ // the folder's main file first — the order of its describes is the order of the story; then the packages' main
148
+ // files, then files by path; within a file — the report order (declaration order)
149
+ const rank = (t: Test): string => mainRank(t.path);
150
+ const sorted = [...tests].sort((a, b) => (rank(a) < rank(b) ? -1 : rank(a) > rank(b) ? 1 : 0));
151
+ for (const t of sorted) {
152
+ const [kind, name] = L.classify(t.path);
153
+ if (kind === "lib") {
154
+ libFiles.add(t.path);
155
+ continue;
156
+ }
157
+ if (kind === "out" || name === null) {
158
+ if (!out.has(t.path)) out.set(t.path, []);
159
+ out.get(t.path)!.push(t);
160
+ continue;
161
+ }
162
+ const gk = kind + "/" + name;
163
+ if (!groups.has(gk)) groups.set(gk, { kind, name, tests: [], tree: newNode(), doc: [], testDocs: new Map() });
164
+ const g = groups.get(gk)!;
165
+ g.tests.push(t);
166
+ let node = g.tree;
167
+ for (const d of t.describes) {
168
+ if (!node.children.has(d)) node.children.set(d, newNode());
169
+ node = node.children.get(d)!;
170
+ }
171
+ node.tests.push(t);
172
+ }
173
+ return { groups, out, libFiles };
174
+ }
175
+
176
+ const addOnce = (list: string[], text: string): void => {
177
+ if (text && !list.includes(text)) list.push(text);
178
+ };
179
+
180
+ /**
181
+ * The folder's main file we expect when the report doesn't have it: `<folder>.test.ts`; for a folder with package
182
+ * subfolders — or `<package>/<folder>.test.ts`.
183
+ */
184
+ function mainPath(g: Group): string {
185
+ const folder = `${L.TESTS}/${SUBDIR[g.kind]}/${g.name}`;
186
+ const packages = g.tests.some((t) => t.path.split("/").length > 4);
187
+ return `${folder}/${g.name}.test.ts` + (packages ? ` or ${folder}/<package>/${g.name}.test.ts` : "");
188
+ }
189
+
190
+ /** The group has a main file: in the report or on disk — in the folder root or in a package subfolder. */
191
+ function hasMain(g: Group, root: string): boolean {
192
+ if (g.tests.some((t) => isMain(t.path))) return true;
193
+ const folder = `${L.TESTS}/${SUBDIR[g.kind]}/${g.name}`;
194
+ const files = (rel: string) => {
195
+ try {
196
+ return readdirSync(path.join(root, rel), { withFileTypes: true });
197
+ } catch {
198
+ return [];
199
+ }
200
+ };
201
+ return files(folder).some((e) =>
202
+ e.isDirectory() ? files(`${folder}/${e.name}`).some((f) => L.isTestFile(f.name) && isMain(`${folder}/${e.name}/${f.name}`)) : L.isTestFile(e.name) && isMain(`${folder}/${e.name}`),
203
+ );
204
+ }
205
+
206
+ /** describe and it names that aren't statements — per file, without repeats. */
207
+ function badNames(groups: Map<string, Group>): [string, string][] {
208
+ const seen = new Set<string>();
209
+ const out: [string, string][] = [];
210
+ for (const g of groups.values()) {
211
+ for (const t of g.tests) {
212
+ for (const n of [...t.describes, t.name]) {
213
+ const k = t.path + "\0" + n;
214
+ if (seen.has(k) || L.isStatement(n, LANG)) continue;
215
+ seen.add(k);
216
+ out.push([n, t.path]);
217
+ }
218
+ }
219
+ }
220
+ return out;
221
+ }
222
+
223
+ /** The decision's description — `<folder>.md` next to the folder's tests, the path from the root. */
224
+ export const descriptionPath =(g: { kind: Kind; name: string }): string => `${L.TESTS}/${SUBDIR[g.kind]}/${g.name}/${g.name}.md`;
225
+
226
+ /**
227
+ * The group's prose: the intro — `<folder>.md` (why, the reason, what was rejected), describe and it — their JSDoc from
228
+ * the sources (reports don't carry comments). A describe without tests in the report (a name from it.each with
229
+ * substitution) loses its prose. Returns files missing on disk (they're without prose), folders without a description,
230
+ * test files with a header — the story is written in one place, a file header doesn't go into the documentation —
231
+ * former main `rule.*` files that must be renamed, and those whose prose can't be taken: the parser didn't parse the
232
+ * file, or a test with JSDoc has a name that can't be computed statically.
233
+ */
234
+ export function attachDocs(
235
+ groups: Map<string, Group>,
236
+ root: string,
237
+ ): { missing: string[]; headers: string[]; legacy: string[]; undescribed: string[]; unread: string[] } {
238
+ const missing: string[] = [];
239
+ const unread: string[] = [];
240
+ const headers: string[] = [];
241
+ const legacy: string[] = [];
242
+ const undescribed: string[] = [];
243
+ for (const g of groups.values()) {
244
+ try {
245
+ const text = readFileSync(path.join(root, descriptionPath(g)), "utf8").trim();
246
+ if (text) g.doc.push(text);
247
+ } catch {
248
+ undescribed.push(descriptionPath(g));
249
+ }
250
+ // file order — as in build: the main one first
251
+ for (const file of [...new Set(g.tests.map((t) => t.path))]) {
252
+ if (isLegacyMain(file)) legacy.push(file);
253
+ let source: string;
254
+ try {
255
+ source = readFileSync(path.join(root, file), "utf8");
256
+ } catch {
257
+ missing.push(file);
258
+ continue;
259
+ }
260
+ let scan: ReturnType<typeof L.scanJs>;
261
+ try {
262
+ scan = L.scanJs(file, source);
263
+ } catch (e) {
264
+ unread.push(`${file}: ${(e as Error).message} — JSDoc prose not taken`);
265
+ continue;
266
+ }
267
+ const { docs } = scan;
268
+ for (const u of scan.unnamed) if (u.doc) unread.push(`${file}:${u.line}: the ${u.kind} name can't be computed statically (${u.name}) — JSDoc prose not taken`);
269
+ if (docs.file) headers.push(file);
270
+ for (const [key, text] of docs.describes) {
271
+ let node: Node | undefined = g.tree;
272
+ for (const d of JSON.parse(key) as string[]) node = node?.children.get(d);
273
+ if (node) addOnce(node.doc, text);
274
+ }
275
+ for (const [key, text] of docs.tests) {
276
+ if (!g.testDocs.has(key)) g.testDocs.set(key, []);
277
+ addOnce(g.testDocs.get(key)!, text);
278
+ }
279
+ }
280
+ }
281
+ return { missing, headers, legacy, undescribed, unread };
282
+ }
283
+
284
+ /** Harness run metadata: test file + name → what isn't in the name (example code, exception reason). */
285
+ type Meta = {
286
+ path?: string;
287
+ code?: string;
288
+ issue?: number;
289
+ reason?: string;
290
+ disables?: { line: number; rules: string[]; description: string }[];
291
+ id?: string;
292
+ sequence?: Call[];
293
+ };
294
+ let META = new Map<string, Meta>();
295
+ // tests whose diagram stands as a mark in the folder description: we don't repeat it under the test
296
+ const PLACED = new Set<string>();
297
+
298
+ /**
299
+ * A Mermaid sequence diagram from a trace: participants — in order of appearance; a name that isn't an identifier —
300
+ * via `as`.
301
+ */
302
+ export function sequenceLines(calls: Call[]): string[] {
303
+ const ids = new Map<string, string>();
304
+ for (const c of calls) for (const n of [c.from, c.to]) if (!ids.has(n)) ids.set(n, n.replace(/[^A-Za-z0-9_]/g, "_"));
305
+ return [
306
+ "```mermaid",
307
+ "sequenceDiagram",
308
+ ...[...ids].map(([n, id]) => ` participant ${id}${id === n ? "" : ` as ${n}`}`),
309
+ // Mermaid reads ";" and "#" as a separator and a character code
310
+ ...calls.map((c) => ` ${ids.get(c.from)}->>${ids.get(c.to)}: ${c.message.replace(/[;#]/g, ",")}`),
311
+ "```",
312
+ ];
313
+ }
314
+
315
+ export function loadMeta(dir: string): Map<string, Meta> {
316
+ const out = new Map<string, Meta>();
317
+ let files: string[];
318
+ try {
319
+ files = readdirSync(dir).filter((f) => f.endsWith(".jsonl")).sort();
320
+ } catch {
321
+ return out;
322
+ }
323
+ for (const f of files) {
324
+ for (const line of readFileSync(path.join(dir, f), "utf8").split("\n")) {
325
+ if (!line.trim()) continue;
326
+ const { file, test, ...rest } = JSON.parse(line) as Meta & { file: string; test: string };
327
+ out.set(file + "\0" + test, rest);
328
+ }
329
+ }
330
+ return out;
331
+ }
332
+
333
+ // the language of the example's code block by its file's extension
334
+ const FENCE_LANG: Record<string, string> = { ts: "ts", mts: "ts", cts: "ts", tsx: "tsx", js: "js", mjs: "js", cjs: "js", jsx: "jsx" };
335
+
336
+ /**
337
+ * The lint disables file — from the name of the harness `lintExceptions` test in any dictionary language:
338
+ * "exceptions in <file>: …".
339
+ */
340
+ const LINT_FILE = new RegExp(`^(?:${locale.every((n) => n.spec.harness.lintExceptions).map((t) => locale.escapeRe(t.split("{file}")[0]!)).join("|")})(.+?): `);
341
+
342
+ /** Under the test line: the example's path and code, the exception's task and reason, rule disables in the file. */
343
+ function metaLines(t: Test): string[] {
344
+ const m = META.get(t.path + "\0" + t.name);
345
+ if (!m) return [];
346
+ const out: string[] = [];
347
+ if (m.code !== undefined) {
348
+ if (m.path) out.push(` \`${m.path}\``);
349
+ out.push(" ```" + (FENCE_LANG[(m.path ?? "").split(".").pop() ?? ""] ?? ""), ...m.code.split("\n").map((l) => (l ? " " + l : "")), " ```");
350
+ }
351
+ if (m.issue !== undefined) out.push(` > #${m.issue} — ${m.reason ?? ""}`);
352
+ else if (m.reason) out.push(` > ${m.reason}`); // out of scope — a reason without a task: it's a decision, not a debt
353
+ const codeFile = LINT_FILE.exec(t.name)?.[1] ?? "";
354
+ for (const d of m.disables ?? []) out.push(` > \`${codeFile}:${d.line}\` ${d.rules.join(", ") || N.spec.lint.allRules} — ${d.description || N.spec.lint.noReason}`);
355
+ if (m.sequence?.length && !PLACED.has(t.path + "\0" + t.name)) out.push(...sequenceLines(m.sequence).map((l) => " " + l));
356
+ return out;
357
+ }
358
+
359
+ function testLine(t: Test): string {
360
+ let line = `- ${ICON[t.status] ?? "❔"} ${L.mdText(t.name)}`;
361
+ if (t.status === "skipped") line += ` — ${N.spec.doc.skipped}` + (t.reason ? `: ${L.mdText(t.reason)}` : "");
362
+ else if (t.status === "failed") line += ` — ${N.spec.doc.failing}`;
363
+ else if (t.status === "todo") line += ` — ${N.spec.doc.todo}`;
364
+ if (t.variants > 1) line += ` (${count(N.spec.doc.variants, t.variants)})`;
365
+ return line;
366
+ }
367
+
368
+ const heading = (level: number, text: string) => "#".repeat(Math.min(level, 6)) + " " + text;
369
+
370
+ /** Prose paragraphs under a heading; empty — nothing. */
371
+ function pushDoc(lines: string[], doc: string[]): void {
372
+ if (doc.length) lines.push(doc.join("\n\n"), "");
373
+ }
374
+
375
+ /**
376
+ * The section's tests — a collapsed block with a counter: the story is read through headings and prose, a check is
377
+ * opened by a click. There are skipped or failing ones — they're in the summary line, the block is open.
378
+ * A test's prose — a quote inside the list item: the list stays dense, the test line stays one line.
379
+ */
380
+ function renderTests(g: Group, tests: Test[], lines: string[]): void {
381
+ if (!tests.length) return;
382
+ const problems = problemsLine(tests);
383
+ const icon = tests.some((t) => t.status === "failed") ? ICON.failed : problems ? ICON.skipped : ICON.passed;
384
+ lines.push(`<details${problems ? " open" : ""}><summary>${icon} ${countsLine(tests)}</summary>`, "");
385
+ for (const t of tests) {
386
+ lines.push(testLine(t));
387
+ const doc = g.testDocs.get(testKey(t));
388
+ if (doc?.length) lines.push(...doc.join("\n\n").split("\n").map((l) => (l ? " > " + l : " >")));
389
+ lines.push(...metaLines(t));
390
+ }
391
+ lines.push("", "</details>", "");
392
+ }
393
+
394
+ function renderNode(g: Group, node: Node, level: number, lines: string[]): void {
395
+ renderTests(g, node.tests, lines);
396
+ for (const [name, child] of node.children) {
397
+ lines.push(heading(level, L.mdText(name)), "");
398
+ pushDoc(lines, child.doc);
399
+ renderNode(g, child, level + 1, lines);
400
+ }
401
+ }
402
+
403
+ /**
404
+ * The description's markdown under a folder heading of level `level`: its headings shift by level − 1 (except code
405
+ * blocks).
406
+ */
407
+ function shiftHeadings(text: string, level: number): string {
408
+ let fence = false;
409
+ return text
410
+ .split("\n")
411
+ .map((l) => {
412
+ if (/^\s*(```|~~~)/.test(l)) fence = !fence;
413
+ return !fence && /^#{1,6}\s/.test(l) ? "#".repeat(level - 1) + l : l;
414
+ })
415
+ .join("\n");
416
+ }
417
+
418
+ /**
419
+ * The group heading at level `level`, the description under it, describes — deeper. There's no "section · path ·
420
+ * counter" line: the section and the path follow from the convention, the status is visible at each test.
421
+ */
422
+ function renderGroup(g: Group, level: number): string {
423
+ const lines = [heading(level, g.name), ""];
424
+ pushDoc(lines, g.doc.map((d) => shiftHeadings(d, level)));
425
+ renderNode(g, g.tree, level + 1, lines);
426
+ return lines.join("\n").trimEnd() + "\n";
427
+ }
428
+
429
+ /**
430
+ * The first paragraph of the group's description (not a heading, a diagram or a table) as one line — the description
431
+ * in the index.
432
+ */
433
+ function summaryOf(g: Group): string {
434
+ const first = (g.doc[0] ?? "").split("\n\n").find((p) => p.trim() && !/^(#{1,6}\s|```|~~~|\|)/.test(p.trim())) ?? "";
435
+ return first.split("\n").map((l) => l.trim()).filter(Boolean).join(" ");
436
+ }
437
+
438
+ function indexLine(g: Group): string {
439
+ const summary = summaryOf(g);
440
+ const problems = problemsLine(g.tests);
441
+ const text = summary && problems ? `${summary} (${problems})` : summary || problems;
442
+ return `- [${g.name}](${SUBDIR[g.kind]}/${g.name}.md)` + (text ? ` — ${text}` : "");
443
+ }
444
+
445
+ function groupNames(groups: Map<string, Group>, kind: Kind): string[] {
446
+ return [...groups.values()].filter((g) => g.kind === kind).map((g) => g.name).sort();
447
+ }
448
+
449
+ function outSection(out: Map<string, Test[]>): string[] {
450
+ if (!out.size) return [];
451
+ const homes = KINDS.map((k) => `\`${L.TESTS}/${SUBDIR[k]}/<name>\``);
452
+ const intro = locale.fill(N.spec.doc.outsideIntro, { homes: homes.slice(0, -1).join(", "), last: homes.at(-1)! });
453
+ const lines = ["", `## ${N.spec.doc.outside}`, "", intro, ""];
454
+ for (const file of [...out.keys()].sort()) lines.push(`- \`${file}\` — ${testsWord(out.get(file)!.length)}`);
455
+ return lines;
456
+ }
457
+
458
+ /** The architecture page — next to README, the first line of the "What it's made of" section. */
459
+ const ARCH_PAGE = "architecture.md";
460
+
461
+ function renderIndex(groups: Map<string, Group>, out: Map<string, Test[]>, model: Model | null): string {
462
+ const lines = [N.spec.doc.mark, `# ${N.spec.doc.title}`, "", N.spec.doc.intro];
463
+ for (const kind of KINDS) {
464
+ const names = groupNames(groups, kind);
465
+ const page = kind === "architecture" && model;
466
+ // a project may have neither a model nor architecture rules at all — then there's no section, not "— none."
467
+ if (!names.length && !page && kind === "architecture") continue;
468
+ lines.push("", `## ${sectionTitle(kind)}`, "");
469
+ if (page) lines.push(`- [${N.spec.c4.title}](${ARCH_PAGE}) — ${C4.modelSummary(model, LANG)}`);
470
+ if (!names.length && !page) lines.push(N.spec.doc.none);
471
+ for (const n of names) lines.push(indexLine(groups.get(kind + "/" + n)!));
472
+ }
473
+ lines.push(...outSection(out));
474
+ return lines.join("\n") + "\n";
475
+ }
476
+
477
+ /** The architecture page: diagrams and the table from the model, then the rules checking the model against the code. */
478
+ function renderArchitecture(groups: Map<string, Group>, model: Model): string {
479
+ const lines = [N.spec.doc.mark, ...C4.architecturePage(model, 1, LANG), `## ${N.spec.doc.rules}`, ""];
480
+ const names = groupNames(groups, "architecture");
481
+ if (!names.length) lines.push(N.spec.doc.none);
482
+ for (const n of names) lines.push(indexLine(groups.get("architecture/" + n)!));
483
+ return lines.join("\n") + "\n";
484
+ }
485
+
486
+ function renderStdout(groups: Map<string, Group>, out: Map<string, Test[]>, model: Model | null): string {
487
+ const lines = [N.spec.doc.mark, `# ${N.spec.doc.title}`, "", N.spec.doc.intro];
488
+ for (const kind of KINDS) {
489
+ const names = groupNames(groups, kind);
490
+ const page = kind === "architecture" && model;
491
+ if (!names.length && !page && kind === "architecture") continue;
492
+ lines.push("", `## ${sectionTitle(kind)}`, "");
493
+ if (page) lines.push(...C4.architecturePage(model, 3, LANG));
494
+ if (!names.length && !page) lines.push(N.spec.doc.none);
495
+ for (const n of names) lines.push(renderGroup(groups.get(kind + "/" + n)!, 3).trimEnd(), "");
496
+ }
497
+ lines.push(...outSection(out));
498
+ return lines.join("\n").trimEnd() + "\n";
499
+ }
500
+
501
+ /**
502
+ * Its own generated file (the marker in the first line) can be deleted; someone else's can't. The marker is in any
503
+ * dictionary language: the project changed its language — the files of the former language are its own too.
504
+ */
505
+ const isOwn = (file: string): boolean => locale.every((n) => n.spec.doc.mark).includes(readFileSync(file, "utf8").split("\n", 1)[0]!);
506
+
507
+ function writeFiles(
508
+ groups: Map<string, Group>,
509
+ out: Map<string, Test[]>,
510
+ outDir: string,
511
+ model: Model | null,
512
+ ): { generated: Set<string>; removed: string[] } {
513
+ const generated = new Set<string>();
514
+ const removed: string[] = [];
515
+ mkdirSync(outDir, { recursive: true });
516
+ const arch = path.join(outDir, ARCH_PAGE);
517
+ if (model) {
518
+ writeFileSync(arch, renderArchitecture(groups, model));
519
+ generated.add(ARCH_PAGE);
520
+ } else if (existsSync(arch) && isOwn(arch)) {
521
+ // the model was removed — its page is outdated
522
+ unlinkSync(arch);
523
+ removed.push(ARCH_PAGE);
524
+ }
525
+ for (const g of groups.values()) {
526
+ const rel = path.join(SUBDIR[g.kind]!, g.name + ".md");
527
+ const full = path.join(outDir, rel);
528
+ mkdirSync(path.dirname(full), { recursive: true });
529
+ writeFileSync(full, N.spec.doc.mark + "\n" + renderGroup(g, 1));
530
+ generated.add(rel);
531
+ }
532
+ writeFileSync(path.join(outDir, "README.md"), renderIndex(groups, out, model));
533
+ // all the tree's tests from the report — the base for spec-diff: harness tests aren't visible in the sources, in the
534
+ // report they are
535
+ const seen = new Set<string>();
536
+ const list = [...groups.values()]
537
+ .flatMap((g) => g.tests.map((t) => ({ path: t.path, describes: t.describes, name: t.name })))
538
+ .filter((t) => {
539
+ const k = JSON.stringify(t);
540
+ return !seen.has(k) && !!seen.add(k);
541
+ })
542
+ .sort((x, y) => (JSON.stringify([x.path, x.describes, x.name]) < JSON.stringify([y.path, y.describes, y.name]) ? -1 : 1));
543
+ writeFileSync(path.join(outDir, "tests.json"), JSON.stringify(list, null, 1) + "\n");
544
+ for (const sub of Object.values(SUBDIR)) {
545
+ const d = path.join(outDir, sub);
546
+ if (!existsSync(d)) continue;
547
+ for (const fn of readdirSync(d).sort()) {
548
+ const rel = path.join(sub, fn);
549
+ if (!fn.endsWith(".md") || generated.has(rel)) continue;
550
+ if (isOwn(path.join(d, fn))) {
551
+ // its own outdated file; someone else's we don't touch
552
+ unlinkSync(path.join(d, fn));
553
+ removed.push(rel);
554
+ }
555
+ }
556
+ }
557
+ return { generated, removed };
558
+ }
559
+
560
+ /** The project's architecture model — one, at this place. */
561
+ const MODEL = `${L.TESTS}/architecture/model.ts`;
562
+
563
+ /**
564
+ * The model from `tests/architecture/model.ts` (export default); no file — null, doesn't load — an error with the
565
+ * reason.
566
+ */
567
+ async function loadModel(root: string): Promise<Model | null> {
568
+ const file = path.join(root, MODEL);
569
+ if (!existsSync(file)) return null;
570
+ let mod: { default?: unknown };
571
+ try {
572
+ mod = (await import(pathToFileURL(file).href)) as { default?: unknown };
573
+ } catch (e) {
574
+ throw new Error(`model ${MODEL} doesn't load: ${(e as Error).message}`);
575
+ }
576
+ const m = mod.default as Model | undefined;
577
+ if (!m || typeof m !== "object" || !m.modules || typeof m.modules !== "object") throw new Error(`model ${MODEL} has no export default with modules`);
578
+ return m;
579
+ }
580
+
581
+ /**
582
+ * `<!-- spec: … -->` marks in group descriptions → diagrams from the model; returns the unrecognized marks with the
583
+ * file.
584
+ */
585
+ const SEQUENCE_MARK = /^[ \t]*<!--\s*spec:\s*sequence-([\w-]+)\s*-->[ \t]*$/gm;
586
+
587
+ function embedMarks(groups: Map<string, Group>, model: Model | null): string[] {
588
+ const problems: string[] = [];
589
+ for (const g of groups.values()) {
590
+ const files = new Set(g.tests.map((t) => t.path));
591
+ g.doc = g.doc.map((d) => {
592
+ // a scenario — by id among this folder's tests: the diagram is in the story, it's no longer under the test
593
+ const withSequences = d.replace(SEQUENCE_MARK, (line, id: string) => {
594
+ const hit = [...META].find(([k, m]) => m.id === id && m.sequence && files.has(k.split("\0")[0]!));
595
+ if (!hit) {
596
+ problems.push(`mark <!-- spec: sequence-${id} --> in ${descriptionPath(g)}: no scenario ${id} in the folder's tests`);
597
+ return line;
598
+ }
599
+ PLACED.add(hit[0]);
600
+ return sequenceLines(hit[1].sequence!).join("\n");
601
+ });
602
+ const { text, bad } = C4.embed(withSequences, model, LANG);
603
+ for (const b of bad) {
604
+ const mark = `<!-- spec: ${b.mark} -->`;
605
+ problems.push(
606
+ b.missing === "model"
607
+ ? `mark ${mark} in ${descriptionPath(g)}: no model ${MODEL}`
608
+ : `unknown mark ${mark} in ${descriptionPath(g)} — available: ${[...Object.keys(C4.MARKS), "sequence-<id>"].join(", ")}`,
609
+ );
610
+ }
611
+ return text;
612
+ });
613
+ }
614
+ return problems;
615
+ }
616
+
617
+ // the former name exceptions — a directory of the decision folder and its old array file: names aren't exceptions any
618
+ // more (#447), nobody reads them
619
+ const NAMES_LEFTOVER = /^names\.exceptions(?:\.(?:ts|mts|js|mjs|json))?$/;
620
+
621
+ /** Leftover name exceptions under tests/ — the directory or the file, as a path from the root. */
622
+ function leftoverNames(root: string, rel = L.TESTS): string[] {
623
+ let entries;
624
+ try {
625
+ entries = readdirSync(path.join(root, rel), { withFileTypes: true });
626
+ } catch {
627
+ return [];
628
+ }
629
+ return entries
630
+ .filter((e) => e.name !== "node_modules")
631
+ .flatMap((e) => (NAMES_LEFTOVER.test(e.name) ? [`${rel}/${e.name}`] : e.isDirectory() ? leftoverNames(root, `${rel}/${e.name}`) : []))
632
+ .sort();
633
+ }
634
+
635
+ const USAGE =
636
+ "spec-doc report.json [report2.xml …] [--root DIR] [--out docs/spec | --stdout] [--strict] [--meta .spec-meta]";
637
+ /** `--help`, `-h` (tests/standards/cli-help): the synopsis, what it does and examples; `elves` prints it under its name. */
638
+ export const HELP = `${USAGE}
639
+
640
+ Documentation from runner reports (Vitest/Jest JSON, Playwright JSON, JUnit XML from bun test): a page per folder of
641
+ tests/capabilities, tests/architecture and tests/standards with prose from JSDoc and <folder>.md, by default into
642
+ docs/spec; --strict — code 1 when a file's header doesn't go into the documentation.
643
+
644
+ Examples:
645
+ spec-doc .spec-report.json --strict
646
+ spec-doc .spec-report.xml e2e-report.json --stdout`;
647
+
648
+ export async function main(argv: string[]): Promise<number> {
649
+ let opts;
650
+ try {
651
+ opts = parseArgs({
652
+ args: argv,
653
+ allowPositionals: true,
654
+ options: {
655
+ root: { type: "string" },
656
+ out: { type: "string", default: path.join("docs", "spec") },
657
+ stdout: { type: "boolean", default: false },
658
+ strict: { type: "boolean", default: false },
659
+ meta: { type: "string" },
660
+ help: { type: "boolean", short: "h", default: false },
661
+ },
662
+ });
663
+ } catch (e) {
664
+ console.error(`spec-doc: ${(e as Error).message}\n${USAGE}`);
665
+ return 2;
666
+ }
667
+ const { values, positionals } = opts;
668
+ if (values.help) return console.log(HELP), 0;
669
+ if (!positionals.length) return console.error(USAGE), 2;
670
+ const root = findRoot(values.root);
671
+ LANG = locale.projectLanguage(root);
672
+ N = locale.names(LANG);
673
+ let tests: Test[] = [];
674
+ // CI is set — the report must exist: otherwise a run that didn't write it silently drops its tests from the spec
675
+ const inCi = !!process.env.CI && !["0", "false"].includes(process.env.CI);
676
+ for (const r of positionals) {
677
+ if (!existsSync(r)) {
678
+ console.error(`spec-doc: no report ${r} — its tests didn't get into the spec${inCi ? "" : " (locally a warning, in CI an error)"}`);
679
+ if (inCi) return 2;
680
+ continue;
681
+ }
682
+ try {
683
+ tests.push(...L.loadReport(r, root));
684
+ } catch (e) {
685
+ console.error(`spec-doc: ${(e as Error).message}`);
686
+ return 2;
687
+ }
688
+ }
689
+ tests = L.mergeTests(tests);
690
+ let model: Model | null;
691
+ try {
692
+ model = await loadModel(root);
693
+ } catch (e) {
694
+ console.error(`spec-doc: ${(e as Error).message}`);
695
+ return 2;
696
+ }
697
+ META = loadMeta(path.resolve(root, values.meta ?? ".spec-meta"));
698
+ const { groups, out, libFiles } = build(tests);
699
+ const { missing, headers, legacy, undescribed, unread } = attachDocs(groups, root);
700
+ const noMain = [...groups.values()].filter((g) => !hasMain(g, root)).map(mainPath);
701
+ // a name that isn't a statement — a warning: the rule stays, the PR isn't red because of it (#447)
702
+ const names = badNames(groups);
703
+ const leftovers = leftoverNames(root);
704
+ const marks = embedMarks(groups, model);
705
+
706
+ if (values.stdout) {
707
+ process.stdout.write(renderStdout(groups, out, model));
708
+ } else {
709
+ const outDir = path.isAbsolute(values.out) ? values.out : path.join(root, values.out);
710
+ const { generated, removed } = writeFiles(groups, out, outDir, model);
711
+ for (const rel of removed) console.error(`spec-doc: deleted stale ${path.join(values.out, rel)}`);
712
+ console.error(`spec-doc: ${values.out}: README.md + ${enCount(generated.size, "file")}`);
713
+ }
714
+
715
+ const nCap = groupNames(groups, "capability").length;
716
+ const nArch = groupNames(groups, "architecture").length;
717
+ const nStd = groupNames(groups, "standard").length;
718
+ const inTree = [...groups.values()].flatMap((g) => g.tests);
719
+ const outN = [...out.values()].reduce((s, v) => s + v.length, 0);
720
+ console.error(
721
+ `spec-doc: capabilities ${nCap}, architecture ${nArch}, standards ${nStd}, ${enCountsLine(inTree)}; ${L.TESTS}/lib files skipped: ${libFiles.size}; outside the tree: ${outN} in ${enCount(out.size, "file")}`,
722
+ );
723
+ for (const file of [...out.keys()].sort()) console.error(`spec-doc: outside the tree: ${file} (${enCount(out.get(file)!.length, "test")})`);
724
+ if (missing.length) console.error(`spec-doc: no sources (${missing.length}) — JSDoc prose not taken: ${missing.join(", ")}`);
725
+ for (const m of unread) console.error(`spec-doc: ${m}`);
726
+ for (const file of undescribed) console.error(`spec-doc: no description ${file} — why, the reason, the rejected options`);
727
+ for (const file of noMain) console.error(`spec-doc: no main file ${file} — its describe blocks open the page`);
728
+ for (const [name, file] of names) console.error(`spec-doc: warning: the name isn't a statement in the project language (${LANG}): «${name}» (${file})`);
729
+ for (const at of leftovers) console.error(`spec-doc: warning: ${at} isn't read — names aren't exceptions any more, delete it`);
730
+ for (const file of headers) {
731
+ const [kind, name] = L.classify(file);
732
+ console.error(`spec-doc: a file header doesn't go into the documentation — move it to ${descriptionPath({ kind, name: name! })}: ${file}`);
733
+ }
734
+ for (const file of legacy) {
735
+ const base = path.posix.basename(file);
736
+ console.error(`spec-doc: the folder's main file is ${L.classify(file)[1]}${base.slice("rule".length)}, not ${base} — rename: ${file}`);
737
+ }
738
+ for (const m of marks) console.error(`spec-doc: ${m}`);
739
+ return (out.size || headers.length || legacy.length || undescribed.length || noMain.length || marks.length) && values.strict ? 1 : 0;
740
+ }
741
+
742
+ if (import.meta.main) {
743
+ process.exitCode = await main(process.argv.slice(2));
744
+ }