@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.
- 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 +203 -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 +221 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4854 -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 +231 -0
- package/skills/task-env/proxy/compose.yaml +51 -0
- package/skills/task-env/reference.md +254 -0
- package/skills/task-env/scripts/task-env.ts +1017 -0
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* knip-hints — a knip reporter for the harness's `deadCode`: configuration hints (a redundant `ignore`, an `entry`
|
|
3
|
+
* without matches, a redundant workspace) as a JSON line
|
|
4
|
+
* `{ "configurationHints": [{ type, identifier, workspace, file? }] }`. knip's `json` reporter doesn't print them,
|
|
5
|
+
* and `--treat-config-hints-as-errors` gives only an exit code — the hints themselves aren't visible.
|
|
6
|
+
*
|
|
7
|
+
* knip --reporter json --reporter .agents/skills/spec/scripts/knip-hints.cjs
|
|
8
|
+
*
|
|
9
|
+
* The second output line is its own: `deadCode` joins both. CommonJS, not TypeScript: the harness takes the file
|
|
10
|
+
* path (`report.file`) with a static import — `import.meta` in a module that Playwright and Jest load as CommonJS is
|
|
11
|
+
* a syntax error (tests/standards/harness-load); knip loads it under both Node and Bun. Types — `knip-hints.d.cts`.
|
|
12
|
+
*/
|
|
13
|
+
"use strict";
|
|
14
|
+
|
|
15
|
+
const path = require("node:path");
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hints as knip collected them (`ReporterOptions.configurationHints`): an identifier may be a regex (an `ignore`
|
|
19
|
+
* pattern), a file path is absolute; `--no-config-hints` — the project itself opted out of hints.
|
|
20
|
+
*/
|
|
21
|
+
function report(options) {
|
|
22
|
+
const hints = options.isDisableConfigHints
|
|
23
|
+
? []
|
|
24
|
+
: options.configurationHints.map((h) => ({
|
|
25
|
+
type: h.type,
|
|
26
|
+
identifier: h.identifier instanceof RegExp ? h.identifier.source.replaceAll("\\/", "/") : String(h.identifier),
|
|
27
|
+
workspace: h.workspaceName ?? ".",
|
|
28
|
+
...(h.filePath ? { file: path.relative(options.cwd, path.resolve(options.cwd, h.filePath)).split(path.sep).join("/") } : {}),
|
|
29
|
+
}));
|
|
30
|
+
process.stdout.write(JSON.stringify({ configurationHints: hints }) + "\n");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
report.file = __filename;
|
|
34
|
+
module.exports = report;
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// Types of the `knip-hints.cjs` reporter: without them a project's TypeScript with `checkJs` would check the file.
|
|
2
|
+
|
|
3
|
+
/** A configuration hint as knip collected it. */
|
|
4
|
+
interface Hint {
|
|
5
|
+
type: string;
|
|
6
|
+
identifier: string | RegExp;
|
|
7
|
+
workspaceName?: string;
|
|
8
|
+
filePath?: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** knip reporter: configuration hints as a JSON line on stdout. */
|
|
12
|
+
declare function report(options: { cwd: string; configurationHints: Hint[]; isDisableConfigHints?: boolean }): void;
|
|
13
|
+
declare namespace report {
|
|
14
|
+
/** Path of the reporter file — for `knip --reporter`. */
|
|
15
|
+
const file: string;
|
|
16
|
+
}
|
|
17
|
+
export = report;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The package's script as a command — one form for package.json, CI, the harness's hints and the help of
|
|
3
|
+
* `elves <script>`: `bun` reads the bin itself, past its shebang, and without the flag would load the project's env
|
|
4
|
+
* files into it — the script would pass their keys on to `gh`, `git` and the checked command
|
|
5
|
+
* (tests/standards/no-env-file, #596); a step of GitHub Actions doesn't see `node_modules/.bin`, and `bunx elves`
|
|
6
|
+
* without the package in node_modules would fetch someone else's `elves` from npm (#558, question 2). The installer
|
|
7
|
+
* imports it, and so does the harness — a module of the package built for Node: constants without imports, the
|
|
8
|
+
* installer's start doesn't load the harness's parser.
|
|
9
|
+
*/
|
|
10
|
+
export const PACKAGE_RUN = "bun --no-env-file node_modules/.bin/elves";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* The package of the project's version through bunx — where `node_modules/.bin/elves` isn't there or doesn't run: a CI
|
|
14
|
+
* job without the project's dependencies (#597) and a project on Yarn 2+ (#608). Yarn 2+ has no `node_modules` with
|
|
15
|
+
* PnP, and with any `nodeLinker` it runs a `.ts` bin by Node, past its shebang — Node doesn't strip types under
|
|
16
|
+
* `node_modules`; bunx runs the bin by its shebang, under Bun without env files (#592). Needs `jq`; `$(…)` works in
|
|
17
|
+
* Yarn's shell too. The first run fetches the package of that version into Bun's cache.
|
|
18
|
+
*/
|
|
19
|
+
export const PACKAGE_BUNX = `bunx @miroshnik/elves@"$(jq -r '.devDependencies["@miroshnik/elves"]' package.json)"`;
|
|
20
|
+
|
|
21
|
+
/** The package's script in a project: on Yarn 2+ (`berry` of the github skill's `packageManager`) — `PACKAGE_BUNX`. */
|
|
22
|
+
export const packageRun = (berry: boolean) => (berry ? PACKAGE_BUNX : PACKAGE_RUN);
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* spec-break — a check by breaking: can the check fail. Applies an edit to the project's source, runs the test command,
|
|
4
|
+
* expects red and always rolls the edit back; prints ✅ (failed) / ❌ (didn't fail) for each break. The agent picks the
|
|
5
|
+
* break for one check — this is a proof, not mutation testing.
|
|
6
|
+
*
|
|
7
|
+
* bun spec-break.ts --file <file> --find <fragment> --replace <replacement> [--name <name>] [--no-baseline] -- <test command…>
|
|
8
|
+
* bun spec-break.ts --plan breaks.json [--no-baseline] [-- <test command…>]
|
|
9
|
+
* bun spec-break.ts --restore
|
|
10
|
+
*
|
|
11
|
+
* A plan is JSON `[{ name, file, find, replace, cmd? }]`: `cmd` — a string for `sh -c`, without it — the command after
|
|
12
|
+
* `--`. The `find` fragment occurs in the file exactly once. Each break is a separate run: the first red test in a serial
|
|
13
|
+
* group hides the rest. Before the breaks — a baseline run of each command: red even without a break, ✅ would prove
|
|
14
|
+
* nothing — code 2 (`--no-baseline` skips it).
|
|
15
|
+
*
|
|
16
|
+
* Rollback: after the run; on SIGINT, SIGTERM, SIGHUP the test command is killed together with its processes and the
|
|
17
|
+
* file is restored. For an abort with no chance of rollback (kill -9, a closed session) a journal is written before the
|
|
18
|
+
* break (`.git/spec-break.json`, outside git — `.spec-break.json`), and the next run or `--restore` restores the file
|
|
19
|
+
* from it; the file was edited after the abort — the journal doesn't touch it, code 2. Codes: 0 — failed on every break,
|
|
20
|
+
* 1 — there is a ❌, 2 — a call error, a red baseline run, the journal not applied.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
24
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
25
|
+
import path from "node:path";
|
|
26
|
+
import { parseArgs } from "node:util";
|
|
27
|
+
|
|
28
|
+
const USAGE = [
|
|
29
|
+
"spec-break --file <file> --find <fragment> --replace <replacement> [--name <name>] [--no-baseline] -- <test command…>",
|
|
30
|
+
"spec-break --plan breaks.json [--no-baseline] [-- <test command…>]",
|
|
31
|
+
"spec-break --restore",
|
|
32
|
+
].join("\n");
|
|
33
|
+
/** `--help`, `-h` (tests/standards/cli-help): the synopsis, what it does and examples; `elves` prints it under its name. */
|
|
34
|
+
export const HELP = `${USAGE}
|
|
35
|
+
|
|
36
|
+
Can the check fail: applies the break, runs the test command, expects red and always rolls the break back; ✅ — failed,
|
|
37
|
+
❌ — didn't. Code 0 — failed on every break, 1 — there is a ❌, 2 — a call error, a red baseline run, the journal not
|
|
38
|
+
applied.
|
|
39
|
+
|
|
40
|
+
Examples:
|
|
41
|
+
spec-break --file src/audit.ts --find "writeAudit(" --replace "void (" -- bun test tests/standards/audit
|
|
42
|
+
spec-break --plan breaks.json -- bun test`;
|
|
43
|
+
|
|
44
|
+
interface Break {
|
|
45
|
+
name: string;
|
|
46
|
+
file: string;
|
|
47
|
+
find: string;
|
|
48
|
+
replace: string;
|
|
49
|
+
cmd?: string;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** The test command: argv after `--` or a plan string for `sh -c`. */
|
|
53
|
+
type Command = string[];
|
|
54
|
+
|
|
55
|
+
/** A record of an applied break: by it the next run restores the file if the rollback didn't happen. */
|
|
56
|
+
interface Journal {
|
|
57
|
+
name: string;
|
|
58
|
+
file: string;
|
|
59
|
+
original: string;
|
|
60
|
+
broken: string;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
class Failure extends Error {}
|
|
64
|
+
|
|
65
|
+
function journalPath(): string {
|
|
66
|
+
const r = spawnSync("git", ["rev-parse", "--git-path", "spec-break.json"], { encoding: "utf8" });
|
|
67
|
+
return r.status === 0 && r.stdout.trim() ? path.resolve(r.stdout.trim()) : path.resolve(".spec-break.json");
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Restore the file from the journal of an aborted run; no journal — nothing. The file was edited after the break — leave it. */
|
|
71
|
+
function restoreJournal(journal: string): void {
|
|
72
|
+
if (!existsSync(journal)) return;
|
|
73
|
+
const j = JSON.parse(readFileSync(journal, "utf8")) as Journal;
|
|
74
|
+
const rel = path.relative(process.cwd(), j.file) || j.file;
|
|
75
|
+
const now = existsSync(j.file) ? readFileSync(j.file, "utf8") : null;
|
|
76
|
+
if (now === j.original) {
|
|
77
|
+
rmSync(journal);
|
|
78
|
+
return;
|
|
79
|
+
}
|
|
80
|
+
if (now !== j.broken) {
|
|
81
|
+
throw new Failure(`${rel} changed after the breakage «${j.name}» — journal ${journal} not applied: compare the file by hand and delete the journal`);
|
|
82
|
+
}
|
|
83
|
+
writeFileSync(j.file, j.original);
|
|
84
|
+
rmSync(journal);
|
|
85
|
+
console.log(`spec-break: restored ${rel} after an interrupted run (breakage «${j.name}»)`);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The break is applicable: the file exists, the fragment occurs in it exactly once. */
|
|
89
|
+
function check(b: Break): void {
|
|
90
|
+
if (!existsSync(b.file)) throw new Failure(`breakage «${b.name}»: no file ${b.file}`);
|
|
91
|
+
const n = readFileSync(b.file, "utf8").split(b.find).length - 1;
|
|
92
|
+
if (n === 0) throw new Failure(`breakage «${b.name}»: «${b.find}» not found in ${b.file}`);
|
|
93
|
+
if (n > 1) throw new Failure(`breakage «${b.name}»: «${b.find}» occurs ${n} times in ${b.file} — make the fragment longer`);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
let child: ReturnType<typeof spawn> | null = null;
|
|
97
|
+
let applied: { journal: string; j: Journal } | null = null;
|
|
98
|
+
|
|
99
|
+
/** Roll the applied break back: the file to its original state, the journal away. */
|
|
100
|
+
function undo(): void {
|
|
101
|
+
if (!applied) return;
|
|
102
|
+
writeFileSync(applied.j.file, applied.j.original);
|
|
103
|
+
rmSync(applied.journal, { force: true });
|
|
104
|
+
applied = null;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Run the command in its own process group (a signal goes to the whole group); the exit code and the output tail. */
|
|
108
|
+
function run(cmd: Command): Promise<{ code: number; tail: string }> {
|
|
109
|
+
return new Promise((resolve, reject) => {
|
|
110
|
+
const out: string[] = [];
|
|
111
|
+
const p = spawn(cmd[0]!, cmd.slice(1), { detached: true, stdio: ["ignore", "pipe", "pipe"] });
|
|
112
|
+
child = p;
|
|
113
|
+
const keep = (d: Buffer) => {
|
|
114
|
+
out.push(d.toString("utf8"));
|
|
115
|
+
if (out.length > 200) out.splice(0, out.length - 200);
|
|
116
|
+
};
|
|
117
|
+
p.stdout!.on("data", keep);
|
|
118
|
+
p.stderr!.on("data", keep);
|
|
119
|
+
p.on("error", (e) => {
|
|
120
|
+
child = null;
|
|
121
|
+
reject(new Failure(`the test command didn't start: ${cmd.join(" ")}: ${e.message}`));
|
|
122
|
+
});
|
|
123
|
+
p.on("close", (code, signal) => {
|
|
124
|
+
child = null;
|
|
125
|
+
resolve({ code: code ?? (signal ? 128 : 1), tail: out.join("").split("\n").slice(-20).join("\n") });
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function onSignal(signal: NodeJS.Signals): void {
|
|
131
|
+
if (child?.pid) {
|
|
132
|
+
try {
|
|
133
|
+
process.kill(-child.pid, "SIGKILL");
|
|
134
|
+
} catch {
|
|
135
|
+
/* the group has already exited */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
const file = applied && path.relative(process.cwd(), applied.j.file);
|
|
139
|
+
undo();
|
|
140
|
+
console.error(`spec-break: interrupted (${signal})${file ? ` — ${file} restored` : ""}`);
|
|
141
|
+
process.exit(signal === "SIGINT" ? 130 : signal === "SIGHUP" ? 129 : 143);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const label = (cmd: Command) => (cmd[0] === "sh" && cmd[1] === "-c" ? cmd[2]! : cmd.join(" "));
|
|
145
|
+
|
|
146
|
+
export async function main(argv: string[]): Promise<number> {
|
|
147
|
+
let opts;
|
|
148
|
+
try {
|
|
149
|
+
opts = parseArgs({
|
|
150
|
+
args: argv,
|
|
151
|
+
allowPositionals: true,
|
|
152
|
+
options: {
|
|
153
|
+
file: { type: "string" },
|
|
154
|
+
find: { type: "string" },
|
|
155
|
+
replace: { type: "string" },
|
|
156
|
+
name: { type: "string" },
|
|
157
|
+
plan: { type: "string" },
|
|
158
|
+
restore: { type: "boolean", default: false },
|
|
159
|
+
"no-baseline": { type: "boolean", default: false },
|
|
160
|
+
help: { type: "boolean", short: "h", default: false },
|
|
161
|
+
},
|
|
162
|
+
});
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.error(`spec-break: ${(e as Error).message}\n${USAGE}`);
|
|
165
|
+
return 2;
|
|
166
|
+
}
|
|
167
|
+
const v = opts.values;
|
|
168
|
+
if (v.help) return console.log(HELP), 0;
|
|
169
|
+
const journal = journalPath();
|
|
170
|
+
try {
|
|
171
|
+
restoreJournal(journal);
|
|
172
|
+
if (v.restore) return 0;
|
|
173
|
+
|
|
174
|
+
const fallback: Command = opts.positionals;
|
|
175
|
+
let breaks: Break[];
|
|
176
|
+
if (v.plan) {
|
|
177
|
+
breaks = JSON.parse(readFileSync(v.plan, "utf8")) as Break[];
|
|
178
|
+
if (!Array.isArray(breaks) || !breaks.length) throw new Failure(`plan ${v.plan} must be a non-empty array [{ name, file, find, replace, cmd? }]`);
|
|
179
|
+
} else if (v.file && v.find !== undefined && v.replace !== undefined) {
|
|
180
|
+
breaks = [{ name: v.name ?? `${v.find} → ${v.replace}`, file: v.file, find: v.find, replace: v.replace }];
|
|
181
|
+
} else {
|
|
182
|
+
throw new Failure(`a breakage is needed: --file, --find and --replace, or --plan\n${USAGE}`);
|
|
183
|
+
}
|
|
184
|
+
const cmdOf = (b: Break): Command => (b.cmd ? ["sh", "-c", b.cmd] : fallback);
|
|
185
|
+
for (const b of breaks) {
|
|
186
|
+
if (!cmdOf(b).length) throw new Failure(`breakage «${b.name}»: no test command — after -- or in the plan's cmd`);
|
|
187
|
+
check(b);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
for (const s of ["SIGINT", "SIGTERM", "SIGHUP"] as const) process.on(s, onSignal);
|
|
191
|
+
|
|
192
|
+
if (!v["no-baseline"]) {
|
|
193
|
+
const cmds = new Map(breaks.map((b) => [JSON.stringify(cmdOf(b)), cmdOf(b)]));
|
|
194
|
+
for (const cmd of cmds.values()) {
|
|
195
|
+
const r = await run(cmd);
|
|
196
|
+
if (r.code !== 0) throw new Failure(`the run is red even without a breakage — breakages not applied: ${label(cmd)} (code ${r.code})\n${r.tail}`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let caught = 0;
|
|
201
|
+
for (const b of breaks) {
|
|
202
|
+
const original = readFileSync(b.file, "utf8");
|
|
203
|
+
const j: Journal = { name: b.name, file: path.resolve(b.file), original, broken: original.replace(b.find, () => b.replace) };
|
|
204
|
+
// the journal goes before the edit: if aborted after it, the next run finds what to restore
|
|
205
|
+
writeFileSync(journal, JSON.stringify(j));
|
|
206
|
+
applied = { journal, j };
|
|
207
|
+
writeFileSync(j.file, j.broken);
|
|
208
|
+
let r: { code: number };
|
|
209
|
+
try {
|
|
210
|
+
r = await run(cmdOf(b));
|
|
211
|
+
} finally {
|
|
212
|
+
undo();
|
|
213
|
+
}
|
|
214
|
+
if (r.code !== 0) {
|
|
215
|
+
caught++;
|
|
216
|
+
console.log(`✅ failed: ${b.name} (${b.file})`);
|
|
217
|
+
} else {
|
|
218
|
+
console.log(`❌ didn't fail: ${b.name} (${b.file}) — the check doesn't catch this breakage`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
console.log(`total: ${caught} of ${breaks.length} breakages made the check fail; files are in their original state`);
|
|
222
|
+
return caught === breaks.length ? 0 : 1;
|
|
223
|
+
} catch (e) {
|
|
224
|
+
if (!(e instanceof Failure)) throw e;
|
|
225
|
+
console.error(`spec-break: ${e.message}`);
|
|
226
|
+
return 2;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (import.meta.main) {
|
|
231
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
232
|
+
}
|
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* spec-claims — checks the entry-point registry against the call log: every entry point (route, page, job, command) is
|
|
4
|
+
* called by at least one capability test. The log is written by the harness's `journal` during the run; logs of shards
|
|
5
|
+
* and runners are gathered from the directory and its subdirectories (a directory per runner). A call not from
|
|
6
|
+
* `tests/capabilities/` doesn't count: claimed behavior is a capability, not a standard or a helper. Ids in the
|
|
7
|
+
* registry don't repeat (a repeat would hide an uncovered entry point behind a covered one), every id of the log is in
|
|
8
|
+
* the registry (otherwise the format diverged or the registry is incomplete).
|
|
9
|
+
*
|
|
10
|
+
* bun spec-claims.ts --entries <entries.json | entries.ts> [--journal .spec-journal] [--exceptions <directory>]
|
|
11
|
+
* [--standard tests/standards/entry-points] [--report .spec-claims.xml]
|
|
12
|
+
*
|
|
13
|
+
* The registry is a JSON array of strings or a project module (default — an array or a function that returns it): entry
|
|
14
|
+
* points from the code, not a hand-written list. Exceptions — the standard folder's `exceptions/` directory, a file per
|
|
15
|
+
* exception (`{ item, issue, reason }`): an entry point without a test, with a task, until a test appears; once it
|
|
16
|
+
* appears — "remove the exception". The report is JUnit in the standard's folder (`spec-doc` puts it into the spec with
|
|
17
|
+
* the description from the standard's `<folder>.md`). Codes: 0 — everything is claimed, 1 — there are unclaimed ones or
|
|
18
|
+
* exception errors, 2 — a call error.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
import { pathToFileURL } from "node:url";
|
|
24
|
+
import { parseArgs } from "node:util";
|
|
25
|
+
|
|
26
|
+
import locale from "../../github/scripts/locale.ts";
|
|
27
|
+
import { exceptionFiles } from "./harness.ts";
|
|
28
|
+
import type { Exception } from "./harness.ts";
|
|
29
|
+
|
|
30
|
+
const USAGE =
|
|
31
|
+
"spec-claims --entries <entries.json | entries.ts> [--journal .spec-journal] [--exceptions tests/standards/entry-points/exceptions] [--standard tests/standards/entry-points] [--report .spec-claims.xml]";
|
|
32
|
+
/** `--help`, `-h` (tests/standards/cli-help): the synopsis, what it does and examples; `elves` prints it under its name. */
|
|
33
|
+
export const HELP = `${USAGE}
|
|
34
|
+
|
|
35
|
+
Every entry point of the registry is called by at least one capability test (the harness's journal). Code 0 — everything
|
|
36
|
+
is claimed, 1 — there are unclaimed ones or exception errors, 2 — a call error.
|
|
37
|
+
|
|
38
|
+
Examples:
|
|
39
|
+
spec-claims --entries tests/standards/entry-points/entries.ts
|
|
40
|
+
spec-claims --entries entries.json --journal .spec-journal --report .spec-claims.xml`;
|
|
41
|
+
const CAPABILITIES = "tests/capabilities/";
|
|
42
|
+
|
|
43
|
+
interface Case {
|
|
44
|
+
name: string;
|
|
45
|
+
failure?: string;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** A JSON file or a project module: default — a value or a function that returns it. */
|
|
49
|
+
async function load(file: string): Promise<unknown> {
|
|
50
|
+
if (file.endsWith(".json")) return JSON.parse(readFileSync(file, "utf8"));
|
|
51
|
+
const mod = (await import(pathToFileURL(file).href)) as { default?: unknown };
|
|
52
|
+
const v = mod.default;
|
|
53
|
+
return typeof v === "function" ? await (v as () => unknown)() : v;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Entry point → test files that called it (all logs of the directory and subdirectories — a directory per runner). */
|
|
57
|
+
function readJournal(dir: string): Map<string, Set<string>> {
|
|
58
|
+
const calls = new Map<string, Set<string>>();
|
|
59
|
+
const files = readdirSync(dir, { recursive: true, encoding: "utf8" });
|
|
60
|
+
for (const f of files.filter((f) => f.endsWith(".jsonl")).sort()) {
|
|
61
|
+
for (const line of readFileSync(path.join(dir, f), "utf8").split("\n")) {
|
|
62
|
+
if (!line.trim()) continue;
|
|
63
|
+
const { id, test } = JSON.parse(line) as { id: string; test: string | null };
|
|
64
|
+
calls.set(id, (calls.get(id) ?? new Set()).add(test ?? "(not from a test)"));
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return calls;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
const esc = (s: string) => s.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
|
|
71
|
+
|
|
72
|
+
/** JUnit as in `bun test`: a suite per file, a nested suite — describe, testcase with file. */
|
|
73
|
+
function junit(file: string, describe: string, cases: Case[]): string {
|
|
74
|
+
const f = esc(file);
|
|
75
|
+
const body = cases
|
|
76
|
+
.map((c) =>
|
|
77
|
+
c.failure
|
|
78
|
+
? ` <testcase name="${esc(c.name)}" file="${f}">\n <failure message="${esc(c.failure)}" />\n </testcase>`
|
|
79
|
+
: ` <testcase name="${esc(c.name)}" file="${f}" />`,
|
|
80
|
+
)
|
|
81
|
+
.join("\n");
|
|
82
|
+
const failures = cases.filter((c) => c.failure).length;
|
|
83
|
+
return `<?xml version="1.0" encoding="UTF-8"?>
|
|
84
|
+
<testsuites name="spec-claims" tests="${cases.length}" failures="${failures}">
|
|
85
|
+
<testsuite name="${f}" file="${f}" tests="${cases.length}" failures="${failures}">
|
|
86
|
+
<testsuite name="${esc(describe)}" file="${f}" tests="${cases.length}" failures="${failures}">
|
|
87
|
+
${body}
|
|
88
|
+
</testsuite>
|
|
89
|
+
</testsuite>
|
|
90
|
+
</testsuites>
|
|
91
|
+
`;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export async function main(argv: string[]): Promise<number> {
|
|
95
|
+
let opts;
|
|
96
|
+
try {
|
|
97
|
+
opts = parseArgs({
|
|
98
|
+
args: argv,
|
|
99
|
+
options: {
|
|
100
|
+
entries: { type: "string" },
|
|
101
|
+
journal: { type: "string", default: ".spec-journal" },
|
|
102
|
+
exceptions: { type: "string" },
|
|
103
|
+
standard: { type: "string", default: "tests/standards/entry-points" },
|
|
104
|
+
report: { type: "string", default: ".spec-claims.xml" },
|
|
105
|
+
help: { type: "boolean", short: "h", default: false },
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
} catch (e) {
|
|
109
|
+
console.error(`spec-claims: ${(e as Error).message}\n${USAGE}`);
|
|
110
|
+
return 2;
|
|
111
|
+
}
|
|
112
|
+
const v = opts.values;
|
|
113
|
+
if (v.help) return console.log(HELP), 0;
|
|
114
|
+
if (!v.entries) return console.error(USAGE), 2;
|
|
115
|
+
let entries: string[];
|
|
116
|
+
let repeated: string[];
|
|
117
|
+
let exceptions: Exception[] = [];
|
|
118
|
+
let calls: Map<string, Set<string>>;
|
|
119
|
+
try {
|
|
120
|
+
const raw = await load(path.resolve(v.entries));
|
|
121
|
+
if (!Array.isArray(raw)) throw new Error(`${v.entries}: the registry is an array of entry points`);
|
|
122
|
+
const ids = raw.map((e) => (typeof e === "string" ? e : String((e as { id?: unknown }).id)));
|
|
123
|
+
const count = new Map<string, number>();
|
|
124
|
+
for (const id of ids) count.set(id, (count.get(id) ?? 0) + 1);
|
|
125
|
+
entries = [...count.keys()].sort();
|
|
126
|
+
repeated = entries.filter((id) => count.get(id)! > 1).map((id) => `${id} ×${count.get(id)}`);
|
|
127
|
+
if (v.exceptions) {
|
|
128
|
+
// a file per exception: the old array in one file is migrated by a command, not read by a second path
|
|
129
|
+
if (existsSync(v.exceptions) && !statSync(v.exceptions).isDirectory()) {
|
|
130
|
+
throw new Error(`--exceptions is an exceptions/ directory (a file per exception), not the file ${v.exceptions} — move it with spec-exceptions`);
|
|
131
|
+
}
|
|
132
|
+
exceptions = exceptionFiles(v.exceptions);
|
|
133
|
+
}
|
|
134
|
+
if (!existsSync(v.journal)) {
|
|
135
|
+
throw new Error(`no log ${v.journal} — the harness's journal writes it during the test run (shard logs go into one directory)`);
|
|
136
|
+
}
|
|
137
|
+
calls = readJournal(v.journal);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error(`spec-claims: ${(e as Error).message}`);
|
|
140
|
+
return 2;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// check test names are in the project language, like the harness's names: they go into the spec
|
|
144
|
+
const N = locale.names(locale.projectLanguage());
|
|
145
|
+
const claimed = (id: string) => [...(calls.get(id) ?? [])].some((t) => t.startsWith(CAPABILITIES));
|
|
146
|
+
const excepted = new Map(exceptions.map((e) => [e.item, e]));
|
|
147
|
+
const cases: Case[] = [];
|
|
148
|
+
cases.push({
|
|
149
|
+
name: locale.fill(N.spec.harness.registryNotEmpty, { registry: N.spec.claims.registry, tag: "" }),
|
|
150
|
+
failure: entries.length ? undefined : "the entry-point registry is empty — the selection from the code is wrong",
|
|
151
|
+
});
|
|
152
|
+
cases.push({
|
|
153
|
+
name: N.spec.claims.unique,
|
|
154
|
+
failure: repeated.length
|
|
155
|
+
? `an id repeats in the registry: ${repeated.join(", ")} — each entry point has its own id, otherwise an uncovered one hides behind a covered one`
|
|
156
|
+
: undefined,
|
|
157
|
+
});
|
|
158
|
+
for (const id of entries.filter((id) => !excepted.has(id))) {
|
|
159
|
+
const from = [...(calls.get(id) ?? [])].sort();
|
|
160
|
+
cases.push({
|
|
161
|
+
name: locale.fill(N.spec.claims.called, { entry: id }),
|
|
162
|
+
failure: claimed(id) ? undefined : `${id} is not called by any capability test${from.length ? ` (calls: ${from.join(", ")})` : ""} — add a test or remove the entry point`,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
const registered = new Set(entries);
|
|
166
|
+
const unknown = [...calls.keys()]
|
|
167
|
+
.filter((id) => !registered.has(id))
|
|
168
|
+
.sort()
|
|
169
|
+
.map((id) => `${id} (calls: ${[...calls.get(id)!].sort().join(", ")})`);
|
|
170
|
+
cases.push({
|
|
171
|
+
name: N.spec.claims.known,
|
|
172
|
+
failure: unknown.length
|
|
173
|
+
? `ids from the log are not in the registry: ${unknown.join("; ")} — the log's id format diverged from the registry, the registry is incomplete, or the log is left over from a previous run (resetJournal)`
|
|
174
|
+
: undefined,
|
|
175
|
+
});
|
|
176
|
+
for (const e of [...excepted.values()].sort((a, b) => (a.item < b.item ? -1 : 1))) {
|
|
177
|
+
let failure: string | undefined;
|
|
178
|
+
if (!Number.isInteger(e.issue) || e.issue <= 0) failure = `exception ${e.item} has no issue`;
|
|
179
|
+
else if (!e.reason?.trim()) failure = `exception ${e.item} has no reason`;
|
|
180
|
+
else if (!entries.includes(e.item)) failure = `entry point ${e.item} is not in the registry — remove the exception: delete ${e.file}`;
|
|
181
|
+
else if (claimed(e.item)) failure = `${e.item} is already called by a capability test — remove the exception: delete ${e.file} (#${e.issue})`;
|
|
182
|
+
cases.push({ name: locale.fill(N.spec.harness.exception, { tag: "", item: e.item, issue: e.issue }), failure });
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
const standard = v.standard.replace(/\/+$/, "");
|
|
186
|
+
writeFileSync(v.report, junit(`${standard}/${path.posix.basename(standard)}.test.ts`, N.spec.claims.describe, cases));
|
|
187
|
+
const unclaimed = entries.filter((id) => !excepted.has(id) && !claimed(id));
|
|
188
|
+
const failed = cases.filter((c) => c.failure);
|
|
189
|
+
console.error(
|
|
190
|
+
`spec-claims: entry points: ${entries.length}, not called by capability tests: ${unclaimed.length}, exceptions: ${excepted.size}; report: ${v.report}`,
|
|
191
|
+
);
|
|
192
|
+
for (const c of failed) console.error(`spec-claims: ✗ ${c.failure}`);
|
|
193
|
+
return failed.length ? 1 : 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (import.meta.main) {
|
|
197
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
198
|
+
}
|