@geonosis/oxlint-plugin-biological-architecture 2.0.0 → 2.2.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/README.md CHANGED
@@ -201,8 +201,9 @@ Then find the stray copy, and mind that the answer is package-manager-specific:
201
201
 
202
202
  ## Where to read on
203
203
 
204
- - `RULES.md` — every rule, its preset, which source tree it came from, and how each divergence was
205
- resolved.
204
+ - `RULES.md` — in this tarball: every rule, its preset, which source tree it came from, how each
205
+ divergence was resolved, the option each gated rule refuses without, and a generated line per rule
206
+ naming every option it accepts.
206
207
  - `skills/geonosis-code/SKILL.md` in the repository — **generated** from every rule's `fixShape`:
207
208
  the shape each gate accepts, in one short document meant to be read before writing code.
208
209
 
package/RULES.md ADDED
@@ -0,0 +1,939 @@
1
+ # Rule inventory
2
+
3
+ Read from the two source trees on 2026-08-29:
4
+
5
+ - `dielime/packages/eslint-plugin-biological-architecture` — **36 rules**
6
+ - `during.day/packages/eslint-plugin-biological-architecture` — **52 rules** (51 when the union was
7
+ cut; `step-opens-its-own-cell` landed there hours later and was folded in afterwards)
8
+
9
+ One plugin name, two rule sets, no shared code. **33 rules had the same filename in both trees; only
10
+ 3 of those 33 were byte-identical.** A fix in one never reached the other.
11
+
12
+ The union here is **54 rules**, plus twenty-two the kit wrote for itself — `layer-walls`,
13
+ `emit-declares-attempts`, `cell-no-cross-feature-organelles`, the `backend-medusa` ten of 1.0.0,
14
+ `no-unregistered-plugin-dir` + `router-schema-parity`, the `themekit` two,
15
+ `no-mock-db-in-integration`, `todo-names-its-plan`, `boundary-time-is-primitive`,
16
+ `durable-body-reads-through-steps` and `subpath-over-barrel` — **76**.
17
+
18
+ | | count |
19
+ |---|---|
20
+ | in both trees, byte-identical | 3 |
21
+ | in both trees, **diverged** | 30 |
22
+ | dielime only | 3 |
23
+ | during.day only | 18 |
24
+ | **union** | **54** |
25
+ | written here (`layer-walls`, `emit-declares-attempts`, `cell-no-cross-feature-organelles`) | 3 |
26
+ | written here, `backend-medusa` (1.0.0) | 7 |
27
+ | written here, the integration seam (1.0.0) | 2 |
28
+ | **total** | **66** |
29
+ | written here, `backend-medusa` (1.0.0) | 10 |
30
+ | **total** | **67** |
31
+ | written here, `themekit` (1.0.0) | 2 |
32
+
33
+ ## How divergence was resolved
34
+
35
+ Two shapes of divergence, and one resolution rule each.
36
+
37
+ **dielime broadened the path patterns.** Every dielime rule scoped its file test as
38
+ `(?:features/[^/]+/)?<tier>/`, so it fires on a FLAT kit (`packages/ui/src/tissues/…`) as well as a
39
+ feature-sliced app. during.day's kept `features/<name>/<tier>/`. **The broader pattern wins** — a
40
+ rule that reaches more files is the stricter rule.
41
+
42
+ **during.day broadened the node coverage.** Its August 2026 sweep added
43
+ `ExportAllDeclaration` / `ExportNamedDeclaration` handlers to every import-direction rule (a
44
+ re-export names a module exactly as an import does), and turned `tissue-no-hooks` from an import
45
+ check into a hook-CALL check — the hole three review rounds missed was a tissue reaching a store
46
+ through a `lib/` hook. **The wider visitor set wins**, for the same reason.
47
+
48
+ Where a divergence was a false-positive fix rather than a reach change (own-sibling stem tolerance,
49
+ `.parts.tsx` entrypoint scoping, re-export shims, react-email and Radix as composition sources,
50
+ `Kind`-vocabulary props), it was kept: both repos' fixtures depend on those, and a rule that fires on
51
+ an accepted convention is a rule people learn to ignore.
52
+
53
+ **Both repos' test suites are ported and green** — during.day's 300-odd hand-built-AST tests, its
54
+ `real-source-tests` cases through the real binary, and dielime's 45-case oxlint fixture lock.
55
+
56
+ ## Named decisions
57
+
58
+ ### `cell-must-be-stateful` (dielime) vs `cell-handles-are-scoped` (during.day) — **both kept, one renamed**
59
+
60
+ These share only the word "cell", and they mean different things by it.
61
+
62
+ - dielime's `cell-must-be-stateful` is the **biological tier**: a file in `cells/` whose whole family
63
+ is props-in / JSX-out is a compound in the wrong folder. It is the inverse of
64
+ `compound-must-be-stateless`. → kept as-is, `frontend`.
65
+ - during.day's `cell-handles-are-scoped` is a **Cloudflare D1 database cell**: a worker that opens a
66
+ cell handle must wrap every invocation in `withCells` or it leaks a pool per call. Nothing to do
67
+ with a tier. → kept, **renamed `worker-handles-are-scoped`**, `backend-d1`.
68
+
69
+ In this kit "cell" is a tier. A backend rule that means something else by it would teach the wrong
70
+ vocabulary at every diagnostic, so the backend one gave up the word.
71
+
72
+ ### The time door is an option, and its exemption is a LIST OF MODULES (1.5.0)
73
+
74
+ `time-through-the-door` shipped with `/packages/utils/src/time.ts` and four exemption directories
75
+ compiled in — a rule exactly one repo could enable, and the thing pinning that repo's last
76
+ generic-utility package in place, because moving the door would have silenced the rule guarding it.
77
+ The door is now the `door` option and the directories are `exempt`, both regex sources over the
78
+ normalised filename, and the rule refuses the run when `door` is missing. The LIBRARY it polices was
79
+ compiled in for one release longer — `dayjs`, so a repo on date-fns kept the raw-`Date` half and
80
+ lost the half that catches a second configured formatter, which is the defect that half exists for.
81
+ It is the `libraries` list now, defaulting to `['dayjs']`, and every diagnostic names the library
82
+ the repo declared.
83
+
84
+ `boundary-time-is-primitive` takes the same fact from the other side and answers it differently on
85
+ purpose. Its exemption is `doors`: modules NAMED IN FULL, one per entry, never a pattern. A rule
86
+ scoped by a glob grants its exemption to wherever the glob stops, and nothing then records who
87
+ decided the boundary or counts what accumulated on the far side — a consumer's path-exempted module
88
+ took on seven unchartered exports, one of them a write, lint-free, in eleven commits. A list of
89
+ module names is a number a reader can hold: a repo has one door, or it has three and knows it.
90
+
91
+ ### An enabled rule that cannot fire **refuses the run** (0.2.0)
92
+
93
+ This replaces the "does nothing until configured" posture below, and it is the one breaking change
94
+ in 0.2.0 that a consumer will feel.
95
+
96
+ Making every repo-specific list an option (law 6) has an obvious implementation and a wrong one:
97
+ return an empty visitor when the option is missing. The result is a rule that is enabled, graded
98
+ `"error"`, and **indistinguishable from compliance**. dielime ran `no-raw-html-atoms` at `"error"`
99
+ for months with no element→atom map. 136 raw elements in the tree. Zero findings. A green gate. It
100
+ was found by hand, not by a gate, because there was no gate — only a rule with nothing to compare
101
+ against, which is the silent-fallback class one level up from a counter reading zero under a format
102
+ it cannot parse.
103
+
104
+ So the posture splits in two:
105
+
106
+ - a rule **nobody enabled** does nothing — that is oxlint's job, and it needs no help from us;
107
+ - a rule that **is** enabled without the options it needs is a **config error**, and `create()`
108
+ throws, naming the rule and the missing option. oxlint refuses and exits non-zero.
109
+
110
+ **Nineteen** rules take this, and the list is not kept by hand: `src/rules/option-gated.ts` names
111
+ each with the option its refusal must say and a bag that makes it run clean, and
112
+ `src/option-gated.test.ts` asks every rule the plugin exports whether it refuses a bare `create()`
113
+ and fails when one of them is not on the list — or is on it and no longer refuses. It found seven
114
+ rules refusing with nobody proving it through the binary, `time-through-the-door` and
115
+ `boundary-time-is-primitive` among them. An empty list counts as missing: it is the same nothing
116
+ spelled longer. One helper, `src/rules/lib/options.ts`, so the
117
+ wording cannot drift rule to rule:
118
+
119
+ ```
120
+ biological-architecture/<rule> is enabled but its `<option>` option is nothing, so it can never
121
+ fire. Configure `<option>`, or turn the rule off — an enabled rule that cannot fire reads exactly
122
+ like a rule that found nothing.
123
+ ```
124
+
125
+ One wrinkle worth knowing before it surprises someone: oxlint renders a plugin error as a diagnostic
126
+ with **no source span**, and `--format=unix` has nowhere to put one, so it prints `:0:0:` and no
127
+ wording. The message is intact in the default and `json` formats. Under unix the run is still
128
+ non-zero, and the summary counts a problem no line accounts for — which is exactly the shape
129
+ `@geonosis/lint-parity` refuses to read as zero findings. Unreadable, never invisible.
130
+
131
+ **The kit takes its own medicine**: its `.oxlintrc.json` enabled `no-brand-names` and
132
+ `no-raw-html-atoms` bare, and both were inert. `no-brand-names` now names the two brands that must
133
+ never re-enter kit source (the ones the fork had compiled into its regex) — proved reaching by
134
+ adding one to a source file and watching it fire.
135
+
136
+ ### `no-brand-names` (dielime only) — brand list is now an **option**
137
+
138
+ The rule shipped with a two-brand regex and a matching CDN-host allowance compiled into it. It could
139
+ only ever serve the repo that wrote it. Now:
140
+
141
+ ```jsonc
142
+ "biological-architecture/no-brand-names": [
143
+ "error",
144
+ { "brands": ["acme"], "allowedSubstrings": ["media.acme.com"] }
145
+ ]
146
+ ```
147
+
148
+ Enabled with no `brands`, it refuses the run — see the decision above.
149
+
150
+ ### `step-opens-its-own-cell` — one law, two spellings, every fact an **option**
151
+
152
+ The law is one sentence: a durable instance hibernates between steps and replays them on retry, so
153
+ each unit of work opens the handle it uses rather than sharing one opened once per run. WHERE that
154
+ is declared is what has moved, because the org now runs two engines at once.
155
+
156
+ `@geonosis/workflows` has a seam for it — `scope.perStep` on the runtime a durable host builds — so
157
+ the finding is a host that declares none. `hosts` names the calls that build one (default
158
+ `createRuntime`, `entrypointFor`, the engine's own two) and `scopeKey` the key (default `perStep`);
159
+ `within` is the tree, and is required:
160
+
161
+ ```jsonc
162
+ "biological-architecture/step-opens-its-own-cell": [
163
+ "error",
164
+ { "within": "packages/workflows/src/" }
165
+ ]
166
+ ```
167
+
168
+ The scope is read anywhere inside the host call rather than at `scope.perStep` exactly, because the
169
+ Cloudflare adapter's entry declares it one closure down —
170
+ `entrypointFor({ runtime: (env) => createRuntime({ scope: … }) })` — and a check anchored on the
171
+ outer object would report every correct one of them. A host whose config cannot be read WHOLE (a
172
+ spread, a variable) is not judged at all: unreadable is not the same as absent, and only one of the
173
+ two is a finding.
174
+
175
+ `sagaflow-js` has no such seam. A repo still on it wrapped `step` in a file of its own, and the
176
+ finding there is a saga importing the engine's `step` around that wrapper — reachable by naming
177
+ `engine` and `wrapper`, and off entirely for a repo that names no engine:
178
+
179
+ ```jsonc
180
+ "biological-architecture/step-opens-its-own-cell": [
181
+ "error",
182
+ {
183
+ "engine": "sagaflow-js",
184
+ "within": "packages/workflows/src/",
185
+ "wrapper": "packages/workflows/src/step\\.ts$"
186
+ }
187
+ ]
188
+ ```
189
+
190
+ Enabled with no `within`, it refuses the run. Type-only imports never fire: they bring no handle, so
191
+ they can share none. The diagnostic quotes the option values rather than any repo's paths, and the
192
+ preset is its own — the invariant is true of a DURABLE run that hibernates between steps and false
193
+ of an inline saga sharing the request's connection, so `backend-workflows` would have been a repo
194
+ fact wearing a preset's name.
195
+
196
+ ### `layer-walls` (written here) — the `no-restricted-imports` factory, as data
197
+
198
+ Neither source repo had it; a third repo of the user's, musa-loading-plan-engine, hand-rolled it as
199
+ an ESLint `no-restricted-imports` factory in `eslint.config.mjs` (kernel ← auto/manual ← tools;
200
+ manual never imports auto). Every layered repo writes that file, and every one of them writes it in
201
+ a different shape, so it is the kit's job.
202
+
203
+ The layers are **data**: `{ name, paths, mayImport }`, `paths` being regex sources tested against the
204
+ normalised filename. A layer may always import itself; `mayImport` lists the others. A file in no
205
+ declared layer is unconstrained — walls are declared, never inferred — and a target in no layer is
206
+ not a wall, so a repo can wall one corner of its tree and leave the rest alone.
207
+
208
+ ```jsonc
209
+ "biological-architecture/layer-walls": ["error", {
210
+ "layers": [
211
+ { "name": "core", "paths": ["packages/x/src/core/"], "mayImport": [] },
212
+ { "name": "cli", "paths": ["packages/x/src/cli\\.ts$"], "mayImport": ["core"] }
213
+ ]
214
+ }]
215
+ ```
216
+
217
+ Three decisions inside it:
218
+
219
+ - **A relative source is resolved against the importing file before it is classified.** `../../shell/boot`
220
+ carries no layer name at all, and a rule matching the string alone lets every wall be walked
221
+ around with two dots. Only `cell-must-not-compose-cell` in this plugin does this today; plan 018
222
+ K1.2 gives every direction rule the same resolver.
223
+ - **A package specifier and a `node:` builtin are not walls by default.** A layer that may import no
224
+ other layer still runs on a machine with a filesystem. `externals: "deny"` is the opt-in for a
225
+ layer that really must reach nothing outside the declared set. A bare specifier a layer's `paths`
226
+ claims is still classified by it, which is what lets `parity` be forbidden from importing
227
+ `@geonosis/oxlint-plugin-biological-architecture` by name.
228
+ - **`ImportExpression` and `require()` are visited too**, beside the two re-export forms. A wall
229
+ visited only on `ImportDeclaration` is a wall with three doors left open.
230
+
231
+ Its own preset, `tooling`: it enforces nothing until a repo declares its layers, and a repo can want
232
+ layer discipline in its tooling without wanting a React tier vocabulary anywhere near it. **The kit
233
+ enables it on itself** in the root `.oxlintrc.json` — seven layers across the three packages, the
234
+ load-bearing one being `parity`, which may import nothing, so `@geonosis/lint-parity` cannot grow an
235
+ import of the plugin it exists to check.
236
+
237
+ ### `emit-declares-attempts` (written here, from a consumer's landmine) — 0.3.0
238
+
239
+ Medusa's `emitEventStep` defaults `attempts` to 1, and the redis worker treats only `attempts > 1`
240
+ as "a retry was configured". An emit that passes `{ priority }` and **no** `attempts` is therefore
241
+ droppable on its first failure while reading as deliberate — it has an options bag, it names a
242
+ priority, a reviewer moves on. The options bag is what hides it.
243
+
244
+ ```ts
245
+ emitEventStep({ data, eventName: 'order.placed', options: { attempts: 3, priority } })
246
+ ```
247
+
248
+ What it judges, and what it refuses to guess:
249
+
250
+ - no `options` at all, or an `options` with no `attempts` → reported;
251
+ - a literal `attempts` of 1 or less → reported, because that is the default spelled out;
252
+ - a **non-literal** `attempts` — `RETRY_BACKGROUND.attempts`, the shared constant — **passes**. The
253
+ rule cannot evaluate it, and one that guessed here would push every repo into inlining the number
254
+ at each call site, which is the opposite of what the shared constant is for;
255
+ - a payload or an options bag it cannot read whole (a variable, a spread) → passes, same reason.
256
+
257
+ The emitter names are the option `emitters`, default `["emitEventStep"]`: a repo that wraps the emit
258
+ under its own name says so instead of forking the rule. Emptied out (`emitters: []`) it refuses the
259
+ run, like every other option a rule cannot fire without.
260
+
261
+ Preset `backend-medusa`, which is twelve rules now.
262
+
263
+ **One correction to the brief that asked for it**: dielime does not guard 50 emit sites this way
264
+ today. It has **four** `emitEventStep` calls (`create-sample-order`, `ingest-shipment-milestone`,
265
+ `convert-sample-to-order` ×2), and **all four would fire** — three carry
266
+ `options: { priority: EventPriority.CRITICAL }` with no attempts, one carries no options at all.
267
+ That is the fixture, and it is worth more than the guarded version would have been.
268
+
269
+ ### The integration seam (1.0.0) — two rules, two new presets, both option-gated
270
+
271
+ `no-unregistered-plugin-dir` (preset **`integrations`**) and `router-schema-parity` (preset
272
+ **`backend-trpc`**). Each gets its own preset because each enforces nothing until a repo says where
273
+ its directories are, and because having integrations says nothing about having a tRPC mirror.
274
+
275
+ **`no-unregistered-plugin-dir`.** Midday ships 35 `app-store` directories and **eight are reachable
276
+ from nothing** — `cal`, `deel`, `dropbox`, `google-drive`, `polar`, `raycast`, `stripe`, `zapier`.
277
+ They break nothing, which is why they survived: they read as integrations to anyone counting
278
+ directories. The rule fires on the manifest file of a directory the registry does not name, and the
279
+ registry is read as SOURCE — a rule that had to import it would need the repo's whole resolution set
280
+ up inside a linter, and every string in it is a name the registry knows whether it is spelled as an
281
+ id or as an import path.
282
+
283
+ dielime is the fixture that matters just as much: measured, **zero** of its provider directories are
284
+ unregistered. A rule that fired on the clean tree would be off within a day. And its third seam has
285
+ no directories at all — `email-gmail.ts` and `whatsapp-360dialog.ts` sit flat beside their registry —
286
+ so it is out of scope because nothing matches `<root>/<dir>/<manifest>`, not by a special case.
287
+
288
+ *`registry` takes a list, because a registry can be split.* dielime's `medusa-config.ts` imports
289
+ `medusa-config.fulfillment.ts`, and the carrier directories are registered in the half that is
290
+ imported. Reading only the file the option named made two REGISTERED directories a standing FAIL
291
+ there — a false positive on a working tree, which is how a rule gets turned off. The halves are
292
+ listed, not followed: chasing imports out of a registry would need the repo's whole module
293
+ resolution set up inside a linter, and would guess. One unreadable half is the same silence a
294
+ single unreadable file has always bought — a configuration answer, not a partial verdict taken from
295
+ the halves that happened to open. The refusal now names the accepted shape too (#75), because a
296
+ repo that reached for a list was told `should be string` and could not learn from the rejection
297
+ whether a list was a shape the rule had or a shape it lacked.
298
+
299
+ *The defect the two test layers caught between them.* The rule's first version scanned every quoted
300
+ string in the registry, and the corpus registry's own comment said "the third is `dead-cal`" — in
301
+ backticks. The scanner counted it as a registration and the rule went silent on the one directory
302
+ its fixture exists to catch. The hand-built nodes could not find this: a hand-built registry has no
303
+ prose in it. Comments are stripped first now, and the direction is the point — a name that appears
304
+ only in a comment is usually the note explaining why the directory was abandoned.
305
+
306
+ **`router-schema-parity`.** during.day holds the mirror by hand at 21 of 22 routers and 21 of 23
307
+ contracts; Midday holds its own at 33 of 37. A router with no schema is a wire contract written
308
+ wherever the handler happens to say it; a schema with no router is a contract nothing serves.
309
+
310
+ Two facts were measured before the rule was written, and both changed its shape:
311
+
312
+ 1. during.day's two halves are in **different packages** — `apps/api/src/trpc/routers` against
313
+ `packages/api-contract/src` — so the options are repo-root-relative. A package-relative option
314
+ could not name them both.
315
+ 2. during.day's three apparent gaps are ONE deliberate arrangement: `routers/documents-mounted.ts`
316
+ mounts the invoice and purchase-order entities and imports both contracts. A strict filename
317
+ mirror reports all three and is wrong all three times, on the very tree the rule was built from.
318
+ So `mounts` (`{ "documents-mounted": ["invoice", "purchase-order"] }`) is config, and the corpus
319
+ carries the case both ways: the mount declared and the three findings it suppresses.
320
+
321
+ ### Two options the consumers forced (1.0.0) — both of them silent zeros
322
+
323
+ Neither of these came from review. Both came from running the rule read-only over the consumer and
324
+ then running a CONTROL that should have made the same files fire.
325
+
326
+ **`no-unregistered-plugin-dir.manifests` is required, with no default.** The rule first shipped a
327
+ default entry-file list — `index.ts`, `index.tsx`, `config.ts`. Over dielime's two provider roots it
328
+ reported zero findings, and every directory there IS registered, so the run read as a pass. It was
329
+ not one: dielime's plugin directories contain `service.ts` and no index at all, so the rule matched
330
+ no file and answered a question it had never asked. The control — the same files, with a registry
331
+ naming none of them — also reported zero, which is what exposed it. With `manifests: ["service.ts"]`
332
+ the control fires on all three directories and the real config on none.
333
+
334
+ **`emit-declares-attempts.budgetHelpers`, empty by default.** The claim this rule carried was that it
335
+ covers dielime's emit sites, so that repo's 132-line source walker could be deleted. Measured: all
336
+ **54** `emitEventStep` sites spell the bag `options: retryableEvent(…)` — zero object literals, zero
337
+ missing bags — and a call is precisely the shape the rule declines to judge. It reported zero, and
338
+ that zero proved only that no bag was absent. Named as a budget helper, a call to `retryableEvent`
339
+ passes and a call to anything else is reported as a budget nobody can check; the control naming a
340
+ helper dielime does not use reports all 54. Empty by default, so no existing consumer moves.
341
+
342
+ Both are the same failure as a counter that reads zero under a format it cannot parse, and both were
343
+ invisible to the rules' own fixtures — a fixture is written to match the rule, which is exactly what
344
+ a consumer's tree does not do.
345
+
346
+ ### `no-mock-db-in-integration` (1.0.0, preset **`testbed`**) — the rule both consumers pass
347
+
348
+ The only rule in the plugin whose landmine is in **neither** source repo. Measured on 2026-08-30:
349
+
350
+ | tree | `mockDb` | `vi.mock('…db…')` | `vi.mock(` in integration suites |
351
+ |---|---:|---:|---:|
352
+ | dielime | 0 | 0 | **0** of 106 integration files |
353
+ | during.day | 0 | 0 | 0 (`vi.mock` appears nowhere in the tree) |
354
+ | Midday | 32 references in 4 files | — | the gate |
355
+
356
+ Midday's shape: `apps/api/src/__tests__/setup.ts:80` exports `mockDb = createMockDb()`;
357
+ `helpers/test-context.ts:27` sets `db: mockDb`; **35 of the 43** test files under `__tests__/` import
358
+ that helper, and all 35 sit in `__tests__/trpc/`. They assert that a procedure calls what it says it
359
+ calls, and nothing about what the database returns. The repo's own `packages/db/src/test/` layer,
360
+ which runs against a real database, is right — the mocked suites are a supplement that became the
361
+ proof (landmine 11 of `docs/analysis-midday-2026-08.md`).
362
+
363
+ So **both consumers are the green fixtures**, and that is the whole reason `integrationDirs` and
364
+ `dbModules` are `requireOption`-gated with no defaults: a default drawn from two repos that do not
365
+ have the pattern would match nothing and report a clean tree it had never looked at — the exact
366
+ failure `no-unregistered-plugin-dir.manifests` was already caught by.
367
+
368
+ Three matching decisions, each with a case in the node layer:
369
+
370
+ - **the three spellings of one act** — `vi.mock`, `jest.mock`, `mock.module`. A repo on bun with
371
+ only `vi`/`jest` checked would be entirely unpoliced.
372
+ - **never a containment test.** `@repo/dbt` is not `@repo/db`, and `__tests__/integration-helpers/`
373
+ is not `__tests__/integration/`. Exact, subpath, or whole path segment.
374
+ - **`mockNames`, empty by default.** Midday's shape has no mock CALL to see: the handle is built in
375
+ a setup file and threaded through a context helper. A rule cannot infer that an identifier is a
376
+ mock, so a repo that has one names it — and a repo that does not gets no rule guessing at its
377
+ identifiers.
378
+
379
+ ### The `backend-medusa` seven (1.0.0) — Medusa's own silences, turned into gates
380
+ ### The `backend-medusa` ten (1.0.0) — Medusa's own silences, turned into gates
381
+
382
+ Every rule below exists because the framework **infers** something instead of making you say it.
383
+ None of them throws at runtime; all of them are found in production, far from the cause. Six were
384
+ scans in dielime's `packages/verify-arch` first and are now also per-file rules; two come from
385
+ Medusa's own source; one from an estimate a route already labels by hand. Three more — the per-file
386
+ halves of R4.3, R3.8 and R5.1 — landed after adoption showed what a text scan misses.
387
+
388
+ | rule | the silence | source |
389
+ |---|---|---|
390
+ | `mutating-step-declares-retry` | `maxRetries` defaults to **0** (`orchestration/transaction/types.ts:48`), so a transient failure reverts real state a retry would have absorbed | dielime scan R5.2 |
391
+ | `step-declares-compensation-or-none` | `stepConfig.noCompensation = !compensateFn` (`create-step.ts:184`) — a deliberate non-compensation and a forgotten one are the same source | analysis §1.1, "highest-value lint gap"; dielime scan R5.6 |
392
+ | `named-when-block` | an unnamed `when()` derives a `when-then-{ulid}` step name (`composer/when.ts:120-126`), so a resumed run cannot find its checkpoint | dielime scan R5.5 |
393
+ | `subscriber-declares-id` | the loader infers a subscriber id from a function or file name; two that infer alike dedupe each other | dielime scan R5.4 |
394
+ | `no-loader-side-effect-in-index-file` | `ResourceLoader.discoverResources` filters files named `index` (`resource-loader.js:51`) | dielime scan #15442 |
395
+ | `service-name-not-reserved` | a module key matching the `Modules` enum silently REPLACES the built-in (`medusa-app.ts:175-198`); `registerModule` guards only aliases and mains | analysis §1.3 — dielime's `catalog_search` scar |
396
+ | `no-index-count-as-exact` | the index engine's count is an EXPLAIN plan-row estimate and reads as an exact total | analysis §3a; dielime's b2b route labels it by hand |
397
+ | `unique-query-step-name` | the step-handler map is a plain assignment and the orchestrator appends a duplicate action with no dedupe, so two un-named query steps in one body last-win: one read never happens | dielime scan R4.3 |
398
+ | `plugin-route-namespaced` | the routes-loader assigns last-write-wins, so a plugin route with no namespace segment is claimable by any other package with no error | dielime scan R3.8 |
399
+ | `route-no-inline-mutation` | a route that writes directly succeeds, returns 200, and skips the compensation and retry class that live behind the workflow door | dielime scan R5.1 |
400
+
401
+ Four postures they share, all of them the one `emit-declares-attempts` set:
402
+
403
+ - **unreadable is not absent.** A config assembled from a spread, or held in a variable, passes —
404
+ the flag or the budget may be in the base, and a rule that guessed there would fire on every repo
405
+ that factored its step configs out.
406
+ - **a value the rule cannot evaluate passes.** `maxRetries: BUDGET` says nothing; only a literal is
407
+ judged, because a rule that demanded a literal would push every repo into inlining numbers.
408
+ - **every framework name is an option** — `stepFactories`, `blocks`, `moduleFactories`,
409
+ `resourceFactories`, `mutationPrefixes`, `estimateFields`, `labelKey`, `paths`, `directories`,
410
+ `reserved` — each with the framework's own value as its default, and each refusing the run when
411
+ emptied out.
412
+ - **arity, not shape, where the framework overloads.** `named-when-block` reads `when(input, cond)`
413
+ vs `when(name, input, cond)` by argument count. The first version of that rule read the FIRST
414
+ ARGUMENT'S TYPE and passed every real call site, because the input is a variable — the hand-built
415
+ test that caught it is `reports the unnamed overload`.
416
+
417
+ Two of them carry a decision worth naming:
418
+
419
+ **`no-loader-side-effect-in-index-file` defaults to `["subscribers/", "jobs/"]`, not all three
420
+ kinds** — measured, not assumed. In the installed 2.19 packages,
421
+ `framework/dist/utils/resource-loader.js:51` still filters `parsedName.name !== "index"` unless the
422
+ caller passes `allowIndex`, and `framework/dist/workflows/workflow-loader.js:34` is the **only**
423
+ caller in the whole framework that does (`grep -rn allowIndex` over the dist returns that call, the
424
+ parameter and its doc comment, and nothing else). So a `workflows/index.ts` **is** discovered on
425
+ 2.19 and a `subscribers/index.ts` is not. A rule firing on a file the framework reads is a false
426
+ positive, and false positives are how a rule gets ignored — so the default is what was measured, and
427
+ a repo below 2.19 adds `workflows/` back. The `medusa` scanner pack in `@geonosis/verify-arch`
428
+ defaults the same way, and `examples/dielime.verify-arch.json` adds the third kind, which is what
429
+ keeps that repo's numbers identical to today's.
430
+
431
+ **The class an AST rule wins over a text scan.** These three are the per-file halves of scans a
432
+ consumer runs repo-wide, and carrying both is deliberate — but where they disagree, the rule is
433
+ right, and R5.6 is the case that proves what the disagreement costs.
434
+
435
+ That scan decided whether a step had a compensation function by counting top-level commas in the
436
+ `createStep(` call. The house style closes a multi-line call with a TRAILING comma, so a step with
437
+ two arguments read as three, and "has a compensation function" was true of every step that ended in
438
+ `,)`. The scan reported **0 for its whole life**, with five real violations behind it — two of which
439
+ became real compensations once someone could see them. `step-declares-compensation-or-none` was
440
+ immune from its first day, because an AST does not count commas: it is handed the arguments.
441
+
442
+ The same class shows up in each of the three added here. `route-no-inline-mutation` reads its
443
+ escape-hatch annotation from the STATEMENT, where the equivalent scan looks on the matching line or
444
+ the one above — and a consumer's own annotated write is spread over six lines by a cast, so the
445
+ annotation lands five lines from the call and the scan is red on a write a person already signed
446
+ off. `unique-query-step-name` tells a name it CANNOT EVALUATE from a name that is absent, which a
447
+ regex over `.config({ name: … })` cannot. And every one of them sees files the scan's collector
448
+ never hands over: a scan reads what its walk collected, a rule is handed every file that compiles.
449
+
450
+ None of that makes the scans redundant. A scan proves the invariant repo-wide in one command,
451
+ whatever the lint stack is doing that week, and it sees facts no per-file rule can — two packages
452
+ claiming one route, a `serviceName` that must be unique across every module. The split is the
453
+ decision rule, not a ranking: whole-graph facts are scans, per-file facts are rules, and the six
454
+ that are both are defence in depth on purpose.
455
+
456
+ **`step-declares-compensation-or-none` judges every step, not only mutating ones.** dielime's R5.6
457
+ scan restricts itself to steps whose text contains a write; the rule does not, because
458
+ `noCompensation = !compensateFn` is true of every step and a read-only step declares
459
+ `noCompensation: true` once and is done. The two are therefore not redundant: the scan is the
460
+ repo-wide backstop, the rule is the finer instrument. `noCompensation: false` is the inferred state
461
+ written out and is still reported.
462
+
463
+ ### `store-route-scopes-tenant-data` (dielime only) — Medusa-specific
464
+
465
+ `/api/store/**/route.ts` is a Medusa convention. It is in `backend-medusa`, and also in
466
+ `tenant-scoping` because that is what it enforces.
467
+
468
+ ### `no-logic-in-component-files` — dielime's narrowed version
469
+
470
+ during.day's version also reported type re-exports and custom hooks in component files. Both are
471
+ covered by dedicated rules in the union (`no-type-definitions-in-components`,
472
+ `no-hook-in-component-disguise`), and during.day's own suite does not test this rule. dielime's
473
+ narrower "one public component per file" version is the one under test, and the one kept.
474
+
475
+ ### `tissue-must-compose` — dielime's, minus its backstop
476
+
477
+ dielime added a "renders ANY PascalCase tag" backstop. during.day has a test proving that backstop
478
+ lets a tissue satisfy the gate by rendering a component it imported as a **type** — precisely the
479
+ lint-theater the rule exists for. The backstop is dropped; dielime's broader `COMPOSITION_PATTERNS`
480
+ (feature barrels, `.parts`/`.sections` siblings, react-email primitives) already recognise real
481
+ arrangements, and dielime's fixtures stay green without it.
482
+
483
+ ### `cell-must-not-compose-cell` — the union's two halves collided on the barrel
484
+
485
+ The kit's version is dielime's scope (`(?:^|/)cells/`, so the rule reaches a `packages/ui` tree with
486
+ no `features/<name>/` segment) plus during.day's visitors (`ExportAllDeclaration` and
487
+ `ExportNamedDeclaration`, so a cell cannot launder an import through `export … from`). Each half is
488
+ right. Together, unguarded, they reported every `cells/index.ts` barrel — because a barrel is
489
+ nothing but `export … from` lines naming sibling cells. dielime's first adoption produced 32 such
490
+ errors, and every one of them was a file obeying `cells-folder-index-is-barrel`, which is also an
491
+ error and demands exactly that shape.
492
+
493
+ Neither fork could see it:
494
+
495
+ - **dielime** had the broad scope but visited `ImportDeclaration` only, so the re-exports in its
496
+ barrels were invisible to the rule;
497
+ - **during.day** visited all three, but scoped to `features/[^/]+/cells/`, and it has no
498
+ `cells/index.ts` or `cells/dynamic.ts*` anywhere in `apps/` or `packages/` — nothing to collide
499
+ with.
500
+
501
+ So: `cell-must-not-compose-cell` returns an empty visitor for the barrel files
502
+ `cells-folder-index-is-barrel` sanctions. A real cell importing — or re-exporting — another cell
503
+ still fires. The list of barrel filenames lives once, in `src/rules/lib/cells-barrel.ts`, and both
504
+ rules read it; two copies of that list is how two rules came to demand opposite things of one file.
505
+ Closing it also fixed a hole in the barrel rule's own pattern, which required a leading `/` and
506
+ therefore no-op'd on the cwd-relative `cells/index.ts` oxlint reports for a package whose own root
507
+ holds `cells/`.
508
+
509
+ ### `documents-share-one-table` — matches `pgTable` as well as `sqliteTable`
510
+
511
+ during.day's fork matched `literalNamedBy(node, 'sqliteTable')` and nothing else, and every tenant
512
+ table on that tree has been `pgTable` since its plan 037. Its own probe: a well-formed per-kind
513
+ table
514
+
515
+ ```ts
516
+ export const invoices = pgTable('invoices', { id: text('id').primaryKey(), orgId: orgIdColumn() })
517
+ ```
518
+
519
+ under `packages/db/src/schema/tenant/` produced **zero** findings from this rule; only the malformed
520
+ variant was caught, by `tenant-tables-carry-org-id`, and by accident. The fork's rule had been dead
521
+ since the tenant plane moved to Postgres.
522
+
523
+ The kit matching both constructors is therefore a fix, not a widening for its own sake. It stands
524
+ because the rule is about the second storage a base entity retired — which is true whatever the
525
+ dialect — and because `dialect-through-the-seam` already says the dialect is a seam fact rather than
526
+ a thing a document kind gets to have an opinion about. Both branches are exercised through the real
527
+ binary in `src/rules/__fixtures__/packages/db/src/schema/tenant/`, so neither can go quiet again.
528
+
529
+ ### `no-raw-html-atoms` — the tier it was always documented for (0.3.0)
530
+
531
+ Its own description said levels 2-6 and it fired at every level, `atoms/` included, where a raw
532
+ `<button>` is not a violation but the whole point of the file. dielime armed it by switching the rule
533
+ off over `**/atoms/**` in its own config: a repo carrying the kit's defect as configuration.
534
+
535
+ It is now silent inside the atom tier — `(?:^|/)(?:features/[^/]+/)?atoms/`, the merged pattern from
536
+ `src/rules/lib/tiers.ts`, which reaches a flat kit as well as a feature-sliced app.
537
+
538
+ **The default is not an allow-list of the five upper tiers**, and that is the load-bearing decision.
539
+ A real tree has files in no tier folder at all — a Medusa admin widget, a Next route segment, a
540
+ one-off surface — and the atom exists there too. Scoping to the five named tiers would have dropped
541
+ about a quarter of one consumer's findings in the same commit that called itself a fix, which is a
542
+ rule getting quieter with a fix's clothes on. So: **everywhere except the atom tier**, and a repo that
543
+ wants less says so:
544
+
545
+ ```jsonc
546
+ "biological-architecture/no-raw-html-atoms": [
547
+ "error",
548
+ [{ "element": "button", "atom": "Button", "importPath": "@kit/ui/atoms/button" }],
549
+ { "scope": ["cells", "tissues"] }
550
+ ]
551
+ ```
552
+
553
+ A `scope` naming something that is not a tier stops the run rather than matching nothing — the inert
554
+ rule the option refusal exists to stop, one option along.
555
+
556
+ ### Other repo-specific literals turned into options
557
+
558
+ | Rule | Option | Unconfigured |
559
+ |---|---|---|
560
+ | `organelle-dependency` | `sharedFeatures` | `['shared']` — during.day's `charts`/`document` are config, not law |
561
+ | `mutating-step-declares-retry` | `retryKey` | `maxRetries` — Medusa's key. `@geonosis/workflows` spells the same budget `retry: { limit, backoff }` (`create-step.ts`), and with the key compiled in a correctly budgeted step on that engine was a finding for ever |
562
+ | `mutating-step-declares-retry` | `retryLimitKey` | `limit` — the key that holds the NUMBER when the budget is an object. #191: reading only the bare form let `retry: { limit: 0 }` pass, which is the same silence as `maxRetries: 0` |
563
+ | `documents-share-one-table` | `perKindTables` | **refuses the run** |
564
+ | `worker-handles-are-scoped` | `within` + `wrapper` | **refuses the run** — `withCells` and `/apps/api/src/` were compiled in, so the rule served one repo's layout and one repo's vocabulary and a scope opener called anything else read as unscoped for ever |
565
+ | `subpath-over-barrel` | `packages` (`{ name, subpaths: { <subpath>: string[] } }[]`) | **refuses the run** — which of a repo's own packages expose subpaths, and which names live under each, is a map only that package's owner has |
566
+ | `durable-body-reads-through-steps` | `ports` | **refuses the run** — what a body reaches the outside world through is the repo's vocabulary; `durableKey` / `durableValue` (default `durable: true`) say how a workflow declares itself durable |
567
+ | `document-sagas-are-generic` | `perKindPrefixes` | **refuses the run** |
568
+ | `document-sagas-are-generic` | `within` | **refuses the run** — `/packages/workflows/src/` was compiled in, so the same per-kind saga one directory over was silent and no other layout could enable the rule at all |
569
+ | `document-sagas-are-generic` | `factories` | `['saga', 'createWorkflow']` — the callee was `saga` alone, so a per-entity `createWorkflow('invoice.create', …)` produced no finding (026-W2, measured). A name assembled from the entity is a template literal the rule cannot read: that is the mint the rule sends people to, not a gap in it |
570
+ | `unique-query-step-name` | `queryStepCalls` | **refuses the run** — the two names it shipped with are Medusa framework calls with no counterpart, so on the org engine the rule had zero reach while reading as enforced (026-W2, measured through the binary). There is no second spelling to inherit: `@geonosis/workflows` has no query-step primitive, and a repeated step name there is DEFINED rather than lost — `reserve`, `reserve#2` in call order (`engine.ts:124`) |
571
+ | `no-raw-html-atoms` | (positional) element→atom map | **refuses the run** |
572
+ | `no-raw-html-atoms` | `scope` (second positional, `{ scope: ["cells", …] }`) | every file except the atom tier |
573
+
574
+ Package specifiers in diagnostics (`@dielime/ui`, `@during/utils/time`) were made generic, and
575
+ `CLAUDE.md §…` citations were removed: they pointed at a law file this package does not ship, and
576
+ the remedy now lives in each rule's `fixShape`.
577
+
578
+ ### The rule core (0.4.0) — `src/rules/lib/`
579
+
580
+ Measured on 2026-08-29, before any of it existed: **37 of 54 rules normalised the filename
581
+ themselves**, in three spellings, one of which (`replace(/\\/g, …)` without the global flag in an
582
+ earlier fork) fixed only the first separator. **21 parsed an import source themselves**, and each
583
+ covered a different subset of the five ways a file can name another. **11 hand-wrote the same
584
+ re-export visitor.** None of that was decided; it was the order the rules were written in, and one
585
+ fix to how a path is read had to be found rule by rule.
586
+
587
+ | module | what it is the one door for |
588
+ |---|---|
589
+ | `lib/paths.ts` | `normalise` (backslashes → `/`), `fileOf` (the last segment, as a diagnostic names it) |
590
+ | `lib/tiers.ts` | `TIERS`, `inTier`, `tierOf`, `featureOf`, `requireTiers` — the ONE classification |
591
+ | `lib/imports.ts` | `sourceOf` (import · `export … from` · `export * from` · `import()` · `require()`), `isTypeOnly`, `resolveRelative` |
592
+ | `lib/visitors.ts` | `onImportSource` — one visitor set over all five shapes — and `mergeVisitors` |
593
+ | `lib/direction-rule.ts` | `directionRule()`: a direction rule as a declaration (scope, forbidden, messages) |
594
+ | `lib/options.ts` | `requireOption` — an enabled rule that cannot fire refuses the run |
595
+ | `lib/cells-barrel.ts` | the barrel list two rules must agree on |
596
+ | `lib/literal-call.ts` | the string a `saga('x', …)`-shaped call names |
597
+
598
+ The **Core** column in the preset table below says which of these each rule stands on. `paths` alone
599
+ means the rule reads a filename and nothing else; `directionRule` means the rule file is a
600
+ declaration and holds no mechanism at all.
601
+
602
+ `onImportSource` visits a type-only import only when the rule counts one. Four do:
603
+ `no-orm-outside-db` and `dialect-through-the-seam` say why in their docs (a type borrowed from the
604
+ ORM is a line a replacement would still have to rewrite), and `atom-no-deps` and
605
+ `molecule-atoms-only` inherited it from dielime — an atom that borrows a domain's type is an atom
606
+ that knows a domain. The other six direction rules skip them, as they always did.
607
+
608
+ ### Direction rules resolve relative imports (0.4.0)
609
+
610
+ **Before:** `cell-must-not-compose-cell` and `layer-walls` resolved a relative source against the
611
+ importing file before judging it. The other eight direction rules read the string as written, so
612
+ `../../billing/organelles/totals` — which names no feature and no tier until it is resolved — was
613
+ invisible to them. Whether a relative climb across a feature boundary was a finding depended on
614
+ which rule happened to look at it. during.day's A2b probe found this; it was one resolver in two
615
+ rules and no resolver in eight.
616
+
617
+ **Now:** the classification every direction rule judges by goes through `resolveRelative` once, in
618
+ the factory. Where an import LANDS is the fact; how it was spelled is what the message prints.
619
+
620
+ **Corrected in 0.5.0, by measurement.** "Ten rules now resolve relative imports" is true of the
621
+ mechanism and misleading about the effect: resolution changes the ANSWER for only the rules whose
622
+ forbidden pattern is **feature-anchored** — `features/<name>/stores/`, `features/<name>/organelles/`
623
+ — because a relative climb carries no `features/` segment until it is resolved. The other seven
624
+ forbid an unanchored substring (`stores/`, `cells/`, `/organelles/`, `molecules/`) which a relative
625
+ source contains as written, so they caught the relative spelling before 0.4.0 and after it alike.
626
+ 0.3.0 against 0.5.0 over a corpus of relative-only cases, one per rule, in
627
+ `proofs/021-relative-spellings/`: three rules answer differently, and one of the three is 0.5.0's own
628
+ new one. Two rules changed in 0.4.0, which is exactly what 0.4.0's parity table measured.
629
+
630
+ **What 0.5.0 added is reach evidence, not behaviour.** Every direction rule now carries BOTH
631
+ spellings in the shipped corpus — the aliased one and a relative climb — so a consumer who meets one
632
+ of them has a fixture to check their install against. `no-orm-outside-db` is the exception: its
633
+ target is a bare package specifier, so a relative case is not expressible for it.
634
+
635
+ This is a **widening**, and it is the only behaviour change in K1.2. Its scope is measured, not
636
+ argued — `proofs/018-T3-relative-imports/`:
637
+
638
+ | tree | A | B | lost | gained |
639
+ |---|---:|---:|---:|---:|
640
+ | the kit's corpus | 99 | 101 | 0 | 2 — `no-cross-feature-stores` and `organelle-dependency`, on the two fixtures written RED for this |
641
+ | dielime, its own config, `apps packages` | 135 | 135 | 0 | 0 |
642
+
643
+ dielime's zero is not luck: that tree has seven relative cross-feature imports of a store or an
644
+ organelle, five of them into `shared/organelles/*` (which `organelle-dependency`'s `sharedFeatures`
645
+ option permits) and two of them in **cells** reaching another feature's organelle — which no rule
646
+ here polices, because `organelle-dependency` is scoped to `organelles/` and `no-cross-feature-stores`
647
+ only looks at stores. That gap is real and is not this decision's to close — it is closed in 0.5.0
648
+ by `cell-no-cross-feature-organelles`, below.
649
+
650
+ ### `cell-no-cross-feature-organelles` (written here, from the gap above) — 0.5.0
651
+
652
+ The boundary the two rules above draw from two sides had a hole in the middle. `organelle-dependency`
653
+ is scoped to `organelles/`: it stops an organelle reaching sideways into another feature's organelle,
654
+ and says nothing about who imports it. `no-cross-feature-stores` is scoped to cells AND organelles,
655
+ but only judges `features/<x>/stores/`. So a **cell** importing another feature's **organelle** — a
656
+ hook holding that feature's state, pulled across the boundary — passed both.
657
+
658
+ It is not hypothetical. dielime, on its own config, with the rule enabled through a temporary config
659
+ and nothing else changed:
660
+
661
+ | site | import |
662
+ |---|---|
663
+ | `apps/web/src/features/site/cells/site-chrome.tsx:27` | `../../checkout/organelles/use-payment-providers-effect` |
664
+ | `apps/web/src/features/site/cells/site-chrome.tsx:28` | `../../search/organelles/search-autocomplete` |
665
+ | `apps/web/src/features/cart/cells/cart-view.tsx:12` | `../../checkout/organelles/use-payment-providers-effect` |
666
+
667
+ Three findings in two files — the 0.4.0 note above said "two", counting files rather than imports.
668
+ All three are relative climbs, so none of them would have been visible before 0.4.0's widening; five
669
+ further climbs in that tree land in `shared/organelles/*` and are allowed, which is what the
670
+ `sharedFeatures` option (default `['shared']`, the same default and the same meaning as
671
+ `organelle-dependency`'s) is for.
672
+
673
+ The rule is a `directionRule()` declaration: scope is the `cells` tier pattern, so it reaches a
674
+ feature-sliced app and a flat kit alike, and the forbidden target is an organelle of a feature that
675
+ is neither the importer's own nor named in `sharedFeatures`. Relative and aliased sources are judged
676
+ the same way, because the factory resolves before it judges. Preset: `frontend`.
677
+
678
+ ## Presets
679
+
680
+ `recommended` = `frontend`. `themekit` exists because both its rules are **inert until a repo says
681
+ how its own tokens are spelled** — the custom-property prefix and the font role names are that
682
+ repo's data, and either one guessed at would be one stylesheet wearing a preset's name. It is also
683
+ the preset a repo can want without wanting a tier vocabulary: a component library conforms to a
684
+ theme whether or not it is organised biologically. `backend-d1` exists because `no-d1-transaction` and
685
+ `worker-handles-are-scoped` are **false** on any other engine — a generic backend preset carrying
686
+ them would be a repo fact wearing a preset's name. `backend-workflows-cf` exists for the same reason:
687
+ `step-opens-its-own-cell` is true of a durable run on Cloudflare Workflows and false of an inline
688
+ one. It was called `backend-sagaflow-cf` until 1.5.0, after the engine it was written for; the old
689
+ name resolves to the same rules for ONE release and says so once, then goes. `package-subpaths` exists because `subpath-over-barrel` is inert until a repo writes out which
690
+ of ITS packages expose subpaths and which names live under each — a map only that package's owner
691
+ has, and one a kit that guessed at it would keep stale on somebody else's behalf.
692
+
693
+ | Rule | Preset(s) | Requires | Core | In dielime | In during.day | Same code |
694
+ |---|---|---|---|---|---|---|
695
+ | `atom-no-deps` | frontend | — | `directionRule` | yes | yes | diverged |
696
+ | `boundary-time-is-primitive` | backend-workflows | `types` | `nodes` + `paths` + `options` | — | — | **written here** (D-050, #145) |
697
+ | `cell-must-be-stateful` | frontend | — | `paths` | yes | — | dielime only |
698
+ | `cell-must-not-compose-cell` | frontend | — | `directionRule` + `cells-barrel` | yes | yes | diverged |
699
+ | `cell-no-cross-feature-organelles` | frontend | — | `directionRule` | — | — | **written here** |
700
+ | `cell-no-tissues` | frontend | — | `directionRule` | yes | yes | diverged |
701
+ | `cells-folder-index-is-barrel` | frontend | — | `paths` + `cells-barrel` | yes | yes | diverged |
702
+ | `compound-must-be-stateless` | frontend | — | `paths` | yes | yes | diverged |
703
+ | `compound-no-stores` | frontend | — | `directionRule` | yes | yes | diverged |
704
+ | `constants-in-constants-file` | backend-workflows | — | `paths` | — | yes | during only |
705
+ | `dialect-through-the-seam` | backend-workflows | — | `onImportSource` + `mergeVisitors` + `paths` | — | yes | during only |
706
+ | `document-sagas-are-generic` | backend-workflows | `perKindPrefixes` | `paths` + `options` + `literal-call` | — | yes | during only |
707
+ | `documents-share-one-table` | backend-workflows | `perKindTables` | `paths` + `options` + `literal-call` | — | yes | during only |
708
+ | `durable-body-reads-through-steps` | backend-workflows | `ports` | `nodes` + `objects` + `steps` + `options` | — | — | **written here** (#167, design-026 §8) |
709
+ | `effect-hook-naming` | frontend | — | `paths` | yes | yes | diverged |
710
+ | `emit-declares-attempts` | backend-medusa | — | `options` | — | — | **written here** |
711
+ | `font-roles-only` | themekit | `roles` | `paths` + `options` + `styling` | — | — | **written here** (U2) |
712
+ | `layer-walls` | tooling | `layers` | `onImportSource` + `imports` + `paths` + `options` | — | — | **written here** |
713
+ | `max-comment-density` | comment-ceiling | — | `paths` | — | yes | during only |
714
+ | `molecule-atoms-only` | frontend | — | `directionRule` | yes | yes | diverged |
715
+ | `molecule-must-compose` | frontend | — | — | yes | yes | diverged |
716
+ | `mutating-step-declares-retry` | backend-medusa + backend-workflows | — | `nodes` + `objects` + `steps` + `options` | — | — | **written here** (dielime scan R5.2) |
717
+ | `named-when-block` | backend-medusa | — | `nodes` + `options` | — | — | **written here** (dielime scan R5.5) |
718
+ | `next-route-segment-is-thin-delegate` | frontend | — | `paths` | yes | yes | diverged |
719
+ | `no-unregistered-plugin-dir` | integrations | `roots` | `options` + `paths` | — | — | **written here** (Midday's 8 dead dirs) |
720
+ | `no-mock-db-in-integration` | testbed | `integrationDirs` | `options` + `paths` | — | — | **written here** (Midday's 35 mocked router suites) |
721
+ | `router-schema-parity` | backend-trpc | `routers` | `options` + `paths` | — | — | **written here** (during.day's mirror) |
722
+ | `no-brand-names` | frontend | `brands` | `paths` + `options` | yes | — | dielime only |
723
+ | `no-card-shaped-div` | frontend | — | — | yes | yes | diverged |
724
+ | `no-cross-feature-stores` | frontend | — | `directionRule` | yes | yes | diverged |
725
+ | `no-d1-transaction` | backend-d1 | — | `paths` | — | yes | during only |
726
+ | `no-duplicate-jsx-patterns` | frontend | — | `paths` | yes | yes | diverged |
727
+ | `no-hook-in-component-disguise` | frontend | — | `paths` | yes | yes | diverged |
728
+ | `no-index-count-as-exact` | backend-medusa | — | `nodes` + `objects` + `options` | — | — | **written here** (Medusa source) |
729
+ | `no-inert-hidden-jsx` | frontend | — | `paths` | yes | yes | diverged |
730
+ | `no-inline-data-in-jsx` | frontend | — | `paths` | yes | yes | diverged |
731
+ | `no-invalid-feature-folders` | frontend | — | `paths` | yes | yes | diverged |
732
+ | `no-loader-side-effect-in-index-file` | backend-medusa | — | `nodes` + `paths` + `options` | — | — | **written here** (dielime scan #15442) |
733
+ | `no-logic-in-component-files` | frontend | — | `paths` | yes | yes | diverged |
734
+ | `plugin-route-namespaced` | backend-medusa | `packages` | `paths` + `options` | — | — | **written here** (dielime scan R3.8) |
735
+ | `no-orm-outside-db` | backend-workflows | — | `directionRule` | — | yes | during only |
736
+ | `no-raw-html-atoms` | frontend | `element→atom map` | `tiers` + `options` | yes | yes | diverged |
737
+ | `no-raw-sql-outside-allowed` | backend-workflows | — | `paths` | — | yes | during only |
738
+ | `no-react-namespace` | frontend | — | — | yes | yes | **identical** |
739
+ | `no-renamed-html-props` | frontend | — | — | yes | yes | **identical** |
740
+ | `no-render-prop-reader` | frontend | — | `paths` | yes | yes | diverged |
741
+ | `no-trivial-wrapper-component` | frontend | — | `paths` | yes | yes | diverged |
742
+ | `no-ts-in-bio-folders` | frontend | — | `paths` | yes | yes | diverged |
743
+ | `no-type-definitions-in-components` | frontend | — | `paths` | yes | yes | diverged |
744
+ | `no-void-port` | backend-workflows | — | — | — | yes | during only |
745
+ | `organelle-dependency` | frontend | — | `directionRule` | yes | yes | diverged |
746
+ | `organelle-single-source` | frontend | — | `onImportSource` + `mergeVisitors` + `paths` | yes | yes | diverged |
747
+ | `queries-require-org-scope` | tenant-scoping | — | `paths` | — | yes | during only |
748
+ | `queue-loop-is-the-library` | backend-workflows | — | — | — | yes | during only |
749
+ | `route-no-inline-mutation` | backend-medusa | — | `nodes` + `paths` + `comments` + `options` | — | — | **written here** (dielime scan R5.1) |
750
+ | `service-name-not-reserved` | backend-medusa | — | `nodes` + `objects` + `options` | — | — | **written here** (Medusa source) |
751
+ | `ssot-no-inline-facts` | backend-workflows | — | `paths` | — | yes | during only |
752
+ | `ssot-no-process-env` | backend-workflows | — | `paths` | — | yes | during only |
753
+ | `step-declares-compensation-or-none` | backend-medusa + backend-workflows | — | `nodes` + `objects` + `steps` + `options` | — | — | **written here** (Medusa source) |
754
+ | `step-opens-its-own-cell` | backend-workflows-cf | `within` | `nodes` + `objects` + `paths` + `options` | — | yes | during only, re-targeted |
755
+ | `store-route-scopes-tenant-data` | backend-medusa + tenant-scoping | — | `paths` | yes | — | dielime only |
756
+ | `subpath-over-barrel` | package-subpaths | `packages` | `nodes` + `options` | — | — | **written here** (#180, during.day asked) |
757
+ | `subscriber-declares-id` | backend-medusa | — | `objects` + `paths` + `options` | — | — | **written here** (dielime scan R5.4) |
758
+ | `tables-declare-their-plane` | backend-workflows + tenant-scoping | — | `paths` + `literal-call` | — | yes | during only |
759
+ | `tenant-tables-carry-org-id` | tenant-scoping | — | `paths` + `literal-call` | — | yes | during only |
760
+ | `tokens-only-colors` | themekit | `tokenPrefix` | `paths` + `options` + `styling` + `colors` | — | — | **written here** (U2) |
761
+ | `time-through-the-door` | backend-workflows | `door` | `paths` + `options` | — | yes | during only, de-hardcoded |
762
+ | `tissue-must-compose` | frontend | — | `paths` | yes | yes | diverged |
763
+ | `tissue-no-data-props` | frontend | — | `paths` | yes | yes | diverged |
764
+ | `tissue-no-hooks` | frontend | — | `paths` | yes | yes | diverged |
765
+ | `tissue-no-organelles` | frontend | — | `directionRule` | yes | yes | diverged |
766
+ | `tissue-no-stores` | frontend | — | `directionRule` | yes | yes | diverged |
767
+ | `todo-names-its-plan` | tooling | `markers` | `comments` + `options` | — | — | **written here** (W28) |
768
+ | `unique-query-step-name` | backend-medusa | `queryStepCalls` | `nodes` + `objects` + `options` | — | — | **written here** (dielime scan R4.3) |
769
+ | `worker-handles-are-scoped` | backend-d1 | `within` | `paths` + `options` | — | yes (as `cell-handles-are-scoped`) | during only, de-hardcoded |
770
+ | `zustand-v5-best-practices` | frontend | — | — | yes | yes | **identical** |
771
+
772
+ Preset sizes: frontend 36 · backend-workflows 16 · backend-medusa 12 · tenant-scoping 4 ·
773
+ backend-d1 2 · tooling 2 · themekit 2 · backend-workflows-cf 1 · backend-trpc 1 · integrations 1 ·
774
+ package-subpaths 1 · testbed 1 · comment-ceiling 1 (+ `capitalized-comments`, which is oxlint's own
775
+ and only makes sense alongside the density rule).
776
+
777
+ `backend-workflows` and `backend-medusa` OVERLAP by two: `mutating-step-declares-retry` and
778
+ `step-declares-compensation-or-none` reach both engines' spellings — measured in 026-W2, and true of
779
+ the retry rule only since its key became an option — so both presets carry them. Moving either out
780
+ of `backend-medusa` would take a live rule away from the repo it was written for, which is a
781
+ downgrade wearing a preset change. `unique-query-step-name` stays Medusa's alone: the collision it
782
+ reports is that framework's step-handler map last-winning, and a repeated step name on the org
783
+ engine is DEFINED rather than lost (`reserve`, `reserve#2`, `engine.ts:124`).
784
+
785
+ ## The build
786
+
787
+ Two builds existed. during.day's `package.json` resolves `.` through a `bun` condition to
788
+ `src/index.ts` and only falls back to `dist/index.js`; without bun, oxlint gets the fallback anyway.
789
+ dielime's is the plain one — `tsup … --format esm --no-dts --platform node --out-dir dist` with
790
+ `"exports": { ".": "./dist/index.js" }` — and that is what this package uses.
791
+
792
+ It is **source-proven**, not assumed: `src/rules/fixtures.test.ts` runs the real `oxlint` binary with
793
+ `jsPlugins: ["@geonosis/oxlint-plugin-biological-architecture"]` over the fixture tree and asserts
794
+ first that *something* fired. A plugin oxlint cannot load reports nothing, and every "should NOT
795
+ fire" case would pass silently without that assertion.
796
+
797
+ ## `fixShape`
798
+
799
+ Every rule exports `{ meta, create, fixShape }`. `fixShape` is a short Markdown string in the voice
800
+ of during.day's `during-code` skill: what the gate rejects and what to write instead.
801
+ `pnpm generate:skill` assembles them into `skills/geonosis-code/SKILL.md`, grouped by preset. A
802
+ staleness test fails when the committed skill is not what the rules generate, so the skill and the
803
+ rules cannot drift.
804
+
805
+ ## `probe`
806
+
807
+ The twenty-six rules whose reach depends on an option also export `probe(options)`, beside `fixShape`. It
808
+ returns `{ path, source }` — the smallest file that MUST fire the rule under the options it was
809
+ handed, at a path satisfying every PATH option the rule reads — or `{ files: [{ path, source }, …] }`
810
+ when one file cannot be enough. A single-file probe stays the bare form; readers accept both, and
811
+ must, because a consumer runs whatever plugin build it has installed.
812
+
813
+ It exists because a rule whose REACH is an option can never fire on a FIXED corpus. The fixtures
814
+ here say `acme` and `corpus/workflows/`; a repo says its own brand and `packages/workflows/src/`,
815
+ so `no-brand-names` and `step-opens-its-own-cell` read as firing nowhere in the two trees that
816
+ enforce them on every file — which is what both source repos reported against themselves on
817
+ 2026-08-30. `@geonosis/doctor`'s `exercised` check reads `probe` off the plugin a config loads,
818
+ writes it into a copy of the corpus, and runs again.
819
+
820
+ A probe is a CLAIM, so `src/probes.test.ts` tries to break every one of them through the real
821
+ binary, under consumer-shaped options, and requires exactly one finding. Two rules deserve the
822
+ detail:
823
+
824
+ - `no-unregistered-plugin-dir` reads its registry off disk, so its probe fires only when the
825
+ configured registry is also in the tree. One file cannot be both the plugin directory and the
826
+ registry, so its probe declares BOTH — the directory under `roots`, and a registry carrying no
827
+ string literal at all, so nothing in it can name the directory beside it. It was a single file
828
+ until 2026-08-30 and fired nothing, which was the truth about the probe and a lie about every
829
+ consumer: dielime enforces this rule at error over four registries and read as a permanent FAIL
830
+ for it.
831
+ - `layer-walls` and the path-regex options are inverted by `lib/probe.ts`'s `pathMatching`, which
832
+ builds a path back out of the pattern and then TESTS it against that pattern, refusing with a
833
+ sentence when the inversion does not hold. It is deliberately not a general regex inverse, and it
834
+ is never trusted.
835
+
836
+ <!-- GENERATED: the options each rule accepts -->
837
+
838
+ ## The options each rule accepts
839
+
840
+ One line per rule, from the rule’s own schema — the shape oxlint validates a config against.
841
+ Which of them a rule REFUSES to run without is the `Requires` column of the inventory above; an
842
+ option not named here is one no rule reads.
843
+
844
+ | Rule | Options it accepts |
845
+ |---|---|
846
+ | `atom-no-deps` | — |
847
+ | `boundary-time-is-primitive` | `doors`, `types` |
848
+ | `cell-must-be-stateful` | — |
849
+ | `cell-must-not-compose-cell` | — |
850
+ | `cell-no-cross-feature-organelles` | `sharedFeatures` |
851
+ | `cell-no-tissues` | — |
852
+ | `cells-folder-index-is-barrel` | — |
853
+ | `compound-must-be-stateless` | — |
854
+ | `compound-no-stores` | — |
855
+ | `constants-in-constants-file` | — |
856
+ | `dialect-through-the-seam` | — |
857
+ | `document-sagas-are-generic` | `factories`, `perKindPrefixes`, `within` |
858
+ | `documents-share-one-table` | `perKindTables` |
859
+ | `durable-body-reads-through-steps` | `durableKey`, `durableValue`, `ports`, `stepFactories`, `workflowFactories` |
860
+ | `effect-hook-naming` | — |
861
+ | `emit-declares-attempts` | `budgetHelpers`, `emitters` |
862
+ | `font-roles-only` | `allow`, `prefix`, `roles` |
863
+ | `layer-walls` | `externals`, `layers`, `typeOnly` |
864
+ | `max-comment-density` | — |
865
+ | `molecule-atoms-only` | — |
866
+ | `molecule-must-compose` | — |
867
+ | `mutating-step-declares-retry` | `mutationPrefixes`, `retryKey`, `retryLimitKey`, `stepFactories` |
868
+ | `named-when-block` | `blocks` |
869
+ | `next-route-segment-is-thin-delegate` | — |
870
+ | `no-brand-names` | `allowedSubstrings`, `brands` |
871
+ | `no-card-shaped-div` | — |
872
+ | `no-cross-feature-stores` | — |
873
+ | `no-d1-transaction` | — |
874
+ | `no-duplicate-jsx-patterns` | — |
875
+ | `no-hook-in-component-disguise` | — |
876
+ | `no-index-count-as-exact` | `estimateFields`, `labelKey` |
877
+ | `no-inert-hidden-jsx` | — |
878
+ | `no-inline-data-in-jsx` | — |
879
+ | `no-invalid-feature-folders` | — |
880
+ | `no-loader-side-effect-in-index-file` | `directories`, `resourceFactories` |
881
+ | `no-logic-in-component-files` | — |
882
+ | `no-mock-db-in-integration` | `dbModules`, `integrationDirs`, `mockNames` |
883
+ | `no-orm-outside-db` | — |
884
+ | `no-raw-html-atoms` | (a positional list), `scope` |
885
+ | `no-raw-sql-outside-allowed` | — |
886
+ | `no-react-namespace` | — |
887
+ | `no-renamed-html-props` | — |
888
+ | `no-render-prop-reader` | — |
889
+ | `no-trivial-wrapper-component` | — |
890
+ | `no-ts-in-bio-folders` | — |
891
+ | `no-type-definitions-in-components` | — |
892
+ | `no-unregistered-plugin-dir` | `manifests`, `registry`, `roots` |
893
+ | `no-void-port` | — |
894
+ | `organelle-dependency` | `sharedFeatures` |
895
+ | `organelle-single-source` | — |
896
+ | `plugin-route-namespaced` | `packages`, `scopes` |
897
+ | `queries-require-org-scope` | — |
898
+ | `queue-loop-is-the-library` | — |
899
+ | `route-no-inline-mutation` | `inlineMutationOk`, `mutationPrefixes`, `paths` |
900
+ | `router-schema-parity` | `ignore`, `mounts`, `routers`, `schemas`, `suffix` |
901
+ | `service-name-not-reserved` | `moduleFactories`, `reserved` |
902
+ | `ssot-no-inline-facts` | — |
903
+ | `ssot-no-process-env` | — |
904
+ | `step-declares-compensation-or-none` | `stepFactories` |
905
+ | `step-opens-its-own-cell` | `engine`, `hosts`, `scopeKey`, `within`, `wrapper` |
906
+ | `store-route-scopes-tenant-data` | — |
907
+ | `subpath-over-barrel` | `packages` |
908
+ | `subscriber-declares-id` | `paths` |
909
+ | `tables-declare-their-plane` | — |
910
+ | `tenant-tables-carry-org-id` | — |
911
+ | `time-through-the-door` | `door`, `exempt`, `libraries` |
912
+ | `tissue-must-compose` | — |
913
+ | `tissue-no-data-props` | — |
914
+ | `tissue-no-hooks` | — |
915
+ | `tissue-no-organelles` | — |
916
+ | `tissue-no-stores` | — |
917
+ | `todo-names-its-plan` | `id`, `markers` |
918
+ | `tokens-only-colors` | `allow`, `tokenPrefix` |
919
+ | `unique-query-step-name` | `queryStepCalls`, `workflowFactories` |
920
+ | `worker-handles-are-scoped` | `within`, `wrapper` |
921
+ | `zustand-v5-best-practices` | — |
922
+
923
+ <!-- END GENERATED -->
924
+
925
+ <!-- GENERATED: the floor each rule’s pattern belongs to -->
926
+
927
+ ## The floor each rule’s pattern belongs to
928
+
929
+ A rule here lints a pattern one of the kit’s floors implements. While the floor is PENDING the
930
+ rule is a requirement with nothing to install behind it — a repo enabling it writes the pattern
931
+ itself, and the doctor says so on the `exercised` line.
932
+
933
+ | Rule | Floor | Shipped |
934
+ |---|---|---|
935
+ | `document-sagas-are-generic` | `document` | PENDING |
936
+ | `documents-share-one-table` | `document` | PENDING |
937
+ | `step-opens-its-own-cell` | `workflows` | yes |
938
+
939
+ <!-- END GENERATED FLOORS -->
@@ -0,0 +1,9 @@
1
+ import { createStep } from '@corpus/workflows'
2
+
3
+ // BREACH: the default spelled out one level down. `limit: 0` inside this engine's budget object is
4
+ // the same silence as Medusa's `maxRetries: 0` — the first dropped connection reverts the write.
5
+ export const publish = createStep(
6
+ { name: 'publish', retry: { backoff: 'exponential', limit: 0 } },
7
+ async (input, { store }) => store.createDocument(input),
8
+ async ({ id }, { store }) => store.deleteDocument(id),
9
+ )
package/dist/index.js CHANGED
@@ -899,6 +899,7 @@ through \`perKindPrefixes\`, the tree that holds the flows through \`within\` (a
899
899
  filename), and \`factories\` if the repo declares them under other names; enabled without
900
900
  \`perKindPrefixes\` or \`within\` the rule refuses the run.`,
901
901
  meta: {
902
+ floor: { name: "document", pending: true },
902
903
  docs: {
903
904
  description: "Document sagas are minted, not written: `documentSagas(entity, effects)` gives every entity that extends Document its create/save/setStatus/remove/send/convert/duplicate/share from one body. A saga or workflow named with a LITERAL for one entity is a fork; a name assembled from the entity is the mint and is unreadable here on purpose. `factories`, `perKindPrefixes` and `within` are options."
904
905
  },
@@ -963,6 +964,7 @@ the dialect is a seam fact, not a reason for a kind to own storage. The forbidde
963
964
  \`perKindTables\` option; enabled without it the rule refuses the run rather than passing every
964
965
  schema silently.`,
965
966
  meta: {
967
+ floor: { name: "document", pending: true },
966
968
  docs: {
967
969
  description: "Documents share ONE table: `documents` holds the head every kind derives and the body its kind declares. A table named for one kind of document is a second storage."
968
970
  },
@@ -1842,6 +1844,7 @@ var writeNameOf = (node) => propertyNameOf(node.callee ?? {}) ?? "a service writ
1842
1844
  // src/rules/mutating-step-declares-retry.ts
1843
1845
  var RULE5 = "mutating-step-declares-retry";
1844
1846
  var DEFAULT_RETRY_KEY = "maxRetries";
1847
+ var DEFAULT_RETRY_LIMIT_KEY = "limit";
1845
1848
  var NO_RETRY2 = 0;
1846
1849
  var mutatingStepDeclaresRetry = {
1847
1850
  create(context) {
@@ -1852,6 +1855,11 @@ var mutatingStepDeclaresRetry = {
1852
1855
  context.options?.[0]?.retryKey ?? DEFAULT_RETRY_KEY,
1853
1856
  "retryKey"
1854
1857
  );
1858
+ const retryLimitKey = requireOption(
1859
+ RULE5,
1860
+ context.options?.[0]?.retryLimitKey ?? DEFAULT_RETRY_LIMIT_KEY,
1861
+ "retryLimitKey"
1862
+ );
1855
1863
  return {
1856
1864
  CallExpression(node) {
1857
1865
  if (!isStepCall(node, factories)) return;
@@ -1874,10 +1882,13 @@ var mutatingStepDeclaresRetry = {
1874
1882
  context.report({ data: { key: retryKey, step }, messageId: "noRetry", node });
1875
1883
  return;
1876
1884
  }
1877
- if (budget.type !== "Literal" || typeof budget.value !== "number") return;
1878
- if (budget.value > NO_RETRY2) return;
1885
+ const nested = propertiesOf(budget);
1886
+ const declared = nested === null ? budget : valueOf(nested, retryLimitKey);
1887
+ if (declared === void 0) return;
1888
+ if (declared.type !== "Literal" || typeof declared.value !== "number") return;
1889
+ if (declared.value > NO_RETRY2) return;
1879
1890
  context.report({
1880
- data: { budget: String(budget.value), key: retryKey, step },
1891
+ data: { budget: String(declared.value), key: retryKey, step },
1881
1892
  messageId: "zeroRetries",
1882
1893
  node
1883
1894
  });
@@ -1888,14 +1899,16 @@ var mutatingStepDeclaresRetry = {
1888
1899
  \`createStep({ name: 'place-order', ...RETRY_DB }, invoke, compensate)\`. A retry budget defaults to
1889
1900
  none, so a step named with a bare string cannot have one \u2014 take the object form and put the budget in
1890
1901
  it, from the repo's shared retry constants rather than as a number at the call site. A budget the
1891
- rule cannot evaluate (a constant, a spread, a nested \`{ limit, backoff }\`) passes: it says nothing
1892
- rather than pushing the number back inline. Read-only and compute steps are exempt \u2014 there is nothing
1893
- to revert. Configure \`retryKey\` to the key this engine writes the budget under (Medusa's
1894
- \`maxRetries\`, the org engine's \`retry\`), \`stepFactories\` if the repo wraps \`createStep\`, and
1895
- \`mutationPrefixes\` if its writes are named otherwise.`,
1902
+ rule cannot evaluate (a constant, a spread) passes: it says nothing rather than pushing the number
1903
+ back inline. A budget written as an OBJECT is read one level down \u2014 \`retry: { limit: 0 }\` is the
1904
+ default spelled out and is reported, \`{ limit: 3 }\` is not, and an object naming no limit at all
1905
+ says nothing. Read-only and compute steps are exempt \u2014 there is nothing to revert. Configure
1906
+ \`retryKey\` to the key this engine writes the budget under (Medusa's \`maxRetries\`, the org engine's
1907
+ \`retry\`), \`retryLimitKey\` if the number inside that object is not \`limit\`, \`stepFactories\` if
1908
+ the repo wraps \`createStep\`, and \`mutationPrefixes\` if its writes are named otherwise.`,
1896
1909
  meta: {
1897
1910
  docs: {
1898
- description: "A mutating step declares a retry class. A budget left unsaid defaults to zero retries, so a transient failure reverts real state a retry would have absorbed. Read-only steps are exempt; a non-literal budget passes. `retryKey`, `stepFactories` and `mutationPrefixes` are options."
1911
+ description: "A mutating step declares a retry class. A budget left unsaid defaults to zero retries, so a transient failure reverts real state a retry would have absorbed. Read-only steps are exempt; a non-literal budget passes, and an object budget is read one level down. `retryKey`, `retryLimitKey`, `stepFactories` and `mutationPrefixes` are options."
1899
1912
  },
1900
1913
  messages: {
1901
1914
  noConfig: 'Step "{{step}}" writes but is named with a bare string, so it carries no retry class \u2014 and `{{key}}` defaults to 0, which means a transient failure reverts real state. Take the object form and give it a retry class from the shared constants.',
@@ -1908,6 +1921,7 @@ to revert. Configure \`retryKey\` to the key this engine writes the budget under
1908
1921
  properties: {
1909
1922
  mutationPrefixes: { items: { type: "string" }, type: "array" },
1910
1923
  retryKey: { type: "string" },
1924
+ retryLimitKey: { type: "string" },
1911
1925
  stepFactories: { items: { type: "string" }, type: "array" }
1912
1926
  },
1913
1927
  type: "object"
@@ -4991,6 +5005,7 @@ On \`sagaflow-js\` there is no such seam and the older shape stays reachable: se
4991
5005
  Configure \`within\`; \`hosts\`, \`scopeKey\`, \`engine\` and \`wrapper\` have defaults or are off. Enabled
4992
5006
  without \`within\` the rule refuses the run.`,
4993
5007
  meta: {
5008
+ floor: { name: "workflows" },
4994
5009
  docs: {
4995
5010
  description: "A durable host declares the scope its steps run in \u2014 `scope.perStep` on the runtime it builds \u2014 because a durable run hibernates and retries, so a handle opened once per run is dead by the time a later step uses it. A repo on an engine with no such seam names its `engine` and `wrapper` instead, and a saga importing the engine's `step` around that wrapper is the finding. The guarded tree is required; enabled without it the rule refuses the run."
4996
5011
  },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@geonosis/oxlint-plugin-biological-architecture",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
4
4
  "types": "./dist/index.d.ts",
5
5
  "description": "Biological tier architecture as lint — the union of the dielime and during.day rule sets, shipped as presets.",
6
6
  "keywords": [
@@ -36,6 +36,7 @@
36
36
  }
37
37
  },
38
38
  "files": [
39
+ "RULES.md",
39
40
  "corpus",
40
41
  "dist"
41
42
  ],
@@ -43,7 +44,7 @@
43
44
  "oxlint": ">=1.77"
44
45
  },
45
46
  "devDependencies": {
46
- "@geonosis/lint-parity": "2.0.0"
47
+ "@geonosis/lint-parity": "2.2.0"
47
48
  },
48
49
  "engines": {
49
50
  "node": ">=22"