From 798161a969abe3c17670bc83026541432efc7020 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 8 Aug 2026 17:43:49 +0000 Subject: [PATCH] feat(lint): startup open-vocabulary verdicts enter the lint vocabulary (#4776) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01WRyk75SSknS2WriyJvA5sC --- .changeset/olive-donkeys-tickle.md | 16 + packages/lint/src/index.ts | 25 + ...nt-startup-registry-verdict.corpus.test.ts | 148 ++++ .../src/lint-startup-registry-verdict.test.ts | 410 ++++++++++ .../lint/src/lint-startup-registry-verdict.ts | 742 ++++++++++++++++++ 5 files changed, 1341 insertions(+) create mode 100644 .changeset/olive-donkeys-tickle.md create mode 100644 packages/lint/src/lint-startup-registry-verdict.corpus.test.ts create mode 100644 packages/lint/src/lint-startup-registry-verdict.test.ts create mode 100644 packages/lint/src/lint-startup-registry-verdict.ts diff --git a/.changeset/olive-donkeys-tickle.md b/.changeset/olive-donkeys-tickle.md new file mode 100644 index 0000000000..6303bdf048 --- /dev/null +++ b/.changeset/olive-donkeys-tickle.md @@ -0,0 +1,16 @@ +--- +'@objectstack/lint': minor +--- + +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). + +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). + +`findStartupRegistryVerdicts(source, { file })` is a pure decision procedure over plugin source (parsed, never executed, never type-checked). It reports two rule ids: + +- `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. +- `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"). + +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. + +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. diff --git a/packages/lint/src/index.ts b/packages/lint/src/index.ts index 22c96e57f2..f30bd89309 100644 --- a/packages/lint/src/index.ts +++ b/packages/lint/src/index.ts @@ -39,6 +39,31 @@ export { } from './validate-null-guards.js'; export type { NullGuardFinding, NullGuardOptions } from './validate-null-guards.js'; +// #4776 — "the provider is not registered YET" and "there is no provider" are +// the same value in a registry that is still filling, and a verdict recorded +// from that value is never retracted. Exported as a decision procedure over +// plugin SOURCE (not over a stack), for the same reason the null-guard one +// above is: cloud graph-lint, the AI authoring path and a plugin author outside +// this repo must reach ONE verdict rather than re-derive it. In-repo +// enforcement is `lint-startup-registry-verdict.corpus.test.ts`, which sweeps +// `packages/**` with it; the SERVICE-registry half of the same family stays +// with `pnpm check:startup-registry-verdict` (see the module note for the +// measured division of labour between the two). +export { + findStartupRegistryVerdicts, + OPEN_VOCABULARY_PROBES, + PRE_SEAL_PHASES, + SEAL_MARKERS, + STARTUP_VERDICT_HINT, + STARTUP_OPEN_VOCABULARY_VERDICT, + STARTUP_VERDICT_ASSERTIVE_WORDING, +} from './lint-startup-registry-verdict.js'; +export type { + StartupRegistryVerdictFinding, + StartupRegistryVerdictOptions, + StartupRegistryVerdictSeverity, +} from './lint-startup-registry-verdict.js'; + export { validateListViewMode, LIST_VIEW_FILTERS_IN_VIEWS_MODE } from './validate-list-view-mode.js'; // [ADR-0078] The functional-completeness gate. All judgement lives in the shared diff --git a/packages/lint/src/lint-startup-registry-verdict.corpus.test.ts b/packages/lint/src/lint-startup-registry-verdict.corpus.test.ts new file mode 100644 index 0000000000..1840303e56 --- /dev/null +++ b/packages/lint/src/lint-startup-registry-verdict.corpus.test.ts @@ -0,0 +1,148 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #4776 — the vocabulary GATE. The rule next door is a pure decision procedure +// that ships to any caller with source in hand; this file is the one that makes +// it bite in THIS repo, by running it over every `.ts` under `packages/`. +// +// Why the enforcement lives in a test rather than in another `scripts/check-*`: +// the shape it looks for is a lint-layer vocabulary, and `@objectstack/lint` is +// where the vocabulary lives. A rule on the package's public export surface +// reads — to a human and to an AI author alike — as a check the platform +// performs (Prime Directive #10, and the closure `authoring-rule-wiring.test.ts` +// draws around it). This is that check. `pnpm check:startup-registry-verdict` +// enforces the SERVICE-registry half of the same family and is untouched; see +// the rule module for the measured division of labour between the two. +// +// Two false greens this is built to refuse: +// +// 1. **A corpus that was never read.** An unreadable directory would silently +// shrink the sweep while the file count stayed comfortably non-zero, and the +// test would report a clean audit over source it never opened — the exact +// shape the rule itself is about, turned on the rule (#4930). So the root is +// resolved up front and the walk carries no `catch`. +// 2. **A rule that matches nothing.** A ratchet that has only ever been green +// cannot be told apart from a dead one (#4690), and this one has been green +// from its first commit. `the sweep can still fire` therefore pushes a +// known-bad source through the SAME sweep function the corpus goes through. +import { readdirSync, readFileSync, statSync } from 'node:fs'; +import { dirname, join, relative } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { describe, expect, it } from 'vitest'; + +import { + findStartupRegistryVerdicts, + type StartupRegistryVerdictFinding, +} from './lint-startup-registry-verdict.js'; + +const srcDir = dirname(fileURLToPath(import.meta.url)); +const repoRoot = join(srcDir, '..', '..', '..'); +const packagesDir = join(repoRoot, 'packages'); + +/** + * Reviewed exceptions. **Shrink-only**, hand-edited: an entry must name WHY the + * site is still here and WHAT closes it. There is deliberately no generator — a + * `--update` flag lets a new violation be admitted by "just run the update + * command", which is how a ratchet stops meaning anything. + * + * Empty on purpose: the three instances this vocabulary was written from + * (#4769 / #4771 / #4772) were all fixed before it landed. The non-vacuity proof + * is the `the sweep can still fire` case below, not the emptiness of this list. + */ +const LEDGER: Readonly> = {}; + +const SKIP_DIRS = new Set(['node_modules', 'dist', 'build', '.git', '.turbo', 'coverage', '.cache', '.next']); + +/** + * Every auditable `.ts` under `dir`. + * + * No `catch`: an error during the walk means the corpus was only partly read, + * which must not be reported as a clean audit. + */ +function collectSourceFiles(dir: string, out: string[] = []): string[] { + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) continue; + const full = join(dir, entry); + if (statSync(full).isDirectory()) collectSourceFiles(full, out); + else if ( + entry.endsWith('.ts') && + !entry.endsWith('.d.ts') && + !entry.includes('.test.') && + !entry.includes('.spec.') && + !entry.includes('.conformance.') + ) { + out.push(full); + } + } + return out; +} + +/** The sweep, as one function, so the corpus and the non-vacuity case share it. */ +function sweep(sources: Array<{ file: string; source: string }>): StartupRegistryVerdictFinding[] { + return sources.flatMap(({ file, source }) => findStartupRegistryVerdicts(source, { file })); +} + +describe('startup open-vocabulary verdicts across packages/ (#4776)', () => { + const stat = statSync(packagesDir); + expect(stat.isDirectory(), `${packagesDir} must be a directory — the sweep's verdict is drawn from reading it`).toBe( + true, + ); + const files = collectSourceFiles(packagesDir); + + it('reads a non-empty corpus', () => { + // A zero-file sweep returns zero findings and would otherwise print as a + // clean audit over nothing at all. + expect(files.length).toBeGreaterThan(500); + }); + + it('no package records a verdict the boot can still contradict', () => { + const findings = sweep( + files.map((file) => ({ file: relative(repoRoot, file), source: readFileSync(file, 'utf8') })), + ); + const unledgered = findings.filter((f) => !(`${f.path}::${f.rule}` in LEDGER)); + + expect( + unledgered.map((f) => `[${f.rule}] ${f.path} — ${f.where}: ${f.message}`), + `${unledgered.length} startup open-vocabulary verdict(s).\n` + + `Each one draws a conclusion from a registry a plugin can still fill during this same boot, and ` + + `records it where nothing retracts it. Fix it (the finding's hint carries the three shapes the ` + + `#4769/#4771/#4772 fixes took), or add an entry to LEDGER in this file WITH the reason it is ` + + `still here and what closes it.`, + ).toEqual([]); + }); + + it('no ledger entry is stale', () => { + // A ledger that outlives its site is a standing permission nobody reviewed. + const findings = sweep( + files.map((file) => ({ file: relative(repoRoot, file), source: readFileSync(file, 'utf8') })), + ); + const live = new Set(findings.map((f) => `${f.path}::${f.rule}`)); + const stale = Object.keys(LEDGER).filter((key) => !live.has(key)); + expect(stale, `stale LEDGER entr(ies) — the site is fixed, delete the line: ${stale.join(', ')}`).toEqual([]); + }); + + it('the sweep can still fire (#4690 — a green ratchet must be told apart from a dead one)', () => { + // #4771, reconstructed: the flow node-type verdict drawn in start(), 0.8s + // before the executor that answers it was registered. Pushed through the + // SAME sweep the corpus goes through, so a change that broke matching would + // fail here instead of quietly turning the corpus green. + const reconstructed = ` + export class AutomationServicePlugin { + name = 'com.objectstack.service-automation'; + async init() {} + async start(ctx) { + const known = this.engine.getRegisteredNodeTypes(); + for (const flow of this.flows) { + if (!known.includes(flow.type)) { + ctx.logger.warn(\`Flow '\${flow.name}' will fail at execution time.\`); + } + } + } + } + `; + const findings = sweep([{ file: 'packages/services/service-automation/src/plugin.ts', source: reconstructed }]); + expect(findings.map((f) => f.rule)).toEqual([ + 'startup-open-vocabulary-verdict', + 'startup-verdict-assertive-wording', + ]); + }); +}); diff --git a/packages/lint/src/lint-startup-registry-verdict.test.ts b/packages/lint/src/lint-startup-registry-verdict.test.ts new file mode 100644 index 0000000000..ed82f371b7 --- /dev/null +++ b/packages/lint/src/lint-startup-registry-verdict.test.ts @@ -0,0 +1,410 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// #4776 — the startup "provider not registered YET" vs "no provider at all" +// misdiagnosis, as a lint vocabulary. +// +// The fixtures are organised as the maintainer's ruling asked: the three +// same-shape defects one showcase cold start produced (#4769 / #4771 / #4772) +// are reconstructed as POSITIVE fixtures where this rule's declared vocabulary +// reaches them, the three shapes their fixes took are NEGATIVE fixtures, and the +// two shapes this rule deliberately does NOT reach have their own tests so the +// boundary is asserted rather than assumed. A rule whose limits are only in its +// prose is a rule whose limits move. +import { describe, expect, it } from 'vitest'; + +import { + findStartupRegistryVerdicts, + OPEN_VOCABULARY_PROBES, + PRE_SEAL_PHASES, + STARTUP_OPEN_VOCABULARY_VERDICT, + STARTUP_VERDICT_ASSERTIVE_WORDING, + STARTUP_VERDICT_HINT, +} from './lint-startup-registry-verdict.js'; + +const ruleIds = (source: string) => findStartupRegistryVerdicts(source, { file: 'plugin.ts' }).map((f) => f.rule); + +// ── Positive fixtures: the defects, reconstructed ──────────────────────────── + +describe('the #4771 shape — a flow node-type verdict drawn while the vocabulary is still filling', () => { + // The original: AutomationServicePlugin.start() pulled flows and judged their + // node types against the executor registry ~0.8s before ApprovalsServicePlugin + // registered the `approval` executor. Eight ADR-0019 approval flows were + // reported as "will fail at execution time"; all eight were false. + const source = ` + export class AutomationServicePlugin { + name = 'com.objectstack.service-automation'; + async init(ctx) { this.engine = new AutomationEngine(); } + async start(ctx) { + const known = this.engine.getRegisteredNodeTypes(); + for (const flow of await this.pullFlows()) { + const unknown = flow.nodes.filter((n) => !known.includes(n.type)); + if (unknown.length > 0) { + this.logger.warn( + \`Flow '\${flow.name}' references node type(s) with no registered executor — these nodes will fail at execution time with NO_EXECUTOR.\`, + ); + } + } + } + } + `; + + it('flags the recorded verdict, naming the rule and the two worlds it cannot tell apart', () => { + const findings = findStartupRegistryVerdicts(source, { file: 'plugin.ts' }); + const structural = findings.filter((f) => f.rule === STARTUP_OPEN_VOCABULARY_VERDICT); + expect(structural).toHaveLength(1); + expect(structural[0].severity).toBe('warning'); + expect(structural[0].where).toBe('AutomationServicePlugin.start()'); + expect(structural[0].message).toContain('`getRegisteredNodeTypes()`'); + expect(structural[0].message).toContain('announced: warn log'); + expect(structural[0].message).toContain('ADR-0018'); + expect(structural[0].message).toContain('no provider in this deployment, or a provider that registers later'); + }); + + it('flags the WORDING too — the half the CI gate never reads', () => { + const findings = findStartupRegistryVerdicts(source, { file: 'plugin.ts' }); + const wording = findings.filter((f) => f.rule === STARTUP_VERDICT_ASSERTIVE_WORDING); + expect(wording).toHaveLength(1); + expect(wording[0].message).toContain('"will fail"'); + expect(wording[0].message).toContain('#4771'); + expect(wording[0].message).toContain('the identical eight'); + }); + + it('every finding prescribes all three sanctioned cures, by the change that shipped each', () => { + for (const finding of findStartupRegistryVerdicts(source, { file: 'plugin.ts' })) { + expect(finding.hint).toBe(STARTUP_VERDICT_HINT); + expect(finding.hint).toContain('createLazyCacheRateLimitStorage()'); + expect(finding.hint).toContain('sealNodeTypeVocabulary()'); + expect(finding.hint).toContain('#4769'); + expect(finding.hint).toContain('#4771'); + expect(finding.hint).toContain('#4772'); + } + }); + + it("the hint makes no assertive claim of its own — the mistake it exists to remove", () => { + // A hint that said "this WILL misreport" would be the same defect wearing + // the rule's badge: the rule cannot execute the boot it reasons about. + expect(STARTUP_VERDICT_HINT.toLowerCase()).not.toMatch(/\bthis will fail\b/); + expect(STARTUP_VERDICT_HINT).toContain('keep the two worlds apart in the wording'); + }); +}); + +describe('the #4771 shape seen from a CONSUMER package — where the CI gate cannot look', () => { + // check:startup-registry-verdict's Rule B keys on the OWNING class's + // `this.nodeExecutors` / `this.actionDescriptors` properties, so a caller in + // another package reaching the same vocabulary through its public accessor is + // invisible to it. This rule matches the accessor, so it is not. + it('flags a cached verdict taken through the public accessor', () => { + const source = ` + export class ReportingPlugin { + name = 'com.acme.reporting'; + async init(ctx) { + const automation = ctx.getService('automation'); + const descriptors = automation.getActionDescriptors(); + this.canRenderApprovals = descriptors.some((d) => d.type === 'approval'); + } + async start() {} + } + `; + const findings = findStartupRegistryVerdicts(source, { file: 'reporting-plugin.ts' }); + expect(findings.map((f) => f.rule)).toEqual([STARTUP_OPEN_VOCABULARY_VERDICT]); + expect(findings[0].message).toContain('cached: this.canRenderApprovals'); + expect(findings[0].where).toBe('ReportingPlugin.init()'); + }); +}); + +describe("the #4772 shape — a misdirecting remedy, transposed onto a vocabulary this rule reaches", () => { + // #4772's own registry (the kernel SERVICE registry) belongs to + // check:startup-registry-verdict — see the boundary test below. What travels + // to this rule is its WORDING failure: the remedy told operators to provision + // Redis for a problem they did not have, and following it changed nothing. + it('flags both the record and the remedy that cannot be acted on', () => { + const source = ` + export class RateLimitPlugin { + name = 'com.acme.ratelimit'; + async init() {} + async start(ctx) { + const connectors = ctx.registry.listConnectors(); + if (!connectors.includes('redis')) { + ctx.logger.warn('No shared store connector is registered — you need Redis for multi-node rate limiting.'); + } + } + } + `; + expect(ruleIds(source)).toEqual([STARTUP_OPEN_VOCABULARY_VERDICT, STARTUP_VERDICT_ASSERTIVE_WORDING]); + }); +}); + +describe('the #4769 shape — a verdict PERSISTED during the boot that contradicts it', () => { + it('flags a durable write of an open-vocabulary verdict', () => { + const source = ` + export class AttestationPlugin { + name = 'com.acme.attestation'; + async init(ctx) { + const tools = ctx.ai.getRegisteredTools(); + await ctx.api.object('sys_attestation').insert({ toolCount: tools.length, verdict: 'complete' }); + } + async start() {} + } + `; + const findings = findStartupRegistryVerdicts(source, { file: 'attestation-plugin.ts' }); + expect(findings.map((f) => f.rule)).toEqual([STARTUP_OPEN_VOCABULARY_VERDICT]); + expect(findings[0].message).toContain('persisted: insert()'); + }); +}); + +// ── Negative fixtures: the three shapes the fixes took ─────────────────────── + +describe('the three sanctioned cures pass', () => { + it('cure 1 — deferral to kernel:ready (#4771 / #4772)', () => { + // The verdict moves into a callback that runs once the registry is complete. + const source = ` + export class AutomationServicePlugin { + name = 'com.objectstack.service-automation'; + async init() {} + async start(ctx) { + ctx.hook('kernel:bootstrapped', () => { + const known = this.engine.getRegisteredNodeTypes(); + for (const flow of this.flows) { + if (!known.includes(flow.type)) this.logger.warn('unknown node type'); + } + }); + } + } + `; + expect(findStartupRegistryVerdicts(source, { file: 'plugin.ts' })).toEqual([]); + }); + + it('cure 1 — lazy re-resolution, the createLazyCacheRateLimitStorage() shape (#4772)', () => { + // The read happens inside the accessor that is HANDED OUT, so every call + // resolves against the registry as it stands then, not as it stood at start. + const source = ` + export class ToolingPlugin { + name = 'com.acme.tooling'; + async init(ctx) { + this.resolveTools = () => { + const tools = ctx.ai.getRegisteredTools(); + if (tools.length === 0) ctx.logger.warn('no tools registered'); + return tools; + }; + } + async start() {} + } + `; + expect(findStartupRegistryVerdicts(source, { file: 'plugin.ts' })).toEqual([]); + }); + + it('cure 2 — seal the vocabulary, then judge (#4771, AutomationEngine.sealNodeTypeVocabulary)', () => { + const source = ` + export class AutomationServicePlugin { + name = 'com.objectstack.service-automation'; + async init() {} + async start(ctx) { + if (!this.nodeTypeVocabularySealed) return; + const known = this.engine.getRegisteredNodeTypes(); + for (const flow of this.flows) { + if (!known.includes(flow.type)) this.logger.warn('no registered executor or descriptor'); + } + } + } + `; + expect(findStartupRegistryVerdicts(source, { file: 'plugin.ts' })).toEqual([]); + }); + + it('cure 3 — the verdict is ordered after the mutation it describes (#4769)', () => { + // The attestation is written from a `kernel:ready` handler, after the seed + // that would contradict it. Same mechanism as cure 1; named separately + // because it is the shape #4769's fix actually took. + const source = ` + export class AttestationPlugin { + name = 'com.acme.attestation'; + async init(ctx) { + ctx.hook('kernel:ready', async () => { + const tools = ctx.ai.getRegisteredTools(); + await ctx.api.object('sys_attestation').insert({ toolCount: tools.length }); + }); + } + async start() {} + } + `; + expect(findStartupRegistryVerdicts(source, { file: 'plugin.ts' })).toEqual([]); + }); +}); + +describe('legal shapes stay legal', () => { + it('a read-only probe records nothing and is correct (getUnknownNodeTypeAudit)', () => { + const source = ` + export class AutomationServicePlugin { + name = 'com.objectstack.service-automation'; + async init() {} + async start(ctx) { + const audit = this.engine.getUnknownNodeTypeAudit(); + return audit; + } + } + `; + expect(findStartupRegistryVerdicts(source, { file: 'plugin.ts' })).toEqual([]); + }); + + it('an info/debug narration is not a verdict', () => { + const source = ` + export class AutomationServicePlugin { + name = 'com.objectstack.service-automation'; + async init() {} + async start(ctx) { + const known = this.engine.getRegisteredNodeTypes(); + ctx.logger.debug(\`node types so far: \${known.length}\`); + } + } + `; + expect(findStartupRegistryVerdicts(source, { file: 'plugin.ts' })).toEqual([]); + }); + + it('a keyed lookup that dispatches work is how the runtime resolves, not a verdict', () => { + const source = ` + export class AutomationServicePlugin { + name = 'com.objectstack.service-automation'; + async init() {} + async start(ctx) { + const executor = this.nodeExecutors.get('approval'); + if (!executor) this.logger.warn('no approval executor'); + } + } + `; + expect(findStartupRegistryVerdicts(source, { file: 'plugin.ts' })).toEqual([]); + }); + + it('a post-boot method is judged against a complete vocabulary and is not this rule\'s business', () => { + const source = ` + export class AutomationServicePlugin { + name = 'com.objectstack.service-automation'; + async init() {} + async start() {} + registerFlow(flow) { + const known = this.engine.getRegisteredNodeTypes(); + if (!known.includes(flow.type)) this.logger.warn('unknown node type — it will fail at execution time'); + } + } + `; + expect(findStartupRegistryVerdicts(source, { file: 'plugin.ts' })).toEqual([]); + }); + + it('a class with no plugin lifecycle is not on the boot path this rule reasons about', () => { + const source = ` + export class FlowInspector { + constructor(engine) { + const known = engine.getRegisteredNodeTypes(); + this.known = known; + console.warn('inspector built with ' + known.length + ' types — unknown ones will fail'); + } + } + `; + expect(findStartupRegistryVerdicts(source, { file: 'inspector.ts' })).toEqual([]); + }); + + it('an options bag with an `init` but no `name` is not a plugin', () => { + const source = ` + export const options = { + init: (ctx) => { + const tools = ctx.ai.getRegisteredTools(); + if (!tools.length) ctx.logger.warn('you need the tools plugin'); + }, + }; + `; + expect(findStartupRegistryVerdicts(source, { file: 'options.ts' })).toEqual([]); + }); + + it('a hedged diagnostic keeps the two worlds apart, so only the record is reported', () => { + const source = ` + export class AutomationServicePlugin { + name = 'com.objectstack.service-automation'; + async init() {} + async start(ctx) { + const known = this.engine.getRegisteredNodeTypes(); + if (!known.includes('approval')) { + ctx.logger.warn('no executor for node type approval has been registered yet (as of plugin start) — a plugin may still contribute one.'); + } + } + } + `; + expect(ruleIds(source)).toEqual([STARTUP_OPEN_VOCABULARY_VERDICT]); + }); +}); + +// ── The declared boundary, asserted rather than assumed ────────────────────── + +describe('what this rule delegates, and to whom', () => { + it('the #4772 service-registry probe is left to check:startup-registry-verdict', () => { + // Not a miss — a division of labour. That shape needs the ADR-0116 + // plugin-manifest model (`dependencies` / `requiresServices` make "absent" a + // FACT), which the CI gate already carries. Answering one question in two + // vocabularies is how vocabularies rot (#5841). + const source = ` + export class AuthPlugin { + name = 'com.objectstack.plugin-auth'; + async init(ctx) { + const cache = await ctx.getServiceAsync('cache'); + if (!cache) ctx.logger.warn('no cache service — you need Redis for multi-node rate limiting'); + this.effectiveSecondaryStorage = cache; + } + async start() {} + } + `; + expect(findStartupRegistryVerdicts(source, { file: 'auth-plugin.ts' })).toEqual([]); + }); + + it('#4769\'s datastore attestation is out of reach for any syntactic rule', () => { + // "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. Stated here so the limit cannot quietly become a claim. + const source = ` + export class MigrationPlugin { + name = 'com.objectstack.migrations'; + async init(ctx) { + const rows = await ctx.api.object('sys_migration').find({}); + if (rows.length === 0) { + await ctx.api.object('sys_migration').insert({ attested: 'datastore-created-empty' }); + } + } + async start() {} + } + `; + expect(findStartupRegistryVerdicts(source, { file: 'migration-plugin.ts' })).toEqual([]); + }); +}); + +// ── The vocabulary itself ──────────────────────────────────────────────────── + +describe('the vocabulary is data, and every entry earns its place', () => { + it('every probe explains WHY its answer is not final', () => { + for (const [probe, note] of OPEN_VOCABULARY_PROBES) { + expect(note.length, `${probe} has no note`).toBeGreaterThan(40); + // The note is the half the author reads; a rule id explains nothing. + expect(note, `${probe}'s note must name what can still change`).toMatch( + /plugin|package|boot|registered|published|contributed/i, + ); + } + }); + + it('every pre-seal phase explains what is still open in it', () => { + expect([...PRE_SEAL_PHASES.keys()]).toEqual(['constructor', 'init', 'start']); + for (const [phase, note] of PRE_SEAL_PHASES) { + expect(note.length, `${phase} has no note`).toBeGreaterThan(40); + } + }); + + it('`start` is covered — the phase the CI gate does not enter', () => { + // The measured complement (2026-08-08): check:startup-registry-verdict + // reported 40 seams across 1501 files under packages/, 0 of them in a + // start(). Its Rule A stops at init() on the sound reasoning that every + // init() has completed by then — true of the SERVICE registry, false of an + // ADR-0018 vocabulary a sibling plugin fills from its own start(). + expect(PRE_SEAL_PHASES.has('start')).toBe(true); + }); + + it('an empty or unparseable source is not a verdict about anyone', () => { + expect(findStartupRegistryVerdicts('')).toEqual([]); + expect(findStartupRegistryVerdicts(' \n ')).toEqual([]); + expect(findStartupRegistryVerdicts('class { { { getRegisteredNodeTypes(')).toEqual([]); + }); +}); diff --git a/packages/lint/src/lint-startup-registry-verdict.ts b/packages/lint/src/lint-startup-registry-verdict.ts new file mode 100644 index 0000000000..b05823c00d --- /dev/null +++ b/packages/lint/src/lint-startup-registry-verdict.ts @@ -0,0 +1,742 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * Startup **open-vocabulary** verdicts — the authoring-side half of #4776. + * + * ## The pattern this names + * + * A boot fills its registries incrementally. 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. + * The registry has one value for two different worlds: + * + * ```ts + * const types = engine.getRegisteredNodeTypes(); // does not contain 'approval' + * // (a) no plugin in this deployment provides it → the conclusion holds + * // (b) ApprovalsServicePlugin registers it in 0.8s → the conclusion is false + * ``` + * + * One showcase cold start on 2026-08-03 produced three instances of the shape + * in three unrelated subsystems, written by three people at three times: + * **#4771** (flow node types judged 0.8s before the `approval` executor + * registered, phrased as "will fail at execution time"), **#4772** (an + * `undefined` cache handle frozen into auth config, with a warning telling + * operators to provision Redis for a problem they did not have) and **#4769** + * (an ADR-0104 attestation written during the same boot that was still seeding + * rows contradicting it). All three are fixed; this rule exists so the fourth + * one is caught where it is WRITTEN. + * + * ## Its relationship to `pnpm check:startup-registry-verdict` (#4777 / PR #4833) + * + * That gate is the repo's CI enforcement of the same family, and this rule does + * **not** restate it. Their division of labour, measured rather than assumed — + * on 2026-08-08 the gate reported **40 seams across 1501 files under + * `packages/`, of which 0 were in a `start()`**: + * + * | | `check:startup-registry-verdict` | this rule | + * |:--|:--|:--| + * | corpus | `packages/**` of THIS repo, via a `scripts/*.mjs` that is never published | any source string a caller hands it — a plugin in a user's app, an AI-authored extension, cloud graph-lint | + * | phases | `constructor` + `init` (Rule A); the registry's OWNING class (Rule B) | `constructor` + `init` + **`start`**, from any consumer | + * | registry | the kernel SERVICE registry, keyed on a literal service name; two open registries named by their `this.` | the **open capability vocabularies** ADR-0018 keeps runtime-extensible, matched by ACCESSOR name so a consumer package is visible | + * | evidence | the shape of the record | the shape of the record **and the wording of the diagnostic** | + * + * The service-registry half (`getService('cache')` in `init()`, cleared by an + * ADR-0116 `dependencies`/`requiresServices` declaration) is deliberately left + * to the gate and is NOT re-implemented here. It needs the plugin-manifest model + * the gate already carries, and answering one question in two vocabularies is + * how vocabularies rot (#5841). What this rule adds is the region the gate + * declares out of reach: the phase it does not enter, the consumer packages + * Rule B cannot see, and the population — plugin authors outside this repo — + * that a `scripts/` file never reaches at all. + * + * ## What it checks + * + * Inside a plugin lifecycle phase that runs BEFORE the vocabulary can be sealed + * (`constructor` / `init` / `start` — see {@link PRE_SEAL_PHASES}), all three of: + * + * 1. a read of an **open** capability vocabulary ({@link OPEN_VOCABULARY_PROBES}) — + * one a plugin may still contribute to from its own `init()`/`start()`; + * 2. a terminal conclusion drawn from what is (not) in it; + * 3. that conclusion **recorded** — announced in a `warn`/`error`/`fatal` log, + * cached in an instance field or module binding, or persisted. + * + * All three, or it is not a finding. A read-only probe is completely legal and + * must not be flagged: `AutomationEngine.getUnknownNodeTypeAudit()` reads the + * executor registry on every call, records nothing, and is correct. + * + * When a site is flagged, its diagnostic TEXT is judged too + * ({@link STARTUP_VERDICT_ASSERTIVE_WORDING}): a message that asserts a terminal + * outcome about a world that has not finished forming ("will fail at execution + * time", "you need Redis") is the half of #4771/#4772 that hurt operators most, + * and the gate never reads message text. That second finding is emitted ONLY at + * a site rule 1 already flagged, so it can add no false positive of its own. + * + * ## The escapes — the three sanctioned cures, recognised by shape + * + * - **Deferral.** Nested function bodies are never descended into. A callback + * registered during the phase (`ctx.hook('kernel:ready', …)`, `on(...)`, a + * lazily-resolved closure) runs when the registry IS complete — that is what + * #4771 and #4772 were fixed INTO, so treating it as a violation would flag + * the cure. Same choice, same reason, as the CI gate. + * - **Lazy resolution.** Same mechanism: `createLazyCacheRateLimitStorage()` + * (plugin-auth, #4772's fix) resolves inside the accessor it returns. + * - **Seal, then judge.** A scope that mentions the vocabulary's seal + * ({@link SEAL_MARKERS}) has asked the host whether the world is closed; + * `AutomationEngine.sealNodeTypeVocabulary()` (#4771's fix) is the reference. + * + * ## What it cannot see (stated up front, not discovered later) + * + * This is a declared vocabulary over syntax. Its reach is exactly as wide as the + * vocabulary and no wider: + * + * 1. `engine.getRegisteredNodeTypes()` is visible; a `resolveTypesOrDefault()` + * three layers down another package is not. Helper following is same-file + * and two levels deep. + * 2. **#4769 is out of reach, deliberately.** Its "registry" is the + * `sys_migration` table, and the write that recorded the verdict is + * syntactically indistinguishable from ordinary first-boot seeding ("no + * rows → insert the defaults"), which is legitimate and common. A rule that + * flagged it would flag every seeder. It is a fixture for the family + * because its FIX is instructive, not because a syntactic rule finds it — + * the same admission the CI gate makes. + * 3. A vocabulary read through a name not in {@link OPEN_VOCABULARY_PROBES} is + * skipped rather than guessed. Found a new open registry? Add its accessor + * here, in the same change that fixes the site. + * + * Widening any of these trades a miss for a false positive, and false positives + * kill an advisory rule faster than misses do. + * + * ## Severity + * + * Always `warning`. The rule reasons about a boot sequence it cannot execute, so + * it advises where it is confident and never gates — the same posture as + * `lintUnknownAuthoringKeys` (#3786) and `validateHookBodyWrites` (#4271). + */ + +import { createRequire } from 'node:module'; +import type ts from 'typescript'; + +// The TypeScript compiler must NOT be imported at module top level: it is ~9 MB +// of CJS and @objectstack/lint sits on the kernel boot path, while this rule +// only parses when a caller actually hands it source. Same lazy-load contract as +// validate-hook-body-writes.ts / validate-react-page-props.ts, guarded by +// lazy-deps.test.ts. +// +// `node:module` is a Node builtin, untouched by esbuild/tsup, so the static +// `createRequire` import survives bundling; the `createRequire(...)` call is +// deferred because `import.meta.url` is rewritten to an empty stub in the CJS +// build. +let cachedTs: typeof ts | null = null; +function loadTypeScript(): typeof ts { + if (cachedTs) return cachedTs; + const anchor = + typeof import.meta !== 'undefined' && import.meta.url + ? import.meta.url + : typeof __filename !== 'undefined' + ? __filename + : process.cwd() + '/'; + try { + cachedTs = createRequire(anchor)('typescript') as typeof ts; + } catch (err) { + throw new Error( + `@objectstack/lint: checking plugin source for startup registry verdicts requires the "typescript" ` + + `package, which could not be loaded (${err instanceof Error ? err.message : String(err)}). It is a ` + + `declared dependency of @objectstack/lint — if this deployment prunes packages, keep "typescript" in ` + + `the image; it is only loaded when plugin source is actually checked.`, + ); + } + return cachedTs; +} + +// ── Rule ids ───────────────────────────────────────────────────────────────── + +/** + * A verdict about an open capability vocabulary, recorded during a phase in + * which a plugin can still contribute to it. + */ +export const STARTUP_OPEN_VOCABULARY_VERDICT = 'startup-open-vocabulary-verdict'; + +/** + * The diagnostic at such a site asserts a terminal outcome about a world that + * has not finished forming. Emitted only where + * {@link STARTUP_OPEN_VOCABULARY_VERDICT} already fired. + */ +export const STARTUP_VERDICT_ASSERTIVE_WORDING = 'startup-verdict-assertive-wording'; + +// ── The vocabulary (词表) ──────────────────────────────────────────────────── + +/** + * Accessors that read a capability vocabulary a plugin can still extend. + * + * Each entry names WHY the answer is not final; the note travels into the + * finding so the author reads the consequence rather than a rule id. + * + * Matched on the accessor NAME rather than on a `this.` property, + * which is what lets a consumer in another package be seen at all — the CI + * gate's Rule B is keyed on the two property names the owning class uses, so a + * caller that reaches the same vocabulary through its public accessor is + * invisible to it. Keyed lookups that ask about ONE item and dispatch on the + * answer (`registry.get(type)`, `registry.has(type)`) are deliberately absent: + * that is how the runtime legitimately resolves work, and reading it as a + * startup verdict is exactly the false-positive class that gets a rule switched + * off. + */ +export const OPEN_VOCABULARY_PROBES: ReadonlyMap = new Map([ + [ + 'getRegisteredNodeTypes', + 'ADR-0018 keeps the flow node-type vocabulary open and runtime-extensible — a plugin registers its executor from its own init()/start(), which can be after this line (#4771)', + ], + [ + 'knownNodeTypes', + 'ADR-0018 keeps the flow node-type vocabulary open and runtime-extensible — a plugin registers its executor from its own init()/start(), which can be after this line (#4771)', + ], + [ + 'getUnknownNodeTypeAudit', + 'the unknown-node-type audit is a snapshot of an OPEN vocabulary a plugin can still extend during boot; it is read-only by design and answers "unknown as of now", never "unknown for this deployment" (#4771)', + ], + [ + 'getActionDescriptors', + 'ADR-0018 action descriptors are published by plugins during boot, so a type missing from them here means "not published YET" (#4771)', + ], + [ + 'getRegisteredExecutors', + 'executors are contributed by plugins during boot — an executor missing here may simply be a plugin that has not started', + ], + [ + 'listExecutors', + 'executors are contributed by plugins during boot — an executor missing here may simply be a plugin that has not started', + ], + [ + 'getRegisteredTools', + 'AI tools are contributed by plugins during boot, so the registered set is not final until every plugin has started', + ], + [ + 'listConnectors', + 'connectors are contributed by plugins during boot (StackSchema.connectors documents provider-bound instances registered by plugins), so the list is not final until every plugin has started', + ], + [ + 'getRegisteredConnectors', + 'connectors are contributed by plugins during boot, so the registered set is not final until every plugin has started', + ], + [ + 'listCapabilities', + 'ADR-0066 capabilities are declared by every installed package, so the set is not final until every plugin has contributed its declarations', + ], + [ + 'getRegisteredCapabilities', + 'ADR-0066 capabilities are declared by every installed package, so the set is not final until every plugin has contributed its declarations', + ], + [ + 'listProviders', + 'providers are contributed by plugins during boot — a provider missing here may simply be a plugin that has not started', + ], + [ + 'getRegisteredProviders', + 'providers are contributed by plugins during boot — a provider missing here may simply be a plugin that has not started', + ], +]); + +/** + * Lifecycle phases that run BEFORE the vocabulary can be considered closed, with + * the reason each one is still open. + * + * `start` is the phase this rule exists for. The CI gate stops at `init`, on the + * sound reasoning that every plugin's `init()` has completed by then — true of + * the SERVICE registry, and false of an open capability vocabulary: ADR-0018 + * executors are registered from `start()` too, and sibling plugins' `start()` + * have not all run. That is literally where #4771 sat. + */ +export const PRE_SEAL_PHASES: ReadonlyMap = new Map([ + ['constructor', 'the constructor runs at composition time — before ANY plugin has been initialized'], + ['init', "another plugin's init() may not have run yet (ADR-0116), so the vocabulary is still filling"], + [ + 'start', + "sibling plugins' start() have not all run, and ADR-0018 lets a plugin register its contributions from start() — the vocabulary is still filling", + ], +]); + +/** + * Identifier fragments whose presence in the scope means the code ASKED whether + * the vocabulary is closed before judging it — the third sanctioned cure. + * Matched as a case-insensitive substring of an identifier so + * `nodeTypeVocabularySealed`, `sealNodeTypeVocabulary` and a host's own + * `vocabularySeal` all count. + */ +export const SEAL_MARKERS: readonly string[] = ['seal']; + +/** Log levels at which a conclusion is ANNOUNCED rather than narrated. */ +const VERDICT_LEVELS = new Set(['warn', 'error', 'fatal']); + +/** Every level the logger-shape matcher recognises (so `info`/`debug` are seen and then ignored). */ +const ALL_LEVELS = new Set(['warn', 'error', 'fatal', 'info', 'debug', 'trace', 'log']); + +/** Calls that write a verdict somewhere that survives the process. */ +const PERSISTENCE_CALLEES = new Set(['insert', 'insertOne', 'update', 'updateOne', 'upsert', 'save', 'saveMetaItem']); + +/** + * Phrases that assert a TERMINAL outcome about a world that has not finished + * forming. Each is the wording of a real regression: + * + * - "will fail at execution time" — #4771's eight false alarms per cold boot; + * - "nothing will register" / "no plugin provides" — the same claim, restated; + * - "you need" / "must install" / "must be provisioned" — #4772's misdirecting + * remedy: an operator who followed it connected Redis and got the identical + * warning. + * + * The honest wordings are the ones that keep the two worlds apart — "not + * registered yet", "as of this point in the boot", "if no plugin contributes it". + */ +const ASSERTIVE_PHRASES: readonly string[] = [ + 'will fail', + 'fails at execution', + 'nothing will register', + 'no plugin provides', + 'is not installed', + 'is not configured', + 'you need', + 'you must install', + 'must be provisioned', +]; + +/** Wordings that keep "not yet" and "not at all" apart — their presence clears the wording finding. */ +const HEDGE_PHRASES: readonly string[] = [ + 'not yet', + 'yet been registered', + 'so far', + 'as of', + 'may still', + 'might still', + 'still be registered', + 'has started', + 'have started', +]; + +// ── Finding shape ──────────────────────────────────────────────────────────── + +export type StartupRegistryVerdictSeverity = 'warning'; + +export interface StartupRegistryVerdictFinding { + /** Always `warning` — this rule advises, it never gates (see module note). */ + severity: StartupRegistryVerdictSeverity; + /** Diagnostic rule id. */ + rule: string; + /** Human-readable location, e.g. `AutomationServicePlugin.start()`. */ + where: string; + /** Source path, e.g. `plugin.ts:412`. */ + path: string; + /** What is wrong. */ + message: string; + /** How to fix it. */ + hint: string; +} + +/** + * The prescription every finding carries. It names the three cures in the order + * AGENTS.md ranks them, and each by the change that actually shipped, so the + * reader can go and read one. + * + * It states no verdict of its own about whether the site is broken at runtime — + * asserting a terminal outcome about a world that has not finished forming is + * the mistake this rule exists to remove, and a hint that did it would be the + * defect wearing the rule's own badge. + */ +export const STARTUP_VERDICT_HINT = + 'Take one of the three shapes the fixes took: ' + + "(1) resolve where the value is USED, not where you start — a lazy accessor or a `kernel:ready`/`kernel:bootstrapped` hook sees a provider that registered later (`createLazyCacheRateLimitStorage()` in plugin-auth, #4772); " + + '(2) seal the vocabulary, then judge — have the host declare the moment it can no longer grow and draw the conclusion there (`AutomationEngine.sealNodeTypeVocabulary()`, called at `kernel:bootstrapped`, #4771); ' + + '(3) order the verdict AFTER the mutation it describes, so it cannot attest to a state this same boot goes on to contradict (the ADR-0104 attestation, #4769). ' + + 'If the conclusion must stay here, keep the two worlds apart in the wording: "no executor registered YET (as of plugin start)" is true; "will fail at execution time" is a claim about a world that has not finished forming.'; + +// ── AST helpers ────────────────────────────────────────────────────────────── + +type Ts = typeof ts; + +function isFunctionLike(t: Ts, node: ts.Node): boolean { + return ( + t.isFunctionDeclaration(node) || + t.isFunctionExpression(node) || + t.isArrowFunction(node) || + t.isMethodDeclaration(node) || + t.isConstructorDeclaration(node) || + t.isGetAccessorDeclaration(node) || + t.isSetAccessorDeclaration(node) + ); +} + +/** + * Walk `node`'s subtree WITHOUT entering bodies that run later. + * + * This is the mechanism behind two of the three cures: a `kernel:ready` callback + * and a lazy accessor are both nested function bodies, so neither is ever + * reached — the rule cannot flag the shape the fixes were fixed INTO. + */ +function walkSameTick(t: Ts, node: ts.Node, visit: (n: ts.Node) => void): void { + node.forEachChild((child) => { + if (isFunctionLike(t, child) || t.isClassDeclaration(child) || t.isClassExpression(child)) return; + visit(child); + walkSameTick(t, child, visit); + }); +} + +/** Walk everything, nested bodies included. */ +function walkAll(t: Ts, node: ts.Node, visit: (n: ts.Node) => void): void { + node.forEachChild((child) => { + visit(child); + walkAll(t, child, visit); + }); +} + +function calleeName(t: Ts, node: ts.Node): string | undefined { + if (!t.isCallExpression(node)) return undefined; + const expr = node.expression; + if (t.isIdentifier(expr)) return expr.text; + if (t.isPropertyAccessExpression(expr) && t.isIdentifier(expr.name)) return expr.name.text; + return undefined; +} + +/** + * `logger.warn(…)` / `this.log.error(…)` / `console.error(…)` → the level. + * Matched on the SHAPE `.(…)` so a renamed local + * (`const log = ctx.logger`) is still seen — same matcher the CI gates use. + */ +function loggerLevel(t: Ts, node: ts.Node): string | undefined { + if (!t.isCallExpression(node)) return undefined; + const expr = node.expression; + if (!t.isPropertyAccessExpression(expr) || !t.isIdentifier(expr.name)) return undefined; + const level = expr.name.text; + if (!ALL_LEVELS.has(level)) return undefined; + const receiver = expr.expression; + let receiverName: string | undefined; + if (t.isIdentifier(receiver)) receiverName = receiver.text; + else if (t.isPropertyAccessExpression(receiver) && t.isIdentifier(receiver.name)) receiverName = receiver.name.text; + if (!receiverName) return undefined; + return /^(logger|log|console)$/i.test(receiverName) ? level : undefined; +} + +/** Every string literal / template chunk in a node's subtree, lower-cased and joined. */ +function literalText(t: Ts, node: ts.Node): string { + const parts: string[] = []; + const take = (n: ts.Node) => { + if (t.isStringLiteralLike(n)) parts.push(n.text); + else if (t.isTemplateHead(n) || t.isTemplateMiddle(n) || t.isTemplateTail(n)) parts.push(n.text); + }; + take(node); + walkAll(t, node, take); + return parts.join(' ').toLowerCase(); +} + +/** Does this subtree mention an identifier containing one of the seal markers? */ +function mentionsSeal(t: Ts, node: ts.Node): boolean { + let found = false; + const visit = (n: ts.Node) => { + if (found) return; + if (t.isIdentifier(n)) { + const lower = n.text.toLowerCase(); + if (SEAL_MARKERS.some((m) => lower.includes(m))) found = true; + } + }; + visit(node); + walkAll(t, node, visit); + return found; +} + +/** Module-level `let`/`var` names — assigning one records a verdict for the process. */ +function moduleLevelMutableBindings(t: Ts, sf: ts.SourceFile): Set { + const names = new Set(); + for (const st of sf.statements) { + if (!t.isVariableStatement(st)) continue; + if (st.declarationList.flags & t.NodeFlags.Const) continue; + for (const d of st.declarationList.declarations) { + if (t.isIdentifier(d.name)) names.add(d.name.text); + } + } + return names; +} + +/** + * Index every named function-like body in the file so a call can be followed to + * what it does. Keyed by bare name — a same-file collision can only make the + * analysis see MORE, never less, which is the safe direction for a rule whose + * finding requires three parts to coincide. + */ +function indexFunctionBodies(t: Ts, sf: ts.SourceFile): Map { + const byName = new Map(); + walkAll(t, sf, (node) => { + if (t.isFunctionDeclaration(node) && node.name && node.body) byName.set(node.name.text, node.body); + else if (t.isMethodDeclaration(node) && t.isIdentifier(node.name) && node.body) byName.set(node.name.text, node.body); + else if ( + t.isVariableDeclaration(node) && + t.isIdentifier(node.name) && + node.initializer && + (t.isArrowFunction(node.initializer) || t.isFunctionExpression(node.initializer)) && + node.initializer.body + ) { + byName.set(node.name.text, node.initializer.body); + } + }); + return byName; +} + +// ── Plugin lifecycle units ─────────────────────────────────────────────────── + +interface Phase { + phase: string; + body: ts.Node; +} + +interface LifecycleUnit { + label: string; + phases: Phase[]; +} + +/** + * Classes and object literals that carry a plugin lifecycle. + * + * The membership test is `init` or `start` — the pair that makes something a + * plugin to the kernel. A class with neither is not on the boot path in a way + * this rule can reason about, and reading its `constructor` would flag ordinary + * construction-time work. + */ +function collectLifecycleUnits(t: Ts, sf: ts.SourceFile): LifecycleUnit[] { + const units: LifecycleUnit[] = []; + + const readClass = (node: ts.ClassDeclaration | ts.ClassExpression) => { + const phases: Phase[] = []; + let hasLifecycle = false; + for (const member of node.members) { + if (t.isMethodDeclaration(member) && t.isIdentifier(member.name)) { + if (member.name.text === 'init' || member.name.text === 'start') hasLifecycle = true; + } else if ( + t.isPropertyDeclaration(member) && + t.isIdentifier(member.name) && + (member.name.text === 'init' || member.name.text === 'start') && + member.initializer && + (t.isArrowFunction(member.initializer) || t.isFunctionExpression(member.initializer)) + ) { + hasLifecycle = true; + } + } + if (!hasLifecycle) return; + for (const member of node.members) { + if (t.isConstructorDeclaration(member) && member.body) { + phases.push({ phase: 'constructor', body: member.body }); + } else if (t.isMethodDeclaration(member) && t.isIdentifier(member.name) && member.body) { + if (PRE_SEAL_PHASES.has(member.name.text)) phases.push({ phase: member.name.text, body: member.body }); + } else if ( + t.isPropertyDeclaration(member) && + t.isIdentifier(member.name) && + PRE_SEAL_PHASES.has(member.name.text) && + member.initializer && + (t.isArrowFunction(member.initializer) || t.isFunctionExpression(member.initializer)) && + member.initializer.body + ) { + phases.push({ phase: member.name.text, body: member.initializer.body }); + } + } + if (phases.length) units.push({ label: node.name?.text ?? '', phases }); + }; + + const readObjectLiteral = (node: ts.ObjectLiteralExpression) => { + const phases: Phase[] = []; + let name: string | undefined; + let hasLifecycle = false; + for (const prop of node.properties) { + if (!t.isPropertyAssignment(prop) || !t.isIdentifier(prop.name)) continue; + const key = prop.name.text; + if (key === 'name' && t.isStringLiteralLike(prop.initializer)) name = prop.initializer.text; + if ((key === 'init' || key === 'start') && isFunctionLike(t, prop.initializer)) hasLifecycle = true; + if ( + PRE_SEAL_PHASES.has(key) && + (t.isArrowFunction(prop.initializer) || t.isFunctionExpression(prop.initializer)) && + prop.initializer.body + ) { + phases.push({ phase: key, body: prop.initializer.body }); + } + } + // An object literal with no `name` is not a plugin — the kernel keys the + // registry on it, so its absence means this is some other options bag that + // happens to carry an `init`. + if (!name || !hasLifecycle || !phases.length) return; + units.push({ label: `${name} (object plugin)`, phases }); + }; + + walkAll(t, sf, (node) => { + if (t.isClassDeclaration(node) || t.isClassExpression(node)) readClass(node); + else if (t.isObjectLiteralExpression(node)) readObjectLiteral(node); + }); + return units; +} + +// ── The rule ───────────────────────────────────────────────────────────────── + +export interface StartupRegistryVerdictOptions { + /** + * Label for the source under check — a path, a package name, whatever the + * caller can point at. Prefixes every finding's `path`. + */ + file?: string; +} + +interface VerdictRecord { + kind: 'announced' | 'cached' | 'persisted'; + detail: string; + line: number; + /** The node that did the recording — the wording check reads its text. */ + node: ts.Node; +} + +/** + * Find startup open-vocabulary verdicts in one TypeScript/JavaScript source. + * + * Pure: parses, never executes, never type-checks, touches no filesystem. An + * unparseable source yields no findings rather than throwing — this advises on + * source someone else owns, and refusing to parse is not a verdict about them. + */ +export function findStartupRegistryVerdicts( + source: string, + options: StartupRegistryVerdictOptions = {}, +): StartupRegistryVerdictFinding[] { + if (!source || !source.trim()) return []; + // Cheap pre-filter: when no accessor from the vocabulary appears anywhere in + // the text, nothing can match and the ~9 MB parser stays unloaded. This is + // what makes a whole-corpus sweep affordable. + let mentionsAny = false; + for (const probe of OPEN_VOCABULARY_PROBES.keys()) { + if (source.includes(probe)) { + mentionsAny = true; + break; + } + } + if (!mentionsAny) return []; + + const t = loadTypeScript(); + const fileLabel = options.file ?? 'source'; + let sf: ts.SourceFile; + try { + sf = t.createSourceFile(fileLabel, source, t.ScriptTarget.Latest, true, t.ScriptKind.TS); + } catch { + return []; + } + + const findings: StartupRegistryVerdictFinding[] = []; + const moduleBindings = moduleLevelMutableBindings(t, sf); + const functionBodies = indexFunctionBodies(t, sf); + const lineOf = (node: ts.Node) => sf.getLineAndCharacterOfPosition(node.getStart(sf)).line + 1; + + for (const unit of collectLifecycleUnits(t, sf)) { + for (const { phase, body } of unit.phases) { + // The bodies that run synchronously as part of this phase — the phase body + // itself plus the same-file helpers it calls, two levels deep. #4771's + // read and its warn sat in different methods. + const scopes: ts.Node[] = []; + const seenNames = new Set(); + const collect = (b: ts.Node, depth: number) => { + scopes.push(b); + if (depth >= 2) return; + walkSameTick(t, b, (child) => { + const name = calleeName(t, child); + if (!name || seenNames.has(name)) return; + const helper = functionBodies.get(name); + if (!helper) return; + seenNames.add(name); + collect(helper, depth + 1); + }); + }; + collect(body, 0); + + // (1) the read. + const reads: Array<{ probe: string; node: ts.Node }> = []; + for (const scope of scopes) { + walkSameTick(t, scope, (node) => { + const name = calleeName(t, node); + if (name && OPEN_VOCABULARY_PROBES.has(name)) reads.push({ probe: name, node }); + }); + } + if (!reads.length) continue; + + // The seal escape: somewhere in this phase's synchronous reach, the code + // asked whether the vocabulary is closed. That is cure (2), and it is a + // property of the SCOPE rather than of one expression — the seal flag is + // typically read in a guard several statements above the judgement. + if (scopes.some((scope) => mentionsSeal(t, scope))) continue; + + // (3) the record. Same-tick only: a record made inside a nested body runs + // later, which is cure (1). + const records: VerdictRecord[] = []; + for (const scope of scopes) { + walkSameTick(t, scope, (node) => { + const level = loggerLevel(t, node); + if (level && VERDICT_LEVELS.has(level)) { + records.push({ kind: 'announced', detail: `${level} log`, line: lineOf(node), node }); + return; + } + const cn = calleeName(t, node); + if (cn && PERSISTENCE_CALLEES.has(cn)) { + records.push({ kind: 'persisted', detail: `${cn}()`, line: lineOf(node), node }); + return; + } + if (t.isBinaryExpression(node) && node.operatorToken.kind === t.SyntaxKind.EqualsToken) { + const lhs = node.left; + let target: string | undefined; + if (t.isPropertyAccessExpression(lhs) && t.isIdentifier(lhs.name)) { + target = + lhs.expression.kind === t.SyntaxKind.ThisKeyword ? `this.${lhs.name.text}` : `.${lhs.name.text}`; + } else if (t.isIdentifier(lhs) && moduleBindings.has(lhs.text)) { + target = `module-level \`${lhs.text}\``; + } + if (target) records.push({ kind: 'cached', detail: target, line: lineOf(node), node }); + } + }); + } + if (!records.length) continue; + + const read = reads[0]; + const record = records[0]; + const where = `${unit.label}.${phase}${phase === 'constructor' ? '' : '()'}`; + const note = OPEN_VOCABULARY_PROBES.get(read.probe)!; + const phaseNote = PRE_SEAL_PHASES.get(phase)!; + + findings.push({ + severity: 'warning', + rule: STARTUP_OPEN_VOCABULARY_VERDICT, + where, + path: `${fileLabel}:${lineOf(read.node)}`, + message: + `\`${read.probe}()\` reads a vocabulary that is still filling, and the conclusion is recorded ` + + `(${record.kind}: ${record.detail}, line ${record.line}). ${note}; ${phaseNote}. ` + + `"absent" here has two meanings the recorded verdict cannot tell apart — no provider in this ` + + `deployment, or a provider that registers later in this same boot — and nothing retracts the ` + + `record when the second one turns out to be the case (#4771 / #4772).`, + hint: STARTUP_VERDICT_HINT, + }); + + // The wording half — only ever at a site already flagged above, so it can + // contribute no false positive of its own. + for (const rec of records) { + if (rec.kind !== 'announced') continue; + const text = literalText(t, rec.node); + if (!text) continue; + if (HEDGE_PHRASES.some((h) => text.includes(h))) continue; + const hit = ASSERTIVE_PHRASES.find((p) => text.includes(p)); + if (!hit) continue; + findings.push({ + severity: 'warning', + rule: STARTUP_VERDICT_ASSERTIVE_WORDING, + where, + path: `${fileLabel}:${rec.line}`, + message: + `the diagnostic says "${hit}" about a vocabulary that can still grow during this boot. ` + + `#4771 printed "will fail at execution time" for eight approval flows 0.8s before the executor ` + + `that runs them was registered, and a deployment that genuinely lacked the plugin printed the ` + + `identical eight — so the line could not tell an operator which of the two they had. #4772's ` + + `remedy ("you need Redis") sent operators to fix a problem they did not have, and connecting ` + + `Redis did not change the message.`, + hint: STARTUP_VERDICT_HINT, + }); + } + } + } + + return findings; +}