@miroshnik/elves 0.0.0 → 2026.1018.1
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.
- package/AGENTS.md +310 -0
- package/README.md +344 -0
- package/bin/ai-dev.ts +7 -0
- package/bin/elves.ts +2847 -0
- package/bin/shims/timeout +113 -0
- package/claude/CLAUDE.md +71 -0
- package/dist/github/scripts/github.js +3428 -0
- package/dist/spec/scripts/architecture.js +628 -0
- package/dist/spec/scripts/eslint-config.js +54 -0
- package/dist/spec/scripts/harness-qzb932c8.js +959 -0
- package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
- package/dist/spec/scripts/harness.js +53 -0
- package/dist/spec/scripts/knip-hints.cjs +34 -0
- package/docs/ci-concurrency.md +82 -0
- package/docs/cloud-sessions.md +204 -0
- package/docs/judgment.md +81 -0
- package/docs/parallel-checkouts.md +220 -0
- package/docs/pr-checks.md +117 -0
- package/docs/testing.md +304 -0
- package/package.json +70 -1
- package/skills/ci-wait/SKILL.md +180 -0
- package/skills/ci-wait/scripts/wait-ci.sh +377 -0
- package/skills/dashboard/SKILL.md +212 -0
- package/skills/dashboard/scripts/dashboard.ts +912 -0
- package/skills/dashboard/scripts/sessions.ts +772 -0
- package/skills/est/SKILL.md +222 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4867 -0
- package/skills/github/SKILL.md +206 -0
- package/skills/github/locales/en.json +449 -0
- package/skills/github/locales/ru.json +449 -0
- package/skills/github/reference.md +1073 -0
- package/skills/github/scripts/github.ts +4390 -0
- package/skills/github/scripts/locale.ts +495 -0
- package/skills/github/scripts/package.json +3 -0
- package/skills/slot/SKILL.md +145 -0
- package/skills/slot/scripts/package.json +3 -0
- package/skills/slot/scripts/slot.ts +279 -0
- package/skills/spec/SKILL.md +247 -0
- package/skills/spec/canon.md +219 -0
- package/skills/spec/checks.md +709 -0
- package/skills/spec/ci.md +378 -0
- package/skills/spec/migration.md +125 -0
- package/skills/spec/reference.md +889 -0
- package/skills/spec/scripts/architecture.ts +839 -0
- package/skills/spec/scripts/c4.ts +184 -0
- package/skills/spec/scripts/eslint-config.ts +82 -0
- package/skills/spec/scripts/harness.ts +1483 -0
- package/skills/spec/scripts/knip-hints.cjs +34 -0
- package/skills/spec/scripts/knip-hints.d.cts +17 -0
- package/skills/spec/scripts/package-run.ts +22 -0
- package/skills/spec/scripts/package.json +3 -0
- package/skills/spec/scripts/spec-break.ts +232 -0
- package/skills/spec/scripts/spec-claims.ts +198 -0
- package/skills/spec/scripts/spec-diff.ts +1111 -0
- package/skills/spec/scripts/spec-doc.ts +744 -0
- package/skills/spec/scripts/spec-exceptions.ts +233 -0
- package/skills/spec/scripts/spec-publish.ts +214 -0
- package/skills/spec/scripts/spec-run.ts +235 -0
- package/skills/spec/scripts/spec-shard.ts +267 -0
- package/skills/spec/scripts/speclib.ts +826 -0
- package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
- package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
- package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
- package/skills/task-env/SKILL.md +229 -0
- package/skills/task-env/proxy/compose.yaml +48 -0
- package/skills/task-env/reference.md +246 -0
- package/skills/task-env/scripts/task-env.ts +981 -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
|
+
}
|