diff --git a/.claude/rules/derived-checks.md b/.claude/rules/derived-checks.md new file mode 100644 index 0000000000..425fcee84e --- /dev/null +++ b/.claude/rules/derived-checks.md @@ -0,0 +1,221 @@ +--- +# Derived checks are not a packages/ phenomenon. Enumerating directories is how +# this rule kept missing the code it is about — admin-css ships product code and +# tests as .mjs, playground scripts assert agreement between a declaration and an +# import, e2e specs recompute coordinates the app already derives, and the prose +# section below applies squarely to docs and READMEs. Match by extension across +# the repo rather than by location. +paths: + - "**/*.ts" + - "**/*.tsx" + - "**/*.mjs" + - "**/*.js" + - "**/*.cjs" + - "**/*.md" + - "**/*.mdx" + # Configuration is where several of this rule's own examples live: the + # changeset package list versus the release group, and `pnpm-workspace.yaml` + # against the hand-maintained ALL_PACKAGES list that `scripts/lint-report.mjs` + # says must mirror it. Editing only the config is exactly the recomputation + # drift this rule is about. + - "**/*.json" + - "**/*.jsonc" + - "**/*.yaml" + - "**/*.yml" + # Shell verifiers count too: `packages/nextly/scripts/phase-gate.sh` parses + # test, lint and type-check counts and compares them against stored baselines, + # which is a derived check in every sense except the language it is written in. + - "**/*.sh" + # Tracked derived artefacts with no source extension of their own: committed + # `*.snap` files ARE the derived view, and `apps/playground/.env.example:17` + # names `packages/nextly/src/shared/lib/env.ts` as its source of truth. + - "**/*.snap" + - "**/.env.example" + - "**/*.env.example" + # And the derived ARTEFACTS, not only the code that derives them: + # `apps/playground/src/plugins/style-fixture/admin.source.css` compiles into a + # derived `admin.css`, and `templates/blog/migrations/*.sql` say outright that + # their structure mirrors `UserExtSchemaService.generateMigrationSQL()`. + - "**/*.css" + - "**/*.sql" + - ".changeset/**" +--- + +When one piece of code checks, mirrors or summarises what another produces: + +## Derive it, do not recompute it + +A narrower view must be derived FROM the richer one. Two implementations of the +same question agree on the day they are written and drift afterwards, and the +drift is silent because both look correct in isolation. + +This has caused defects in five unrelated packages that share no code: block +rendering versus derived metadata, a contrast validator versus its measurement, +an email form schema versus its descriptors, a changeset's package list versus +the release group, and a data probe versus the conversion it guards. + +Two rules follow, and the second is the one that gets missed: + +- Export the answer from one place and have both callers ask it. +- **A test is a derived view of the code like any other.** Prefer OBSERVING the + real call — spy on the arguments a function actually receives — over + reconstructing the same call in the test. A hand-copied argument list keeps + passing after someone edits the line the test exists to watch. + +## A derived check must match on three axes, not one + +Asking "does it compute the same thing" is not enough: + +1. **Computation** — the same expression. +2. **Domain** — the same rows, records or inputs. A probe using the identical + expression over a different row set is still a divergence. + + **`LIMIT 1` is sound exactly when ONE row settles the claim.** Which rows + those are depends on what is being claimed, not on the clause: + - claiming _something exists_ → one match settles it. `LIMIT 1` on the match + is sound. + - claiming _everything satisfies P_ → one match of P settles nothing, but one + match of NOT-P refutes it. So search for the counterexample and `LIMIT 1` + is sound; search for a witness of P and it is not. + + The two queries look nearly identical in the source, which is why the claim + has to be written down next to them. A universal check phrased as a witness + hunt passes on the first agreeable row and never reads the rest. + + **In SQL, `NOT P` is not the complement of `P`.** The logic is three-valued: + for a NULL input both `P` and `NOT P` evaluate to UNKNOWN, and `WHERE` + keeps only TRUE. So `WHERE NOT (price > 0) LIMIT 1` returns no row for a + table full of NULL prices and certifies "every price is positive" — the + counterexample hunt, done correctly, silently reporting the opposite of the + truth. Decide first whether NULL violates the claim, then write the + predicate that says so: `WHERE (price > 0) IS NOT TRUE` catches NULLs as + violations, `WHERE price IS NOT NULL AND NOT (price > 0)` excludes them + deliberately. Either is fine; the bare `NOT` is the one that is neither. + +3. **Failure semantics** — "the answer is no" and "I could not ask" are + different outcomes. A check that reports a lock timeout as a data verdict + blocks valid work while naming the wrong cause. Pin the error you mean, and + remember the signal may be WRAPPED: a driver error's code often lives on + `.cause`, and how deep depends on the transport, not on your code. + +Underneath all three sits time-of-check-to-time-of-use, which may need a +different answer per dialect. Say which dialect a mitigation covers rather than +implying one policy fits all. + +## Citing a known defect invalidates the claims that assume its absence + +Two implementations drifting apart is the code version of this. The prose +version is a document that invokes a known defect for one purpose — arguing +severity, justifying a workaround — while another paragraph asserts something +that is only true if the defect does not exist. Both sit on the page together +and neither looks wrong alone, which is why re-reading does not catch it. + +The check is mechanical rather than a matter of care: after citing a defect, +re-read every statement about the POPULATION it affects. + +Worked example, from a schema task in this repo. A standing "core schema +changes may not reach existing databases" was cited for severity in one +paragraph. The next asserted that no existing database could hold duplicate +rows, derived from the constraint its schema declares — which is precisely the +guarantee the cited defect removes. The safety analysis was built on the +absence of the defect being argued from. + +The same shape appears without any document involved, which is worth knowing +because it is the harder one to see. A control that needed to modify a test file +was run in a DISPOSABLE worktree specifically so there would be nothing to +restore. Twenty minutes later a control on a tooling script modified it in place +with a hand-rolled backup, the backup was taken after a previous run had already +contaminated the file, and "restoring" reinstated the contamination. + +The instinct was not missing. It was SCOPED — available under "test code", not +transposed to "tooling" — and the boundary was a category in the author's head +rather than anything present in the work. Both halves were twenty minutes apart, +both were the same person's, and neither looked wrong at the time. When you +solve something structurally, ask what else you are doing right now that the +same structure would fix. + +## When a check reaches for a NAME, ask what structurally decides it + +A name, a version string or a string pattern is nearly always a proxy, and the +cases that motivated writing the check are the ones that violate the proxy. + +The reason is worth stating, because it tells you when the rule applies: **a +name is a claim made by someone else; structure is the thing itself.** The +engine chose the collision suffix, the vendor chose what version to report, a +previous release of your own code chose the prefix. You never controlled any of +those strings, and the check exists precisely for the cases where the other +party's choice diverges from your expectation — so the divergence and the check +have the same cause. Not every string in a codebase has that property; the ones +assigned by something outside your control do. + +Five instances in one area, each found only after the name-based version had +been written: + +- classify a driver failure by SQLSTATE at the specificity YOUR claim needs, not + by a hand-kept list of codes; +- identify a database object by its **structural signature**, not by its name — + an engine appends `_2` on collision and truncates at its identifier limit, so + there is no single string to match; +- decide a capability by **probing it on a scratch object**, not by reading the + server's version — the platforms worth detecting are the ones that misreport; +- decide indexability by asking the **shared rule**, not by restating which + types a dialect can key; +- confirm a merge by **comparing the PR's delta**, not by grepping for a marker + that may occur elsewhere. + +The tell is a check whose correctness depends on how some other system chose to +spell something. Ask instead what property makes the answer true, and query +that. It is usually available and it usually costs the same. + +**"Structural" is not automatically "coarse", and the first two above are where +that bites.** Replacing a name with a broader property is only correct when the +broader property still separates the cases you must tell apart: + +- SQLSTATE **class** `23` is right for "is this an integrity failure at all". It + is wrong the moment the caller must distinguish one from another — this repo's + `packages/nextly/src/database/errors.ts` maps `23505`, `23503` and `23502` to + unique, foreign-key and not-null respectively, and collapsing them to the class + would report a missing NOT NULL as a duplicate. Match at the specificity the + claim requires: class when the claim is about the family, code when it is about + the member. +- A **column set** is right for "is any object covering these columns present". + It is wrong for "which object implements this guarantee", because one table can + carry several objects over the same columns — and this repo already treats + `{ columns: ["code"], unique: false }` and `{ columns: ["code"], unique: true }` + as different indexes during an index-to-unique transition. Match the signature + the CLAIM needs: columns, uniqueness, and whether a constraint owns the object. + + Note what that signature does NOT currently separate. `indexKey` in + `schema/pipeline/diff/index-util.ts` SORTS the columns, so `(a, b)` and + `(b, a)` compare equal — even though only the first serves a left-prefix + lookup on `a`. Today the pipeline emits single-column indexes, so nothing + depends on the distinction; the moment a composite one is emitted, the key + will silently treat two different objects as one. Stated here rather than + fixed, because widening the key changes every comparison that uses it. + +So the rule is not "prefer the broadest structural property". It is: identify by +structure rather than by someone else's spelling, at the granularity your claim +actually needs — which is the separating-property test applied to the identifier +itself. + +## A bare `catch` is only a defect when its fallback makes a CLAIM + +`catch { return conservative }` that degrades to caution is sound for the +failures it was written for, and this repo has several that are deliberately +so. It stops being sound when the same `catch` also swallows a failure that is +not about the data at all — a bad credential, a missing config, a dropped +connection, a `TypeError` in the handler. Those come back as "be cautious", +which blocks valid work while naming no cause, and the wider the catch the +longer that takes to find. Catch the errors you mean, and let an unexpected one +be seen: rethrow it, or log it with its code before degrading. + +`catch { return verdict }` that +asserts something about the user's data manufactures a confident wrong +diagnosis — and it poisons every test asserting the negative outcome, because +those tests go green on the strength of _some_ error rather than the right one. + +**The direction is a joint property of the fallback and the CALLER's use**, so +the unit to audit is the call site. The live risk for a well-documented shared +helper is gaining a second caller with the opposite polarity, where the +comment above it still reads correctly and nothing at the definition looks +wrong. diff --git a/.claude/rules/verifying-merged-work.md b/.claude/rules/verifying-merged-work.md new file mode 100644 index 0000000000..e2ca6a4981 --- /dev/null +++ b/.claude/rules/verifying-merged-work.md @@ -0,0 +1,195 @@ +--- +paths: + - "**/*" +--- + +## A squash merge makes every ancestry check unsound + +Merging squashes the branch into one new commit, so the branch head is **never** +an ancestor of `main`. `git branch --merged`, `git log | grep ` and "is +this commit in main" therefore answer confidently and wrongly. Verify by +CONTENT. + +**Which marker you grep for is load-bearing.** The commonest shape is a lost +TAIL: commits land on the branch after GitHub computed the merge, or the merge +runs from a stale head, so the END of the branch goes missing and a marker taken +from an early commit passes cleanly on a PR that dropped its last three. + +That heuristic says where to look FIRST; it is not what makes a check +sufficient. A branch that was rebased, amended or force-pushed after the merge +was computed diverges differently — rewriting an EARLIER commit while leaving +the final patch text unchanged means the stale merge still contains your +final-commit marker, and the check passes over content that was rewritten +underneath it. When history was rewritten, compare the delta (step 3) rather +than trusting any single marker. + +1. Confirm what was actually merged, then FETCH the object before probing it: + + ``` + gh pr view N --json headRefOid,mergeCommit + git fetch origin # gh reports; it does not fetch + ``` + + `gh pr view` prints PR information and adds nothing to the local object + database, so probing a reported SHA without this exits with + `unable to resolve revision` — which reads as a failed verification rather + than as a missing object. + + Fetch BOTH. A squash commit does not have the PR head as an ancestor, so + fetching only the merge commit leaves `headRefOid` unresolvable — and step 3 + dereferences it. Outside the PR worktree, or after the branch is deleted, + that is where the procedure stops. + + Probe that **merge commit**, never `origin/main`. Run before a fetch and + `origin/main` is still the pre-merge ref, so every check reports loss + falsely; run after `main` advances and a later commit can make omitted + content look present. + +2. Take the check from the **final** commit, in whichever direction it changed + things. A marker only proves anything if it is UNIQUE to that commit and the + search is SCOPED to the path it changed — a string that also occurs elsewhere + answers the same way whether or not the commit landed. Match it as a FIXED + string: a marker containing `.`, `[` or `*` is otherwise a pattern, and can + match text it was never taken from. + - it ADDED content → `git grep -F -e "$marker" -- `; + expect a hit. The `-e` is not optional: a marker beginning with `-`, which + a Markdown list item usually does, is otherwise parsed as an option and + exits 129 without checking anything. + - it only REMOVED content → same command; expect NO hit. Grepping for ADDED + text here finds nothing whether or not the commit landed, which reads as + failure either way and proves nothing. + + **An absent marker is only evidence once you have shown the search CAN + find it.** `git grep` exits 1 for "no lines selected" and for "the + pathspec matched no files" alike, so a mistyped or since-renamed `` + certifies the removal without ever reading the file. Run the same command + against `^` and require a hit: that proves the path resolves + and the marker is real. + + **But that control validates the INSTRUMENT, not the outcome, and for a + removal it cannot validate the outcome at all.** If the text was added + earlier in the same PR and removed later, `^` — the state + `main` was in before the merge — never contained it, so its absence + afterwards is guaranteed whether or not the removal landed. Neither + preimage separates "the removal merged" from "the text was never there". + Absence is simply not a witness here. + + So for a removal, use the control to prove the search works, then prove + the outcome with the DELTA in step 3: the removal hunk must appear in + `git diff ^.. -- `. That is a positive + observation of the change landing rather than an inference from nothing + being found. + + - it changed a file mode, a binary, or a rename → text search cannot see it. + +3. When nothing is unique to the commit, or the change is a mode/binary/rename, + compare the PR's **delta** — not the whole object. Diffing the merged path + entry against the branch-head entry (`git ls-tree`, blob ids) is wrong as soon + as `main` changed ANOTHER hunk of the same file after the branch point: a + correct squash contains both changes, the blobs legitimately differ, and the + check reports a loss that did not happen. Compare what the PR itself changed: + `git diff .. -- ` against + `git diff ^.. -- `, expecting the PR's hunks + in the second. The squash side is diffed from its OWN parent: a squash + commit's parent IS `main` at the moment of the merge, so that range is + exactly the squash patch. Using `` there sweeps in every commit + `main` gained after the branch point, and the PR's hunks disappear among + them. Whole-object equality is sound only when `main` never touched the + path. `--stat` is never sound: it reports only that a path was touched, which + any earlier commit in the same PR already guarantees. +4. If the final commit is a pure revert of an earlier one in the same PR, check the + NET effect, not the last hunk. + +The danger window is push-a-fix-then-merge-immediately, which is what everyone +does once CI is green and threads are cleared. A PR has already merged here +missing its last commit, reading as complete with every thread resolved. + +## Before calling a red run flake, name the mechanism + +A green re-run answers "is this deterministic?". It does NOT answer "is the +cause gone?" — a failure with a conditional trigger passes whenever the +condition happens not to hold, and reads exactly like flake. + +State the mechanism that would make it intermittent, and prefer evidence that +does not depend on a second run: + +- **Legitimate:** a wall-clock ratio assertion on a machine running several + test matrices at once measures load, not code. +- **Legitimate:** the diff never reached the failing subsystem, so it could not + have caused it. **Unreachability is what exonerates a PR, not the re-run** — + and that argument holds whether or not the second run is green. +- **Not sufficient:** "it passed the second time." + +## Environment states wear the costume of code defects + +After a rebase onto a moved `main`, a package you never touched failing to +resolve (`Cannot find module ...`) is USUALLY an environment state. Three +candidates, and the remedies differ: + +- **Missing build output** — the import names a workspace package (`nextly/...`, + `@nextlyhq/...`) and dozens of files fail at once. Its `dist` was never built. + Run integration tests from the ROOT so turbo builds first; `pnpm install` does + not produce `dist` and will leave this exactly as it was. +- **Stale build output** — `dist` EXISTS but predates a source or export-map + change the rebase brought in, so it lacks the subpath now being imported. An + existence check on the directory says "built" and is wrong. Rebuild from the + root rather than trusting that `dist` is there. +- **Stale install** — the import names an external dependency, or one package + resolves while its sibling does not, after `pnpm-lock.yaml` moved underneath + you. `pnpm install --frozen-lockfile` in that worktree. + +**Do not label it environmental without looking at what `main` changed.** If the +moved `main` altered a workspace export map, a package manifest, a tsconfig path +mapping or a shared build config, an untouched package failing to resolve is a +real regression wearing the same costume. + +The comparison needs the **pre-rebase** base, and this is the trap: after the +rebase, `git merge-base HEAD origin/main` IS `origin/main` — it is now an +ancestor — so the diff is empty no matter what the rebase brought in. An empty +diff then reads as "main changed nothing relevant", which is the opposite of +what it means. Capture the old base before rebasing, or recover it afterwards: + +``` +OLD=$(git rev-parse "$(git branch --show-current)@{1}") # pre-rebase tip +git diff $(git merge-base $OLD origin/main)..origin/main +``` + +The BRANCH reflog is the reliable source: a rebase moves the branch ref once, so +`@{1}` is its pre-rebase tip and nothing but another update to that +branch disturbs it. + +Two tempting alternatives are both wrong. **`ORIG_HEAD` is volatile** — it is +rewritten by any later command that sets it, `git reset` included, so a single +`git reset --hard HEAD` after the rebase leaves it pointing at the REBASED tip +and the diff comes out empty again. **`HEAD@{1}` is not the pre-rebase tip +either** — after a multi-step rebase it is the last `rebase (pick)` entry, so the +merge base comes out as the new `origin/main` +again and the diff is empty exactly as before, with the fix appearing to be in +place. + +**Read that delta UNFILTERED.** A path list here is the same enumeration trap: +the first version named `package.json`, `tsconfig*.json` and `turbo.jsonc`, and +would have printed nothing for a change to `packages/*/tsup.config.ts` (which +decides what `dist` contains) or to `pnpm-lock.yaml` (which decides what +resolves) — the two inputs most likely to explain the failure being diagnosed. +Scan the whole delta, then narrow. + +Then decide: a rebuild that "fixes" it silently absorbs a breaking change into +your branch, and a rebuild that does not fix it has told you something. + +Related, and cheap to get wrong: + +- **Run gates from the worktree ROOT.** From a package directory, + `pnpm check-types --force` becomes a bare `tsc --force` and fails on the flag. +- **Most packages fail lint on a WARNING**, because their script is + `eslint . --max-warnings 0`. Check by exit code, not by grepping output for + "error": the log reads "0 errors, 1 warning" and still exits 1. + Two qualifications worth knowing before you trust a green: + - The pre-push hook runs `pnpm turbo lint --continue --filter='./packages/*'`, + not the root `pnpm lint`. It does not cover `apps/*` or `e2e/`. + - Not every package opts in. `packages/admin-css` runs a bare `eslint .`, so a + warning there exits 0 and the hook stays green. +- **Never run a unit suite while an integration leg is in flight.** Unrelated + files time out and read like a broad regression. +- **Never work a PR branch in the shared checkout.** Use `git worktree add`; + another session switching branches underneath you removes files mid-command. diff --git a/.github/review-prompt.md b/.github/review-prompt.md index a2b5043175..43649c8c38 100644 --- a/.github/review-prompt.md +++ b/.github/review-prompt.md @@ -34,6 +34,10 @@ Read before forming any opinion (PR-side versions if the PR touches them): - `AGENTS.md` (root): the primary contract. Cite it with line-anchored permalinks in findings. - `ARCHITECTURE.md`: layering rules and the "Key invariants (do not break these)" section. +- `.claude/rules/derived-checks.md` and `.claude/rules/verifying-merged-work.md`: the + detailed guidance behind several AGENTS.md rules, with worked examples. These load + automatically only in Claude clients, which is why they are enumerated here — a reviewer + running anywhere else would otherwise never see them. - `.claude/skills/reviewing-a-pr/SKILL.md` and `.claude/skills/release-and-changesets/SKILL.md`. - `packages/nextly/AGENTS.md` / `packages/admin/AGENTS.md` when the PR touches those packages. - `packages/plugin-sdk/STABILITY.md` / `packages/ui/STABILITY.md` when public surface changes. diff --git a/AGENTS.md b/AGENTS.md index 881b9c4652..16d2ff61e3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -55,9 +55,28 @@ Before editing a package, read its README.md and check for a nested AGENTS.md. with self-import errors that look real but are not. - Integration tests self-skip when the dialect's URL is unset. Use the root scripts: `pnpm test:integration:postgres17` (localhost:5435), - `:postgres15` (:5434), `:mysql` (:3307), `:sqlite` (no URL needed). Start - the databases with `pnpm docker:test`. NEVER point a TEST\_\* URL at a - database you did not create for the test run. + `:postgres15` (:5434), `:mysql` (:3307), `:sqlite` (no URL needed). NEVER + point a TEST\_\* URL at a database you did not create for the test run. +- The test databases are their own containers in `docker-compose.test.yml`, + separate from the dev stack. Neither `pnpm docker:test` nor `pnpm docker:up` + starts them: `docker:test` only PROBES a connection and exits 1 when it + fails, and `docker:up` brings up the DEV stack, which conflicts by container + name where one already exists. A `DBS DOWN` failure followed by a start + command that changes nothing reads like a broken environment; it is usually + just the wrong command. + - Already created but stopped, which is the usual case: + `docker start nextly-postgres17-test nextly-mysql-test` (add + `nextly-postgres15-test` for the 15 leg). + - Never created, on a fresh clone: + `docker compose -f docker-compose.test.yml up -d postgres17-test postgres15-test mysql-test`. + `postgres15-test` is the only service on 5434, so omitting it leaves the + documented `:postgres15` leg with nothing to connect to. + - Why not always the second: the services set a fixed `container_name`, so + exactly one compose project can own them, and the owner is whichever + directory first brought them up. In a repo worked through many worktrees + that is rarely the one you are standing in, and compose then tries to + CREATE containers whose names are taken and fails. `docker start` addresses + them by name and does not care which project owns them. - Integration files in `packages/nextly` run sequentially on purpose (`fileParallelism: false`, single fork): system-table suites share fixed table names. Do not "fix" slow integration runs by re-enabling parallelism. @@ -66,6 +85,76 @@ Before editing a package, read its README.md and check for a nested AGENTS.md. - Some unit suites have a known pre-existing failing baseline. NEVER add to it: run the tests for the area you touch before and after your change, and fix any new failure you introduce. +- A test is only evidence once you have seen it FAIL for the intended reason. + Break the code, confirm the intended test fails, restore. After changing a + test, re-run its break: a fix to the test is a change to the experiment. + What counts as the intended failure depends on when the test runs: + - A RUNTIME test that stops COMPILING proves nothing — the assertion never + executed, so the red says only that the break was malformed. + - A COMPILE-TIME contract test is the opposite case: compilation IS the + mechanism. In `*.test-d.ts`, widening a type makes its `@ts-expect-error` + unused and `check-types` fails for exactly the intended reason. Red is not + the evidence though — the EXPECTED DIAGNOSTIC is. A typo, a bad import or + an unrelated type error in the same file all stop compilation too, and + prove nothing about the property. + - `@ts-expect-error` is the sharp edge here, because it suppresses ANY error + on the line that follows. A test asserting "this call is rejected" stays + green once the code starts erroring for a different reason, and stays green + after the original rejection stops happening. Two things that look like + mitigations and are not: a comment naming the expected code, which `tsc` + never reads; and a positive control asserting the ACCEPTED form still + compiles, which an unrelated error confined to the rejected line leaves + untouched. Only an assertion the checker EVALUATES distinguishes the cases — + `expectTypeOf(...)`, or a diagnostic-aware type test that names the error it + expects. If the property cannot be asserted that way, say in the file that + the directive is unverified rather than letting it read as coverage. + - The count must not drop by ACCIDENT: a suite that silently stopped being + discovered reads as a pass, which is what that guards. Removing a test on + purpose is a different act, sometimes correct (below), and the PR says + which test went and why. +- Before you assert or measure, name the property that SEPARATES a correct + implementation from the plausible broken one you are worried about, and check + that it is the property you are about to test. A necessary-but-insufficient + property returns green from both, and it does so carrying the authority of + having been checked, which closes the question. Two worked examples, both real: + - measuring whether an old database constraint could be DROPPED, when what + decides the repair is whether the code can FIND it. Dropping succeeded, and + the repair would still have skipped every database silently. + - asserting a generated identifier is `length <= 63`, when a plain truncation + is also 63 characters. The one test guarding the naming passed on the broken + implementation; distinctness was the separating property. + + The operational form is to ask what ELSE would produce the same green. If + anything other than the property under test does — a fixture that never + reaches the mechanism, an unregistered type falling through to a default, an + assertion satisfied by absence, a search whose glob missed the directory — + the property is not covered yet. Add the positive control that makes the + mechanism's presence observable, and run it. + +- Whatever you are currently judging WITH is not being judged. A probe, a + derived check, a test, a post-apply verifier and the baseline diff that reads + the suite all had the same defect in one week here, and every one of them + existed to catch the layer above it. They were hard to see not because the + defect was subtle but because each occupied the position auditing is done + from, so nothing stood further out to look at it. Periodically step out one + level and give the instrument the same treatment as its subject: a positive + control on an input where you know the answer, and where the answer is not + "nothing". Confirming an instrument against a case that did not move cannot + distinguish it from one that reports nothing under any circumstances. +- A test that passes both with and without the fix is worse than no test: the + next reader takes the green as coverage. **Repair it first.** Usually the + fixture never reaches the mechanism or the assertion is satisfied by absence, + and both are fixable. Deleting the ONLY attempted coverage for a behaviour + trades a misleading green for no signal at all, which is not an improvement. + Deletion is right in two cases, and they need different notes: + - **redundant** — the behaviour is genuinely covered elsewhere. Say in the + file that remains WHERE, so the next reader can follow it. + - **obsolete** — the code no longer does the thing. There is no remaining + file, and demanding one would force a false coverage comment. Say what + behaviour was removed and in which change instead. + + Either way this is the deliberate removal the count rule exempts, so state the + drop rather than letting it look like a suite that went missing. ## Conventions (enforced; violations will be rejected in review) @@ -99,6 +188,33 @@ Before editing a package, read its README.md and check for a nested AGENTS.md. - Admin styling is token-driven: use `--nx-*` custom properties (defined for light AND dark in `packages/ui/src/styles/theme.css`). Zero hardcoded colors, and every visual change must work in both modes. +- One question has ONE implementation. When a narrower view of something is + needed, DERIVE it from the richer one; never compute it alongside. Two + functions that agree today drift, and the drift is silent because both look + correct. This has produced defects in five unrelated packages. +- Unreachability is a property of the current call graph, not of the code, and + the call graph changes underneath you. "This cannot happen" is not a reason to + omit a guard — it is a reason the guard is CHEAP, provided it is cheap: an + assertion over values already in hand costs nothing when its rejection branch + never runs. A guard that queries, reads or recomputes still pays that cost on + every call whether or not it can ever reject, so a purely DEFENSIVE one can + move behind the work it protects. Never move a guard that is a PRECONDITION — + authorization, ownership, validity, quota. "Behind the work" there means the + mutation has already happened when the request is rejected, which turns a cost + saving into a security hole. Preconditions run first, whatever they cost. +- Prefer a boundary the system cannot cross to a check that looks for crossings. + A scan over syntax has an unbounded surface and can only ever be patched; a + declared dependency graph, a type, or a manifest assertion is complete by + construction. But a manifest assertion is only a boundary if the RESOLVER + agrees with it: under pnpm a root dependency, or one hoisted for another + workspace package, stays importable from a package whose own manifest never + declares it, so "X is absent from this package.json" does not mean "this + package cannot reach X". Make the boundary real before trusting it — a + resolution test that imports the package's entry from an isolated context, or + a build that fails on an undeclared import — and only then drop the visitor. +- A documented rule with nothing enforcing it is not a control, and filing a + task is not installing one. If the correct path and the easy path differ, + the rule will be broken by someone who knows it. ## Changesets and releases