Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
50 changes: 50 additions & 0 deletions .dependency-cruiser.cjs
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,35 @@ const AMBIENT = [
];
const ambientPattern = `^(node:)?(${AMBIENT.join("|")})$`;

/**
* Packages an architecture decision already rejected, each with the record
* that rejected it. A denylist is cheaper than the argument a second time.
*
* @kubernetes/client-node the compiler renders, it does not apply: adapters
* build typed objects and one serializer owns the
* bytes (0067).
* ajv Zod is the one validator; a second one means two
* declarations of the same shape (0066).
* zod-to-json-schema Zod generates JSON Schema itself, from the input
* variant of each schema (0066).
* handlebars, ejs, text templating is the generation this compiler
* mustache, nunjucks replaces: objects in, one serializer out (0067).
*/
const DENIED = [
"@kubernetes/client-node",
"ajv",
"zod-to-json-schema",
"handlebars",
"ejs",
"mustache",
"nunjucks",
];
// The resolved path, not the specifier: an installed package resolves under
// node_modules/, and one that is merely written resolves to itself.
const deniedPattern = `^(node_modules/)?(${DENIED.map((name) =>
name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"),
).join("|")})(/|$)`;

module.exports = {
forbidden: [
{
Expand Down Expand Up @@ -194,6 +223,27 @@ module.exports = {
from: { path: "^src/", pathNot: "\\.test\\.ts$" },
to: { dependencyTypes: ["npm-dev"] },
},
{
name: "no-denied-dependency",
severity: "error",
comment:
"A package an architecture decision already rejected. See the DENIED " +
"list above for which record rejected which, and " +
"docs/architecture-rules.md RULE-022 for the ledger row.",
from: { path: "^src/" },
to: { path: deniedPattern },
},
{
name: "no-unresolvable-import",
severity: "error",
comment:
"A relative import that resolves to nothing is an edge no other rule " +
"can check: the graph cannot tell which ring it crossed. Bare " +
"specifiers are left to the package manager and the type checker, " +
"which say something more useful about a missing package.",
from: { path: "^src/" },
to: { couldNotResolve: true, path: "^[.]" },
},
{
name: "not-to-deprecated-core",
severity: "error",
Expand Down
9 changes: 9 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,15 @@
- 'name': 'Requirements ledger'
'run': 'npm run lint:requirements'

# The rule ledger (docs/architecture-rules.md): every row resolves its
# enforcer, every enforced row names a fixture that asserts on its
# witness, every pending row carries a ticket and a reason, and every
# rule the two rule configurations name is claimed by exactly one
# enforced row. See
# docs/adr/architecture/0104-every-enforced-rule-has-an-id-a-row-and-a-fixture.md.
- 'name': 'Rule ledger'
'run': 'npm run lint:rules'

# README.md and CONTRIBUTING.md, held to the repository they describe:
# every named script exists, every backtick-quoted path exists, every
# quoted coverage number matches vitest.config.ts, every quoted Node
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ npm run verify # lint, format, typecheck, ADR contract, tests + coverage
`npm run lint:adrs` alone runs the decision-record contract, and `npm test`
runs the suite without enforcing coverage. `npm run test:coverage` (part of
`npm run verify`) enforces the ratchet in `vitest.config.ts`: statements
97.94%, branches 90.72%, functions 100%, lines 97.76%.
98.31%, branches 92.43%, functions 100%, lines 98.19%.

## Conventions

Expand Down
3 changes: 2 additions & 1 deletion docs/adr/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ which domain a decision lives in.
| directory | holds | `normative:` pointers resolve against | linted |
|---|---|---|---|
| [`model/`](model/) | the v1 model: the layers, composition, derivation, the adapters | `spec/v1/` | yes |
| [`architecture/`](architecture/) | the compiler's own structure: layering, ports, error model, gates | `docs/architecture.md` | yes |
| [`architecture/`](architecture/) | the compiler's own structure: layering, ports, error model, gates | `docs/architecture.md`, `docs/architecture-rules.md` | yes |
| [`deferred/`](deferred/README.md) | delivery mechanics and co-testing, defined separately | sections these chapters deliberately lack | no |

## Premises
Expand Down Expand Up @@ -204,3 +204,4 @@ Decisions about the compiler's own structure, not about the model. Their
| [0101](architecture/0101-coverage-is-a-ratchet.md) | Coverage is a ratchet: the thresholds sit on what the suite reaches, and only rise | settled |
| [0102](architecture/0102-the-gate-grows-with-the-code.md) | A new gate's script and its CI job land in the same pull request, and a test proves the two stay matched | settled |
| [0103](architecture/0103-a-behaviour-ledger-names-what-a-test-proves.md) | A behaviour ledger names every guarantee and the test that proves it, and a meta test holds the two together | settled |
| [0104](architecture/0104-every-enforced-rule-has-an-id-a-row-and-a-fixture.md) | Every enforced rule has an id, a ledger row and a fixture that proves it fires | settled |
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
---
tier: decision
status: proposed
claim: settled
date: 2026-09-14
normative: docs/architecture-rules.md#rules
rests-on: ["0001"]
---

# Every enforced rule has an id, a ledger row and a fixture that proves it fires, and a rule not enforced yet says so with a reason

## Rests on

Every rule this repository enforces is enforced by a named thing on disk: a
rule in the dependency-cruiser ruleset, a rule id in the ESLint configuration,
a gate script, or a file that carries the rule itself. So a ledger of {id,
family, sentence, enforcer, fixture} rows can be checked by reading the tree
in both directions, rather than by trusting whoever last touched either side.
False if: a rule exists that no artifact on disk enforces, which would leave a
row with nothing to resolve to and no way to tell it from a rule quietly
deleted. Settled by: `scripts/lint-rules.ts` resolving every enforced row's
enforcer against the tree, and every rule the two rule configurations name
being claimed by exactly one enforced row, with none left over.

## Why

[0069](0069-boundaries-enforced-on-the-graph.md) put the layer boundaries on
the module graph because review is not the control that catches the
maintainer's own mistakes ([0001](../model/0001-estate-scale-and-ownership.md)).
That left a second-order gap, and it is the one this decision closes: a rule
can stop being enforced without anything going red. Delete a ruleset entry,
turn an ESLint rule off, rewrite a negative fixture past the case it was
written for, and the suite stays green, because a rule that no longer fires
breaks nothing. The coverage ratchet
([0101](0101-coverage-is-a-ratchet.md)) does not see it either: the lines still
run.

The fix is the shape the other meta gates already take: state the rule as a
comparison over two things that exist on disk. Here the comparison runs both
ways, and the second direction is what makes it more than a document. Forwards,
a row must resolve: its enforcer must be configured, and its fixture must be a
real test that mentions the literal it asserts on, so deleting the case takes
the witness with it. Backwards, every rule the configurations name must be
claimed by exactly one **enforced** row, so a rule added without a row fails,
and so does a live rule quietly moved to the pending list.

That backwards direction is the whole answer to the obvious objection, which is
that a pending list is where rules go to be forgotten. A row may be pending only
while nothing enforces it; the moment something does, pending stops being an
available answer. What is left is a pending row that names a ticket and a
reason and claims no fixture, which is a decision on the page rather than an
omission off it.

The id is `RULE-NNN` and the family is a column rather than a prefix. The id is
what a failure quotes and what a grep finds, so it has to survive
reclassification: a family-prefixed id would become a lie the first time a rule
moved between families, and renumbering to fix that would break every citation
already written. `REQ-NNN` in
[the behaviour ledger](0103-a-behaviour-ledger-names-what-a-test-proves.md) is
a separate namespace on purpose: that ledger answers what a person can rely on,
this one answers what a tool enforces, and one rule can serve several
behaviours.

## Alternatives

| option | cost if taken | why rejected |
|---|---|---|
| No ledger; trust each rule's own negative fixture | Nothing to write or maintain | A fixture proves a rule fires while the fixture exists. Nothing notices the rule and its fixture leaving together, which is exactly how a ruleset decays |
| Fold the rules into `docs/requirements.md` | One document instead of two | A rule and a behaviour answer different questions, and one row cannot carry both an enforcer and a proof of a guarantee without one of them becoming decoration |
| A family-prefixed id, such as `LAY-004` | The family is readable in the id | Every reclassification breaks a citation, and the pressure is then to leave a rule in the wrong family rather than renumber. The family is a column, which can be revised for free |
| Pending rows in a separate file, out of the table | The enforced table stays short and clean | The point of a pending row is to be read beside the enforced ones. A second file is a list nobody opens |
| A severity column, so a rule can land as a warning first | A gentler path for a new rule | A warning in CI is a rule nobody enforces. A rule not worth failing a build for is a pending row with a reason, which is the same information without the false green |

## Reversibility

Undo cost today: deleting one document, one gate script and two test files.
Becomes irreversible once: never; the ledger describes this repository's own
tree, and nothing outside it reads either.

## Consequences

- Adding a rule to either rule configuration without a ledger row fails the
suite. Paid by whoever adds the rule, in the same pull request, which is
where the reason for it is freshest.
- An enforced row must name a fixture that asserts on a literal, so a rule
whose failure output nobody has ever looked at cannot be written down as
enforced. Paid once per rule.
- The ledger knows only about rules this repository names. The two presets
(`recommended` and `strictTypeChecked`) carry far more rules than any row
here, and they are one row between them; a preset silently narrowing in a
minor release is not something this gate would see. Paid by whoever upgrades
the linter.
- Most rows are pending today, because `src/` does not exist. That is the
ledger working: the vocabulary and the machinery land before the code they
will hold, so the compiler's first module meets them rather than prompting
them. Paid down by
[issue #30](https://github.com/JorisJonkers-dev/deploy-kit/issues/30), which
every pending row names.
4 changes: 3 additions & 1 deletion docs/adr/architecture/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ and no chapter of `spec/v1` depends on one.
The consequence for the contract every ADR satisfies is one field: a
`normative:` pointer here names a section of
[`docs/architecture.md`](../../architecture.md), the normative document for code
structure, rather than a section of `spec/v1`. Everything else is unchanged
structure, or of [`docs/architecture-rules.md`](../../architecture-rules.md),
the ledger of rules that structure is held to, rather than a section of
`spec/v1`. Everything else is unchanged
(frontmatter schema, a falsifiable claim per premise, `rests-on` naming premises
only, the Alternatives table, the citation rule) and `scripts/lint-adrs.ts`
enforces all of it here exactly as it does for the model, anchor check included.
Expand Down
Loading
Loading