@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,709 @@
1
+ # spec skill checks — the harness, the architecture model, the entry-point log
2
+
3
+ Reference for the "Checks" section in `SKILL.md`: how to wire each mechanical check into the project's tests.
4
+
5
+ Which check for what:
6
+
7
+ | What to check | With what | Where in `tests/` |
8
+ |---|---|---|
9
+ | A convention on every item of a registry from the code ("every mutation writes an audit record") | `invariant` | `standards/<name>/` |
10
+ | A lint rule: «not allowed», «allowed», «outside scope» | `examples`, `eslintLinter` | `standards/<name>/` + an `eslint.ts` fragment |
11
+ | An ESLint rule in a standard's folder | `collectEslint` in `eslint.config.*` | the tree's `eslint.ts` fragments |
12
+ | Out of scope and an item's exception with its own file | `fileOf` in `invariant`; a mark `spec-outside(<decision>)` / `spec-exception(<decision>) #N` in the file (`marksIn`), in JSON — the value of the `"//"` key | the item's file (a route, a script, a migration, a workspace package's `package.json`) |
13
+ | Exceptions with a ratchet | `exceptionsIn()` → `exceptions` in `invariant`; a disable `-- #N reason` + `lintExceptions` | the decision folder's `exceptions/<item>.json`; migration — `spec-exceptions` |
14
+ | Architecture model: module boundaries, directories, packages | `boundariesConfig`, `architecture` | `architecture/model.ts`, `architecture/<name>/` |
15
+ | External systems (C1): hosts, packages, keys and headers — only in adapters, a perimeter without an adapter, network in tests, CSP | `externals` in the model, `architecture`, `networkGuard`, `cspConnectSrc` | `architecture/model.ts`, test setup |
16
+ | Containers (C2): modules and libraries, links, deploy configs (compose, Supabase, Vercel, Terraform), storage clients | `containers` in the model, `architecture`, `deployUnits` | `architecture/model.ts` |
17
+ | Monorepo workspace packages — in the model's roots; dev tools outside the model — out of scope with a reason | `architecture` (`outside`), `workspacePackages` | `architecture/model.ts`, a mark in the package's `package.json` |
18
+ | Interaction order in a scenario, a sequence diagram | `trace` in boundary wrappers, `sequence` | a capability test |
19
+ | Every entry point is called by a capability test | `journal` + `spec-claims` after the run | `standards/entry-points/` |
20
+ | No code without a consumer (files, exports, dependencies, knip config; production mode) | `deadCode` from the knip report, the `knip-hints.cjs` reporter | `standards/dead-code/` |
21
+ | Environment variables are declared and read (monorepo — per app) | `envVars`, `dotenvNames`, `configGet` in `readers` | `standards/env/` |
22
+
23
+
24
+ A mechanical check is runner tests: they are in the report, and so in
25
+ `docs/spec` and in `spec-diff`. The harness doesn't depend on the runner — you
26
+ pass it the runner's `it` (Vitest, Jest, bun test, `node:test`). Import it from
27
+ the project's package: `@miroshnik/elves/spec/harness` (the model —
28
+ `@miroshnik/elves/spec/architecture`, the ESLint fragment —
29
+ `@miroshnik/elves/spec/eslint-config`); a runner on Node takes the package's
30
+ build, Bun and the type checker — its TypeScript source.
31
+
32
+ - The type checker reads that source, and the harness imports `speclib.ts`
33
+ and `architecture.ts` with the `.ts` extension: `allowImportingTsExtensions`
34
+ (together with `noEmit`) is needed in *every* tsconfig that checks the
35
+ importing file: the root
36
+ one, `tsconfig.spec.json`, the e2e package's config; otherwise `tsc` gives
37
+ TS5097. A build config with emit (`tsconfig.build.json` for `nest build`)
38
+ doesn't accept the flag: tests are excluded from it, and when it extends the
39
+ checking config, it turns off both the flag and `noEmit`.
40
+ - A project on `module: commonjs` under TypeScript 5.x without
41
+ `esModuleInterop` can't import the harness: `import path from "node:path"` is
42
+ TS1259, and with a runner that transpiles to CommonJS (ts-jest), `path` is
43
+ `undefined`. Tests with the harness go under a tsconfig with
44
+ `esModuleInterop` or `module: nodenext`; TypeScript 6+ always enables
45
+ `esModuleInterop`.
46
+
47
+ **Standards and architecture run once — `test:spec`.** They are static checks
48
+ over the code: no app, browser or database, the same result in any job and
49
+ environment. One run per CI run is enough: the `test:spec` script in a fast job
50
+ of its own; `test` excludes them with the runner's own filter (Vitest
51
+ `--exclude`, Jest `--testPathIgnorePatterns`), the e2e run doesn't take them —
52
+ Playwright's `testDir` holds the browser tests only (a standard that itself
53
+ needs a browser is one of them and runs there, once). Measured in a project
54
+ where they ran inside the unit and e2e jobs: about 14% of the unit time and
55
+ 37% of e2e, the longest branch of the run. The entry-point reconciliation
56
+ isn't one of them: `spec-claims` reads the logs of all runs, after them
57
+ (below). Scripts — `SKILL.md`, "Wiring into a repository"; the workflow —
58
+ `ci.md`.
59
+
60
+ **`invariant` — registry + invariant.** A behavior convention ("every mutation
61
+ writes an audit record", "every route checks a permission") is checked on every
62
+ element of a registry taken from the code, not on a single example:
63
+
64
+ ```ts
65
+ import { describe, expect, it } from "vitest";
66
+ import { invariant } from "@miroshnik/elves/spec/harness";
67
+
68
+ const mutations = await listMutations(); // from the code: router, schema, files — not a handwritten list
69
+
70
+ describe("every mutation writes an audit record", () => {
71
+ invariant(it, {
72
+ registry: "mutations",
73
+ items: mutations,
74
+ name: (m) => `${m.name} writes an audit record`,
75
+ check: async (m) => expect(await auditRowsAfter(m)).toHaveLength(1),
76
+ violator: { name: "mutation without an audit record", item: fakeMutationWithoutAudit },
77
+ });
78
+ });
79
+ ```
80
+
81
+ Tests: «registry «mutations» is not empty» — a typo in a
82
+ path or a query gives an empty registry and a green check that checks nothing;
83
+ «violator does not pass: …» — the check must fail
84
+ on a known violator; one test per element, named with a statement; element
85
+ tests are ordered by name, not by registry order — the order of files and
86
+ queries depends on the machine, and `docs/spec` mustn't. No counters in names:
87
+ as the registry grows, `spec-diff` shows only the new elements. The registry is
88
+ computed before the tests are registered: an async one — with a top-level
89
+ `await` in the file.
90
+
91
+ On real code:
92
+
93
+ - *several conventions over one registry in a folder* — each has a `rule`
94
+ ("audit", "cancel"): the service tests name it
95
+ («registry «forms» is not empty (audit)»), an exception in the folder's shared
96
+ `exceptions/` carries the same `rule` (without `rule` — it applies to all of
97
+ the folder's conventions; a `rule` the folder doesn't have is red, below);
98
+ - *outside scope* — `outside: [{ item, reason }]` (by `key`): the login form, a
99
+ section that differs on purpose — its own test «outside scope: <key>»
100
+ with the reason on the page, not a filter in the test code; the element
101
+ dropped out of the registry — «remove from scope»; an element
102
+ with its own file — a mark in it (below, "Mark on the element");
103
+ - *required elements* — `includes: ["invoice", …]`: the not-empty test won't
104
+ notice that the query lost half the registry;
105
+ - *a check made of several halves* (client and server) — `violator` as a list:
106
+ a violator for each half;
107
+ - *a registry over source files* — `sources(root, ["src"])`: the file list is
108
+ cheap, the text is read in the element's check, not while collecting tests;
109
+ the violator is `source("src/fake.ts", "…text…")`.
110
+
111
+ **`examples` — examples for a lint rule.** A rule is checked on code where it
112
+ must fire («not allowed»), stay silent («allowed») and not apply
113
+ («outside scope» — a file the rule doesn't cover). The linter goes
114
+ through an adapter: `eslintLinter()` takes the project's ESLint and
115
+ `eslint.config.*`, and an example is checked the same way `lint` would check a
116
+ file at that path.
117
+
118
+ ```ts
119
+ import { describe, it } from "vitest";
120
+ import { eslintLinter, examples } from "@miroshnik/elves/spec/harness";
121
+
122
+ describe("the domain doesn't write to the console", () => {
123
+ examples(it, {
124
+ linter: eslintLinter(),
125
+ rule: "no-console",
126
+ bad: [{ name: "console.log in the domain", path: "src/domain/invoice.ts", code: "console.log(1);" }],
127
+ good: [{ name: "logger in the domain", path: "src/domain/invoice.ts", code: "log.info(1);" }],
128
+ outside: [{ name: "console.log in a build script", path: "scripts/build.ts", code: "console.log(1);" }],
129
+ });
130
+ });
131
+ ```
132
+
133
+ Tests — «not allowed: …», «allowed: …», «outside scope: …»; for «not allowed», `count` is how
134
+ many times the rule must fire (by default — at least once). A config with heavy
135
+ plugins takes seconds to load — `const linter = eslintLinter();
136
+ await linter.ready();` at the top level of the file: loading happens while
137
+ collecting tests, not under the first example's timeout. Another rule firing
138
+ counts neither for nor against; an example that doesn't parse is a failed test;
139
+ no «not allowed» — a failed test «rule … has a «not allowed» example».
140
+ There is only one adapter so far — ESLint: ast-grep and
141
+ Biome will be added when a project needs them (the core doesn't depend on the
142
+ linter, `Linter` is an interface with one required method, `lint`).
143
+
144
+ **A rule lives in its standard's folder.** A flat config fragment is
145
+ `eslint.ts` next to the test (`export default [{ files: ["src/domain/**"],
146
+ rules: {…} }]`, paths from the project root); the project's `eslint.config.*`
147
+ only collects the tree's fragments — `lint` and the editor see the rule as
148
+ usual:
149
+
150
+ ```js
151
+ import { collectEslint } from "@miroshnik/elves/spec/eslint-config";
152
+ export default [...base, ...(await collectEslint(import.meta.dirname))];
153
+ ```
154
+
155
+ ESLint caches the config in-process: the editor sees a new or deleted fragment
156
+ after the ESLint server restarts; `lint` in the CLI — right away.
157
+
158
+ Flat config doesn't merge rule options: two fragments with the same core rule
159
+ (`no-restricted-syntax`, `no-restricted-imports`) on the same files — the last
160
+ one wins, and the first one's restrictions silently disappear. A standard's
161
+ restriction is `restrict`: its own rule `spec/<id>` in the shared `spec`
162
+ plugin, so the restrictions of different standards on the same files all apply:
163
+
164
+ ```ts
165
+ import { restrict } from "@miroshnik/elves/spec/eslint-config";
166
+ export default restrict({ id: "no-alert", selectors: ["CallExpression[callee.name='alert']"], message: "alert — use notifications instead", files: ["src/**"] });
167
+ ```
168
+
169
+ A fragment is `eslint.ts`, `eslint.mts` (ESM even in a project without
170
+ `"type": "module"`, without the `MODULE_TYPELESS_PACKAGE_JSON` warning),
171
+ `eslint.mjs` or `eslint.js`; `tests/lib` holds helpers, not fragments.
172
+
173
+ **Exceptions are explicit, have a task, and go away when no longer needed.**
174
+
175
+ - *Registry + invariant:* an element without its own file — the `exceptions/`
176
+ directory in the decision folder (with its own file — a mark, below), one file
177
+ per exception — `exceptions/importLegacy.json`:
178
+ `{ "item": "importLegacy", "issue": 12, "reason": "…" }` (`rule` is
179
+ optional); in the test, the harness's `const exceptions = exceptionsIn();`
180
+ (the directory of the calling test's folder — from where its file is, not
181
+ from the runner's cwd — or `exceptionsIn("<folder>")`), in `invariant` —
182
+ `exceptions` and `key` (a stable element identifier: a name, a path). A file
183
+ per element rather than an array: debt-paydown subtasks each delete their own
184
+ file in parallel and don't conflict. The file name is the element in Latin
185
+ letters (the check doesn't require it); the same element in two files, a
186
+ non-JSON file or one without `item` is an error with the path. An excepted
187
+ element gets «exception: <key> (#N)» instead of the regular test:
188
+ green while it violates the convention; once it complies — red
189
+ «remove the exception: delete <file>» (a
190
+ ratchet: debt only goes down; the path is from the repository root, even when
191
+ a package's runner runs from the package's directory). An exception without a
192
+ task, without a reason, or for an element outside the registry is red. An
193
+ exception with a `rule` that no invariant in the folder has (a typo, the rule
194
+ was renamed or deleted) is picked up by no invariant — a red test
195
+ «exceptions with a rule belong to the folder's invariant rules» with the file path; the rules of the
196
+ folder's other files (subfolders sharing `exceptions/`) are looked up as a
197
+ string in their code. The form of `rule` isn't checked: the file name is
198
+ normalized. One directory per decision folder: `exceptions/` in a subfolder or
199
+ a non-`.json` file in the directory — a red test
200
+ «exceptions are <element>.json files in the decision folder's exceptions/» with the paths
201
+ (an exception from a subfolder isn't read). A leftover `exceptions.ts` in the
202
+ decision folder or its subfolder — a red test
203
+ «exceptions are one file per element in exceptions/, not exceptions.ts» with a migration hint:
204
+ `bun --no-env-file node_modules/.bin/elves spec-exceptions` spreads
205
+ each file into the decision folder's directory, changes the import in the folder's tests to
206
+ `exceptionsIn()`, and the path in `package.json` and the CI workflow to the
207
+ directory; whatever it didn't rewrite — as a `!` line, exit code 1.
208
+ - *Mark on the element* — outside scope and an exception for an element with its
209
+ own file (a route, a script, a migration): in `invariant` — `fileOf: (item) =>
210
+ path from the root` (the project root — the directory with `tests/`, without
211
+ a correction for the runner's cwd; a path with no file — red
212
+ «element files from fileOf exist»), the record is a comment in that file, like
213
+ `eslint-disable` for lint:
214
+
215
+ ```ts
216
+ // spec-outside(audit): login — there is no user yet, nothing for the audit to record
217
+ // spec-exception(audit) #12: legacy data import — audit in #12
218
+ ```
219
+
220
+ The element is deleted — the mark goes too; two PRs don't edit a shared file.
221
+ A record in `outside` or `exceptions/` outlived the element: one PR deleted a
222
+ route, another added it as outside scope — after both merges `main` is red
223
+ (measurement #211, three of five breakages). `audit` is the decision folder's
224
+ name: a file may be an element of registries in different folders, and the
225
+ mark names its own; `audit/cancel` is the folder invariant with that `rule`,
226
+ without it — all of the folder's invariants. A mark starts the comment text
227
+ (`//`, `/*`, a block's `*`, `#`, `--`, `<!--`): in JS/TS — only in comments,
228
+ not in strings; the format in the middle of prose isn't a mark (`marksIn`).
229
+ JSON has no comments — there the mark is the value of the top-level key `"//"`
230
+ (an npm convention), a string or a list of strings:
231
+ `"//": "spec-outside(modules): shared ESLint config — a development
232
+ tool"`; another key or a nested `"//"` isn't a mark, JSONC
233
+ (`tsconfig.json`) takes a regular comment. The tests are the same:
234
+ «outside scope: <key>» with the reason on the page, «exception: <key> (#N)» with
235
+ the ratchet «remove the mark in <file>:<line>»; without a task or a reason — red with the mark's location; a
236
+ `rule` that no folder invariant has — red
237
+ «marks with a rule belong to the folder's invariant rules». `fileOf` returns `null` — the element has no
238
+ file of its own (a schema line, a router entry): `outside` and `exceptions/`.
239
+ A mark in a file of two elements — red «a mark belongs in one element's file» (to
240
+ which one is unknown); a mark plus a
241
+ record in `outside` or `exceptions/` for the same element — red
242
+ «one record per element». Existing registries aren't
243
+ converted all at once — only new ones and the one you are editing.
244
+ - *Lint rule:* an exception is a disable in the code, right where the violation
245
+ is: `// eslint-disable-next-line no-console -- #12 reason`. `collectEslint`
246
+ turns on `reportUnusedDisableDirectives: "error"` — a disable that no longer
247
+ silences anything is a `lint` error. The format is checked by the project's
248
+ standard:
249
+
250
+ ```ts
251
+ import { it } from "vitest";
252
+ import { lintExceptions } from "@miroshnik/elves/spec/harness";
253
+
254
+ lintExceptions(it, { root: process.cwd(), dirs: ["src"] });
255
+ ```
256
+
257
+ One test per file with disables («exceptions in <file>: <rules> (#N)» —
258
+ the disables' tasks are in the name, the spec shows
259
+ them): each disable names its rule and has `-- #N reason`; a blanket
260
+ `eslint-disable` without rules is forbidden — it silences both the rules and
261
+ the check inside the linter, so the format is checked by a test, not by an
262
+ ESLint rule. The file tests are the list of exceptions in the spec.
263
+
264
+ **Architecture model — `architecture.ts`.** One per project,
265
+ `tests/architecture/model.ts`: modules (path, purpose), which modules and
266
+ external packages each one depends on.
267
+
268
+ ```ts
269
+ import type { Model } from "@miroshnik/elves/spec/architecture";
270
+
271
+ export default {
272
+ name: "Invoices", // the system on the documentation diagrams
273
+ roots: ["src"],
274
+ aliases: { "@/": "src/" }, // an import by alias is local, not a package; in a monorepo — a list of paths
275
+ modules: {
276
+ domain: { path: "src/domain", purpose: "invoice rules, no I/O" },
277
+ infra: { path: "src/infra", purpose: "database and services", dependsOn: ["domain"], packages: ["pg"] },
278
+ },
279
+ } satisfies Model;
280
+ ```
281
+
282
+ - *Module boundaries* — `tests/architecture/boundaries/eslint.ts`:
283
+ `export default boundariesConfig(model, boundaries)` (the project's
284
+ `eslint-plugin-boundaries` plugin; for TypeScript aliases — a resolver via
285
+ `settings`). Importing a module not in `dependsOn` is a
286
+ `boundaries/dependencies` error in `lint` and the editor; in
287
+ `boundaries.test.ts` — `examples` for that rule.
288
+ - *Model against code* — `architecture(it, { root, model })` in
289
+ `tests/architecture/modules/modules.test.ts`:
290
+ «directory <dir> is in module <module>» for every
291
+ code directory (outside modules — red; module paths nest — `src` and
292
+ `src/lib`: a directory belongs to the module with the longest path, which comes
293
+ first in the ESLint rules), «<module> imports <package>» (a
294
+ package not allowed for the module — red),
295
+ «<module> uses allowed package <package>» (not imported — red: the model
296
+ diverged from the code). In a monorepo —
297
+ «workspace package <path> is in the code roots» for every package from `workspaces` (`package.json`) and
298
+ `pnpm-workspace.yaml` that has code: `roots` are written by hand, and a new
299
+ package outside them would escape the check entirely (a root inside a package
300
+ — `apps/web/src` — covers the package). An alias specific to each app is a
301
+ list of paths: `"@/": ["apps/web/src/", "apps/admin/src/"]`; the prefix
302
+ decides whether an import is local. Exceptions from `exceptions/` go to the
303
+ registry that has their element (a host — to the hosts registry, not the
304
+ directories one); `rule` is the registry name when the element is in several.
305
+ Intentionally outside the model — development tools (the shared ESLint config
306
+ and tsconfig, a local HTTPS bridge, test helpers, the scripts directory) —
307
+ `outside: [{ item, reason }]`, routed the same way: it's a decision, not debt,
308
+ and an exception with a task would outlive the task's closing. On the page —
309
+ «outside scope: <element>» with the reason; the element is gone — red
310
+ «remove from scope». A workspace package is an element with its own file: the
311
+ mark `"//": "spec-outside(<decision>): reason"` in its `package.json`
312
+ (`<decision>` is the test's folder: `modules` for
313
+ `tests/architecture/modules/`). Imports are parsed statically: relative ones,
314
+ aliases, Node built-in modules and `import type` aren't packages. Packages are
315
+ checked by parsing, not by the plugin: `eslint-plugin-boundaries` 7.2 doesn't
316
+ catch external packages even with an explicit ban.
317
+ - *Documentation* — from the same model: `spec-doc` writes `architecture.md`
318
+ (C4 diagrams of levels C1–C3 and a module table), the marker
319
+ `<!-- spec: c4-component -->` in `<folder>.md` embeds the diagram into the
320
+ story of a capability or a rule. There is no handwritten diagram — it has
321
+ nothing to fall behind.
322
+
323
+ **Entry points — `journal` and `spec-claims`.** Every entry point (a route, a
324
+ page, a job, a command) is called by at least one capability test — by the call
325
+ log, not by coverage (coverage says "it executed", not "it was called by
326
+ claimed behavior").
327
+
328
+ - *Log:* the test build wraps the router or the handlers —
329
+ `journal("POST /invoices")` from `harness.ts`. The test file is determined from
330
+ the stack (including the `at async <path>` frame after `await`); where there is
331
+ no test on the stack (e2e: the request arrives at the server) — explicitly:
332
+ `journal(id, { test: test.info().file })` in Playwright. Records go to
333
+ `.spec-journal/<process>.jsonl` (`SPEC_JOURNAL` sets another directory; it's
334
+ in `.gitignore`).
335
+ - *A call counts if the test made it:* the test is on the call's stack or is
336
+ passed along the causal chain (an e2e fixture that sends the request; a
337
+ request header — the NestJS recipe below). Vitest's
338
+ `expect.getState().testPath` in a wrapper is no substitute for the stack: it's
339
+ the test running *now*, and it also gets credited with calls from a timer, a
340
+ background job, cron or a queue that fired during the test — the entry point
341
+ comes out "called" without claimed behavior. `testPath` works where the test
342
+ itself takes it — in a request helper.
343
+ - *A directory per runner, reset at the start of a run:* logs pile up from run
344
+ to run, and a deleted test keeps "calling" the entry point. A runner writes to
345
+ its own directory and wipes it before the tests — the harness's
346
+ `resetJournal()` in globalSetup (bun test — in `preload`): it wipes the
347
+ `*.jsonl` files of the `SPEC_JOURNAL` directory and leaves subdirectories —
348
+ other runners' logs — alone; `spec-claims` reads the directory with its
349
+ subdirectories.
350
+
351
+ ```ts
352
+ // vitest.config.ts; Playwright — the same in playwright.config.ts with ".spec-journal/e2e"
353
+ process.env.SPEC_JOURNAL ??= ".spec-journal/unit"; // before the workers: they inherit the environment
354
+ export default defineConfig({ test: { globalSetup: ["tests/setup/journal.ts"] } });
355
+
356
+ // tests/setup/journal.ts — once per run, before the tests
357
+ import { resetJournal } from "@miroshnik/elves/spec/harness";
358
+ export default () => resetJournal();
359
+ ```
360
+ - *Log in Next.js:* the entry points are `page.tsx` exports, `route.ts` methods
361
+ and the exports of `"use server"` modules.
362
+ - Vitest — a Vite plugin in the test build wraps them with a `journal` call (a
363
+ page — by file path, a route — `<method> <path>`, an action —
364
+ `<file>#<export>`).
365
+ - e2e — a Playwright fixture records the browser's requests: navigating to a
366
+ page and an RSC request (`RSC: 1`) — a page, a prefetch
367
+ (`Next-Router-Prefetch`) — not, `/api/…` — a route by method, a POST with the
368
+ `Next-Action` header — an action via
369
+ `.next/server/server-reference-manifest.json` (`filename` +
370
+ `exportedName`); e2e tests import `test` only from this fixture — a
371
+ `no-restricted-imports` rule on `@playwright/test`, otherwise the call won't
372
+ get into the log.
373
+ - *Log in NestJS:* the entry points are methods with Nest metadata: controller
374
+ handlers (`@Get()`…), GraphQL resolvers (`@Query()`, `@Mutation()`),
375
+ `@nestjs/schedule` jobs (`@Cron()`, `@Interval()`).
376
+ - The wrapper is on the method itself, not an interceptor: tests call a
377
+ resolver both directly and over HTTP, and an interceptor sees only the
378
+ latter. The test build's setup (`setupFiles`, before the modules are
379
+ imported) replaces `Reflect.decorate`: after the decorators, a method with
380
+ entry-point metadata is replaced with a wrapper that calls `journal`.
381
+ `Reflect.decorate` is called by an SWC or tsc build (`unplugin-swc` for
382
+ Vitest — Nest requires it anyway for `emitDecoratorMetadata`); esbuild
383
+ doesn't call it.
384
+
385
+ ```ts
386
+ const decorate = Reflect.decorate.bind(Reflect) as (...a: unknown[]) => PropertyDescriptor | undefined;
387
+ (Reflect as any).decorate = (decorators: unknown, target: any, key?: string | symbol, desc?: PropertyDescriptor) => {
388
+ const result = decorate(decorators, target, key, desc);
389
+ const original = result?.value;
390
+ if (key === undefined || typeof original !== "function" || !entryId(target.constructor, key, original)) return result;
391
+ const wrapped = function (this: unknown, ...args: unknown[]) {
392
+ // id at call time: the controller path is class metadata, and the class decorator runs after the methods'
393
+ journal(entryId(target.constructor, key, original)!, { test: requestTest.getStore() }); // no request — the stack
394
+ return original.apply(this, args);
395
+ };
396
+ // Nest metadata lives on the method function: without copying it the route and the resolver are lost
397
+ for (const k of Reflect.getOwnMetadataKeys(original)) Reflect.defineMetadata(k, Reflect.getOwnMetadata(k, original), wrapped);
398
+ return { ...result, value: wrapped };
399
+ };
400
+ ```
401
+
402
+ - `entryId(class, method, function)` — an id from the metadata: the HTTP path
403
+ and method (`PATH_METADATA`, `METHOD_METADATA` from
404
+ `@nestjs/common/constants`), the resolver's type and name
405
+ (`@nestjs/graphql`), the job name (`@nestjs/schedule`); not an entry point —
406
+ `null`.
407
+ - Over HTTP there is no test on the stack: the request helper in the tests sets
408
+ a header with `expect.getState().testPath` (the helper is called from the
409
+ test), and the test app's middleware puts it into `AsyncLocalStorage`
410
+ (`requestTest`) for the duration of the request. A timer and cron that a
411
+ request didn't start get no context and, without a test on the stack, don't
412
+ count.
413
+ - The registry uses the same metadata through the same `entryId`, otherwise the
414
+ format diverges: the test build (SWC), after importing the app module, walks
415
+ the wrapped methods and writes JSON, which `spec-claims --entries` reads —
416
+ Node without a build won't load Nest decorators.
417
+ - *Log of an SPA with a client-side router* (TanStack Router, React Router on
418
+ Vite): the server doesn't see link navigation — the entry point is the
419
+ router's route in the page, not a request.
420
+ - An e2e fixture listens to the router in the page and records the route
421
+ template (`/invoices/$id`), not the URL (`/invoices/42`): the registry comes
422
+ from the route tree, with the same id.
423
+ - TanStack Router: the router is `self.__TSR_ROUTER__` (it appears after the
424
+ app loads — the init script waits for it), subscribe with
425
+ `router.subscribe("onResolved", …)` and `"onRendered"` (the first navigation
426
+ may finish before subscribing), the id is the `routeId` of the last of
427
+ `router.state.matches`. React Router: the test build puts the
428
+ `createBrowserRouter` router on `window`, subscribe with
429
+ `router.subscribe(state => …)`, the template is the paths of
430
+ `state.matches` joined in order.
431
+ - Bind to the context, not the page: `context.exposeBinding` (a call from the
432
+ page → `journal(id, { test: testInfo.file })`) and `context.addInitScript` —
433
+ otherwise a popup and a new tab (`target="_blank"`, `window.open`) go
434
+ unlogged.
435
+ - *The reconciliation report for `spec-doc`:* reconciliation isn't always run
436
+ locally — with no report file (`.spec-claims.xml`), `spec-doc` warns and
437
+ builds the spec without it; in CI (`CI` is set) a missing report is exit code
438
+ 2: the run's tests would silently drop out.
439
+ - *Registry:* entry points from the code — a JSON array or a project module
440
+ (`export default async () => [...]`: routes from the router files, jobs from a
441
+ registry). A handwritten list won't do: a new entry point would slip through.
442
+ - *Reconciliation — after all tests and shards have run* (shard logs go into one
443
+ directory as artifacts):
444
+
445
+ ```bash
446
+ bun --no-env-file node_modules/.bin/elves spec-claims --entries tests/standards/entry-points/entries.ts [--exceptions tests/standards/entry-points/exceptions]
447
+ ```
448
+
449
+ Tests «<entry> is called by a capability test»
450
+ — a call from `tests/capabilities/` (one from a standard or a helper doesn't
451
+ count); «registry «entry points» is not empty»;
452
+ «entry-point ids do not repeat in the registry» (a repeat would hide an
453
+ uncovered entry point behind a covered one); «ids from the log are in the
454
+ registry» (otherwise the log's id format diverged from the registry, the
455
+ registry is incomplete, or the log is left over from a previous run);
456
+ «exception: <entry> (#N)» — green while there is no test, once there is one —
457
+ «remove the exception». `--exceptions` is a directory, one
458
+ file per exception (no directory — no exceptions; a file instead of a
459
+ directory — exit code 2 with a migration hint). Exit code 1 on unclaimed
460
+ entry points. The report is `.spec-claims.xml` (JUnit) in the standard's
461
+ folder `tests/standards/entry-points` (`--standard`): pass it to `spec-doc`
462
+ together with the runners' reports — the reconciliation gets into the spec
463
+ with the standard's `entry-points.md` description.
464
+
465
+ **Scenarios — `sequence` and `trace`.** How the parts of the system interact in
466
+ a scenario is a sequence diagram from a capability test's trace, not a drawing.
467
+
468
+ - *Trace:* the test build wraps the model's boundaries — the entry point (next to
469
+ `journal`), an external system's adapter, a storage client, a call to another
470
+ container — and calls `trace(from, to, message)` with the names of model
471
+ elements. Outside a scenario and inside a participant (`from === to`) the call
472
+ isn't recorded: rearranging things inside a module doesn't change the
473
+ diagram.
474
+ - *Scenario:* `sequence(it, "the order gets paid", async () => { … }, { id:
475
+ "payment", order: ["app → db: reserve", "app → stripe: charge"], model })`
476
+ — a test whose trace goes into the run metadata (`.spec-meta`). `order` — when
477
+ the order is itself a decision: the steps must come in this sequence (anything
478
+ in between), otherwise the test is red; `model` — participants only from the
479
+ model. Without `order` the diagram is documentation. A file's scenarios run
480
+ one at a time (`test.concurrent` would mix the traces).
481
+ - *Diagram:* `spec-doc` draws a `sequenceDiagram` under the scenario's test; the
482
+ marker `<!-- spec: sequence-payment -->` in `<folder>.md` puts it into the
483
+ story (then it isn't under the test); a marker without a scenario among the
484
+ folder's tests — `--strict` exit code 1.
485
+
486
+ **Code without a consumer — `deadCode` (knip).** A file, export or dependency
487
+ that no entry point reaches is dead code: knip finds it by the import graph
488
+ (knip's entry points come from its config in the project).
489
+
490
+ ```ts
491
+ import { describe, it } from "vitest";
492
+ import { deadCode, exceptionsIn } from "@miroshnik/elves/spec/harness";
493
+
494
+ const exceptions = exceptionsIn(); // the folder's exceptions/<finding>.json: { "item": "export:src/math.ts#factorial", … }
495
+ describe("no code without a consumer", () => deadCode(it, { root: process.cwd(), exceptions }));
496
+ // code that only tests reach: knip --production, tests tagged " (production)"
497
+ describe("product code is needed by the product", () => deadCode(it, { root: process.cwd(), exceptions, production: true }));
498
+ // knip arguments: deadCode(it, { root, args: ["--tsConfig", "tsconfig.test.json"] }) — tests outside the main tsconfig
499
+ ```
500
+
501
+ Tests by kind — «no files without a consumer», «no exports without a consumer»,
502
+ «no types without a consumer», «no dependencies without an import»,
503
+ «no imports of unlisted packages», «no unresolved imports» and the knip 6 kinds —
504
+ «no enum members without a consumer»,
505
+ «no namespace members without a consumer», «no duplicate exports»,
506
+ «no calls to unlisted binaries»,
507
+ «no unused catalog entries»,
508
+ «no references to missing catalog entries» — failing with the
509
+ list of findings. «all knip findings are of known kinds» fails on a kind the harness doesn't know (new in knip, `cycles`
510
+ with `--cycles`): a finding doesn't disappear silently.
511
+
512
+ **Finding key** — `<kind>:<file>#<name>`, the file comes from knip's report:
513
+ `export:src/math.ts#factorial`, `enumMember:src/color.ts#Color.Blue`; for a
514
+ dependency and a binary from scripts — the workspace's `package.json`
515
+ (`dependency:packages/a/package.json#lodash`), for an unlisted package — the
516
+ file with the import (`unlisted:packages/a/src/x.ts#zod`), for a catalog — its
517
+ file and the catalog name (`catalog:pnpm-workspace.yaml#default.vue`,
518
+ `catalogReference:packages/a/package.json#react18.react`); for a file —
519
+ `file:src/legacy.ts`. An exception in one workspace doesn't hide the same
520
+ package in another. An exception is a key with a task: green while knip finds
521
+ it; once it stops — «remove the exception»; an old key without a file
522
+ (`dependency:lodash`) — a failed test listing the keys to replace.
523
+
524
+ **Configuration hints** — the test «no knip configuration hints»: a redundant `ignore*`, an `entry` or `project` with no
525
+ matches, a redundant workspace — as a string `<workspace>#<type>:<what>`
526
+ (`packages/a#ignoreDependencies:lodash`, `.#entry-empty:src/main.ts`), an
527
+ exception is `hint:<string>`. knip doesn't put them into the JSON report — the
528
+ skill's reporter `knip-hints.cjs` prints them as the second line of output;
529
+ `hints: false` — no check.
530
+
531
+ **Running knip** — when the tests are registered, not in the first test: on a
532
+ monorepo knip takes longer than a test timeout (5 s in Vitest). Without
533
+ `report`, the project's
534
+ `node_modules/.bin/knip --reporter json --reporter <skill>/knip-hints.cjs` is
535
+ run (plus `args`); `report` is ready output of the same command (`knip --reporter
536
+ json --reporter node_modules/@miroshnik/elves/skills/spec/scripts/knip-hints.cjs > .knip.json`);
537
+ without the hints line — a failed test with that command. An exit code other
538
+ than 0/1 or output that isn't JSON — all the kind tests fail with the reason: a
539
+ knip crash isn't "no dead code".
540
+
541
+ **Several runs** in a folder (production mode, workspaces via `args`) — each has
542
+ a `rule` (with `production: true` — `production`): the ` (<rule>)` tag in the
543
+ test names. An exception with a `rule` belongs only to its own run; without
544
+ `rule` it silences the finding in all runs, and the ratchet belongs to the run
545
+ without `rule`: in production knip doesn't look at devDependencies and tests,
546
+ and "remove it" there would be false. A `rule` that no run has — a failed
547
+ reconciliation test, as with `invariant`.
548
+
549
+ **Environment variables — `envVars`.** A variable the code reads
550
+ (`process.env.X`, `import.meta.env.X`, `?.X`, `["X"]`, `?.["X"]`,
551
+ destructuring — `const { X, Y = "d", Z: z } = process.env` and from
552
+ `import.meta.env`, reading through an alias — `(env: Env = process.env) =>
553
+ env.X`, `const e = process.env; e["X"]`, the result of Vite's `loadEnv` —
554
+ `const env = loadEnv(mode, root); env.VITE_X`) is declared in the environment
555
+ schema; a declared one is read. Comments aren't read. `declared` comes from the
556
+ project's schema (zod schema keys, t3-env) or `.env.example` —
557
+ `dotenvNames(text)`: `X=`, `export X=` and a commented-out `# X=` — an optional
558
+ variable with a default in the code; prose in a comment isn't a declaration:
559
+
560
+ ```ts
561
+ envVars(it, { root: process.cwd(), dirs: ["src"], declared: Object.keys(envSchema.shape) });
562
+ envVars(it, { root: process.cwd(), dirs: ["src", "vite.config.ts"], declared: dotenvNames(readFileSync(".env.example", "utf8")) });
563
+ ```
564
+
565
+ `dirs` — code directories and individual files (a config at the package root);
566
+ `codeFiles` and `sources` take them the same way.
567
+
568
+ Custom ways of reading — `readers`: a regexp (the name is the `name` group or
569
+ the first one, no `g` flag needed) or a function `(code, file) => names` over
570
+ the code without comments. A config accessor (NestJS `ConfigService`:
571
+ `config.get('X')`, `getOrThrow<string>('X')` on an object under any name) is the
572
+ ready-made `configGet`; it isn't built in — `.get('X')` also exists on a Map and
573
+ a cache:
574
+
575
+ ```ts
576
+ envVars(it, { root: process.cwd(), declared, readers: [configGet, /\bsecret\(\s*"(?<name>[A-Z_]+)"/] });
577
+ ```
578
+
579
+ **Monorepo — `apps` instead of `declared`.** A workspace package reads the
580
+ environment of the app whose build it ends up in: its reads belong to every app
581
+ that has it in `dependencies`, transitively (`devDependencies` don't get into
582
+ the build). Packages — `workspaces` of the root `package.json` and
583
+ `pnpm-workspace.yaml` (`!` — exclude); `dirs` — from each package's directory,
584
+ an app has its own `dirs`. An element is `<app>/<VAR>`: tests
585
+ «api/DATABASE_URL is declared», exception keys and `outside` are
586
+ the same:
587
+
588
+ ```ts
589
+ envVars(it, {
590
+ root: process.cwd(),
591
+ dirs: ["src", "vite.config.ts"],
592
+ readers: [configGet],
593
+ apps: {
594
+ api: { dir: "apps/api", declared: dotenvNames(readFileSync("apps/api/.env.example", "utf8")) },
595
+ web: { dir: "apps/web", declared: Object.keys(webEnv.shape) },
596
+ },
597
+ });
598
+ ```
599
+
600
+ Tests «<VAR> is declared» and «<VAR> is read in the code»; service ones (`NODE_ENV`, `CI`, `PORT`… — `ENV_SERVICE`) are outside
601
+ the check. A project with its own registries on `invariant` takes the same
602
+ parts: `envNamesInCode(codeOnly(text, file), { file, readers })`, `codeFiles`,
603
+ `ENV_SERVICE`. Intentionally outside the check — `outside: [{ item, reason }]`:
604
+ the test «outside scope: <VAR>» with the reason on the page; the variable is in
605
+ neither the code nor the schema — «remove from scope»; `ignore` (without a
606
+ reason) — a failed test «move it to outside». An exception
607
+ and outside scope belong to the check whose registry has the variable: read
608
+ without being declared — to «is declared», declared without being read — to
609
+ «is read».
610
+
611
+ **Example code and exception reasons — in the spec.** While registering the
612
+ tests, the harness writes run metadata — `.spec-meta/<process>.jsonl`
613
+ (`SPEC_META` sets another directory): for an `examples` example — the path and
614
+ the code, for an exception (`invariant`, `deadCode`, `lintExceptions`) — the
615
+ task and the reason. `spec-doc` takes them from `.spec-meta` at the root (or
616
+ `--meta`) and shows them under the test's line: the convention is visible as
617
+ code, the debt as tasks. CI collects the shards' metadata into one directory,
618
+ like the entry-point log (`ci.md`).
619
+
620
+ **External systems (C1) — `externals` in the model.** The model names every
621
+ external system and its adapter module:
622
+
623
+ ```ts
624
+ externals: {
625
+ payments: { purpose: "card payments", adapter: "stripe", hosts: ["api.stripe.com"], packages: ["stripe"], env: ["STRIPE_KEY"] },
626
+ telegram: { purpose: "messenger", adapter: ["bot", "notify"], hosts: ["api.telegram.org"] },
627
+ cloudflare: { purpose: "CDN in front of the site", adapter: "geo", headers: ["CF-IPCountry"] },
628
+ waf: { purpose: "WAF in front of the site" }, // perimeter: no adapter
629
+ },
630
+ ```
631
+
632
+ `architecture` then adds: «host <h> is only in adapter <a>» (a URL literal outside the adapter or a host outside the model —
633
+ red; not hosts: comments, `xmlns` and W3C namespace URIs, the example domains
634
+ `example.com` and `.example`, `.invalid` per RFC 2606, navigation links — the
635
+ `href` value of a link and of an object, except `<link href>`: a link is opened
636
+ by the visitor's browser, while `location.href =` is navigation by code, a
637
+ host), «package <p> of external system <x> is only with adapter <a>» (the model
638
+ has no contradictions),
639
+ «key <K> of external system <x> is read only by adapter <a>» (reading — as in
640
+ `envVars`, custom ways — the same
641
+ `readers`: `architecture(it, { root, model, readers: [configGet] })`),
642
+ «header <H> … is read only by adapter <a>» (a string with the header name, case-insensitive — integration through perimeter
643
+ headers). An IP in a URL literal is a host of an external system too; not
644
+ hosts — only loopback (`localhost`, `*.localhost`, `127.0.0.0/8`, `[::1]`) and
645
+ `0.0.0.0`. Several adapters — `adapter` as a list: the host, package, key and
646
+ header may be in any of them. No adapter — a perimeter (CDN, WAF in front of the
647
+ system): no module talks to it, so its host, key or header in the code and its
648
+ package in a module are red; on C1 it's a «perimeter» in front of the
649
+ system.
650
+
651
+ Network in tests — `networkGuard`, in the test setup **before the mocks**:
652
+
653
+ ```ts
654
+ const guard = networkGuard(model, globalThis.fetch);
655
+ globalThis.fetch = guard;
656
+ afterEach(() => guard.check());
657
+ ```
658
+
659
+ The guard is the network under the mocks (`vi.spyOn`, `vi.stubGlobal`, MSW
660
+ `server.listen()` are installed on top and answer first): only what isn't mocked
661
+ reaches it. It lets out to the network only loopback and the `allow` hosts
662
+ (`networkGuard(model, fetch, { allow: ["db"] })` — a CI service, an integration
663
+ test's sandbox); a request to a declared external system fails with the host and
664
+ system name — a forgotten mock doesn't go out to the network; to a host outside
665
+ the model (and to `.test`, a public IP) — «declare the external system». The code under test may catch the `fetch` exception —
666
+ `check()` after the test fails on every rejected request. The browser's CSP
667
+ header is `cspConnectSrc(model)`: the project's test compares its `connect-src`
668
+ with it, and a new host not in the model won't get into the browser.
669
+
670
+ **Containers (C2) — `containers` in the model.** Deployable units and the links
671
+ between them:
672
+
673
+ ```ts
674
+ containers: {
675
+ app: { purpose: "web application", modules: ["web", "domain", "repo"], deploy: ["compose:app"], uses: ["db"] },
676
+ mail: { purpose: "sending email", modules: ["mailer"], deploy: ["supabase-function:send-email"] },
677
+ db: { purpose: "database", deploy: ["compose:db"], clients: ["pg"] },
678
+ },
679
+ ```
680
+
681
+ `architecture` then adds: «module <m> is in container <c>» (in none or in
682
+ several — red),
683
+ «dependency <a> → <b> is within a container» (the target is in every container
684
+ of the source; between containers —
685
+ only a `uses` link), «deployable unit <id> is container <c>» (a unit from the
686
+ configs without the model and a
687
+ model container without a config are red),
688
+ «client <pkg> of storage <s> is in container <c>» (without `uses` to the
689
+ storage — red).
690
+
691
+ A monorepo's shared package, bundled into several apps, is a module with
692
+ `library: true`: its containers are the containers of the modules that depend on
693
+ it (transitively) and those where it's named in `modules`; there's no need to
694
+ list it in every container. The test is
695
+ «library <m> is in containers <c1, c2>» (in
696
+ none — red: no module of the containers depends on it); its dependency is within
697
+ each of its containers, its storage client — with a `uses` link from each. On
698
+ C3 a library is outside the container boundaries.
699
+
700
+ Deployable units (`deployUnits`) — services in `docker-compose.yml` /
701
+ `compose.yml`, `supabase/functions/<name>`, crons in `vercel.json`, Terraform
702
+ compute resources in `*.tf` — `terraform:<type>.<name>` (`aws_ecs_service`,
703
+ `aws_lambda_function`, `aws_apprunner_service`, Cloud Run and Cloud Functions,
704
+ `azurerm_container_app`, `kubernetes_deployment`…; hidden directories,
705
+ including `.terraform`, aren't read); a resource in a Terraform module counts
706
+ once per module, however many instances of the module create it. Other formats
707
+ will be added when a project needs them. These checks don't catch production
708
+ drift from the configs.
709
+