@miroshnik/elves 0.0.0 → 2026.1018.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +310 -0
- package/README.md +344 -0
- package/bin/ai-dev.ts +7 -0
- package/bin/elves.ts +2847 -0
- package/bin/shims/timeout +113 -0
- package/claude/CLAUDE.md +71 -0
- package/dist/github/scripts/github.js +3428 -0
- package/dist/spec/scripts/architecture.js +628 -0
- package/dist/spec/scripts/eslint-config.js +54 -0
- package/dist/spec/scripts/harness-qzb932c8.js +959 -0
- package/dist/spec/scripts/harness-v9s1v9w3.js +16045 -0
- package/dist/spec/scripts/harness.js +53 -0
- package/dist/spec/scripts/knip-hints.cjs +34 -0
- package/docs/ci-concurrency.md +82 -0
- package/docs/cloud-sessions.md +203 -0
- package/docs/judgment.md +81 -0
- package/docs/parallel-checkouts.md +220 -0
- package/docs/pr-checks.md +117 -0
- package/docs/testing.md +304 -0
- package/package.json +70 -1
- package/skills/ci-wait/SKILL.md +180 -0
- package/skills/ci-wait/scripts/wait-ci.sh +377 -0
- package/skills/dashboard/SKILL.md +212 -0
- package/skills/dashboard/scripts/dashboard.ts +912 -0
- package/skills/dashboard/scripts/sessions.ts +772 -0
- package/skills/est/SKILL.md +221 -0
- package/skills/est/reference.md +381 -0
- package/skills/est/scripts/est.ts +4854 -0
- package/skills/github/SKILL.md +206 -0
- package/skills/github/locales/en.json +449 -0
- package/skills/github/locales/ru.json +449 -0
- package/skills/github/reference.md +1073 -0
- package/skills/github/scripts/github.ts +4390 -0
- package/skills/github/scripts/locale.ts +495 -0
- package/skills/github/scripts/package.json +3 -0
- package/skills/slot/SKILL.md +145 -0
- package/skills/slot/scripts/package.json +3 -0
- package/skills/slot/scripts/slot.ts +279 -0
- package/skills/spec/SKILL.md +247 -0
- package/skills/spec/canon.md +219 -0
- package/skills/spec/checks.md +709 -0
- package/skills/spec/ci.md +378 -0
- package/skills/spec/migration.md +125 -0
- package/skills/spec/reference.md +889 -0
- package/skills/spec/scripts/architecture.ts +839 -0
- package/skills/spec/scripts/c4.ts +184 -0
- package/skills/spec/scripts/eslint-config.ts +82 -0
- package/skills/spec/scripts/harness.ts +1483 -0
- package/skills/spec/scripts/knip-hints.cjs +34 -0
- package/skills/spec/scripts/knip-hints.d.cts +17 -0
- package/skills/spec/scripts/package-run.ts +22 -0
- package/skills/spec/scripts/package.json +3 -0
- package/skills/spec/scripts/spec-break.ts +232 -0
- package/skills/spec/scripts/spec-claims.ts +198 -0
- package/skills/spec/scripts/spec-diff.ts +1111 -0
- package/skills/spec/scripts/spec-doc.ts +744 -0
- package/skills/spec/scripts/spec-exceptions.ts +233 -0
- package/skills/spec/scripts/spec-publish.ts +214 -0
- package/skills/spec/scripts/spec-run.ts +235 -0
- package/skills/spec/scripts/spec-shard.ts +267 -0
- package/skills/spec/scripts/speclib.ts +826 -0
- package/skills/spec/scripts/vendor/babel-parser.LICENSE +19 -0
- package/skills/spec/scripts/vendor/babel-parser.cjs +14615 -0
- package/skills/spec/scripts/vendor/babel-parser.d.cts +3 -0
- package/skills/task-env/SKILL.md +231 -0
- package/skills/task-env/proxy/compose.yaml +51 -0
- package/skills/task-env/reference.md +254 -0
- package/skills/task-env/scripts/task-env.ts +1017 -0
|
@@ -0,0 +1,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
|
+
|