@geonosis/doctor
v3.7.0
Published
The adoption doctor — declared ≠ loaded, enabled ≠ exercised, a baseline that grew, a runner whose exit code is the only verdict.
Maintainers
Readme
@geonosis/doctor
Through the front door: geonosis doctor — the metapackage pins this and every other kit tool at
ONE version, and passes the exit code through unchanged.
The questions a version bump is not finished until something has asked. Most are ways enforcement
has reported green while measuring nothing — in real repos, for weeks at a time. drift asks
whether the gate is still there at all; observability and deployed ask the same question one
layer out, whether what a pipeline reported is what actually happened; exams and seams ask
whether an adoption can see the floors it took. geonosis doctor --help lists every one.
pnpm add -D @geonosis/doctor
npx geonosis doctorRun it after every bump and in CI. It exits 1 when any line is a FAIL or an UNJUDGED — a question it could not ask is not a pass — and 2 when the run could not be made at all.
A directory that is not a project is a 2, not a pass. With no package.json and no
oxlint config anywhere under the root there is nothing here to examine, and every line it would
print is about a gate that is not there. It used to answer doctor PASS at exit 0 off six SKIPs and
three vacuous OKs — from the tool whose thesis is that a SKIP is not a pass.
geonosis doctor [--root <dir>] [--only <check,…>] [--json] [--strict] [--baseline-against [<ref>]] [--oxlint <path>]One check, asked alone
geonosis doctor --only loaded runs that check and no other, in well under a second: a manifest read and a resolution per config. It exists because the doctor subsumed a consumer's own declared-vs-loaded script that lived in a 2.5 s local gate, and eleven seconds for every check cannot live there — so the question that catches a stale nested copy earliest had fallen out of the gate that runs most often (measured in a consumer, 2026-08-30: a per-workspace bun add left one workspace loading 1.0.0 against configs written for 1.1.0, and the script about to be deleted caught it). --only loaded,runner takes a list; a name the doctor does not have is refused with the ones it does. A named check that has nothing in the tree to read prints no section of its own, so it is UNJUDGED (exit 1) naming the check: a --only run that judged nothing is not a doctor PASS. A full run is unchanged. A loaded FAIL names the remedy: geonosis update --to <version>, which removes the nested copies and reinstalls.
The checks
loaded — declared ≠ loaded
oxlint resolves a jsPlugins specifier from the config file's directory, not the working
directory. A nested apps/<ws>/node_modules/@scope/plugin left behind by a per-workspace install
therefore runs while every package.json and the lockfile say a different version. One consumer
measured a whole bug report against 0.3.0 on a repo pinned to 0.4.0, and nothing in either repo
could have told them.
The configs are the ones oxlint would load: every .oxlintrc.json, .oxlintrc.jsonc,
oxlint.config.ts and oxlint.config.mts in the tree, and every config a lint command names with
-c or --config — a script of any workspace, or a step of a geonosis.json tier — whatever it is
called. Each is read the way oxlint reads it: a JSON one with // and /* */ comments and trailing
commas accepted, so the file can keep the reason a rule is off beside it; a .ts, .mts or .js
one imported as a module, its default export the config. Only a config oxlint itself could not load
is a FAIL, and it names why.
So for every config in the tree that names a @geonosis/* plugin, this resolves that
plugin from the config's own directory, imports it, reads meta.version, and compares it to
the version the nearest manifest up from there declares:
OK apps/web/.oxlintrc.json: @geonosis/… loaded 0.6.0 = declared 0.6.0 (apps/web/package.json)
FAIL apps/web/.oxlintrc.json: @geonosis/… loaded 0.3.0, declared 0.6.0 (apps/web/package.json)
— a nested copy at apps/web/node_modules/@geonosis/…It then lists every copy resolution reaches, one question per workspace directory. Never a
find: on pnpm the entries under node_modules/@scope/ are symlinks into .pnpm/, which keeps
every version ever installed, so counting directories answers 7 on a tree that is perfectly correct
and 0 with the store excluded. Resolution is the only form of the question that is true on bun,
npm and pnpm alike.
A declared range this check cannot read is a FAIL, not a pass. A version it cannot judge is a refusal.
A geonosis bin a git hook cannot start
Every committed hook — lefthook.yml, .husky/*, .githooks/* — is read, and a line running a
geonosis bin through pnpm, npx, bunx or yarn is a FAIL naming the direct call that
replaces it.
A git hook's PATH is not the shell's: no nvm shim, no corepack shim, nothing a version manager put
there. A Medusa storefront's first hooked commit was "refused" on 2026-08-30 and the ledger never ran — pnpm
was not found, and the sentence its author read was a version manager's failure wearing a gate's
refusal costume. That is the worst shape a gate has, because it is indistinguishable from the gate
working. node_modules/.bin/<bin> is linked by every package manager and needs nothing on PATH.
A hook file naming no geonosis bin is a SKIP with the sentence, never a vacuous OK.
The FAIL names what the fix costs elsewhere: knip's lefthook plugin does not resolve
node_modules/.bin/<bin> back to the package that ships it, so taking this refusal makes knip call
that dependency unused. The line carries the ignoreDependencies entry that answers it, to be added
in the same commit — a consumer told to do one thing by one gate and undo it by another does
neither (#176).
It is a loaded line, not a drift one: it reads a committed file and nothing else, and
a Workers + D1 app's fast tier runs --only loaded, so as a drift line it fired in CI and never locally.
group — one version, one copy
The packages published on ONE version, asked of every workspace: one copy repo-wide, and one number
per workspace. A consumer carried @geonosis/visual-diff at 1.1.0 and 1.4.0 in one tree while
every other line read green, because they are all scoped to the plugin a config names (#158).
Membership ships with the kit; a repo declares its OWN held-together sets under doctor.groups in
geonosis.json. Asked of the resolver, never of find, and a SKIP when the tree installs none of
the group.
It also reads what the ROOT declares: the door (@geonosis/cli, or the geonosis metapackage, whose @geonosis/cli owns the tools: doors are followed through the installed manifests' dependencies, #80)
plus the bins its own scripts, hooks, script files, workflows and tiers call by name — and WARNs a
declaration the door already brings that nothing calls (#177).
The options a rule actually runs under
Every reader here that consumes a rule's configuration reads overrides[] as well as rules, and
resolves them the way oxlint does — measured, not assumed, against oxlint 1.80:
- an override entry naming a rule again replaces its options for the files its
filesglobs claim; nothing is deep-merged into the base; - when several overrides claim one file, the last of them wins outright;
- a rule an override names and the base block never mentions is enabled, on those files.
Reading rules alone is one config with two answers. A Medusa storefront registers its Medusa providers from
three files — a base medusa-config.ts and two carrier registries — and declares that exactly
where oxlint reads it, in overrides naming the carrier directories. The rule resolved it; drift
read the base object and reported two working carriers as directories the registry "never names";
exercised would have called a rule enabled only in an override "not enabled here" and skipped it.
The glob subset understood is ** across segments, * and ? within one, and {a,b}; a pattern
with no separator matches a basename anywhere. Like pathMatching in the rule plugin, it is a
subset that says so rather than a dependency that would have to be right about everything.
exercised — enabled ≠ exercised
A rule at "error" over a tree with no violation of it, and a rule at "error" that can never
fire at all, produce the identical green. One consumer ran no-raw-html-atoms that way for months
with 136 raw elements in the tree.
For each config, every rule it enables is run against the corpus the loaded plugin ships, under that config's own options — because the options are usually where the reach went:
OK .oxlintrc.json: 31 enabled, 31 exercised
FAIL .oxlintrc.json: 2 enabled rules fire nowhere in the corpus: bio/no-brand-names, bio/…The reach engine is @geonosis/lint-parity's
corpusOf, and the corpus is found on the loaded plugin, at corpus/manifest.json. A plugin
that ships no corpus is a SKIP: it never claimed to be evidence about anything.
A rule enabled without the options it needs judges nothing and reports only that it is missing
(D-084). Here that is a FAIL — its one finding is the refusal, and a refusal is not reach — and
loaded names the option.
The probe a rule declares for itself
A rule whose REACH is an option can never fire on a fixed corpus. The fixtures say acme and
corpus/workflows/; a repo says its own brand and packages/workflows/src/, and the rule reads as
firing nowhere in a tree that enforces it on every file. Both source repos reported exactly that
against themselves on 2026-08-30, and both were right about the fact.
So an option-gated rule declares a probe(options) beside its fixShape, returning the smallest
source that must fire it under the options it was handed, at a path that lands where it looks. When
a rule fires nowhere, this writes that probe into a copy of the corpus — beside the fixtures,
because a rule that reads a sibling off disk is answering about a tree — and runs it again:
OK .oxlintrc.json: 17 enabled, 17 exercised — 1 through its declared probe under this repo's options
FAIL .oxlintrc.json: 1 enabled rule fires nowhere in the corpus and nothing through the probe it
declares either: bio/no-unregistered-plugin-dir under [{"roots":…,"registry":…}]
UNJUDGED .oxlintrc.json: bio/atom-no-deps declares no probe, so its scope does not reach the corpusIt does not soften the check. A probe that was PLACED under this repo's options and still fired nothing is a FAIL naming the option VALUES — the question was asked and the answer was no, and the values are what a reader has to go and look at.
A rule with no probe, or one whose options admit no constructible path, is UNJUDGED: the question could not be ASKED. That is not a pass — the run still exits non-zero — and it is not a failure either, because a FAIL over a rule a repo is enforcing on every file reads as an invitation to delete the rule, and one consumer names exactly such a rule as live enforcement in its own law. The sentence says why the corpus cannot answer instead.
The probes are read off the plugin the config LOADS, the way loaded reads its version — this
package still depends on no rule plugin. A plugin too old to declare any answers with none, and the
check behaves exactly as it did before they existed.
The probe's PATH is half of it. step-opens-its-own-cell records zero findings against the shipped
corpus not because its source is wrong but because the fixture is at corpus/workflows/ and the
consumer's within says packages/workflows/src/ — the same bytes at the second path fire. So a
probe derives its path from the option, never from a fixed corpus location, and
lib/probe.ts's pathMatching builds one back out of the regex and then tests it against that
regex, refusing with a sentence rather than placing a file the rule will never look at.
A probe declares FILES, not a file. no-unregistered-plugin-dir reports a directory its registry
does not name and reads that registry off disk, returning silently when it is not there — so one
file could never exercise it, and a Medusa storefront, enforcing it at error over four registries, read as a
permanent FAIL for a gate that was working. Its probe now declares the directory AND the registry,
and every file a probe declares is mounted into the copy. A probe declaring only { path, source }
is the one-file case of the same thing and is read unchanged, which matters: this reads probes off
the plugin build the consumer has INSTALLED, and every build published before this shape existed
answers in the bare form.
The corpus a shipped one cannot be
A rule whose reach is a repo's own vocabulary cannot be answered by the plugin's corpus at all —
layer-walls fires on the layers this config declares, no-brand-names on the brands it names,
plugin-route-namespaced on its package root, step-opens-its-own-cell on its within — and the
shipped corpus says acme and layers/core. Both source repos hit exactly this: a Medusa storefront's no-brand-names and a Workers + D1 app's
step-opens-its-own-cell read as firing nowhere in trees that enforce them correctly.
So a repo may ship its own, and point at it:
// geonosis.json
{ "doctor": { "corpus": "lint-corpus" } }The directory holds fixtures in this repo's vocabulary, at paths that mirror this repo's own
(the layer patterns are path patterns), and a manifest.json naming exactly the rules it is
evidence about. A rule is exercised when it fires in either corpus. Two refusals guard it: a
declared corpus that is not there is a FAIL, and a rule the repo's own manifest names that fires
nowhere in it is a FAIL — claiming evidence and shipping none is worse than claiming none.
It is read for the config beside geonosis.json and no other: a nested config sets different
options, and asking one config's fixtures to answer for another's would report firing nowhere.
The engine a rule presumes (#159)
A rule that reaches ONE engine carries it in its own metadata — presumes: { engine, packages } —
and this asks every manifest the config governs — its own, the ones above it up to the root, and the
workspaces underneath — whether any of those packages is declared, naming the ones it read. A
backend one directory BELOW a root config declares the engine for the rules that config enables over
it (#227). A Workers + D1 app ran three Medusa rules at error over sagaflow trees: the default step
factory is createStep, sagaflow has none, and the possible findings were zero. Enabled-with-no-
engine and clean are the identical green.
A WARN, because which rules a repo enables is the repo's decision and the two actions — drop the rule from that config, or install the engine — are the reader's. A plugin too old to declare any presumption answers with none, and says nothing.
What the number is evidence about (#160)
The exercised line says, on the same line as its number, that the firing happened in the corpus
and in the probes and says nothing about this tree. "17 enabled, 17 exercised" was read as
liveness in a consumer; three of those 17 could never fire on the surface that repo writes, and its
own --prove plant was green in a spelling its code does not use. Nobody goes looking for the
denominator of a green, so it goes where the green is.
baseline — a number that may only shrink, against another ref
A ratchet lowers its own baseline whenever a number shrinks; that is what locks a win in, and it is also what lets a branch write any number it likes and stay green on every gate it runs. Nothing inside one checkout can tell the difference. Only the same file at another ref can:
geonosis doctor --baseline-against # origin/main, if the repo has one
geonosis doctor --baseline-against mainFAIL gate-baseline.json: oxlintErrors 12 → 135 (grew against origin/main)Off unless asked for. It is the only check that runs git, and this tool gets pointed at repos other people are working in.
runner — an exit code is not a verdict
@cloudflare/vitest-pool-workers exited 0 over suites it had just reported as failing, for weeks,
in a consumer, so every gate that trusted the status read green over red. A workspace whose test
script runs vitest without asking for a JSON report, and which no testFailures counter in
report mode covers, gets a WARN. --strict makes it a FAIL.
bun test is a different answer, because it is a different runner and it was measured: on bun 1.4.0
a planted failing assertion exits 1, so does an awaited one, so does a throw in afterAll, and an
all-passing run exits 0. Its exit code IS the verdict, so a bun workspace is OK and the line says so
naming bun. It used to be warned with vitest's sentence — eight lines of it in one consumer's
report, each blaming a runner the check had not read, which is how a reader learns to skim the
section and miss the vitest lines beside them that are true.
A workspace whose test script runs a runner over zero test files is a SKIP, not a warning: one
consumer has a config-only workspace that runs vitest run over nothing, so there is no
result for an exit code to be wrong about. The message says what was counted.
A workspace whose verify step carries proves: { plant, expect } is OK, naming the step: geonosis verify prove planted a failing test there and saw the step go red, which is the measurement the warning asks for (#81). The pool-workers sentence is said only where the workspace's vitest config (--config, else the first vitest.config.* or vite.config.*, as vitest finds it) loads @cloudflare/vitest-pool-workers, and the line names that file. One cause is one line: workspaces with the same warning share it, listing their manifests, and counters or verify steps that run one bin through the same wrapper share one line listing their keys (#75). The advice for a wrapped bin is env PATH="$PWD/node_modules/.bin:$PATH" node_modules/.bin/<bin>, since a bare node_modules/.bin/<bin> drops the workspace's bins from PATH and a test that spawns one by name reads ENOENT as empty output (#74).
The message names the runner it read. What it does not do is see through a wrapper: a script that
runs bun ./scripts/vitest-strict.ts reads as neither, and that consumer's wrapper is in fact the
exemplary fix — it spawns vitest with --reporter=json, reads the report, and fails on a missing
one. The verdict is right and the sentence understates it.
A falsification nothing runs
geonosis ratchet --prove plants each counter's known-bad input and requires the counter to read
it — a gate that has never been seen red has not been shown to measure (D-029). The ratchet is
strict about this where the flag runs, and until now nothing asked whether the flag is ever run: a
prove that lives in the release ritual is run by the release that remembers it, and a counter can
spend a release reading zero off a mis-escaped match (#140, measured in a consumer).
A repo with counters and no ratchet run carrying --prove, directly or through a script, in any
verify tier, script or workflow is a WARN naming a tier it already declares and advising
geonosis ratchet --prove. A --prove elsewhere on the line (echo --prove) does not count. A
ratchet run is recognised however the door is spelled: geonosis ratchet under pnpm exec, npx
(with its flags), pnpm dlx or bunx, a path to the bin, and a pinned spec such as
[email protected]; the same reading serves the once check, stack.lint and the census check's
reading of a counter's command (npx [email protected] --config …). A repo with no ratchet configured
gets no line at all.
A bin spelled through a package runner
pnpm exec <bin>, npx <bin> and bunx <bin> cost about 3 s of start-up each run (3.1–4.1 s against
0.34–0.65 s for node_modules/.bin/<bin>, measured under load), and pnpm exec installs when the
manifests and node_modules disagree. The ratchet and geonosis verify run the bin directly when a
command starts with the runner, so those spellings cost nothing in a gate. A WARN names the step
(verify.fast[1], or a counter's key) when the runner comes after another command
(cd apps/api && bunx vitest run) and the bin is installed: that spelling is run as written.
envelope — every gate read as many things as it was handed
For each .geonosis/envelopes/<tool>.json a gate wrote, considered === read + refused + excused.
Four denominator bugs landed in one day and every one was green — a migrations run that reported on
three of four files, a plan check that printed PASS — 0 plan(s) over a directory of twenty-one, a
parity run over a tree the second config ignored. Each published a numerator and no denominator.
Reads the files and imports nothing of the tools that wrote them; no envelopes at all is a SKIP with
the sentence.
drift — the gate that is no longer there
The checks above ask whether a gate measures what it names. This one asks whether it still runs.
| Line | Verdict | What it is |
|---|---|---|
| .github/workflows/*.yml | FAIL | a job or step switched off by a condition that can never be true. One consumer carried 90 tests, a pgvector container and an E2E suite and ran none of it, the disablement documented as "TEMPORARY" in a comment. It is "never downgrade a rule", one level up |
| test files | FAIL | a *.test.* file with no test script in any workspace from it up to the root. A monorepo that runs one vitest over the whole tree has no orphan in it, and this says so. A test file whose relative imports (./, ../) do not resolve where it sits, by the extensions and index files TS and vite try, could not be run by any test script in place: it is a SKIP, one line per file, naming the first import that does not resolve (#83) |
| script paths | FAIL | a scripts entry that hands bun, node or tsx a file that is not on disk. A Workers + D1 app's bun run fast began bun scripts/check-plugin-version.ts && … and the script had been deleted: the 2.5 s gate had failed at its first command for as long as nobody looked. Only a word with a file extension is asked about, so bun test, bun run lint and node --version are not paths. A missing file an EARLIER segment of the same script produces is OK, naming that segment: --outfile, -o, --outdir/--outDir covering the path, tsc whose -p/--build/default tsconfig outDir (through extends) covers it, or a call to the workspace's own build script (npm run build, pnpm build, bun run build, yarn build) when it has one (#79) |
| generated files | FAIL | a file carrying geonosis:gated-by: <gate> that nothing here runs the gate for — checked against every manifest script, every ratchet counter command and every workflow, because what matters is that SOMETHING runs it. A file marked geonosis:snapshot: <YYYY-MM-DD> <reason> in its head is a record of a source that is gone, not an instrument: SKIP, naming the date and the reason; a marker with no valid date or no reason is a FAIL (#82). A file that names its writer and no gate at all fails on that alone: a Workers + D1 app's AGENTS.md is a 1,117-line byte-copy of its own law, written by geonosis sync, and nothing ever ran sync --check — the doctor stood beside it warning about the shorter original |
| NODE_OPTIONS | WARN | a manifest script preloading through NODE_OPTIONS beside a ratchet counter whose command shells to pnpm. The nested pnpm inherits the option: a Medusa storefront's ts5 shim sent it looking for a .pnpmfile.mjs that is not there, the counter died, the ratchet refused correctly, and nothing in the message was near the cause. Both halves must be present, and the line names the script and the counter |
| workspace bins | FAIL | a script calling a bin THIS repo's own workspaces declare, with no node_modules/.bin/<name> at the root. pnpm links a workspace bin only for the packages that depend on it: a Medusa storefront's @shop/gate-tools had to become an explicit root devDependency before the gate scripts could find it, and naming that took a debugging session. External commands are not judged — that would be a guess |
| plugin directories | FAIL | a manifest directory under the roots no-unregistered-plugin-dir names that the registry never mentions. SKIP when the rule is not configured — the roots are the repo's, read from the rule it already wrote, never a list invented here |
| publicHoistPattern | FAIL / SKIP | a pattern in pnpm-workspace.yaml with no root node_modules link for a workspace package it matches. pnpm dropped one during an unrelated add, then answered "Already up to date" to every install afterwards over a clean git status — the tree was fixed only by deleting .modules.yaml and .pnpm-workspace-state-v1.json, which the line carries verbatim. Only workspace packages are judged, because one exists on disk by construction and a missing link for it cannot be "the install never ran"; the root package is never asked about, since no package manager puts a repo's root inside its own node_modules. A pattern matching no workspace package is SKIP, never a pass |
| @geonosis/observability | WARN | it is declared in a manifest here and no source file imports it (a test file does not count) — installed is not composed. The kit ships the sink adapter and the reference consumer sent production errors nowhere, because nothing at its composition root ever built one; from outside the process that reads exactly like a project with nothing to report (#165). No line at all in a repo that never installed it |
| an allowBuilds entry | WARN | a platform-split package allowed by ONE platform name. pnpm 11.5.1 writes this file itself when it refuses a build script, and what it writes is the platform of the machine that ran the install — so every other platform refuses the install by the name that is missing, on somebody else's machine (#188, measured here). How many a family publishes is the registry's answer, so the line names the family and the shape rather than a count it would have to guess |
| the tool runtime | WARN | an installed kit package declares bun in its devEngines.runtime (releases up to 3.2.0 declared engines.bun, which yarn 1 warns on once per package) — every kit bin starts on bun — and the tree neither runs on bun (packageManager: bun@…, or a bun.lock) nor has it. On a laptop bun is usually on PATH and every gate runs; the first machine without it, CI as a rule, fails with env: bun: No such file or directory, which names no package and no fix. The remedy is the manager's own, each measured: pnpm (11.8.0 and 12.10.1) installs what the root devEngines.runtime names and puts it on the PATH of pnpm exec and scripts, and the line carries the block to add; yarn 1 (1.22.22) and npm (11.13.0) read devEngines for nothing and install no runtime, so the line says yarn add -D bun@<range> / npm install -D bun@<range>, which links node_modules/.bin/bun onto the PATH of yarn <bin>, npm run, npm exec and npx (not of a bare shell calling node_modules/.bin/<bin>); bun needs nothing. Where the manager is not named by packageManager or a lockfile, the line gives all three |
| a workspace's lint script | WARN / SKIP | the file set the script covers, against the one a committed hook lints. One consumer's app script covered its source directory and not its test one, so the pre-commit hook — which lints whatever is STAGED — was the first door ever to read a moved test file, and it found violations the package gate had never seen (#172). The line names the globs left to the hook alone. SKIP when no committed hook lints staged files: there is no second file set to be narrower than |
| the law | WARN / SKIP | its measured length against geonosis.json → law.maxLines. SKIP when the repo declares no ceiling — a consumer read "1,104 lines against a ceiling of 200" for a number it never chose, which is unactionable in both directions. The length is still printed, because that is the number a repo declares against |
| .claude/settings.json | WARN | nothing under enabledPlugins enables the kit's plugin, so the gates run and the method does not (D-041). The user-scope ~/.claude/settings.json is read too and acknowledged as "installed elsewhere — not this repo's to declare": both source repos got this WARN while the plugin was installed machine-wide. The key is read, never the word geonosis appearing somewhere in the file |
| geonosis.json blocks | WARN | a block whose package no manifest declares — nothing reads it, and a stale node_modules copy is named as what a previous install left behind unless a declared package's dependencies reach it; a block whose package is declared and does not resolve — run the install; or a declared package with no block, running on defaults nobody chose |
The two directions do not read the same source. Whether a repo still chose a package is a question
for the manifests: a release block whose package had been removed from every manifest, with a
stale node_modules directory still present, resolved perfectly and the check said nothing (#78).
Whether a tool will actually load is the forward question, and that one is resolution's.
The "installed with no block" direction is scoped to direct dependencies. @geonosis/review and
@geonosis/testbed arrive transitively through @geonosis/cli, and asking a consumer to configure
a package they never installed is noise — which is how a reader learns to skip the section (#61). A
transitive presence gets an OK line naming where it came from, and no demand.
observability — a sink nobody can reach
The exporter named by the observability block in geonosis.json: is a sink configured, is its
endpoint reachable, and did an event arrive inside maxAgeSeconds. Read from the lastEventFile
the sink writes — never by importing the library being checked, because a check that needs the
library cannot run in the tree where the library is missing.
workers — a production Worker that logs nothing
Cloudflare's own Workers best practices call a production Worker without observability "a black box":
the intermittent error arrives and the data to diagnose it was never collected. This reads the
observability block of every wrangler config in the tree — wrangler.jsonc, wrangler.json or
wrangler.toml in the root and in each workspace directory, plus any path release.wrangler names —
and judges the top level, which a deploy without --env publishes, and an environment named
production or prod that carries a block of its own (an environment without one inherits the top
level and is not judged twice). Every config is read by wrangler's own reader
(experimental_readRawConfig), so a .toml needs a wrangler installed to be read.
| | |
|---|---|
| no wrangler config anywhere | no line at all: the check is asked only where a Workers config exists |
| no observability block, or one that does not say whether logs are on (enabled, or logs.enabled, which wins) | WARN |
| logs off | WARN |
| logs on and no head_sampling_rate stated (logs.head_sampling_rate or the block's own) | WARN — the default is 1, every request, and persisted logs are billed from 1 December 2026 |
| logs on at a rate of 0 | WARN — enabled, and recording nothing |
| a rate that is not a number from 0 to 1 | FAIL — the range Cloudflare accepts |
| logs on at a stated rate | OK, and one more OK line naming what else the block states: traces and their rate, issues, and the destinations named for logs and for traces |
| a config that cannot be parsed, or a release.wrangler path that is not there | UNJUDGED, naming the file |
| a Pages project (pages_build_output_dir, the key wrangler tells Pages apart by) | SKIP — a Pages config takes no observability settings; its Functions logs are read with wrangler pages deployment tail or in the dashboard |
| a Pages project with an observability block other than { "enabled": true } | FAIL — wrangler pages deploy refuses the file (measured on wrangler 4.149.0) |
An environment is production by name. geonosis.json → doctor.workers.productionEnvs replaces the
default ["production", "prod"], and "doctor": { "workers": false } turns the check off.
A committed worker-configuration.d.ts beside a wrangler config is compared with it by running
wrangler types --check, with the flags the file's own header records, because wrangler 4.151.0's
--check ignores the ones it is given (proofs/076 in the kit's repository). Types the config has
moved away from FAIL, naming the wrangler types command that regenerates them; a run that
gives no verdict is UNJUDGED.
deployed — declared ≠ deployed
wrangler versions upload applies no triggers. A cron or a route edited in the config is therefore
silently ignored in production for ever, with a green pipeline; wrangler secret put creates AND
deploys a version outside the gate entirely. Nothing in either source repo measured any of it.
.geonosis/deployed.json is what the consumer's pipeline writes after promote — the versions,
the crons and routes it applied, the secret names and the binding names. This line compares it
against what the tree declares: the wrangler configs geonosis.json → release.wrangler names
(read by wrangler's own reader, .toml included, with release.wranglerEnv selecting an
environment block, because named environments inherit nothing per binding) and release.secrets.
| | |
|---|---|
| the file is absent | SKIP, with the sentence: it is written by the pipeline after promote, and its absence is not a pass |
| release.wrangler and release.secrets are both empty | SKIP — this repo has declared nothing a deployment is supposed to carry |
| a config it was told to read cannot be read | FAIL — a config nobody can parse is a finding, never a skip |
| anything declared is not deployed, or anything deployed is not declared | FAIL, naming each |
It reads the file, and imports nothing of @geonosis/release — the same wall observability keeps,
held by the kit's own layer-walls config, so a repo can install either package alone.
release — what the release contract asks of a Workers repo
Read from the wrangler configs geonosis.json → release.wrangler names (with release.wranglerEnv
selecting an environment block), by the same functions geonosis release plan and
geonosis release migrations use. No line at all when the repo declares no Worker and no wrangler config is found; with release.workers named and release.wrangler empty the line is a WARN naming the key and the configs found, and with a wrangler config in a workspace and no Worker declared it is a SKIP saying the same. A Pages project is not counted.
| | |
|---|---|
| a config with no version_metadata binding while release.proof.mustAssertVersion is on | FAIL, naming the file: the smoke cannot ask the Worker which version answered, and an override that was not applied is answered by the old version |
| a ramp (release.steps) over a Worker with assets and no affinity declared | WARN: an HTML page from one version requests a content-hashed asset the other does not have; Cloudflare-Workers-Version-Key pins a client to one version |
| a D1 database with a migrations_dir, in a repo that gates SQLite migrations, with no bookmark under its name or binding in .geonosis/deployed.json | WARN, naming the command that records one: geonosis release migrations --since <ref> --record-bookmark <db> |
| a config it was told to read cannot be read | FAIL |
| none of the above | OK |
rails — the egress a run is bounded by, in the settings that load
The hosts geonosis.json → rails.egress.allow declares, read back out of the settings that
actually LOAD rather than the file this repo rendered. Claude Code merges
sandbox.network.allowedDomains across the managed, user, project and local scopes; a settings file
that fails validation is silently ignored under -p, and a managed allowManagedDomainsOnly makes
every other scope's list ignored. deniedEgress in .geonosis/rails-run.json is a gate at zero
(D-025), asked whether or not an allowlist is declared: one denied attempt fails.
exams — a floor adopted and never examined
A floor bump that breaks search is invisible to a tier that never runs the search exam. A verify tier declares COMMANDS, not globs, so "the exam runs in the fast tier" is not a claim anything can check — but whether a test file importing the exam exists at all is.
For every @geonosis/* package a manifest in the tree declares, this reads the declarations that
package ships and takes every export ending in Conformance as its exam. The exams are read off
the installed floor, never off a list held here: a table in this package would be a second source
for a fact the floors already state, and wrong the day a floor adds one.
An exam named <stem>Conformance whose package also exports define<Stem> is asked only of a tree
with a source file importing that package and naming define<Stem> — entityConformance of a tree
that calls defineEntity, never of one that only opens sessions. An exam with no such export
(sessionConformance, searchConformance) is asked of every tree that declares the package.
| | | |---|---| | no declared package ships an exam | SKIP — nothing here to ask about | | a test file imports an exam | OK for that exam, naming the file | | the floor is declared and no test file imports an exam it ships | WARN for that exam — one line per exam, never one per package (#353) |
It WARNs rather than FAILs: the check cannot tell a floor adopted last week from one whose exam somebody deleted, and a FAIL on every fresh install is the shape that teaches a reader to skim.
seams — a declared seam that names nothing
adoption.seams in geonosis.json is what geonosis update measures a floor's adoption by:
lines deleted against lines added in the files a consumer says hold a floor's shape. A glob whose
file was deleted and never undeclared measures nothing, and every bump report reads 0 deleted,
0 added over it — a Workers + D1 app carried three of eight that way for a month.
| Seen | Line |
| ----------------------------------------------- | --------------------------------------------------- |
| no adoption.seams | SKIP, naming the key |
| every glob names a file (never in node_modules) | OK, with the count |
| a glob names no file | WARN per glob, saying to drop or replace it |
formatter — the kit's rules, out of the formatter's reach
A consumer links or copies the plugin's rule files into .claude/rules/geonosis, and tiers.md
there is generated. oxfmt --migrate=prettier writes ignorePatterns: [], so the first format of
a repo that migrated rather than scaffolded rewrote them. The repo's own oxfmt is asked with
--list-different, so its ignore rules are read by the tool that applies them: WARN, naming the
entry to add, when the files are within its reach; UNJUDGED when oxfmt could not be asked.
once — a tool a counter reads runs once
A repo that adopted the ratchet before --record kept running tsc, oxlint and vitest twice per
gate: once as a step (yarn typecheck && yarn lint && yarn test && yarn ratchet, or the same steps
in a geonosis.json verify tier), and once more inside the ratchet, because a counter whose entry
has a command and no output runs that command itself. One consumer's yarn verify took 361 s.
For every counter entry of typecheckErrors, oxlintErrors, oxlintWarnings and testFailures
that has a command and no output, this reads the commands of each verify tier of geonosis.json
and each script of the root package.json (a script naming another script, pnpm --filter, yarn
workspace, turbo run and -r are followed), and WARNs where one gate holds both a ratchet step that
measures and a step that runs the same tool over the same files the counter's command does. The same
files means the same directory and the same arguments once the ones that only change how the run is
printed (--format, --reporter, --outputFile, a --config naming the default) are set aside; two
runs over different scopes are two measurements and are not asked. A ratchet step with --tier is
asked only about counters that tier measures. Only the file, key and text are read: no one's prose is
parsed, and no tool is run.
The line names the exact change: the step to replace with geonosis ratchet --record <counter>
(pnpm exec, bunx, yarn or npx in front of it in a verify tier, the door alone in a script) and
the "output" to add to each counter entry, the file geonosis init and create-geonosis give the
same counter. geonosis post-update prints it as a BY HAND line.
| What it finds | Verdict | | ------------------------------------------------------------------------------ | ---------------------------------------- | | no ratchet config | nothing | | no counter runs its own tool | OK | | a counter runs its own tool and no step beside a ratchet step runs the same one | OK, with the count | | a step and a counter run the same tool over the same files | WARN, naming both edits |
census — a counter of a rule its config does not run
geonosis init and geonosis update adopt a rule census-first (#158): the rule parked at "off"
and an oxlintRule counter with "config", which raises it to "error" in a copy and counts what it
finds. The counter matches (rule) in oxlint's output, so where the config does not run the rule at
all it reads 0, and the ratchet banks that 0 as a clean tree. For every oxlintRule counter this
asks oxlint what the config the counter reads resolves to (oxlint --print-config): "config", or
the --config or -c of its command, or .oxlintrc.json. oxlint 1.88.0 leaves a JS plugin's
rules out of that answer, and drops an extended config's jsPlugins from it, so those rules are
read from the config and every config it extends; a rule id is a JS plugin's where oxlint refuses
it in a config with no jsPlugins.
| Seen | Line |
| --- | --- |
| no oxlintRule counter | SKIP |
| a command that is not oxlint itself (pnpm lint) | SKIP, naming it |
| the config is not there, or no layer of it names the rule, or "plugins" leaves its plugin out | FAIL |
| the rule is off and the counter names no "config" to raise it | FAIL |
| the rule is off only in a config this one extends, which the counter does not raise | FAIL |
| oxlint could not print the config | UNJUDGED |
| the config runs the rule, or names it off for the counter to raise | OK |
rules — the kit's rule files, as the installed kit ships them
Claude Code loads .claude/rules/ from the repo itself. A symlink to a directory outside the repo
needs an approval, and then only the rules with no paths list load, so the kit's scoped rules are
copies: geonosis init and geonosis update write .claude/rules/geonosis/<file>.md, each
with a # geonosis-kit <version> line directly under the opening --- (a marker above it would
stop Claude Code reading the paths list). The copy this package compares against is the one it
ships itself, generated from plugin/rules.
| Seen | Line |
| ----------------------------------------------------------------------------------------- | --------------------------------------------------------- |
| no geonosis.json and no kit plugin in any .oxlintrc.json | SKIP |
| .claude/rules/geonosis missing | WARN, naming geonosis update --to <version> |
| the directory is a symlink, or not a directory | WARN |
| a copy is missing, edited, or written by another kit version | WARN per file, naming it and the fix |
| a marked file the installed kit no longer ships | WARN per file |
| every copy is what the installed kit ships | OK |
| .geonosis/post-update.json names a kit older than the installed one | WARN, naming geonosis post-update --from <that kit> |
| no .geonosis/post-update.json, and .geonosis/bump-report.json says an update ran here | WARN, naming geonosis post-update |
| no record and no bump report, as in a fresh clone | no line |
| geonosis post-update last ran for the installed kit | OK |
A file with no marker is the user's and is never asked about.
geonosis post-update writes .geonosis/post-update.json when it runs to the end. An update run by
a kit of 3.2.0 or older never calls it, so the BY HAND steps and release notes of the new version
are not printed; this doctor is the new version's, so it is the copy that can say so.
downgrades — what the gated lint leaves switched off
"Never downgrade a rule" holds for the kit's own rules by its gates. A repo's own lint config can still
switch rules off wholesale, and a disable directive left behind after its rule went off suppresses
nothing while telling a reader a violation sits there. This check asks every oxlint and eslint run a
verify tier reaches, through scripts, turbo and package managers.
| Seen | Line |
| --- | --- |
| no tier runs oxlint or eslint | SKIP |
| a run that does not fail on an unused disable directive, and the tree holds one | FAIL, with the count and the setting to add |
| the same, and the tree holds none | WARN: nothing would catch the next one |
| eslint: a rule its plugin's recommended config turns on, switched off | WARN, each with what it reports here |
| oxlint: a rule the config the run loads names "off" | WARN, each with what it reports here |
| oxlint: a rule named "off" that an oxlintRule counter with "config" raises in that config and counts | OK, naming the counter and its baseline: a census, not a downgrade |
| a run with no oxlint or eslint in a node_modules/.bin at or above the directory it runs in | UNJUDGED, naming the directory |
| oxlint: the config the run loads cannot be loaded | UNJUDGED, with why |
| every run reports unused directives as errors and switches nothing off | OK |
Each run is judged with the oxlint or eslint nearest the directory it runs in, the way the package
manager runs it: in a pnpm workspace that is the workspace's own node_modules/.bin, not the root's.
The counts are measured: eslint runs again with each rule raised (--rule), oxlint against a copy of
its config beside the original with each rule raised, over the run's own targets. For oxlint that is
--report-unused-disable-directives-severity=error; for eslint, linterOptions.reportUnusedDisableDirectives: "error".
turbo-env — a turbo task handed the variables of its tier
geonosis verify runs a per-workspace step that names a turbo task ("turbo": "typecheck") as one
turbo run. turbo's default strict env mode hands a task only the variables its turbo.json
declares, so a task that declares none of GEONOSIS_BASE, GEONOSIS_GATE_RUN,
GEONOSIS_HEAVY_SLOT_HELD and GEONOSIS_SLOTS runs without them: a step that selects by
GEONOSIS_BASE takes the whole tree, and a heavy tool inside it takes a second slot.
| Seen | Line |
| --- | --- |
| no verify step names a turbo task | SKIP |
| no turbo.json or turbo.jsonc at the root: the step runs its own command | SKIP |
| the config cannot be read | UNJUDGED, with why |
| envMode: "loose" (or global.envMode) | OK |
| in every workspace whose package.json has the task's script, a GEONOSIS_* variable matched by globalEnv, globalPassThroughEnv, global.env, global.passThroughEnv, or the env or passThroughEnv of the task turbo runs for that workspace: the package task (<name>#<task>) if the root declares one, else the generic task, with the workspace turbo.json's keys replacing the root's (or extending them after $TURBO_EXTENDS$) | OK |
| a workspace whose task declares none of them | WARN, naming the step, the task, the workspaces and the keys to add |
The keys to add:
"tasks": {
"test": { "env": ["GEONOSIS_BASE"], "passThroughEnv": ["GEONOSIS_*"] }
}env puts GEONOSIS_BASE in the task's hash, which a task that selects by it needs; one that does
not read it leaves it out of env and keeps its cache across marks. passThroughEnv hands the task
the slot and gate variables without hashing them.
What --format=unix does not tell you
Measured on oxlint 1.80: a JS plugin rule that throws from create() is reported once per file,
and the unix formatter renders each of those as
:0:0: [Warning]— no path, no rule id, no sentence. The default formatter carries the whole message, including which rule refused and which option it is missing. So when a config's corpus run is refused, this tool asks oxlint a second time, over one trivial file, in the format that answers.
A tool that only ever read --format=unix would report that something went wrong and be unable to
say what.
--json
The whole report, for a CI step to read:
{
"counts": { "FAIL": 1, "OK": 12, "SKIP": 2, "UNJUDGED": 0, "WARN": 0 },
"findings": [{ "check": "loaded", "message": "…", "subject": "apps/web/.oxlintrc.json", "verdict": "FAIL" }],
"ok": false,
"ran": ["loaded", "group", "exercised"],
"root": "/repo"
}What it does not depend on
Not the rule plugin. This tool asks which plugin a config loads; a copy of that plugin in its own
dependency tree would be one more copy for the question to be wrong about. Not
@geonosis/observability and not @geonosis/release either: both of those lines read a FILE the
consumer wrote. It depends on @geonosis/lint-parity for the reach engine and on nothing else, and
the kit's own layer-walls config holds every one of those walls.
Apache-2.0.
