feat(lint): startup open-vocabulary verdicts enter the lint vocabulary (#4776) - #6791
Conversation
#4776) A boot fills its registries incrementally, so "is X registered?" asked while one is still filling has an answer that is simply not final yet. Turning that not-yet into a verdict AND RECORDING the verdict is the defect: the provider registers a moment later and nothing goes back to undo the record. One showcase cold start produced three instances in three unrelated subsystems (#4769 / #4771 / #4772), all since fixed individually. This is the maintainer-ruled option B — the same-shape misdiagnosis enters the lint vocabulary — landing where the vocabulary lives rather than as a second copy of the CI gate's: - `findStartupRegistryVerdicts(source, { file })`, a pure decision procedure over plugin source, reporting `startup-open-vocabulary-verdict` (a read of an ADR-0018-open capability vocabulary during constructor/init/start whose conclusion is announced, cached or persisted) and `startup-verdict-assertive-wording` (emitted only at a site the first rule flagged, when the diagnostic asserts a terminal outcome about a world that has not finished forming). - The three sanctioned cures are recognised by shape and pass: deferral to a `kernel:ready` handler, lazy re-resolution, and seal-then-judge. - `lint-startup-registry-verdict.corpus.test.ts` sweeps every `.ts` under `packages/` with it (1502 files, 0 findings) behind a shrink-only ledger, and pushes a reconstructed #4771 through the same sweep so a green ratchet can be told apart from a dead one (#4690). The kernel SERVICE-registry half stays with `pnpm check:startup-registry-verdict` (#4777 / PR #4833) and is untouched; the rule module states the measured division of labour — that gate reported 40 seams across 1501 files, 0 of them in a `start()`, which is the phase this rule exists for. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WRyk75SSknS2WriyJvA5sC
|
The latest updates on your projects. Learn more about Vercel for GitHub. 1 Skipped Deployment
|
📓 Docs Drift CheckThis PR changes 1 package(s): 3 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:
|
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01WRyk75SSknS2WriyJvA5sC
Local gate sweep — enumerated fresh from
|
| suite | result |
|---|---|
@objectstack/lint |
1680 passed / 67 files — includes the 24 new rule tests, the 4 corpus-sweep tests, and the lazy-deps / rule-id-barrel-exports / authoring-rule-wiring contracts |
@objectstack/metadata-protocol (the other @objectstack/lint consumer) |
671 passed / 60 files |
tsc --noEmit on @objectstack/lint |
clean |
Rebased onto current main
origin/main had moved 5 commits ahead (e1e7629 → 4bb6f01) while this work was in flight. Merged in and re-verified against the widened corpus:
- rule + corpus tests: 28 passed
- corpus sweep: 1505 files, 0 findings, 848 ms (was 1502 / 0 / 792 ms — the three new source files carry no vocabulary accessor, so they are pre-filtered out before parsing)
Generated by Claude Code
Fixes #4776
Implements the maintainer-approved 2026-08-06 ruling, quoted verbatim:
Scope is
packages/lint/src/**+.changeset/. Nothing inpackages/objectql/core/ kernel;scripts/check-startup-registry-verdict*and its gate wiring are untouched.Premise check (the ruling's own dispatch precondition)
The ruling required confirming that the vocabulary shape covers the three cases' actual fix forms before starting. All three fixes are in-tree and took three different routes; each is a negative fixture in the test suite, and the rule passes all three:
ApprovalsServicePluginregistered theapprovalexecutor, asserting "will fail at execution time"AutomationEngine.sealNodeTypeVocabulary()(packages/services/service-automation/src/engine.ts:4353), called by the plugin atkernel:bootstrapped;warnUnknownNodeTypesnow runs only after the sealSEAL_MARKERSescapecache21ms beforeCacheServicePluginregistered it, froze theundefined, and advised "you need Redis"createLazyCacheRateLimitStorage()(packages/plugins/plugin-auth/src/rate-limit-storage.ts) resolves inside the accessor it hands outpackages/objectql/src/engine.ts:4414)Premise holds, with one honest correction to the dispatch's framing. The hypothesis A1 described a rule that re-covers the kernel service registry (
getService('cache')ininit()). Re-implementing that here would be a second vocabulary for one verdict — the thing AGENTS.md forbids and #5841 was filed about — because deciding it correctly needs the ADR-0116 plugin-manifest model (dependencies/requiresServicesare what make "absent" a fact) thatcheck:startup-registry-verdictalready carries. So this rule targets the complement instead, and the boundary is asserted by a test rather than only claimed in prose (what this rule delegates, and to whom).A2 — the existing gate's coverage boundary (measured, not assumed)
pnpm check:startup-registry-verdict --list, run on this branch's merge base (2026-08-08):Breakdown of those 40 seams:
init()only (28 of 34 cleared by an ADR-0116 declaration)this.nodeExecutors/this.actionDescriptorsservice-automation/src/engine.tsstart()What the gate declares out of reach, in its own header:
start()for the service registry (sound: everyinit()has completed by then); helpers beyond the same file; non-literal service names; #4769's database-as-registry; and — implicitly — everything outside this repo, since it is ascripts/*.mjsthat is never published.This rule takes exactly that complement:
check:startup-registry-verdictpackages/**of this repoconstructor+init; the registry's owning classconstructor+init+start, from any consumer packagethis.<prop>start()is the phase this rule exists for. The gate's reasoning for stopping atinit()is correct for the service registry and false for an ADR-0018 vocabulary: a sibling plugin registers its executor from its ownstart(), and siblingstart()s have not all run. That is literally where #4771 sat.What landed
packages/lint/src/lint-startup-registry-verdict.ts—findStartupRegistryVerdicts(source, { file }), a pure decision procedure (parses via a lazily-loadedtypescript; never executes, never type-checks, touches no filesystem). Two rule ids:startup-open-vocabulary-verdict— all three of: (1) a read of an open capability vocabulary (OPEN_VOCABULARY_PROBES, 13 accessors, each carrying the reason its answer is not final), (2) inside a pre-seal phase (PRE_SEAL_PHASES:constructor/init/start), (3) with the conclusion recorded — announced in awarn/error/fatallog, cached in an instance field or module-levellet, or persisted. All three, or it is not a finding.startup-verdict-assertive-wording— emitted only at a site the first rule already flagged, so it can add no false positive of its own. Fires when the diagnostic asserts a terminal outcome about a world that has not finished forming, and is cleared by any hedge that keeps the two worlds apart ("not yet", "as of", "may still").The three cures are recognised by shape, not by allowlist: nested function bodies are never descended into (so a
kernel:readyhandler and a lazy accessor are both invisible), and a scope mentioning a seal identifier is skipped.STARTUP_VERDICT_HINTobeys the card's own lesson — it prescribes all three fix shapes by the change that shipped each, names #4769/#4771/#4772 as precedents, and makes no assertive claim of its own about whether the flagged site misbehaves at runtime. A hint that said "this will fail" would be the defect wearing the rule's badge. There is a test for that.A1 — corpus measurement
The rule run over every
.tsunderpackages/(same file filter the CI gate uses):startup-open-vocabulary-verdictstartup-verdict-assertive-wordingZero new hits, so there is no triage table to fill in — and zero is exactly the reading a green ratchet does not earn on its own (#4690). Two things make the zero mean something:
service-automation/src/engine.tsis parse-eligible and clean because every judgement sits behindnodeTypeVocabularySealed;plugin-authis clean because the read moved inside the lazy accessor;objectql's attestation is clean because the verdict now follows the mutation. Each is a negative fixture in the suite, so a regression in the escape logic fails a test rather than quietly flagging the cure.lint-startup-registry-verdict.corpus.test.tspushes a reconstructed bug(service-automation): flow 节点类型校验跑在插件贡献的执行器注册之前 —— 每个 ADR-0019 approval flow 都被误报「will fail at execution time」 #4771 through the same sweep function the corpus goes through and asserts both rule ids come back. A change that broke matching fails there instead of turning the corpus green.One limitation stated rather than hidden: this session's clone carries 50 commits, so the ideal proof — pointing the rule at the pre-fix commits of 2026-08-03 and watching it report — was not reachable. The reconstructed fixtures stand in for it, and they are reconstructions of the shipped defect text, not of the rule's own matcher.
Where it is enforced
packages/lint/src/lint-startup-registry-verdict.corpus.test.tssweepspackages/**on everypnpm testin the package, behind a shrink-only, hand-editedLEDGER(empty; no--updateflag, deliberately) with a companion staleness check. It refuses two false greens: a corpus that was never fully read (the root is resolved up front, the walk carries nocatch, and a floor is pinned on the file count) and a rule that matches nothing (the non-vacuity case above).This is also the answer to "who consumes an exported rule" — the closure
authoring-rule-wiring.test.tsdraws around Prime Directive #10. The rule is not a(stack) => Finding[]member ofAUTHORING_RULESbecause it does not take a stack; the barrel comment says where its enforcement lives instead of leaving it advertised-only.Deliberate non-coverage
#4769's shape is out of reach for any syntactic rule, and this PR says so in a test rather than in a comment. Its "registry" is the
sys_migrationtable, and "no rows → write the row" is exactly what legitimate first-boot seeding looks like — a rule that flagged it would flag every seeder, and a rule people switch off is worth less than no rule because it also reports success. This is the same admission the CI gate makes; #4769 is in the family because its fix is instructive.Reverse verification (predicted → actual)
sweep can still firecase — same source minus the seal, both ids returnedpackages/**sweep is cleantypescriptload dominates and the walk is free. That is why the sweep could land as a test rather than as anotherscripts/check-*.lazy-deps.test.tspasses — the new module must not eagerly importtypescriptcreateRequireanchor asvalidate-hook-body-writes.tsrule-id-barrel-exports.test.tsfails until both ids are re-exportedindex.tsauthoring-rule-wiring.test.ts's unwired-rule closure would demand a ledger entryVerification
packages/lint— 1680 tests / 67 files, all passing (includes the 24 new rule tests, the 4 corpus-sweep tests, and thelazy-deps/rule-id-barrel-exports/authoring-rule-wiringcontracts).tsc --noEmiton@objectstack/lint— clean.turbo run build --filter='!@objectstack/docs') — 71/71 successful..github/workflows/lint.yml— results in a follow-up comment on this PR.Out of scope, per the ruling
Option C (three-state
ServiceLookup, ~47 plugins) stays shelved: 「C(kernel 三态契约,动 47 插件)挂议不启动,如 B 落地后同形缺陷仍复发再提请。」 The restart condition is a recurrence after B, and this rule plus the corpus ledger is what will make a recurrence visible.🤖 Generated with Claude Code
https://claude.ai/code/session_01WRyk75SSknS2WriyJvA5sC
Generated by Claude Code