@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,1483 @@
1
+ /**
2
+ * harness — tests of mechanical checks for the spec tree (tests/capabilities, tests/architecture, tests/standards):
3
+ * a check registers ordinary runner tests, so it shows up in the runner's report and in the spec-doc documentation.
4
+ *
5
+ * "Registry + invariant" — a behavior convention ("every mutation writes an audit record") is checked on every element
6
+ * of a registry taken from the code, not on one example. "Examples" — a lint rule is checked on code where it must
7
+ * fire, stay silent and not apply; the linter goes through an adapter (ESLint — `eslintLinter`).
8
+ *
9
+ * The runner doesn't matter: `it` passes the test (Vitest, Jest, bun test, node:test — all have `it(name, fn)`).
10
+ * Import from the project's package (`@miroshnik/elves/spec/harness`); the project's test loads it
11
+ * under the project's runner — Bun, Node, Vitest, Playwright or Jest, as ESM or as CommonJS — so only erasable
12
+ * TypeScript syntax and `node:` APIs, with no dependencies (tests/standards/harness-load).
13
+ */
14
+
15
+ import { spawnSync } from "node:child_process";
16
+ import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, rmSync } from "node:fs";
17
+ import { createRequire } from "node:module";
18
+ import path from "node:path";
19
+ import { pathToFileURL } from "node:url";
20
+
21
+ import locale from "../../github/scripts/locale.ts";
22
+ import type { Names } from "../../github/scripts/locale.ts";
23
+ import type { Model } from "./architecture.ts";
24
+ import knipHints from "./knip-hints.cjs";
25
+ import { PACKAGE_BUNX, PACKAGE_RUN } from "./package-run.ts";
26
+ import { commentsOf, decisionFolder, EXCEPTIONS_DIR, exceptionFile, stringsOf } from "./speclib.ts";
27
+
28
+ /**
29
+ * Harness test names are in the project language (`language` in `.agents/elves.json` of the directory the runner runs
30
+ * from): they stand in the project's spec next to its own. The language is read when tests are registered, not when the
31
+ * module loads.
32
+ */
33
+ const localeNames = (): Names => locale.names(locale.projectLanguage());
34
+
35
+ /** The runner's `it`: a name and a body; the body throws (expect) on a violation. */
36
+ export type It = (name: string, fn: () => void | Promise<unknown>) => unknown;
37
+
38
+ export interface Invariant<T> {
39
+ /** Registry name — for the "not empty" test: "mutations", "test files that spawn processes". */
40
+ registry: string;
41
+ /**
42
+ * Registry elements — from the code (router, schema, files), not a hand-written list: a new element won't slip past
43
+ * the check. Computed before tests are registered — an async registry is awaited with a top-level `await` in the test
44
+ * file.
45
+ */
46
+ items: readonly T[];
47
+ /** Name of an element's test — a statement about it: "createInvoice writes an audit record". */
48
+ name: (item: T) => string;
49
+ /** Check of one element: throws if the element breaks the convention. */
50
+ check: (item: T) => unknown;
51
+ /**
52
+ * A known violator: the check must fail on it — otherwise it checks nothing. A check made of several halves (client
53
+ * and server) has a violator for each.
54
+ */
55
+ violator: { name: string; item: T } | readonly { name: string; item: T }[];
56
+ /** Element key for exceptions, scope and `includes` — a stable identifier (name, path); defaults to the test name. */
57
+ key?: (item: T) => string;
58
+ /**
59
+ * The element's file from the project root (route, script, migration): "outside scope" and the element's exception
60
+ * are a mark in it (`marksIn`), not a record in the test. Element removed — the mark is gone too; two PRs don't edit
61
+ * a shared file. `null` — no file of its own (a schema row, a router entry): `outside` and `exceptions/`. The root is
62
+ * the directory with the `tests/` tree the decision folder is in, whatever the runner's cwd (a package directory in a
63
+ * monorepo) — no correction for cwd; an absolute path is taken as is; a path with no file — a failing test.
64
+ */
65
+ fileOf?: (item: T) => string | null | undefined;
66
+ /**
67
+ * Convention identifier when a folder has several over one registry: exceptions only with this `rule`; service tests
68
+ * name it: registry "forms" is not empty (audit).
69
+ */
70
+ rule?: string;
71
+ /**
72
+ * Exceptions — the decision folder's `exceptions/` directory (`exceptionsIn()`): the element, the task that lifts it,
73
+ * and the reason.
74
+ */
75
+ exceptions?: readonly Exception[];
76
+ /** Outside scope — elements the convention deliberately doesn't cover, with a reason: it is shown on the page. */
77
+ outside?: readonly { item: string; reason: string }[];
78
+ /** Keys that must be in the registry: "not empty" won't notice that the selection lost half of the elements. */
79
+ includes?: readonly string[];
80
+ }
81
+
82
+ /** An exception from a convention: the element key, the number of the task that pays off the debt, and the reason. */
83
+ export interface Exception {
84
+ item: string;
85
+ issue: number;
86
+ reason: string;
87
+ /**
88
+ * The convention (the invariant's `rule`) the exception belongs to; without it — all invariants of the folder. No
89
+ * invariant of the folder has the rule — a failing test "exceptions with a rule belong to the folder's invariant
90
+ * rules" with the file path.
91
+ */
92
+ rule?: string;
93
+ /** Exception file — set by `exceptionsIn` or a mark: the ratchet names which file to delete or where the mark is. */
94
+ file?: string;
95
+ /** Line of the mark in the element's file — an exception by a mark, not by a file in `exceptions/`. */
96
+ line?: number;
97
+ }
98
+
99
+ /** An element's "outside scope": key and reason; for a mark — its location. */
100
+ interface Outside {
101
+ item: string;
102
+ reason: string;
103
+ file?: string;
104
+ line?: number;
105
+ }
106
+
107
+ /**
108
+ * A mark in the element's file — a comment, like `eslint-disable` for lint: `spec-outside(<decision>[/<rule>]): reason`
109
+ * or `spec-exception(<decision>[/<rule>]) #N: reason`. The decision is the decision folder's name (`audit` —
110
+ * `tests/standards/audit`): a file can be an element of registries of different folders, the mark names its own;
111
+ * `rule` is the folder's invariant, without it — all of them.
112
+ */
113
+ export interface Mark {
114
+ kind: "outside" | "exception";
115
+ decision: string;
116
+ rule?: string;
117
+ issue?: number;
118
+ reason: string;
119
+ file: string;
120
+ line: number;
121
+ }
122
+
123
+ // a mark starts the comment text; the reason runs to the end of the line, without a block comment's closing
124
+ const MARK = /^spec-(outside|exception)\(([^()\s]*)\)\s*(?:#(\d+))?\s*:?\s*(.*?)\s*(?:\*\/|-->)?\s*$/;
125
+ // not a JS file: the mark right after the comment start — //, /*, * of a block, #, --, <!--, ;
126
+ const MARK_LINE = /(?:^|\s)(?:\/\/|\/\*+|\*|#|--|<!--|;)\s*(spec-(?:outside|exception)\(.*)$/;
127
+
128
+ function markOf(file: string, line: number, text: string): Mark | null {
129
+ const m = MARK.exec(text);
130
+ if (!m) return null;
131
+ const [decision, ...rule] = m[2]!.split("/");
132
+ return { kind: m[1] as Mark["kind"], decision: decision!, ...(rule.length ? { rule: rule.join("/") } : {}), ...(m[3] ? { issue: Number(m[3]) } : {}), reason: m[4]!, file, line };
133
+ }
134
+
135
+ // a JSON string or a structural token: number values, true and null carry no marks — skipped
136
+ const JSON_TOKEN = /"(?:[^"\\]|\\.)*"|[{}[\]:,]/g;
137
+
138
+ /**
139
+ * JSON comments — values of the top-level `"//"` key (an npm convention: `package.json` has no comments), a string or a
140
+ * list of strings: the offset in the text and the string.
141
+ */
142
+ function jsonNotes(text: string): [number, string][] {
143
+ const out: [number, string][] = [];
144
+ const notes: boolean[] = []; // open containers: true — a list under the top-level "//" key
145
+ let key = "";
146
+ let prev = "";
147
+ for (const m of text.matchAll(JSON_TOKEN)) {
148
+ const t = m[0];
149
+ const note = notes.length === 1 && prev === ":" && key === "//";
150
+ if (t === "{" || t === "[") notes.push(note && t === "[");
151
+ else if (t === "}" || t === "]") notes.pop();
152
+ else if (t.startsWith('"')) {
153
+ if (note || (notes.length === 2 && notes[1])) out.push([m.index, JSON.parse(t) as string]);
154
+ else if (notes.length === 1 && (prev === "{" || prev === ",")) key = JSON.parse(t) as string;
155
+ }
156
+ prev = t;
157
+ }
158
+ return out;
159
+ }
160
+
161
+ /** Strict JSON: it has no comments. JSONC (`tsconfig.json`) isn't parsed — its marks are in comments. */
162
+ function isJson(text: string): boolean {
163
+ try {
164
+ JSON.parse(text);
165
+ return true;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Marks in a file: in JS/TS code — only in comments (strings, templates, regexes are skipped, a parser does it), in
173
+ * JSON — the value of the top-level `"//"` key (a string or a list; JSONC with comments — like other files), in other
174
+ * files (SQL, shell, HTML) — right after the comment start. A mark starts at the beginning of the comment text: a
175
+ * mention of the format mid-prose is not a mark.
176
+ */
177
+ export function marksIn(file: string, text: string): Mark[] {
178
+ const out: Mark[] = [];
179
+ if (/\.json$/i.test(file) && isJson(text)) {
180
+ for (const [at, note] of jsonNotes(text)) {
181
+ const mark = markOf(file, text.slice(0, at).split("\n").length, note);
182
+ if (mark) out.push(mark);
183
+ }
184
+ return out;
185
+ }
186
+ if (!CODE.test(file)) {
187
+ text.split("\n").forEach((l, i) => {
188
+ const m = MARK_LINE.exec(l);
189
+ const mark = m && markOf(file, i + 1, m[1]!);
190
+ if (mark) out.push(mark);
191
+ });
192
+ return out;
193
+ }
194
+ for (const [at, comment] of commentsOf(text, file)) {
195
+ if (!comment.includes("spec-")) continue;
196
+ const first = text.slice(0, at).split("\n").length;
197
+ comment.split("\n").forEach((l, i) => {
198
+ const mark = markOf(file, first + i, l.replace(/^\s*(?:\/\/+|\/\*+|\*+)?\s*/, ""));
199
+ if (mark) out.push(mark);
200
+ });
201
+ }
202
+ return out;
203
+ }
204
+
205
+ /**
206
+ * The hint's command — the package's script, as in package.json: under Yarn's PnP (`process.versions.pnp`) there is no
207
+ * `node_modules/.bin/elves` — the package through bunx (#608). The harness doesn't import the github skill's
208
+ * `packageManager` (tests/standards/skill-imports); on Yarn 2+ with `node_modules` the agent's shell finds the bin by
209
+ * `PACKAGE_RUN` — `bun` reads it itself.
210
+ */
211
+ const MIGRATE = `migrate with spec-exceptions: ${process.versions.pnp ? PACKAGE_BUNX : PACKAGE_RUN} spec-exceptions`;
212
+
213
+ /**
214
+ * Exceptions of a decision folder — the `exceptions/` directory, a file per exception (`<element>.json`:
215
+ * `{ item, issue, reason, rule? }`): parallel PRs each add and remove their own file, while in a shared array
216
+ * neighboring lines conflict. The directory is `<dir>/exceptions`, by default the calling test's folder (by the stack,
217
+ * like `meta`); no directory — no exceptions. A file that isn't a JSON exception, or one element (with the same `rule`)
218
+ * in two files — an error with the paths.
219
+ */
220
+ export function exceptionsIn(dir?: string): Exception[] {
221
+ // from the test file's location, not from cwd: a monorepo package's runner runs from its own directory
222
+ const folder = dir ?? callerFolder()?.abs;
223
+ if (folder === undefined) throw new Error("exceptionsIn: not called from a test file — pass the decision folder's directory");
224
+ return exceptionFiles(path.join(folder, EXCEPTIONS_DIR));
225
+ }
226
+
227
+ /**
228
+ * Exceptions from a directory of files (`<decision folder>/exceptions`): a file per exception, by file name. No
229
+ * directory — no exceptions: git doesn't keep an empty directory, the last lifted exception takes it along. An
230
+ * exception's `file` is from the repository root (`shownPath`): the "delete" hint is read from there, not from the
231
+ * runner's cwd.
232
+ */
233
+ export function exceptionFiles(at: string): Exception[] {
234
+ let names: string[];
235
+ try {
236
+ names = readdirSync(at).filter((n) => !n.startsWith(".")).sort();
237
+ } catch {
238
+ return [];
239
+ }
240
+ const out: Exception[] = [];
241
+ const seen = new Map<string, string>();
242
+ for (const name of names) {
243
+ const abs = path.resolve(at, name);
244
+ const file = shownPath(abs);
245
+ if (!name.endsWith(".json")) throw new Error(`${file}: an exception is an <element>.json file with { item, issue, reason }`);
246
+ let data: Partial<Exception>;
247
+ try {
248
+ data = JSON.parse(readFileSync(abs, "utf8")) as Partial<Exception>;
249
+ } catch (e) {
250
+ throw new Error(`${file}: not JSON — ${(e as Error).message}`);
251
+ }
252
+ if (!data || typeof data !== "object" || typeof data.item !== "string") throw new Error(`${file}: an exception is { item, issue, reason }, rule is optional`);
253
+ const key = `${data.rule ?? ""}\0${data.item}`;
254
+ const twin = seen.get(key);
255
+ if (twin) throw new Error(`exception ${data.item}${data.rule ? ` (${data.rule})` : ""} is in two files: ${twin}, ${file} — keep one`);
256
+ seen.set(key, file);
257
+ out.push({ item: data.item, issue: data.issue as number, reason: String(data.reason ?? ""), ...(data.rule ? { rule: data.rule } : {}), file });
258
+ }
259
+ return out;
260
+ }
261
+
262
+ /**
263
+ * Repository root — the nearest directory up from cwd with `.git` (a directory or a worktree file); none — cwd. Paths
264
+ * in hints are from it: a monorepo package's runner runs from the package directory, while the hint is read from the
265
+ * root.
266
+ */
267
+ function repoRoot(): string {
268
+ const cwd = process.cwd();
269
+ for (let d = cwd; ; d = path.dirname(d)) {
270
+ if (existsSync(path.join(d, ".git"))) return d;
271
+ if (path.dirname(d) === d) return cwd;
272
+ }
273
+ }
274
+
275
+ /** A file path for a hint: from the repository root; outside the repository — absolute. */
276
+ function shownPath(abs: string): string {
277
+ const rel = path.relative(repoRoot(), abs);
278
+ return (rel.startsWith("..") || path.isAbsolute(rel) ? abs : rel).split(path.sep).join("/");
279
+ }
280
+
281
+ /**
282
+ * The calling test's decision folder: from the project root (`tests/<kind>/<name>`, outside `tests/` — the file's
283
+ * directory), its absolute path and the project root (the directory with the `tests/` tree) that `rel` is counted from.
284
+ */
285
+ function callerFolder(): { rel: string; abs: string; base: string } | null {
286
+ const t = testFileAt(new Error().stack ?? "", process.cwd());
287
+ if (!t) return null;
288
+ const rel = folderOf(t.rel);
289
+ const base = t.abs.slice(0, t.abs.length - t.rel.length);
290
+ return { rel, abs: path.join(base, rel), base };
291
+ }
292
+
293
+ /** A test file's decision folder from the project root: `tests/<kind>/<name>`; outside `tests/` — its directory. */
294
+ function folderOf(file: string): string {
295
+ const parts = file.split("/");
296
+ return parts[0] === "tests" && parts.length > 3 ? parts.slice(0, 3).join("/") : path.posix.dirname(file);
297
+ }
298
+
299
+ /**
300
+ * Files under a directory from the project root (`base`, cwd by default), in order; hidden ones and dependencies are
301
+ * skipped.
302
+ */
303
+ function filesUnder(rel: string, base = process.cwd()): string[] {
304
+ let entries;
305
+ try {
306
+ entries = readdirSync(path.resolve(base, rel), { withFileTypes: true });
307
+ } catch {
308
+ return [];
309
+ }
310
+ return entries
311
+ .filter((e) => !e.name.startsWith(".") && e.name !== "node_modules")
312
+ .flatMap((e) => (e.isDirectory() ? filesUnder(`${rel}/${e.name}`, base) : [`${rel}/${e.name}`]))
313
+ .sort();
314
+ }
315
+
316
+ /**
317
+ * The calling test folder's exceptions in the wrong place or form (the `exceptionFile` rule): an old `exceptions.ts` of
318
+ * the folder or a subfolder — a failing test with a migration hint; an `exceptions/` directory in a subfolder and
319
+ * non-JSON in the directory — a failing test with the paths. One per folder per run; the check registers it itself — a
320
+ * project that still imports `exceptions.ts` doesn't call `exceptionsIn`.
321
+ */
322
+ function legacyExceptions(it: It): void {
323
+ const caller = callerFolder();
324
+ if (caller === null) return;
325
+ const { rel: folder, base } = caller;
326
+ if (decisionFolder(`${folder}/_`) !== folder || seen.has(`legacy\0${folder}`)) return;
327
+ seen.add(`legacy\0${folder}`);
328
+ const found = filesUnder(folder, base).flatMap((f) => {
329
+ const x = exceptionFile(f);
330
+ return x?.dir === EXCEPTIONS_DIR ? [{ f, x }] : [];
331
+ });
332
+ const at = (f: string) => shownPath(path.join(base, f));
333
+ const legacy = found.filter(({ x }) => x.legacy).map(({ f, x }) => `${at(f)}: exceptions — a file per element in ${x.folder}/${EXCEPTIONS_DIR}/ — ${MIGRATE}`);
334
+ const misplaced = found.filter(({ x }) => x.error).map(({ f, x }) => `${at(f)}: ${x.error}`);
335
+ const N = localeNames();
336
+ if (legacy.length) {
337
+ it(N.spec.harness.exceptionFiles, () => {
338
+ throw new Error(legacy.join("\n"));
339
+ });
340
+ }
341
+ if (misplaced.length) {
342
+ it(N.spec.harness.exceptionJson, () => {
343
+ throw new Error(misplaced.join("\n"));
344
+ });
345
+ }
346
+ }
347
+
348
+ /**
349
+ * Invariant rules of a test file per run, plus the exceptions and marks the invariant didn't take: their `rule` is
350
+ * another rule's.
351
+ */
352
+ interface RuleLedger {
353
+ rules: Set<string>;
354
+ skipped: Exception[];
355
+ marks: Mark[];
356
+ }
357
+
358
+ // by `it` and test file: a test collector inside a harness test has its own `it`, its check doesn't mix with others'
359
+ const ledgers = new WeakMap<It, Map<string, RuleLedger>>();
360
+
361
+ /**
362
+ * An exception or mark with a `rule` that no invariant of the folder has (a typo, the rule renamed or removed) is taken
363
+ * by no invariant: the ratchet doesn't see it, and the debt silently drops out of the spec. When an invariant sets
364
+ * aside an exception or mark with another `rule`, a check test is registered — one for exceptions and one for marks,
365
+ * one per file; it runs after the file is collected and knows the rules of all its invariants.
366
+ * Rules of other files of the folder (subfolders with a shared `exceptions/`) run in another process or later — their
367
+ * check looks them up as strings in the code of those files.
368
+ */
369
+ function unknownRules(it: It, rule: string | undefined, exceptions: readonly Exception[], marks: readonly Mark[]): void {
370
+ const caller = testFileAt(new Error().stack ?? "", process.cwd());
371
+ const file = caller?.rel ?? "";
372
+ const byFile = ledgers.get(it) ?? new Map<string, RuleLedger>();
373
+ ledgers.set(it, byFile);
374
+ const ledger = byFile.get(file) ?? { rules: new Set<string>(), skipped: [], marks: [] };
375
+ byFile.set(file, ledger);
376
+ if (rule) ledger.rules.add(rule);
377
+ const reconcile = <X extends { rule?: string }>(list: X[], add: readonly X[], name: string, id: (x: X) => string, line: (x: X, have: string) => string): void => {
378
+ if (!add.length) return;
379
+ const first = !list.length;
380
+ list.push(...add);
381
+ if (!first) return;
382
+ it(name, () => {
383
+ const lost = new Map<string, X>();
384
+ let elsewhere: Set<string> | null = null;
385
+ for (const x of list) {
386
+ if (ledger.rules.has(x.rule!)) continue;
387
+ elsewhere ??= caller ? stringsBeside(caller.rel, caller.abs.slice(0, caller.abs.length - caller.rel.length)) : new Set();
388
+ if (!elsewhere.has(x.rule!)) lost.set(id(x), x);
389
+ }
390
+ if (!lost.size) return;
391
+ const rules = [...ledger.rules].sort();
392
+ const have = rules.length ? `the file's invariants have: ${rules.join(", ")}` : "the file's invariants have no rule";
393
+ throw new Error([...lost.values()].map((x) => line(x, have)).join("\n"));
394
+ });
395
+ };
396
+ const N = localeNames();
397
+ reconcile(
398
+ ledger.skipped,
399
+ exceptions.filter((e) => e.rule && e.rule !== rule),
400
+ N.spec.harness.exceptionRules,
401
+ (e) => e.file ?? `${e.rule}\0${e.item}`,
402
+ (e, have) => `${e.file ?? `exception ${e.item}`}: no invariant of the folder has rule «${e.rule}» (${have}) — fix the rule or ${e.file ? "delete the file" : "remove the exception"}`,
403
+ );
404
+ reconcile(ledger.marks, marks, N.spec.harness.markRules, (m) => `${m.file}:${m.line}`, (m, have) => `${m.file}:${m.line}: no invariant of the folder has rule «${m.rule}» (${have}) — fix the mark or remove it`);
405
+ }
406
+
407
+ /**
408
+ * Strings in the code of other files of the test's decision folder (`base` — the project root): the rules of their
409
+ * invariants that this run doesn't see.
410
+ */
411
+ function stringsBeside(file: string, base: string): Set<string> {
412
+ const out = new Set<string>();
413
+ for (const f of filesUnder(folderOf(file), base)) {
414
+ if (f === file || !CODE.test(f)) continue;
415
+ for (const s of stringsOf(readFileSync(path.resolve(base, f), "utf8"), f)) out.add(s);
416
+ }
417
+ return out;
418
+ }
419
+
420
+ /**
421
+ * What to do with an unneeded exception: remove the mark, delete the exception file, and an exception without a file —
422
+ * remove it from the list.
423
+ */
424
+ const dropHint = (e: Exception): string => (e.line ? `remove the mark in ${e.file}:${e.line}` : e.file ? `remove the exception: delete ${e.file}` : "remove the exception");
425
+
426
+ /**
427
+ * The folder's marks in element files (`fileOf`): "outside scope" and exceptions by element key; marks with another
428
+ * invariant's `rule` go to the rule check. A mark in a file of two elements is taken by neither — a failing test: which
429
+ * one it belongs to is unknown. `fileOf` is read from the project root the decision folder is counted from, not from
430
+ * the runner's cwd; a path with no file is a failing test, not a skip: the mark is there, the harness wouldn't see it.
431
+ */
432
+ function elementMarks<T>(it: It, spec: Invariant<T>, key: (item: T) => string, tag: string): { outside: Outside[]; exceptions: Exception[]; skipped: Mark[] } {
433
+ const out = { outside: [] as Outside[], exceptions: [] as Exception[], skipped: [] as Mark[] };
434
+ const folder = spec.fileOf && callerFolder();
435
+ if (!folder) return out;
436
+ const decision = path.posix.basename(folder.rel);
437
+ const byFile = new Map<string, string[]>();
438
+ for (const item of spec.items) {
439
+ const f = spec.fileOf!(item);
440
+ if (f) byFile.set(f, [...(byFile.get(f) ?? []), key(item)]);
441
+ }
442
+ const shared: string[] = [];
443
+ const missing: string[] = [];
444
+ for (const [file, keys] of byFile) {
445
+ let text: string;
446
+ try {
447
+ text = readFileSync(path.resolve(folder.base, file), "utf8");
448
+ } catch (e) {
449
+ const why = (e as NodeJS.ErrnoException).code === "ENOENT" ? "no such file: fileOf is a path from the project root (the directory with tests/), without a correction for the runner's cwd" : (e as Error).message;
450
+ missing.push(`${keys.join(", ")}: fileOf ${file} — ${why}`);
451
+ continue;
452
+ }
453
+ if (!text.includes("spec-")) continue;
454
+ // an absolute path (built from a root) is shown from the repository root, like other hints
455
+ const shown = path.isAbsolute(file) ? shownPath(file) : file;
456
+ const mine = marksIn(shown, text).filter((m) => m.decision === decision);
457
+ out.skipped.push(...mine.filter((m) => m.rule && m.rule !== spec.rule));
458
+ const marks = mine.filter((m) => !m.rule || m.rule === spec.rule);
459
+ if (!marks.length) continue;
460
+ if (keys.length > 1) {
461
+ shared.push(`${shown}:${marks[0]!.line}: the mark is read by elements ${keys.join(", ")} — which one it belongs to is unknown: an element without its own file goes to outside and exceptions/`);
462
+ continue;
463
+ }
464
+ for (const m of marks) {
465
+ if (m.kind === "outside") out.outside.push({ item: keys[0]!, reason: m.reason, file: shown, line: m.line });
466
+ else out.exceptions.push({ item: keys[0]!, issue: m.issue ?? 0, reason: m.reason, ...(m.rule ? { rule: m.rule } : {}), file: shown, line: m.line });
467
+ }
468
+ }
469
+ if (shared.length) {
470
+ it(locale.fill(localeNames().spec.harness.markOneElement, { tag }), () => {
471
+ throw new Error(shared.join("\n"));
472
+ });
473
+ }
474
+ if (missing.length) {
475
+ it(locale.fill(localeNames().spec.harness.elementFiles, { tag }), () => {
476
+ throw new Error(missing.join("\n"));
477
+ });
478
+ }
479
+ return out;
480
+ }
481
+
482
+ /**
483
+ * Tests of a convention: "registry is not empty" (a typo in a path doesn't pass silently), "violator does not pass"
484
+ * (the check can fail) and a test per element. No counters in the names: the registry grows — the spec diff shows only
485
+ * the new elements.
486
+ */
487
+ export function invariant<T>(it: It, spec: Invariant<T>): void {
488
+ legacyExceptions(it);
489
+ const N = localeNames();
490
+ // several conventions over one registry in one describe: the rule tells the service tests apart
491
+ const tag = spec.rule ? ` (${spec.rule})` : "";
492
+ it(locale.fill(N.spec.harness.registryNotEmpty, { registry: spec.registry, tag }), () => {
493
+ if (!spec.items.length) throw new Error(`registry «${spec.registry}» is empty — nothing to check: the registry's path or query is wrong`);
494
+ });
495
+ const key = spec.key ?? spec.name;
496
+ const byKey = new Map(spec.items.map((item) => [key(item), item]));
497
+ if (spec.includes?.length) {
498
+ it(locale.fill(N.spec.harness.registryFinds, { registry: spec.registry, items: spec.includes.join(", "), tag }), () => {
499
+ const missing = spec.includes!.filter((k) => !byKey.has(k));
500
+ if (missing.length) throw new Error(`registry «${spec.registry}» lacks: ${missing.join(", ")} — the registry's query lost elements`);
501
+ });
502
+ }
503
+ const violators = Array.isArray(spec.violator) ? spec.violator : [spec.violator as { name: string; item: T }];
504
+ for (const v of violators) {
505
+ it(locale.fill(N.spec.harness.violator, { tag, name: v.name }), async () => {
506
+ try {
507
+ await spec.check(v.item);
508
+ } catch {
509
+ return;
510
+ }
511
+ throw new Error(`the check passed on violator «${v.name}» — it checks nothing`);
512
+ });
513
+ }
514
+ // a mark in the element's file is its record; the same record also in the test or exceptions/ — a failing test, and
515
+ // the mark applies
516
+ const marks = elementMarks(it, spec, key, tag);
517
+ const marked = new Map<string, string>();
518
+ const twice: string[] = [];
519
+ for (const m of [...marks.outside, ...marks.exceptions]) {
520
+ const at = `${m.file}:${m.line}`;
521
+ if (marked.has(m.item)) twice.push(`${m.item}: marks ${marked.get(m.item)} and ${at} — keep one`);
522
+ else marked.set(m.item, at);
523
+ }
524
+ // an exception without a rule applies to all conventions of the folder, with a rule — only to its own
525
+ const listed = [...(spec.exceptions ?? []).filter((e) => !e.rule || e.rule === spec.rule), ...(spec.outside ?? [])] as (Exception | Outside)[];
526
+ for (const x of listed.filter((x) => marked.has(x.item))) twice.push(`${x.item}: mark ${marked.get(x.item)} and ${x.file ?? "outside in the test"} — keep one`);
527
+ if (twice.length) {
528
+ it(locale.fill(N.spec.harness.oneRecord, { tag }), () => {
529
+ throw new Error(twice.join("\n"));
530
+ });
531
+ }
532
+ const unmarked = <X extends { item: string }>(xs: readonly X[] | undefined): X[] => (xs ?? []).filter((x) => !marked.has(x.item));
533
+ const excepted = new Map([...unmarked(spec.exceptions).filter((e) => !e.rule || e.rule === spec.rule), ...marks.exceptions].map((e) => [e.item, e]));
534
+ const outside = new Map<string, Outside>([...unmarked(spec.outside), ...marks.outside].map((o) => [o.item, o]));
535
+ // by name, not in registry order: the order of files and queries depends on the machine, the spec doesn't
536
+ const named = spec.items.filter((item) => !excepted.has(key(item)) && !outside.has(key(item))).map((item) => ({ name: spec.name(item), item }));
537
+ named.sort(byName);
538
+ for (const { name, item } of named) {
539
+ it(name, async () => {
540
+ await spec.check(item);
541
+ });
542
+ }
543
+ // outside scope — deliberately, with the reason on the page; the element is gone — remove the scope record
544
+ for (const o of [...outside.values()].sort((a, b) => (a.item < b.item ? -1 : a.item > b.item ? 1 : 0))) {
545
+ const name = locale.fill(N.spec.harness.outside, { tag, item: o.item });
546
+ meta(name, { reason: o.reason });
547
+ it(name, () => {
548
+ if (!o.reason.trim()) throw new Error(`element ${o.item} outside scope has no reason${o.line ? ` — add the reason to the mark in ${o.file}:${o.line}` : ""}`);
549
+ if (!byKey.has(o.item)) throw new Error(`element ${o.item} is not in registry «${spec.registry}» — remove from scope`);
550
+ });
551
+ }
552
+ // ratchet: an exception lives while the element breaks the convention; once it complies — remove the exception
553
+ const exceptions = [...excepted.values()].sort((a, b) => (a.item < b.item ? -1 : a.item > b.item ? 1 : 0));
554
+ for (const e of exceptions) {
555
+ const name = locale.fill(N.spec.harness.exception, { tag, item: e.item, issue: e.issue });
556
+ meta(name, { issue: e.issue, reason: e.reason });
557
+ it(name, async () => {
558
+ if (!Number.isInteger(e.issue) || e.issue <= 0) throw new Error(`exception ${e.item} has no task — ${e.line ? `add #N to the mark in ${e.file}:${e.line}` : "issue: the number of the task that pays off the debt"}`);
559
+ if (!e.reason.trim()) throw new Error(`exception ${e.item} has no reason${e.line ? ` — add the reason to the mark in ${e.file}:${e.line}` : ""}`);
560
+ if (!byKey.has(e.item)) throw new Error(`element ${e.item} is not in registry «${spec.registry}» — ${dropHint(e)}`);
561
+ try {
562
+ await spec.check(byKey.get(e.item)!);
563
+ } catch {
564
+ return;
565
+ }
566
+ throw new Error(`${e.item} already complies with the convention — ${dropHint(e)} (#${e.issue})`);
567
+ });
568
+ }
569
+ unknownRules(it, spec.rule, spec.exceptions ?? [], marks.skipped);
570
+ }
571
+
572
+ const byName = (a: { name: string }, b: { name: string }): number => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
573
+
574
+ /** A linter message about code: the rule (null — not a rule, e.g. a parse error) and the text. */
575
+ export interface LintMessage {
576
+ ruleId: string | null;
577
+ message: string;
578
+ fatal?: boolean;
579
+ }
580
+
581
+ /** The project's linter: code and file path (from the project root — by it the config decides if the rule applies). */
582
+ export interface Linter {
583
+ lint(code: string, filePath: string): Promise<LintMessage[]>;
584
+ /**
585
+ * Load the config ahead of time: `await linter.ready()` at the top level of the file — not under the first example's
586
+ * timeout. Optional for an adapter without a slow load; `eslintLinter()` always has it.
587
+ */
588
+ ready?(): Promise<void>;
589
+ }
590
+
591
+ /** An example for a rule: name (a statement), path from the project root, and code. */
592
+ export interface Example {
593
+ name: string;
594
+ path: string;
595
+ code: string;
596
+ /** For "not allowed": how many times the rule must fire (at least once by default). */
597
+ count?: number;
598
+ }
599
+
600
+ export interface Examples {
601
+ linter: Linter;
602
+ /** Rule identifier as the linter reports it: `no-console`, `boundaries/element-types`. */
603
+ rule: string;
604
+ /** On this code the rule must fire — otherwise it checks nothing. */
605
+ bad: Example[];
606
+ /** On this code the rule must stay silent. */
607
+ good?: Example[];
608
+ /** A file outside the rule's scope: the same violation is allowed there. */
609
+ outside?: Example[];
610
+ }
611
+
612
+ /**
613
+ * Tests of a rule: "not allowed: …" — the rule fired, "allowed: …" and "outside scope: …" — it didn't. Other rules'
614
+ * hits count neither for nor against; an example that doesn't parse is a failing test. Without "not allowed" — a
615
+ * failing test "rule … has a 'not allowed' example": there's nothing to prove the rule can fail.
616
+ */
617
+ export function examples(it: It, spec: Examples): void {
618
+ const fires = async (e: Example): Promise<LintMessage[]> => {
619
+ const messages = await spec.linter.lint(e.code, e.path);
620
+ const fatal = messages.find((m) => m.fatal);
621
+ if (fatal) throw new Error(`the example doesn't parse: ${e.path} — ${fatal.message}`);
622
+ return messages.filter((m) => m.ruleId === spec.rule);
623
+ };
624
+ // the linter config loads at registration, not under the first example's timeout; a load error shows up in an example
625
+ spec.linter.ready?.().catch(() => {});
626
+ const N = localeNames();
627
+ if (!spec.bad.length) {
628
+ it(locale.fill(N.spec.harness.badExample, { rule: spec.rule }), () => {
629
+ throw new Error(`rule ${spec.rule} has no «not allowed» example — nothing proves it can fail`);
630
+ });
631
+ }
632
+ for (const e of [...spec.bad.map((x) => [N.spec.harness.bad, x] as const), ...(spec.good ?? []).map((x) => [N.spec.harness.good, x] as const), ...(spec.outside ?? []).map((x) => [N.spec.harness.outsideExample, x] as const)]) {
633
+ meta(`${e[0]}: ${e[1].name}`, { path: e[1].path, code: e[1].code });
634
+ }
635
+ for (const e of spec.bad) {
636
+ it(`${N.spec.harness.bad}: ${e.name}`, async () => {
637
+ const hits = (await fires(e)).length;
638
+ if (e.count !== undefined && hits !== e.count) throw new Error(`rule ${spec.rule} fired ${hits} ${hits === 1 ? "time" : "times"}, expected ${e.count} — ${e.path}`);
639
+ if (!hits) throw new Error(`rule ${spec.rule} didn't fire: ${e.path}`);
640
+ });
641
+ }
642
+ const silent = (label: string) => (e: Example) =>
643
+ it(`${label}: ${e.name}`, async () => {
644
+ const hit = await fires(e);
645
+ if (hit.length) throw new Error(`rule ${spec.rule} fired: ${e.path} — ${hit.map((m) => m.message).join("; ")}`);
646
+ });
647
+ (spec.good ?? []).forEach(silent(N.spec.harness.good));
648
+ (spec.outside ?? []).forEach(silent(N.spec.harness.outsideExample));
649
+ }
650
+
651
+ /**
652
+ * Linter — the project's ESLint with its config (`eslint.config.*` in `cwd`): an example is checked the same way `lint`
653
+ * will check a file at that path. ESLint is taken from the project's dependencies (`module` — another path to the
654
+ * package); it loads on the first example, so test registration is synchronous.
655
+ */
656
+ export function eslintLinter(opts: { cwd?: string; module?: string } = {}): Required<Linter> {
657
+ const cwd = path.resolve(opts.cwd ?? process.cwd());
658
+ type Engine = {
659
+ lintText(code: string, o: { filePath: string }): Promise<{ messages: LintMessage[] }[]>;
660
+ calculateConfigForFile(filePath: string): Promise<unknown>;
661
+ };
662
+ let engine: Promise<Engine> | null = null;
663
+ const load = async (): Promise<Engine> => {
664
+ const entry = opts.module ?? createRequire(path.join(cwd, "package.json")).resolve("eslint");
665
+ const { ESLint } = (await import(pathToFileURL(entry).href)) as { ESLint: new (o: { cwd: string }) => Engine };
666
+ return new ESLint({ cwd });
667
+ };
668
+ let warmed: Promise<void> | null = null;
669
+ return {
670
+ async lint(code, filePath) {
671
+ engine ??= load();
672
+ const [result] = await (await engine).lintText(code, { filePath: path.join(cwd, filePath) });
673
+ return (result?.messages ?? []).map((m) => ({ ruleId: m.ruleId, message: m.message, fatal: m.fatal }));
674
+ },
675
+ // ESLint loads the config lazily, on the first file: computing the config for a project file loads it now
676
+ ready() {
677
+ engine ??= load();
678
+ warmed ??= engine.then((e) => e.calculateConfigForFile(path.join(cwd, "index.js"))).then(() => undefined);
679
+ return warmed;
680
+ },
681
+ };
682
+ }
683
+
684
+ /** A lint rule disable in code: file, line, rules (empty — all) and the description after " -- ". */
685
+ export interface Disable {
686
+ file: string;
687
+ line: number;
688
+ rules: string[];
689
+ description: string;
690
+ }
691
+
692
+ const CODE = /\.[cm]?[jt]sx?$/;
693
+ const SKIP_DIRS = new Set(["node_modules", ".git", "dist", "build", "coverage"]);
694
+ const DIRECTIVE = /^(?:\/\/|\/\*)\s*eslint-disable(?:-next-line|-line)?(?=\s|\*\/|$)([^\n]*?)(?:\*\/|$)/m;
695
+
696
+ /** A code file for a registry over sources: path from the root and text. */
697
+ export interface SourceFile {
698
+ file: string;
699
+ readonly text: string;
700
+ }
701
+
702
+ /** A violator of a registry over sources — as text, with no file on disk. */
703
+ export function source(file: string, text: string): SourceFile {
704
+ return { file, text };
705
+ }
706
+
707
+ /**
708
+ * Registry of code files in directories and files (from the root; a file — a config at the package root,
709
+ * `vite.config.ts`): the list is cheap, the text is read on first access — parsing all of `src/` happens in the element
710
+ * check, not when tests are collected.
711
+ */
712
+ export function sources(root: string, dirs: string[] = ["src"]): SourceFile[] {
713
+ return codeFiles(root, dirs).map((file) => {
714
+ let text: string | undefined;
715
+ return {
716
+ file,
717
+ get text() {
718
+ return (text ??= readFileSync(path.join(root, file), "utf8"));
719
+ },
720
+ };
721
+ });
722
+ }
723
+
724
+ /**
725
+ * Code files in directories (from the root), in order, without repeats; an element of `dirs` is a directory or a code
726
+ * file (a config at the package root); a missing one is skipped.
727
+ */
728
+ export function codeFiles(root: string, dirs: string[]): string[] {
729
+ const out = new Set<string>();
730
+ const walk = (rel: string): void => {
731
+ let entries;
732
+ try {
733
+ entries = readdirSync(path.join(root, rel), { withFileTypes: true });
734
+ } catch (e) {
735
+ // not a directory — a code file on its own
736
+ if ((e as { code?: string }).code === "ENOTDIR" && CODE.test(rel)) out.add(rel);
737
+ return;
738
+ }
739
+ for (const e of entries) {
740
+ const child = rel ? `${rel}/${e.name}` : e.name;
741
+ if (e.isDirectory()) {
742
+ if (!SKIP_DIRS.has(e.name)) walk(child);
743
+ } else if (CODE.test(e.name)) out.add(child);
744
+ }
745
+ };
746
+ for (const d of dirs) walk(d.replace(/\/+$/, ""));
747
+ return [...out].sort();
748
+ }
749
+
750
+ /**
751
+ * Text without comments (replaced with spaces, line breaks kept): code scanners don't see prose and old code. Comments
752
+ * come from parsing, as in the name scanner: `//` in a string or JSX text is not a comment. `file` is the path for
753
+ * parsing by extension (`.tsx` — with JSX; none — TypeScript, then TSX); a source that can't be parsed is an error.
754
+ */
755
+ export function codeOnly(text: string, file?: string): string {
756
+ let out = "";
757
+ let at = 0;
758
+ for (const [i, c] of commentsOf(text, file)) {
759
+ out += text.slice(at, i) + c.replace(/[^\n]/g, " ");
760
+ at = i + c.length;
761
+ }
762
+ return out + text.slice(at);
763
+ }
764
+
765
+ /**
766
+ * Lint rule disables in a file: `// eslint-disable-next-line a, b -- #12 reason` and the same block comments. ESLint
767
+ * reads directives only in comments: `// eslint-disable` in a string is a fixture or a message, not a disable.
768
+ */
769
+ export function disablesIn(file: string, text: string): Disable[] {
770
+ const out: Disable[] = [];
771
+ for (const [at, comment] of commentsOf(text, file)) {
772
+ const m = DIRECTIVE.exec(comment);
773
+ if (!m) continue;
774
+ const [head, ...desc] = m[1]!.split(/\s--\s|\s--$/);
775
+ out.push({
776
+ file,
777
+ line: text.slice(0, at).split("\n").length,
778
+ rules: head!.split(",").map((r) => r.trim()).filter(Boolean),
779
+ description: desc.join(" -- ").trim(),
780
+ });
781
+ }
782
+ return out;
783
+ }
784
+
785
+ /**
786
+ * Lint rule exceptions — disables in code: a test per file with disables ("exceptions in <file>: <rules>"), each
787
+ * disable names its rule and has "-- #N reason". A blanket `eslint-disable` without rules is forbidden: it silences
788
+ * both the rules and this check inside the linter — hence it's a test, not an ESLint rule. No code files — a failing
789
+ * test: the path is wrong. The ratchet (a disable no longer silences anything) is in the `collectEslint` config.
790
+ */
791
+ export function lintExceptions(it: It, opts: { root: string; dirs?: string[] }): void {
792
+ const dirs = opts.dirs ?? ["src"];
793
+ const files = codeFiles(opts.root, dirs);
794
+ const N = localeNames();
795
+ it(locale.fill(N.spec.harness.codeFiles, { dirs: dirs.join(", ") }), () => {
796
+ if (!files.length) throw new Error(`no code files in ${dirs.join(", ")} — the path is wrong`);
797
+ });
798
+ for (const file of files) {
799
+ const text = readFileSync(path.join(opts.root, file), "utf8");
800
+ if (!text.includes("eslint-disable")) continue;
801
+ const disables = disablesIn(file, text);
802
+ if (!disables.length) continue;
803
+ const rules = [...new Set(disables.flatMap((d) => (d.rules.length ? d.rules : [N.spec.lint.allRules])))].sort();
804
+ // the disables' tasks go into the name: the spec shows which decision allows the exception
805
+ const issues = [...new Set(disables.map((d) => /^#(\d+)/.exec(d.description)?.[1]).filter((x): x is string => !!x).map(Number))].sort((a, b) => a - b);
806
+ const name = locale.fill(N.spec.harness.lintExceptions, { file, rules: rules.join(", "), issues: issues.length ? ` (${issues.map((n) => `#${n}`).join(", ")})` : "" });
807
+ meta(name, { disables: disables.map((d) => ({ line: d.line, rules: d.rules, description: d.description })) });
808
+ it(name, () => {
809
+ const bad = disables.flatMap((d) => {
810
+ if (!d.rules.length) return [`${d.file}:${d.line} — a disable without a rule name: a blanket eslint-disable silences everything, checks included`];
811
+ if (!/^#\d+\s+\S/.test(d.description)) return [`${d.file}:${d.line} — a disable without «-- #N reason»: ${d.rules.join(", ")}`];
812
+ return [];
813
+ });
814
+ if (bad.length) throw new Error(bad.join("\n"));
815
+ });
816
+ }
817
+ }
818
+
819
+ // a test file in the call stack: *.test.* / *.spec.* / *.e2e.* (V8 and JavaScriptCore; file:// — for ESM)
820
+ // for a module's top-level frame Bun writes only the line, no column: "at /…/x.test.ts:4"
821
+ // V8 writes an anonymous async function after await as the frame "at async <path>" — no name, no parentheses
822
+ const STACK_FILE = /(?:\(|\bat\s+(?:async\s+)?)(?:file:\/\/)?(\/[^()\n]*?\.(?:test|spec|e2e)\.[cm]?[jt]sx?)(?=:\d+)/g;
823
+ const seen = new Set<string>();
824
+ const journaled = new Set<string>();
825
+ let journalFile: string | null = null;
826
+
827
+ /** The test file the call comes from, relative to the project root — by the stack; not from a test — null. */
828
+ function callerTest(root: string): string | null {
829
+ return testFileIn(new Error().stack ?? "", root);
830
+ }
831
+
832
+ /**
833
+ * The test file in the call stack, from the project root: the first frame `*.test.*` / `*.spec.*` / `*.e2e.*` (V8 and
834
+ * JavaScriptCore, `file://` for ESM, `at async` after await); not from a test — null.
835
+ */
836
+ export function testFileIn(stack: string, root: string): string | null {
837
+ return testFileAt(stack, root)?.rel ?? null;
838
+ }
839
+
840
+ /** The same plus the file's absolute path: `rel` — from the project root, `abs` ends with `rel`. */
841
+ function testFileAt(stack: string, root: string): { rel: string; abs: string } | null {
842
+ for (const m of stack.matchAll(STACK_FILE)) {
843
+ const abs = m[1]!;
844
+ const rel = path.relative(root, abs).split(path.sep).join("/");
845
+ if (!rel.startsWith("..")) return { rel, abs };
846
+ const i = abs.indexOf("/tests/");
847
+ if (i >= 0) return { rel: abs.slice(i + 1), abs };
848
+ }
849
+ return null;
850
+ }
851
+
852
+ let metaFile: string | null = null;
853
+
854
+ /**
855
+ * Run metadata for `spec-doc`: what the test name lacks — the example's code, the task and the exception's reason. A
856
+ * JSON line `{ file, test, … }` in `.spec-meta/<process>.jsonl` (`SPEC_META` — another directory); the test file comes
857
+ * from the registration stack. The standard's page shows them under the test's line.
858
+ */
859
+ function meta(test: string, data: object, file: string | null = callerTest(process.cwd())): void {
860
+ const root = process.cwd();
861
+ if (!file) return;
862
+ const key = `meta\0${file}\0${test}`;
863
+ if (seen.has(key)) return;
864
+ seen.add(key);
865
+ if (!metaFile) {
866
+ const dir = path.resolve(root, process.env.SPEC_META ?? ".spec-meta");
867
+ mkdirSync(dir, { recursive: true });
868
+ metaFile = path.join(dir, `${process.pid}-${Math.random().toString(36).slice(2, 10)}.jsonl`);
869
+ }
870
+ appendFileSync(metaFile, JSON.stringify({ file, test, ...data }) + "\n");
871
+ }
872
+
873
+ /**
874
+ * Entry-point call log: the test environment records a call of an entry point (route, job, command) — with a router or
875
+ * handler wrapper in the test build. The test file is found by the stack; where there's no test stack (e2e: the
876
+ * request arrives at the server) — pass it explicitly (`test.info().file` in Playwright). A record is a JSON line in
877
+ * `.spec-journal/<process>.jsonl` (`SPEC_JOURNAL` — another directory, each runner has its own: `.spec-journal/unit`);
878
+ * `spec-claims` checks it against the registry after all tests and shards have run.
879
+ */
880
+ export function journal(id: string, opts: { test?: string; root?: string } = {}): void {
881
+ const root = opts.root ?? process.cwd();
882
+ const test = opts.test ? path.relative(root, path.resolve(root, opts.test)).split(path.sep).join("/") : callerTest(root);
883
+ const key = `${id}\0${test}`;
884
+ if (journaled.has(key)) return;
885
+ journaled.add(key);
886
+ if (!journalFile) {
887
+ const dir = journalDir(root);
888
+ mkdirSync(dir, { recursive: true });
889
+ journalFile = path.join(dir, `${process.pid}-${Math.random().toString(36).slice(2, 10)}.jsonl`);
890
+ }
891
+ appendFileSync(journalFile, JSON.stringify({ id, test }) + "\n");
892
+ }
893
+
894
+ const journalDir = (root: string): string => path.resolve(root, process.env.SPEC_JOURNAL ?? ".spec-journal");
895
+
896
+ /**
897
+ * Run start — logs of previous runs go away: otherwise a deleted test keeps "calling" an entry point. Called once per
898
+ * runner run, before the tests (Vitest and Playwright globalSetup, bun test preload); erases `*.jsonl` in its own
899
+ * directory (`SPEC_JOURNAL`), leaves subdirectories — other runners' logs — alone.
900
+ */
901
+ export function resetJournal(opts: { root?: string } = {}): void {
902
+ const dir = journalDir(opts.root ?? process.cwd());
903
+ if (existsSync(dir)) {
904
+ for (const f of readdirSync(dir).filter((f) => f.endsWith(".jsonl"))) rmSync(path.join(dir, f), { force: true });
905
+ }
906
+ journalFile = null;
907
+ journaled.clear();
908
+ }
909
+
910
+ /** A call at a model boundary: who, to whom, what. */
911
+ export interface Call {
912
+ from: string;
913
+ to: string;
914
+ message: string;
915
+ }
916
+
917
+ // the running scenario's trace: a file's tests run in turn, one scenario at a time (test.concurrent isn't for sequence)
918
+ let activeTrace: Call[] | null = null;
919
+
920
+ /**
921
+ * A call at a model boundary — written by a wrapper in the test build, next to `journal`: an entry point, an external
922
+ * system adapter, a storage client, a call to another container. Participants are model element names. Outside a
923
+ * scenario (`sequence`) — nothing; a call within a participant (`from === to`) is not a boundary: a rearrangement
924
+ * inside a module doesn't change the diagram.
925
+ */
926
+ export function trace(from: string, to: string, message: string): void {
927
+ if (activeTrace && from !== to) activeTrace.push({ from, to, message });
928
+ }
929
+
930
+ const callLine = (c: Call): string => `${c.from} → ${c.to}: ${c.message}`;
931
+
932
+ /** Diagram participants, when a model is given: modules, external systems, containers. */
933
+ const modelParticipants = (m: Model): Set<string> => new Set([...Object.keys(m.modules), ...Object.keys(m.externals ?? {}), ...Object.keys(m.containers ?? {})]);
934
+
935
+ /**
936
+ * A capability scenario — a test whose trace of calls at model boundaries becomes a sequence diagram on the capability
937
+ * page (`spec-doc`: under the test, or by the mark `<!-- spec: sequence-<id> -->` in `<folder>.md`). `order` — a
938
+ * declared order (`"app → db: reserve"`) when the order itself is a decision: the trace must contain its steps in this
939
+ * sequence (anything in between), otherwise the test is red. `model` — participants only from the model. Without
940
+ * `order` the diagram is documentation.
941
+ */
942
+ export function sequence(
943
+ it: It,
944
+ name: string,
945
+ fn: () => void | Promise<unknown>,
946
+ opts: { id?: string; order?: string[]; model?: Model } = {},
947
+ ): void {
948
+ // the test file — by the registration stack: when the test runs, it's no longer in the stack
949
+ const file = callerTest(process.cwd());
950
+ it(name, async () => {
951
+ const calls: Call[] = [];
952
+ activeTrace = calls;
953
+ try {
954
+ await fn();
955
+ } finally {
956
+ activeTrace = null;
957
+ }
958
+ meta(name, { ...(opts.id ? { id: opts.id } : {}), sequence: calls }, file);
959
+ if (opts.model) {
960
+ const known = modelParticipants(opts.model);
961
+ const unknown = [...new Set(calls.flatMap((c) => [c.from, c.to]).filter((x) => !known.has(x)))];
962
+ if (unknown.length) throw new Error(unknown.map((x) => `participant ${x} is not a model element`).join("\n"));
963
+ }
964
+ if (opts.order?.length) {
965
+ const lines = calls.map(callLine);
966
+ let j = 0;
967
+ for (const l of lines) if (l === opts.order[j]) j++;
968
+ if (j < opts.order.length) {
969
+ const after = j ? `after «${opts.order[j - 1]}»` : "from the start of the scenario";
970
+ throw new Error(`the order of scenario «${name}» is broken: no «${opts.order[j]}» ${after}\ntrace:\n${lines.join("\n") || "— empty"}`);
971
+ }
972
+ }
973
+ });
974
+ }
975
+
976
+ // knip finding kinds (knip.dev, "Reporters", JSON) → test name in the project language and the exception key prefix
977
+ const KNIP: [(n: Names) => string, string, string[]][] = [
978
+ [(n) => n.spec.harness.knip.files, "file", ["files"]],
979
+ [(n) => n.spec.harness.knip.exports, "export", ["exports", "nsExports"]],
980
+ [(n) => n.spec.harness.knip.types, "type", ["types", "nsTypes"]],
981
+ [(n) => n.spec.harness.knip.dependencies, "dependency", ["dependencies", "devDependencies", "optionalPeerDependencies"]],
982
+ [(n) => n.spec.harness.knip.unlisted, "unlisted", ["unlisted"]],
983
+ [(n) => n.spec.harness.knip.unresolved, "unresolved", ["unresolved"]],
984
+ // knip 6 kinds (JSONReportEntry): enum and namespace members, duplicate exports, binaries, pnpm catalog
985
+ [(n) => n.spec.harness.knip.enumMembers, "enumMember", ["enumMembers"]],
986
+ [(n) => n.spec.harness.knip.nsMembers, "nsMember", ["namespaceMembers"]],
987
+ [(n) => n.spec.harness.knip.duplicates, "duplicate", ["duplicates"]],
988
+ [(n) => n.spec.harness.knip.binaries, "binary", ["binaries"]],
989
+ [(n) => n.spec.harness.knip.catalog, "catalog", ["catalog"]],
990
+ [(n) => n.spec.harness.knip.catalogReferences, "catalogReference", ["catalogReferences"]],
991
+ ];
992
+ // report row keys that aren't finding kinds: the file and the owners (CODEOWNERS)
993
+ const KNIP_ROW = new Set(["file", "owners", ...KNIP.flatMap(([, , keys]) => keys)]);
994
+ // kinds whose key used to have no file (`dependency:lodash`) and covered the package in all workspaces
995
+ const KEYED_BY_NAME = new Set(["dependency", "unlisted", "binary", "catalog"]);
996
+ // the configuration hints reporter: knip's JSON doesn't have them; the path comes from the file itself, import.meta
997
+ // can't be used here (harness-load)
998
+ const HINTS_REPORTER = knipHints.file;
999
+
1000
+ type KnipItem = { name: string; namespace?: string };
1001
+ type KnipHint = { type: string; identifier: string; workspace?: string };
1002
+ type KnipReport = { issues?: Record<string, unknown>[]; configurationHints?: KnipHint[] };
1003
+
1004
+ /**
1005
+ * A knip finding for display and the exception key: a file — its path, the rest — `<file>#<name>`. The file comes from
1006
+ * knip's report: for a dependency — the workspace's `package.json`, for an unlisted package — the file with the
1007
+ * import, for a catalog — the catalog file; a name in a namespace (an enum member, a catalog entry) —
1008
+ * `<namespace>.<name>`.
1009
+ */
1010
+ function knipShown(kind: string, file: string, item: KnipItem | KnipItem[]): string {
1011
+ if (Array.isArray(item)) return `${file}#${item.map((x) => x.name).join(" = ")}`; // duplicates: one export's names
1012
+ if (kind === "file") return item.name;
1013
+ return `${file}#${item.namespace ? item.namespace + "." : ""}${item.name}`;
1014
+ }
1015
+
1016
+ /** A configuration hint for display and the exception key (`hint:<…>`): workspace, hint type and what it names. */
1017
+ const hintShown = (h: KnipHint): string => `${h.workspace ?? "."}#${h.type}:${h.identifier}`;
1018
+
1019
+ /** Parsed knip output: findings by kind (key → display), lines of unknown kinds, hints (null — not in the output). */
1020
+ interface KnipFindings {
1021
+ kinds: Map<string, Map<string, string>>;
1022
+ unknown: string[];
1023
+ hints: Map<string, string> | null;
1024
+ }
1025
+
1026
+ /**
1027
+ * Findings by kind: kind key (`file:…`, `export:<file>#<name>`, `dependency:<package.json>#<package>`) → what to
1028
+ * display.
1029
+ */
1030
+ function knipFindings(report: KnipReport): KnipFindings {
1031
+ const kinds = new Map(KNIP.map(([, kind]) => [kind, new Map<string, string>()]));
1032
+ const unknown = new Set<string>();
1033
+ for (const issue of report.issues ?? []) {
1034
+ const file = String(issue.file ?? "");
1035
+ for (const [, kind, keys] of KNIP) {
1036
+ for (const k of keys) {
1037
+ for (const item of (issue[k] as (KnipItem | KnipItem[])[] | undefined) ?? []) {
1038
+ const shown = knipShown(kind, file, item);
1039
+ kinds.get(kind)!.set(`${kind}:${shown}`, shown);
1040
+ }
1041
+ }
1042
+ }
1043
+ // a kind the harness doesn't know (new in knip, cycles with --cycles) is not silence: the `catalog:` reference
1044
+ // was such a kind
1045
+ for (const [k, v] of Object.entries(issue)) if (!KNIP_ROW.has(k) && Array.isArray(v) && v.length) unknown.add(`${k} (${file})`);
1046
+ }
1047
+ const hints = Array.isArray(report.configurationHints) ? new Map(report.configurationHints.map((h) => [`hint:${hintShown(h)}`, hintShown(h)])) : null;
1048
+ return { kinds, unknown: [...unknown].sort(), hints };
1049
+ }
1050
+
1051
+ /**
1052
+ * knip output — a `json` report line and a `knip-hints.cjs` hints line; a report from a file — also a single JSON. Not
1053
+ * JSON — an error.
1054
+ */
1055
+ function knipOutput(text: string): KnipReport {
1056
+ try {
1057
+ return JSON.parse(text) as KnipReport;
1058
+ } catch {
1059
+ const lines = text.split("\n").filter((l) => l.trim());
1060
+ if (!lines.length) throw new Error("empty output");
1061
+ return Object.assign({}, ...lines.map((l) => JSON.parse(l) as KnipReport)) as KnipReport;
1062
+ }
1063
+ }
1064
+
1065
+ export interface DeadCode {
1066
+ /** Project root for knip — the directory with its config and `node_modules/.bin/knip`; finding keys are from it. */
1067
+ root: string;
1068
+ /** Ready output of `knip --reporter json --reporter <skill>/knip-hints.cjs` (from `root`) instead of running knip. */
1069
+ report?: string;
1070
+ /** The project's knip arguments: `--tsConfig`, `--workspace`, … */
1071
+ args?: readonly string[];
1072
+ /** Exceptions — `exceptionsIn()`: finding key, task, reason; with `rule` — only for the run with this `rule`. */
1073
+ exceptions?: readonly Exception[];
1074
+ /** Production mode (`knip --production`): code reached only by tests is a finding. The run's rule is `production`. */
1075
+ production?: boolean;
1076
+ /** Run name when a folder has several (modes, workspaces): a tag in test names and exceptions with this `rule`. */
1077
+ rule?: string;
1078
+ /** knip configuration hints are findings (yes by default); `false` — not checked. */
1079
+ hints?: boolean;
1080
+ }
1081
+
1082
+ /**
1083
+ * Code without a consumer — from knip's report: a test per finding kind (files, exports, types, dependencies, unlisted
1084
+ * packages, unresolved imports, pnpm catalog…) — failing with the list of findings; "all knip findings are of known
1085
+ * kinds" — a new knip kind doesn't pass silently; "no knip configuration hints" — a redundant ignore and an entry
1086
+ * without matches. The report — a file (`report`) or a run of the project's knip (`node_modules/.bin/knip`) — at test
1087
+ * registration, not under the first test's timeout. An exception is a finding key (`file:src/legacy.ts`,
1088
+ * `export:src/math.ts#factorial`, `dependency:packages/a/package.json#lodash`, `hint:.#ignore:src/gen/**`) with a task:
1089
+ * green while knip finds it, otherwise "remove". An exception with `rule` — only for its own run; without `rule` — it
1090
+ * silences the finding in all runs of the folder, and the ratchet is at the run without `rule`: in production knip
1091
+ * doesn't look at devDependencies and tests, and "remove" there would be false.
1092
+ */
1093
+ export function deadCode(it: It, opts: DeadCode): void {
1094
+ const rule = opts.rule ?? (opts.production ? "production" : undefined);
1095
+ const tag = rule ? ` (${rule})` : "";
1096
+ const hints = opts.hints ?? true;
1097
+ // knip at registration: on a monorepo it takes longer than the test timeout (5 s in Vitest); a failure goes into
1098
+ // the tests, not into collecting the file
1099
+ let findings: KnipFindings | Error;
1100
+ try {
1101
+ findings = runKnip(opts, hints);
1102
+ } catch (e) {
1103
+ findings = e as Error;
1104
+ }
1105
+ const load = (): KnipFindings => {
1106
+ if (findings instanceof Error) throw findings;
1107
+ return findings;
1108
+ };
1109
+ legacyExceptions(it);
1110
+ const all = opts.exceptions ?? [];
1111
+ const own = all.filter((e) => e.rule === rule || (!e.rule && !rule));
1112
+ const silenced = new Set([...own, ...all.filter((e) => !e.rule)].map((e) => e.item));
1113
+ const open = (found: Map<string, string>) => [...found].filter(([key]) => !silenced.has(key)).map(([, shown]) => shown).sort();
1114
+ const N = localeNames();
1115
+ for (const [name, kind] of KNIP) {
1116
+ it(name(N) + tag, () => {
1117
+ const found = open(load().kinds.get(kind)!);
1118
+ if (found.length) throw new Error(found.join("\n"));
1119
+ });
1120
+ }
1121
+ it(locale.fill(N.spec.harness.knip.known, { tag }), () => {
1122
+ const { unknown } = load();
1123
+ if (unknown.length) throw new Error(`knip found what the harness doesn't parse: ${unknown.join(", ")} — exclude the kind from the knip run (--exclude) or update the spec skill`);
1124
+ });
1125
+ if (hints) {
1126
+ it(locale.fill(N.spec.harness.knip.hints, { tag }), () => {
1127
+ const found = load().hints;
1128
+ const reporter = path.relative(opts.root, HINTS_REPORTER).split(path.sep).join("/");
1129
+ if (!found && opts.report) throw new Error(`report ${opts.report} has no configuration hints — generate it: knip --reporter json --reporter ${reporter} > ${opts.report} (or hints: false)`);
1130
+ if (!found) throw new Error(`knip printed no configuration hints — reporter ${reporter} didn't run`);
1131
+ const left = open(found);
1132
+ if (left.length) throw new Error(left.join("\n"));
1133
+ });
1134
+ }
1135
+ for (const e of [...own].sort((a, b) => (a.item < b.item ? -1 : 1))) {
1136
+ const name = locale.fill(N.spec.harness.exception, { tag, item: e.item, issue: e.issue });
1137
+ meta(name, { issue: e.issue, reason: e.reason });
1138
+ it(name, () => {
1139
+ if (!Number.isInteger(e.issue) || e.issue <= 0) throw new Error(`exception ${e.item} has no task`);
1140
+ if (!e.reason.trim()) throw new Error(`exception ${e.item} has no reason`);
1141
+ const kind = e.item.split(":")[0]!;
1142
+ const found = kind === "hint" ? load().hints : load().kinds.get(kind);
1143
+ if (found?.has(e.item)) return;
1144
+ const name = e.item.slice(kind.length + 1);
1145
+ // a key without a file is from the old format: name the same package's findings to replace it with
1146
+ const renames =
1147
+ KEYED_BY_NAME.has(kind) && !name.includes("#")
1148
+ ? [...(found?.keys() ?? [])].filter((k) => {
1149
+ const tail = k.slice(k.lastIndexOf("#") + 1);
1150
+ return tail === name || (kind === "catalog" && tail.endsWith("." + name));
1151
+ })
1152
+ : [];
1153
+ if (renames.length) throw new Error(`key ${e.item} has no file: the exception would hide ${name} in every workspace — rename it to ${renames.join(", ")} (#${e.issue})`);
1154
+ throw new Error(`knip no longer finds ${e.item} — ${dropHint(e)} (#${e.issue})`);
1155
+ });
1156
+ }
1157
+ unknownRules(it, rule, all, []);
1158
+ }
1159
+
1160
+ /**
1161
+ * knip's report: the `report` file or a run of the project's knip with the hints reporter; a knip failure is an error,
1162
+ * not "clean".
1163
+ */
1164
+ function runKnip(opts: DeadCode, hints: boolean): KnipFindings {
1165
+ let text: string;
1166
+ if (opts.report) text = readFileSync(path.resolve(opts.root, opts.report), "utf8");
1167
+ else {
1168
+ const args = ["--reporter", "json", ...(hints ? ["--reporter", HINTS_REPORTER] : []), ...(opts.production ? ["--production"] : []), ...(opts.args ?? [])];
1169
+ // a monorepo report is megabytes: the default output buffer (1 MB in Node) would cut it off
1170
+ const r = spawnSync(path.join(opts.root, "node_modules", ".bin", "knip"), args, { cwd: opts.root, encoding: "utf8", maxBuffer: 256 * 1024 * 1024 });
1171
+ if (r.error) throw new Error(`knip didn't start: ${r.error.message} — install knip in the project or pass report`);
1172
+ // 0 — clean, 1 — there are findings; anything else is a knip failure, not "no dead code"
1173
+ if (r.status !== 0 && r.status !== 1) throw new Error(`knip exited with code ${r.status ?? r.signal}: ${(r.stderr || r.stdout).trim().slice(0, 1000)}`);
1174
+ text = r.stdout;
1175
+ }
1176
+ let report: KnipReport;
1177
+ try {
1178
+ report = knipOutput(text);
1179
+ } catch {
1180
+ throw new Error(`knip printed non-JSON — the report can't be parsed: ${text.trim().slice(0, 300)}`);
1181
+ }
1182
+ return knipFindings(report);
1183
+ }
1184
+
1185
+ // variable name and field access: .X, ?.X, ["X"], ?.["X"], [`X`]
1186
+ const ENV_NAME = "[A-Z_][A-Z0-9_]*";
1187
+ const FIELD = `(?:\\?\\.|\\.)(${ENV_NAME})|(?:\\?\\.)?\\[\\s*["'\`](${ENV_NAME})["'\`]\\s*\\]`;
1188
+ const ENV_OBJECT = "\\bprocess\\??\\.env\\b|\\bimport\\.meta\\??\\.env\\b";
1189
+ // the whole environment source: process.env, import.meta.env, Vite's loadEnv result (argument parentheses — one level)
1190
+ const ENV_SOURCE = `(?:${ENV_OBJECT}|\\bloadEnv\\s*\\((?:[^()]|\\([^()]*\\))*\\))(?!\\s*(?:\\?\\.|[.[]))`;
1191
+ // type annotation before "=": a comma only inside <…>, else in `(a: A, env = process.env)` a would become the alias
1192
+ const TYPE = "(?:\\s*:\\s*(?:[^=;,(){}<>]|<[^<>]*>)+?)?";
1193
+ const ENV_READ = new RegExp(`(?:${ENV_OBJECT})(?:${FIELD})`, "g");
1194
+ const ENV_ALIAS = new RegExp(`(?<![\\w$.])([A-Za-z_$][\\w$]*)${TYPE}\\s*=\\s*${ENV_SOURCE}`, "g");
1195
+ const ENV_DESTRUCTURE = new RegExp(`\\{([^{}]*)\\}${TYPE}\\s*=\\s*${ENV_SOURCE}`, "g");
1196
+ const IS_ENV_NAME = new RegExp(`^${ENV_NAME}$`);
1197
+
1198
+ /**
1199
+ * Service environment variables — outside the `envVars` check; a project with its own registries on `invariant` skips
1200
+ * the same ones.
1201
+ */
1202
+ export const ENV_SERVICE: readonly string[] = ["NODE_ENV", "CI", "TZ", "PORT", "HOME", "PATH", "PWD", "DEV", "PROD", "MODE", "SSR", "BASE_URL"];
1203
+
1204
+ /**
1205
+ * The project's own way of reading the environment beyond the built-in ones: a regexp — the name in the `name` group or
1206
+ * the first one (the `g` flag isn't needed), a function — names from comment-free code and the file path from the root.
1207
+ */
1208
+ export type EnvReader = RegExp | ((code: string, file: string) => Iterable<string>);
1209
+
1210
+ /**
1211
+ * Config accessor: `.get('X')`, `.getOrThrow<string>('X')` on an object of any name (NestJS `ConfigService`, a custom
1212
+ * config). Not built in: `.get('X')` exists on Map, caches and `URLSearchParams` — a project adds it in `readers`.
1213
+ */
1214
+ export const configGet: RegExp = new RegExp(`\\.(?:get|getOrThrow)\\s*(?:<(?:[^<>()]|<[^<>()]*>)*>)?\\s*\\(\\s*["'\`](?<name>${ENV_NAME})["'\`]`);
1215
+
1216
+ /**
1217
+ * Names of environment variables a source reads. One parameter: a project calls `sources.flatMap(envNamesIn)`, and an
1218
+ * optional second one would get the `flatMap` index — a type error (#303). The file is known — `envNamesInCode`.
1219
+ */
1220
+ export function envNamesIn(source: string): string[] {
1221
+ return envNamesInCode(codeOnly(source));
1222
+ }
1223
+
1224
+ /**
1225
+ * The same on comment-free code — `codeOnly(text, file)`: the set of parser plugins by file extension. `readers` — the
1226
+ * project's own ways of reading (`configGet`, a regexp, a function), `file` — the path for a function.
1227
+ */
1228
+ export function envNamesInCode(text: string, opts: { file?: string; readers?: readonly EnvReader[] } = {}): string[] {
1229
+ const out = new Set<string>();
1230
+ // a source under another name: a default parameter `(env = process.env)`, `const e = import.meta.env`,
1231
+ // `const env = loadEnv(mode, root)`
1232
+ for (const [, alias] of text.matchAll(ENV_ALIAS)) {
1233
+ const a = alias!.replace(/\$/g, "\\$");
1234
+ for (const m of text.matchAll(new RegExp(`(?<![\\w$.])${a}(?:${FIELD})`, "g"))) out.add(m[1] ?? m[2]!);
1235
+ }
1236
+ for (const m of text.matchAll(ENV_READ)) out.add(m[1] ?? m[2]!);
1237
+ // `const { A = "x", B: b } = process.env`: the key runs up to ":" or "=", the `...rest` remainder is not a variable
1238
+ for (const m of text.matchAll(ENV_DESTRUCTURE)) {
1239
+ for (const part of m[1]!.split(",")) {
1240
+ const name = part.split(/[:=]/)[0]!.trim().replace(/^(["'`])(.*)\1$/, "$2");
1241
+ if (IS_ENV_NAME.test(name)) out.add(name);
1242
+ }
1243
+ }
1244
+ for (const r of opts.readers ?? []) {
1245
+ if (typeof r === "function") {
1246
+ for (const n of r(text, opts.file ?? "")) out.add(n);
1247
+ continue;
1248
+ }
1249
+ for (const m of text.matchAll(new RegExp(r.source, r.flags.includes("g") ? r.flags : r.flags + "g"))) {
1250
+ const n = m.groups?.name ?? m.slice(1).find((x) => x !== undefined);
1251
+ if (n) out.add(n);
1252
+ }
1253
+ }
1254
+ return [...out];
1255
+ }
1256
+
1257
+ /**
1258
+ * Declared variables of a dotenv file (`.env.example`) — for `declared`: `X=…`, `export X=…` and commented-out
1259
+ * `# X=…` — optional ones with a default in the code: they aren't in `.env`, they are in the environment schema. Prose
1260
+ * in a comment is not a declaration. In order, without repeats.
1261
+ */
1262
+ export function dotenvNames(text: string): string[] {
1263
+ const out = new Set<string>();
1264
+ const commented = new RegExp(`^\\s*#+\\s*(?:export\\s+)?(${ENV_NAME})\\s*=`);
1265
+ for (const line of text.split(/\r?\n/)) {
1266
+ const m = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=/.exec(line) ?? commented.exec(line);
1267
+ if (m) out.add(m[1]!);
1268
+ }
1269
+ return [...out];
1270
+ }
1271
+
1272
+ /** Environment variables read by code files (from the root): name → files. `cache` — names of a file shared by apps. */
1273
+ function envReads(root: string, files: readonly string[], readers?: readonly EnvReader[], cache = new Map<string, string[]>()): Map<string, string[]> {
1274
+ const out = new Map<string, string[]>();
1275
+ for (const file of files) {
1276
+ let names = cache.get(file);
1277
+ if (!names) {
1278
+ names = envNamesInCode(codeOnly(readFileSync(path.join(root, file), "utf8"), file), { file, readers });
1279
+ cache.set(file, names);
1280
+ }
1281
+ for (const n of names) out.set(n, [...new Set([...(out.get(n) ?? []), file])]);
1282
+ }
1283
+ return out;
1284
+ }
1285
+
1286
+ /** A directory's package.json (from the root): name and `dependencies` packages; no file or not JSON — null. */
1287
+ function packageJson(root: string, dir: string): { name?: string; dependencies: string[]; workspaces?: unknown } | null {
1288
+ try {
1289
+ const p = JSON.parse(readFileSync(path.join(root, dir, "package.json"), "utf8")) as { name?: string; dependencies?: Record<string, string>; workspaces?: unknown };
1290
+ return { name: p.name, dependencies: Object.keys(p.dependencies ?? {}), workspaces: p.workspaces };
1291
+ } catch {
1292
+ return null;
1293
+ }
1294
+ }
1295
+
1296
+ /**
1297
+ * Workspace patterns: `workspaces` of the root package.json (an array or `{ packages }`) and `packages` of
1298
+ * pnpm-workspace.yaml.
1299
+ */
1300
+ function workspacePatterns(root: string): string[] {
1301
+ const out: string[] = [];
1302
+ const ws = packageJson(root, ".")?.workspaces;
1303
+ const list: unknown = Array.isArray(ws) ? ws : (ws as { packages?: unknown } | undefined)?.packages;
1304
+ if (Array.isArray(list)) out.push(...list.filter((x): x is string => typeof x === "string"));
1305
+ let yaml = "";
1306
+ try {
1307
+ yaml = readFileSync(path.join(root, "pnpm-workspace.yaml"), "utf8");
1308
+ } catch {
1309
+ /* not pnpm */
1310
+ }
1311
+ // parsing without dependencies: `packages: [a, b]` on one line or a "- pattern" list under the key up to the next
1312
+ // top-level key
1313
+ const flow = /^packages:\s*\[([^\]]*)\]/m.exec(yaml);
1314
+ if (flow) out.push(...flow[1]!.split(","));
1315
+ const block = /^packages:[ \t]*(?:#.*)?\r?\n((?:[ \t]+.*(?:\r?\n|$)|[ \t]*\r?\n)*)/m.exec(yaml);
1316
+ for (const l of block?.[1]!.split(/\r?\n/) ?? []) {
1317
+ const m = /^\s+-\s*(.+?)\s*(?:#.*)?$/.exec(l);
1318
+ if (m) out.push(m[1]!);
1319
+ }
1320
+ return out.map((p) => p.trim().replace(/^(["'])(.*)\1$/, "$2").replace(/^\.\//, "").replace(/\/+$/, "")).filter(Boolean);
1321
+ }
1322
+
1323
+ /** Workspace pattern → path regexp: `*` — one level, `**` — any depth. */
1324
+ function globRegExp(pattern: string): RegExp {
1325
+ const segs = pattern.split("/");
1326
+ const re = segs.map((s, i) => {
1327
+ const last = i === segs.length - 1;
1328
+ if (s === "**") return last ? ".*" : "(?:[^/]+/)*";
1329
+ return s.replace(/[.+^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^/]*").replace(/\?/g, "[^/]") + (last ? "" : "/");
1330
+ });
1331
+ return new RegExp(`^${re.join("")}$`);
1332
+ }
1333
+
1334
+ /** Workspace packages by name: directory from the root and `dependencies`. A pattern with `!` is an exclusion. */
1335
+ function workspacePackages(root: string): Map<string, { dir: string; dependencies: string[] }> {
1336
+ const patterns = workspacePatterns(root);
1337
+ const include = patterns.filter((p) => !p.startsWith("!"));
1338
+ const yes = include.map(globRegExp);
1339
+ const no = patterns.filter((p) => p.startsWith("!")).map((p) => globRegExp(p.slice(1)));
1340
+ // walk only under the patterns' constant part and no deeper than them: without `**` it doesn't descend into the
1341
+ // packages' code directories
1342
+ const bases = include.map((p) => {
1343
+ const segs = p.split("/");
1344
+ const glob = segs.findIndex((s) => /[*?]/.test(s));
1345
+ return (glob < 0 ? segs : segs.slice(0, glob)).join("/");
1346
+ });
1347
+ const depth = include.some((p) => p.includes("**")) ? Infinity : Math.max(0, ...include.map((p) => p.split("/").length));
1348
+ const near = (d: string) => bases.some((b) => !b || b === d || b.startsWith(d + "/") || d.startsWith(b + "/"));
1349
+ const out = new Map<string, { dir: string; dependencies: string[] }>();
1350
+ const walk = (rel: string, level: number): void => {
1351
+ let entries;
1352
+ try {
1353
+ entries = readdirSync(path.join(root, rel), { withFileTypes: true });
1354
+ } catch {
1355
+ return;
1356
+ }
1357
+ for (const e of entries) {
1358
+ if (!e.isDirectory() || e.name.startsWith(".") || SKIP_DIRS.has(e.name)) continue;
1359
+ const child = rel ? `${rel}/${e.name}` : e.name;
1360
+ if (!near(child)) continue;
1361
+ if (yes.some((r) => r.test(child)) && !no.some((r) => r.test(child))) {
1362
+ const p = packageJson(root, child);
1363
+ if (p?.name) out.set(p.name, { dir: child, dependencies: p.dependencies });
1364
+ }
1365
+ if (level + 1 < depth) walk(child, level + 1);
1366
+ }
1367
+ };
1368
+ if (include.length) walk("", 0);
1369
+ return out;
1370
+ }
1371
+
1372
+ /**
1373
+ * A monorepo app: a directory with package.json, declared variables, its own code directories and files (`dirs` by
1374
+ * default).
1375
+ */
1376
+ export interface EnvApp {
1377
+ dir: string;
1378
+ declared: readonly string[];
1379
+ dirs?: string[];
1380
+ }
1381
+
1382
+ /**
1383
+ * Reads of monorepo apps: files of the app and of the workspace packages from its `dependencies`, transitively
1384
+ * (devDependencies don't get into the build) — a package reads the environment of the app whose build it got into.
1385
+ * `dirs` — from each package's directory.
1386
+ */
1387
+ function appReads(root: string, apps: Record<string, EnvApp>, dirs: string[], readers?: readonly EnvReader[]): { app: string; declared: readonly string[]; reads: Map<string, string[]> }[] {
1388
+ const ws = workspacePackages(root);
1389
+ const cache = new Map<string, string[]>();
1390
+ return Object.entries(apps).map(([app, a]) => {
1391
+ const dir = path.posix.normalize(a.dir).replace(/\/+$/, "");
1392
+ const pkgs = [dir];
1393
+ const queue = [...(packageJson(root, dir)?.dependencies ?? [])];
1394
+ const seenDeps = new Set<string>();
1395
+ while (queue.length) {
1396
+ const name = queue.shift()!;
1397
+ const p = ws.get(name);
1398
+ if (!p || seenDeps.has(name)) continue;
1399
+ seenDeps.add(name);
1400
+ if (!pkgs.includes(p.dir)) pkgs.push(p.dir);
1401
+ queue.push(...p.dependencies);
1402
+ }
1403
+ const files = pkgs.flatMap((p) => codeFiles(root, (p === dir ? (a.dirs ?? dirs) : dirs).map((d) => path.posix.join(p, d))));
1404
+ return { app, declared: a.declared, reads: envReads(root, [...new Set(files)], readers, cache) };
1405
+ });
1406
+ }
1407
+
1408
+ /**
1409
+ * Environment variables — declared and read: "<VAR> is declared" for each one the code reads (`process.env.X`,
1410
+ * `import.meta.env?.X`, `["X"]`, destructuring, an alias of the source and of Vite's `loadEnv` result, own ways —
1411
+ * `readers`), and "<VAR> is read in the code" for each declared one (`declared` — from the project's environment
1412
+ * schema: zod, t3-env, `dotenvNames(.env.example)`). Monorepo — `apps` instead of `declared`: an element is
1413
+ * `<app>/<VAR>`, workspace packages' reads belong to the apps whose build they're in. Service ones (`ENV_SERVICE` and
1414
+ * `ignore`) are outside the check.
1415
+ */
1416
+ export function envVars(
1417
+ it: It,
1418
+ opts: {
1419
+ root: string;
1420
+ /** Code directories and files: from the root, in a monorepo — from each package's directory; `src` by default. */
1421
+ dirs?: string[];
1422
+ readers?: readonly EnvReader[];
1423
+ outside?: readonly { item: string; reason: string }[];
1424
+ /** Deprecated: no reason and no ratchet — a failing test "move it to outside". */
1425
+ ignore?: readonly string[];
1426
+ exceptions?: readonly Exception[];
1427
+ } & ({ declared: readonly string[]; apps?: undefined } | { apps: Record<string, EnvApp>; declared?: undefined }),
1428
+ ): void {
1429
+ const skip = new Set([...ENV_SERVICE, ...(opts.ignore ?? [])]);
1430
+ const N = localeNames();
1431
+ if (opts.ignore?.length) {
1432
+ it(locale.fill(N.spec.harness.ignoreNoReason, { items: [...opts.ignore].sort().join(", ") }), () => {
1433
+ throw new Error("ignore holds no reason — move it to outside: [{ item, reason }] (the reason is on the page; once the variable is gone — «remove from scope»)");
1434
+ });
1435
+ }
1436
+ const dirs = opts.dirs ?? ["src"];
1437
+ // one app — the element key is the variable name; monorepo apps — <app>/<VAR>
1438
+ const apps = opts.apps
1439
+ ? appReads(opts.root, opts.apps, dirs, opts.readers)
1440
+ : [{ app: "", declared: opts.declared, reads: envReads(opts.root, codeFiles(opts.root, dirs), opts.readers) }];
1441
+ const byApp = new Map(apps.map((a) => [a.app, { declared: new Set(a.declared), reads: a.reads }]));
1442
+ const of = (app: string) => byApp.get(app) ?? { declared: new Set<string>(), reads: new Map<string, string[]>() };
1443
+ type Item = { app: string; v: string };
1444
+ const keyOf = (i: Item) => (i.app ? `${i.app}/${i.v}` : i.v);
1445
+ const readItems = apps.flatMap((a) => [...a.reads.keys()].filter((v) => !skip.has(v)).map((v) => ({ app: a.app, v })));
1446
+ const declaredItems = apps.flatMap((a) => [...new Set(a.declared)].filter((v) => !skip.has(v)).map((v) => ({ app: a.app, v })));
1447
+ // an exception and "outside scope" go to the check whose registry has the variable; in none — to the first one:
1448
+ // "remove" there
1449
+ const inReads = new Set(readItems.map(keyOf));
1450
+ const inDeclared = new Set(declaredItems.map(keyOf));
1451
+ const forReads = <T extends { item: string }>(xs?: readonly T[]) => xs?.filter((x) => inReads.has(x.item) || !inDeclared.has(x.item));
1452
+ const forDeclared = <T extends { item: string }>(xs?: readonly T[]) => xs?.filter((x) => inDeclared.has(x.item));
1453
+ invariant(it, {
1454
+ registry: N.spec.harness.env.inCode,
1455
+ items: readItems,
1456
+ name: (i) => locale.fill(N.spec.harness.env.declaredName, { name: keyOf(i) }),
1457
+ key: keyOf,
1458
+ check: (i) => {
1459
+ const schema = i.app ? `the environment schema of app ${i.app}` : "the environment schema";
1460
+ if (!of(i.app).declared.has(i.v)) throw new Error(`${i.v} is read in ${(of(i.app).reads.get(i.v) ?? []).join(", ")} but not declared in ${schema}`);
1461
+ },
1462
+ violator: { name: N.spec.harness.env.undeclared, item: { app: "", v: "__UNDECLARED__" } },
1463
+ exceptions: forReads(opts.exceptions),
1464
+ outside: forReads(opts.outside),
1465
+ });
1466
+ invariant(it, {
1467
+ registry: N.spec.harness.env.declared,
1468
+ items: declaredItems,
1469
+ name: (i) => locale.fill(N.spec.harness.env.readName, { name: keyOf(i) }),
1470
+ key: keyOf,
1471
+ check: (i) => {
1472
+ if (of(i.app).reads.has(i.v)) return;
1473
+ throw new Error(
1474
+ i.app
1475
+ ? `${i.v} is declared in the environment schema of app ${i.app} but read neither by it nor by its packages — remove it from the environment schema`
1476
+ : `${i.v} is declared but not read — remove it from the environment schema`,
1477
+ );
1478
+ },
1479
+ violator: { name: N.spec.harness.env.unread, item: { app: "", v: "__UNREAD__" } },
1480
+ exceptions: forDeclared(opts.exceptions),
1481
+ outside: forDeclared(opts.outside),
1482
+ });
1483
+ }