Skip to content

Commit f012f55

Browse files
os-zhuangclaude
andauthored
feat(lint): startup open-vocabulary verdicts enter the lint vocabulary (#4776) (#6791)
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. Claude-Session: https://claude.ai/code/session_01WRyk75SSknS2WriyJvA5sC Co-authored-by: Claude <noreply@anthropic.com>
1 parent de43f94 commit f012f55

5 files changed

Lines changed: 1341 additions & 0 deletions

File tree

.changeset/olive-donkeys-tickle.md

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
---
2+
'@objectstack/lint': minor
3+
---
4+
5+
Add the startup open-vocabulary verdict rule — "not registered YET" and "no provider at all" are the same value, and a verdict recorded from it is never retracted (#4776).
6+
7+
A boot fills its registries incrementally, so asking one "is X there?" while it is still filling is fine — the answer 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 of the shape in three unrelated subsystems (#4769, #4771, #4772).
8+
9+
`findStartupRegistryVerdicts(source, { file })` is a pure decision procedure over plugin source (parsed, never executed, never type-checked). It reports two rule ids:
10+
11+
- `startup-open-vocabulary-verdict` — inside `constructor` / `init` / `start`, a read of a capability vocabulary ADR-0018 keeps runtime-extensible whose conclusion is **recorded** (announced in a `warn`/`error` log, cached in an instance field or module binding, or persisted). All three parts, or it is not a finding — a read-only probe is legal and is not flagged.
12+
- `startup-verdict-assertive-wording` — emitted only at a site the first rule already flagged, when the diagnostic asserts a terminal outcome about a world that has not finished forming ("will fail at execution time", "you need Redis").
13+
14+
Every finding's hint prescribes the three shapes the fixes took: resolve where the value is used (a `kernel:ready` hook or a lazy accessor — `createLazyCacheRateLimitStorage()`, #4772), seal the vocabulary then judge (`AutomationEngine.sealNodeTypeVocabulary()`, #4771), or order the verdict after the mutation it describes (#4769). All three cures are recognised by shape and pass.
15+
16+
Severity is always `warning` — the rule reasons about a boot sequence it cannot execute, so it advises and never gates. The kernel SERVICE-registry half of the same family stays with `pnpm check:startup-registry-verdict`; the rule module states the measured division of labour between the two.

packages/lint/src/index.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,31 @@ export {
3939
} from './validate-null-guards.js';
4040
export type { NullGuardFinding, NullGuardOptions } from './validate-null-guards.js';
4141

42+
// #4776 — "the provider is not registered YET" and "there is no provider" are
43+
// the same value in a registry that is still filling, and a verdict recorded
44+
// from that value is never retracted. Exported as a decision procedure over
45+
// plugin SOURCE (not over a stack), for the same reason the null-guard one
46+
// above is: cloud graph-lint, the AI authoring path and a plugin author outside
47+
// this repo must reach ONE verdict rather than re-derive it. In-repo
48+
// enforcement is `lint-startup-registry-verdict.corpus.test.ts`, which sweeps
49+
// `packages/**` with it; the SERVICE-registry half of the same family stays
50+
// with `pnpm check:startup-registry-verdict` (see the module note for the
51+
// measured division of labour between the two).
52+
export {
53+
findStartupRegistryVerdicts,
54+
OPEN_VOCABULARY_PROBES,
55+
PRE_SEAL_PHASES,
56+
SEAL_MARKERS,
57+
STARTUP_VERDICT_HINT,
58+
STARTUP_OPEN_VOCABULARY_VERDICT,
59+
STARTUP_VERDICT_ASSERTIVE_WORDING,
60+
} from './lint-startup-registry-verdict.js';
61+
export type {
62+
StartupRegistryVerdictFinding,
63+
StartupRegistryVerdictOptions,
64+
StartupRegistryVerdictSeverity,
65+
} from './lint-startup-registry-verdict.js';
66+
4267
export { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js';
4368

4469
// [ADR-0078] The functional-completeness gate. All judgement lives in the shared
Lines changed: 148 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,148 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
//
3+
// #4776 — the vocabulary GATE. The rule next door is a pure decision procedure
4+
// that ships to any caller with source in hand; this file is the one that makes
5+
// it bite in THIS repo, by running it over every `.ts` under `packages/`.
6+
//
7+
// Why the enforcement lives in a test rather than in another `scripts/check-*`:
8+
// the shape it looks for is a lint-layer vocabulary, and `@objectstack/lint` is
9+
// where the vocabulary lives. A rule on the package's public export surface
10+
// reads — to a human and to an AI author alike — as a check the platform
11+
// performs (Prime Directive #10, and the closure `authoring-rule-wiring.test.ts`
12+
// draws around it). This is that check. `pnpm check:startup-registry-verdict`
13+
// enforces the SERVICE-registry half of the same family and is untouched; see
14+
// the rule module for the measured division of labour between the two.
15+
//
16+
// Two false greens this is built to refuse:
17+
//
18+
// 1. **A corpus that was never read.** An unreadable directory would silently
19+
// shrink the sweep while the file count stayed comfortably non-zero, and the
20+
// test would report a clean audit over source it never opened — the exact
21+
// shape the rule itself is about, turned on the rule (#4930). So the root is
22+
// resolved up front and the walk carries no `catch`.
23+
// 2. **A rule that matches nothing.** A ratchet that has only ever been green
24+
// cannot be told apart from a dead one (#4690), and this one has been green
25+
// from its first commit. `the sweep can still fire` therefore pushes a
26+
// known-bad source through the SAME sweep function the corpus goes through.
27+
import { readdirSync, readFileSync, statSync } from 'node:fs';
28+
import { dirname, join, relative } from 'node:path';
29+
import { fileURLToPath } from 'node:url';
30+
import { describe, expect, it } from 'vitest';
31+
32+
import {
33+
findStartupRegistryVerdicts,
34+
type StartupRegistryVerdictFinding,
35+
} from './lint-startup-registry-verdict.js';
36+
37+
const srcDir = dirname(fileURLToPath(import.meta.url));
38+
const repoRoot = join(srcDir, '..', '..', '..');
39+
const packagesDir = join(repoRoot, 'packages');
40+
41+
/**
42+
* Reviewed exceptions. **Shrink-only**, hand-edited: an entry must name WHY the
43+
* site is still here and WHAT closes it. There is deliberately no generator — a
44+
* `--update` flag lets a new violation be admitted by "just run the update
45+
* command", which is how a ratchet stops meaning anything.
46+
*
47+
* Empty on purpose: the three instances this vocabulary was written from
48+
* (#4769 / #4771 / #4772) were all fixed before it landed. The non-vacuity proof
49+
* is the `the sweep can still fire` case below, not the emptiness of this list.
50+
*/
51+
const LEDGER: Readonly<Record<string, string>> = {};
52+
53+
const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.turbo', 'coverage', '.cache', '.next']);
54+
55+
/**
56+
* Every auditable `.ts` under `dir`.
57+
*
58+
* No `catch`: an error during the walk means the corpus was only partly read,
59+
* which must not be reported as a clean audit.
60+
*/
61+
function collectSourceFiles(dir: string, out: string[] = []): string[] {
62+
for (const entry of readdirSync(dir)) {
63+
if (SKIP_DIRS.has(entry)) continue;
64+
const full = join(dir, entry);
65+
if (statSync(full).isDirectory()) collectSourceFiles(full, out);
66+
else if (
67+
entry.endsWith('.ts') &&
68+
!entry.endsWith('.d.ts') &&
69+
!entry.includes('.test.') &&
70+
!entry.includes('.spec.') &&
71+
!entry.includes('.conformance.')
72+
) {
73+
out.push(full);
74+
}
75+
}
76+
return out;
77+
}
78+
79+
/** The sweep, as one function, so the corpus and the non-vacuity case share it. */
80+
function sweep(sources: Array<{ file: string; source: string }>): StartupRegistryVerdictFinding[] {
81+
return sources.flatMap(({ file, source }) => findStartupRegistryVerdicts(source, { file }));
82+
}
83+
84+
describe('startup open-vocabulary verdicts across packages/ (#4776)', () => {
85+
const stat = statSync(packagesDir);
86+
expect(stat.isDirectory(), `${packagesDir} must be a directory — the sweep's verdict is drawn from reading it`).toBe(
87+
true,
88+
);
89+
const files = collectSourceFiles(packagesDir);
90+
91+
it('reads a non-empty corpus', () => {
92+
// A zero-file sweep returns zero findings and would otherwise print as a
93+
// clean audit over nothing at all.
94+
expect(files.length).toBeGreaterThan(500);
95+
});
96+
97+
it('no package records a verdict the boot can still contradict', () => {
98+
const findings = sweep(
99+
files.map((file) => ({ file: relative(repoRoot, file), source: readFileSync(file, 'utf8') })),
100+
);
101+
const unledgered = findings.filter((f) => !(`${f.path}::${f.rule}` in LEDGER));
102+
103+
expect(
104+
unledgered.map((f) => `[${f.rule}] ${f.path}${f.where}: ${f.message}`),
105+
`${unledgered.length} startup open-vocabulary verdict(s).\n` +
106+
`Each one draws a conclusion from a registry a plugin can still fill during this same boot, and ` +
107+
`records it where nothing retracts it. Fix it (the finding's hint carries the three shapes the ` +
108+
`#4769/#4771/#4772 fixes took), or add an entry to LEDGER in this file WITH the reason it is ` +
109+
`still here and what closes it.`,
110+
).toEqual([]);
111+
});
112+
113+
it('no ledger entry is stale', () => {
114+
// A ledger that outlives its site is a standing permission nobody reviewed.
115+
const findings = sweep(
116+
files.map((file) => ({ file: relative(repoRoot, file), source: readFileSync(file, 'utf8') })),
117+
);
118+
const live = new Set(findings.map((f) => `${f.path}::${f.rule}`));
119+
const stale = Object.keys(LEDGER).filter((key) => !live.has(key));
120+
expect(stale, `stale LEDGER entr(ies) — the site is fixed, delete the line: ${stale.join(', ')}`).toEqual([]);
121+
});
122+
123+
it('the sweep can still fire (#4690 — a green ratchet must be told apart from a dead one)', () => {
124+
// #4771, reconstructed: the flow node-type verdict drawn in start(), 0.8s
125+
// before the executor that answers it was registered. Pushed through the
126+
// SAME sweep the corpus goes through, so a change that broke matching would
127+
// fail here instead of quietly turning the corpus green.
128+
const reconstructed = `
129+
export class AutomationServicePlugin {
130+
name = 'com.objectstack.service-automation';
131+
async init() {}
132+
async start(ctx) {
133+
const known = this.engine.getRegisteredNodeTypes();
134+
for (const flow of this.flows) {
135+
if (!known.includes(flow.type)) {
136+
ctx.logger.warn(\`Flow '\${flow.name}' will fail at execution time.\`);
137+
}
138+
}
139+
}
140+
}
141+
`;
142+
const findings = sweep([{ file: 'packages/services/service-automation/src/plugin.ts', source: reconstructed }]);
143+
expect(findings.map((f) => f.rule)).toEqual([
144+
'startup-open-vocabulary-verdict',
145+
'startup-verdict-assertive-wording',
146+
]);
147+
});
148+
});

0 commit comments

Comments
 (0)