diff --git a/package.json b/package.json index dd877e1ffe..8da9353d68 100644 --- a/package.json +++ b/package.json @@ -69,6 +69,7 @@ "check:skill-examples": "node scripts/check-skill-examples.mjs", "check:skill-eval-tokens": "node scripts/check-skill-eval-tokens.mjs", "check:doc-types": "node scripts/check-doc-component-types.mjs", + "check:registry-bare-names": "node scripts/check-registry-bare-name-collisions.mjs", "check:prompt-keys": "node scripts/check-prompt-component-keys.mjs", "check:doc-snippets": "node scripts/check-doc-snippet-types.mjs", "check:doc-examples": "node scripts/check-doc-example-types.mjs", diff --git a/scripts/__tests__/registry-bare-name-collisions.test.ts b/scripts/__tests__/registry-bare-name-collisions.test.ts new file mode 100644 index 0000000000..70862c38cf --- /dev/null +++ b/scripts/__tests__/registry-bare-name-collisions.test.ts @@ -0,0 +1,289 @@ +import { describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +// Plain-JS shared helpers. Their types are INFERRED from the .mjs sources by +// `tsconfig.scripts.json` (`allowJs`), so no `@ts-expect-error` here — +// re-adding one is itself an error (TS2578). See objectui#3494. +import { + CONTROLS, + FLOORS, + KNOWN_BARE_NAME_COLLISIONS, + checkFloors, + claimantId, + evaluate, + formatFindings, + groupBareKeys, + isGeneratedPath, + isTestPath, + judgeAgainstLedger, +} from '../check-registry-bare-name-collisions.mjs'; +import { + readOwnRegistrations, + readRegistrationClaims, + readUnitProjectShape, + unitProjectFiles, +} from '../unit-registry-collision.mjs'; + +/** + * objectui#9264 — `Registry.register` writes a bare-name fallback next to the + * namespaced key and the bare write is last-one-wins, so when two + * registrations claim one bare name under DIFFERENT full types, import order + * decides which declaration governs an authored node. A runtime `console.warn` + * surfaces that; nothing FAILS on it, and `registerLazy` does not even warn. + * + * This file is the failing half. It is deliberately two kinds of test: + * + * - the SWEEP over the real tree, which is the gate; and + * - the JUDGEMENT over planted populations, because a gate whose red path is + * only ever reached by breaking the repository is a gate nobody has seen + * fail. Every disposition below is planted and asserted in both + * directions. + */ + +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'); + +/** A claim record shaped the way the sweep produces them. */ +function claim(over: Record = {}) { + return { + type: 'widget', + namespace: 'a', + fullType: 'a:widget', + skipFallback: false, + claimsBare: true, + guarded: false, + method: 'register', + receiver: 'ComponentRegistry', + line: 1, + origin: 'literal', + file: 'packages/a/src/index.tsx', + package: 'packages/a', + ...over, + }; +} + +describe('registry bare-name collisions — the sweep over this repository', () => { + const result = evaluate(repoRoot); + + it('reports no finding: no new bare-name collision, no stale ledger entry, no blind spot', () => { + expect(result.findings.length, `\n${formatFindings(result.findings)}\n`).toBe(0); + }); + + it('is non-vacuous — every population it depends on clears its floor', () => { + expect(checkFloors(result.counts, FLOORS)).toEqual([]); + }); + + it('every unresolvable registration call sits in a file declared OPEN, with its reason', () => { + // A key this gate cannot read is a key it cannot judge. The declaration is + // what keeps that from being silent; the sweep itself fails on an + // undeclared one, so this asserts the population is not empty for the + // empty reason. + expect(result.unresolved.length).toBeGreaterThan(0); + }); + + it('sees the cases this repository has ALREADY solved — the stand-down control', () => { + // If the instrument cannot see a solved collision it cannot be trusted + // about an unsolved one. These six are resolved by a deliberate + // `skipFallback: true` and must come back visible. + for (const key of CONTROLS.standDown) { + const group = result.groups.get(key); + expect(group?.standDowns.length, `${key} must be visible as a skipFallback stand-down`).toBeGreaterThan(0); + } + }); + + it('sees live multi-claimant keys that AGREE — the other direction of the control', () => { + // A sweep that stopped seeing claimants would report zero collisions for + // the empty reason. These keys have two or more claimants naming ONE full + // type: visible, and correctly not reported. + for (const key of CONTROLS.agreed) { + const group = result.groups.get(key); + expect(group?.claimants.length, `${key} must be visible as a multi-claimant key`).toBeGreaterThan(1); + expect(group?.owners).toHaveLength(1); + expect(group?.verdict).toBe('agreed'); + } + }); + + it('holds every contested key in the shrink-only ledger, claimants and all', () => { + for (const group of result.contested) { + const entry = KNOWN_BARE_NAME_COLLISIONS.find((e: { key: string }) => e.key === group.key); + expect(entry, `contested bare key ${group.key} must be declared in the ledger`).toBeDefined(); + expect([...group.claimants].map(claimantId).sort()).toEqual([...(entry?.claimants ?? [])].sort()); + } + }); + + it('holds no build OUTPUT — the population must not move when someone builds', () => { + // Measured while this gate landed: 1633 files before `turbo run build`, + // 1639 after, the six being `apps/site/.next`, `apps/site/.source` and a + // generated `plugin.d.ts`. A population that depends on build state is one + // no two runs agree on. + for (const file of result.files) { + expect(isGeneratedPath(file), `${file} is generated output and must not be in the population`).toBe(false); + } + expect(isGeneratedPath('apps/site/.next/types/routes.d.ts')).toBe(true); + expect(isGeneratedPath('apps/site/.source/server.ts')).toBe(true); + expect(isGeneratedPath('apps/console/plugin.d.ts')).toBe(true); + expect(isGeneratedPath('apps/console/src/register-plugins.ts')).toBe(false); + }); + + it('keeps test sources out of the population — a test-body registration governs nothing', () => { + expect(isTestPath('packages/a/src/__tests__/x.test.tsx')).toBe(true); + expect(isTestPath('packages/a/src/x.test.ts')).toBe(true); + expect(isTestPath('examples/schema-catalog/test/x.tsx')).toBe(true); + expect(isTestPath('packages/a/src/index.tsx')).toBe(false); + }); +}); + +describe('the judgement, on planted populations', () => { + it('FAILS on two claimants naming different full types', () => { + const groups = groupBareKeys([ + claim({ type: 'widget', namespace: 'a', fullType: 'a:widget' }), + claim({ type: 'widget', namespace: 'b', fullType: 'b:widget', file: 'packages/b/src/index.tsx' }), + ]); + expect(groups.get('widget')?.verdict).toBe('contested'); + const findings = judgeAgainstLedger(groups, []); + expect(findings.map((f: { reason: string }) => f.reason)).toEqual(['new-bare-name-collision']); + expect(findings[0].detail).toContain('a:widget'); + expect(findings[0].detail).toContain('b:widget'); + }); + + it('passes when the claimants AGREE on one full type — the console-stub shape', () => { + const groups = groupBareKeys([ + claim({ method: 'registerLazy', file: 'apps/console/src/register-plugins.ts' }), + claim(), + ]); + expect(groups.get('widget')?.verdict).toBe('agreed'); + expect(judgeAgainstLedger(groups, [])).toEqual([]); + }); + + it('passes when the second registration STOOD DOWN with skipFallback', () => { + const groups = groupBareKeys([ + claim(), + claim({ namespace: 'b', fullType: 'b:widget', skipFallback: true, claimsBare: false, file: 'packages/b/src/index.tsx' }), + ]); + const group = groups.get('widget'); + expect(group?.verdict).toBe('sole'); + expect(group?.handled).toBe(true); + expect(judgeAgainstLedger(groups, [])).toEqual([]); + }); + + it('passes when the other claimant is GUARDED by an existence check', () => { + const groups = groupBareKeys([ + claim(), + claim({ namespace: 'b', fullType: 'b:widget', guarded: true, file: 'packages/b/src/index.tsx' }), + ]); + const group = groups.get('widget'); + expect(group?.verdict).toBe('sole'); + expect(group?.guarded).toHaveLength(1); + expect(judgeAgainstLedger(groups, [])).toEqual([]); + }); + + it('FAILS when a new claimant joins a key already in the ledger', () => { + const groups = groupBareKeys([ + claim({ type: 'widget', namespace: 'a', fullType: 'a:widget' }), + claim({ type: 'widget', namespace: 'b', fullType: 'b:widget', file: 'packages/b/src/index.tsx' }), + claim({ type: 'widget', namespace: 'c', fullType: 'c:widget', file: 'packages/c/src/index.tsx' }), + ]); + const ledger = [ + { + key: 'widget', + claimants: ['packages/a/src/index.tsx · a:widget · register', 'packages/b/src/index.tsx · b:widget · register'], + note: 'planted', + }, + ]; + const findings = judgeAgainstLedger(groups, ledger); + expect(findings.map((f: { reason: string }) => f.reason)).toEqual(['ledger-entry-grew']); + expect(findings[0].detail).toContain('packages/c/src/index.tsx · c:widget · register'); + }); + + it('FAILS on a ledger entry whose collision is gone — the ledger is shrink-only', () => { + const groups = groupBareKeys([claim()]); + const findings = judgeAgainstLedger(groups, [{ key: 'widget', claimants: [], note: 'planted' }]); + expect(findings.map((f: { reason: string }) => f.reason)).toEqual(['stale-ledger-entry']); + }); + + it('a registration with no namespace claims the bare name outright', () => { + const groups = groupBareKeys([ + claim({ namespace: null, fullType: 'widget' }), + claim({ namespace: 'b', fullType: 'b:widget', file: 'packages/b/src/index.tsx' }), + ]); + expect(groups.get('widget')?.verdict).toBe('contested'); + }); +}); + +describe('the shared walker', () => { + const tsx = [ + "ComponentRegistry.register('calendar',", + ' ({ schema }: { schema: CalendarSchema }) => (),', + " { namespace: 'ui', skipFallback: true },", + ');', + ].join('\n'); + + it('parses a .tsx source as TSX — otherwise the JSX swallows the meta object', () => { + // The direction that matters: mis-parsed, this registration loses its meta + // entirely and a `skipFallback: true` call reads as a bare-name claimant, + // manufacturing a finding that is not there (objectui#9264). + const [asTsx] = readRegistrationClaims(tsx, 'calendar.tsx').claims; + expect([asTsx.fullType, asTsx.skipFallback, asTsx.claimsBare]).toEqual(['ui:calendar', true, false]); + }); + + it('resolves a key that arrives through a loop — but only when asked', () => { + const source = [ + "const TAGS = ['aside', 'main'] as const;", + 'for (const tag of TAGS) {', + " ComponentRegistry.register(tag, C, { namespace: 'ui' });", + '}', + ].join('\n'); + expect(readRegistrationClaims(source, 'f.ts').claims).toHaveLength(0); + const opened = readRegistrationClaims(source, 'f.ts', { resolveLoops: true }); + expect(opened.claims.map((c: { fullType: string }) => c.fullType)).toEqual(['ui:aside', 'ui:main']); + expect(opened.claims.every((c: { origin: string }) => c.origin === 'loop')).toBe(true); + }); + + it('reads the `forEach` spelling of the same loop', () => { + const source = [ + "const tags = ['header', 'footer'] as const;", + 'tags.forEach((tag) => {', + " ComponentRegistry.register(tag, C, { namespace: 'ui' });", + '});', + ].join('\n'); + const { claims } = readRegistrationClaims(source, 'f.ts', { resolveLoops: true }); + expect(claims.map((c: { fullType: string }) => c.fullType)).toEqual(['ui:header', 'ui:footer']); + }); + + it('marks a registration behind an existence check as guarded', () => { + const source = [ + 'function registerPlaceholder(type: string) {', + ' if (!ComponentRegistry.get(type)) {', + " ComponentRegistry.register('x', P, { namespace: 'protocol-placeholder' });", + ' }', + '}', + "ComponentRegistry.register('y', P, { namespace: 'ui' });", + ].join('\n'); + const { claims } = readRegistrationClaims(source, 'f.ts'); + expect(claims.map((c: { type: string; guarded: boolean }) => [c.type, c.guarded])).toEqual([ + ['x', true], + ['y', false], + ]); + }); + + it('leaves `readOwnRegistrations` answering exactly what objectui#7134 reads', () => { + // The factoring must not move that gate's population. A loop key stays + // UNRESOLVED there, because that gate floors the sites it reports. + const source = [ + "ComponentRegistry.register('grid', C, { namespace: 'view', skipFallback: true });", + "ComponentRegistry.register('x', C);", + "for (const v of ['a', 'b']) ComponentRegistry.registerLazy(v, l, { namespace: 'ns' });", + ].join('\n'); + const read = readOwnRegistrations(source, 'f.ts'); + expect(read.keys).toEqual(['view:grid', 'x']); + expect(read.unresolved).toHaveLength(1); + }); + + it('the TSX fix cannot move objectui#7134’s population: that project holds no .tsx file', () => { + const shape = readUnitProjectShape(fs.readFileSync(path.join(repoRoot, 'vitest.config.mts'), 'utf8')); + const files: string[] = unitProjectFiles(repoRoot, shape); + expect(files.length).toBeGreaterThan(500); + expect(files.filter((f) => f.endsWith('.tsx'))).toEqual([]); + }); +}); diff --git a/scripts/check-registry-bare-name-collisions.mjs b/scripts/check-registry-bare-name-collisions.mjs new file mode 100644 index 0000000000..991cefb515 --- /dev/null +++ b/scripts/check-registry-bare-name-collisions.mjs @@ -0,0 +1,597 @@ +#!/usr/bin/env node +/** + * check-registry-bare-name-collisions -- who governs a BARE registry key. + * + * node scripts/check-registry-bare-name-collisions.mjs # the gate + * node scripts/check-registry-bare-name-collisions.mjs --table # the measured population + * node scripts/check-registry-bare-name-collisions.mjs --json # the same, machine-readable + * + * ## The hazard (objectui#9264) + * + * `Registry.register` writes a bare-name fallback ALONGSIDE the namespaced key + * -- its own branch is `if (meta?.namespace && !meta?.skipFallback)` -- and the + * bare write is last-one-wins. So when two registrations name the same bare + * key with DIFFERENT full types, which declaration governs an authored + * `{ "type": "" }` node depends on module import order. The symptom is + * not an error: it is the wrong renderer, chosen by import order, for a node + * that type-checks fine. + * + * Two things already exist and neither closes it: + * + * - a RUNTIME warning in `Registry.register`, whose text begins "bare-name + * fallback is being overwritten by". It surfaces a collision in a browser + * console; it fails nothing, and `registerLazy` -- which takes the same + * `meta?.namespace && !meta?.skipFallback` branch -- has no guard at all. + * - `skipFallback: true`, the documented opt-out, applied case by case + * (`@object-ui/fields`' `FIELD_TYPES_SKIP_FALLBACK`, `record:line_items`, + * `ui:calendar`, the `plugin-grid` and `plugin-list` aliases). It is + * OPT-IN: a registration that forgets it takes the bare name silently. + * + * This gate is the missing half: a new contested bare key FAILS here. + * + * ## What counts as a collision, and what deliberately does not + * + * ⭐ The predicate is DISTINCT FULL TYPES, not "more than one registration". + * This repository's normal shape is a console lazy stub and the plugin itself + * claiming one bare key under the SAME full type -- the shape objectui#6416 + * converged `plugin-report` onto, and the reason + * `report-bare-key-ownership.test.ts` replays registrations in both orders. A + * bare key whose claimants all name one full type has ONE owner by + * construction, whichever phase a host observes. Counting registrations rather + * than declarations would report every one of those pairs as a defect. + * + * Three further dispositions are carried rather than filtered, because "did + * not contest" and "was not seen" must never collapse into one answer: + * + * - STOOD DOWN -- a namespaced registration with `skipFallback: true`. It + * would have claimed the bare name and declined. These are the cases the + * repository has ALREADY solved, and the gate's own controls require them + * to be visible: an instrument that cannot see a solved case cannot be + * trusted about an unsolved one. + * - GUARDED -- a registration inside `if (!ComponentRegistry.get(type))`, + * the placeholder registrar's shape. It writes the bare key only when + * nothing holds it, and a later real `register` overwrites it either way, + * so it never decides ownership. + * - OPEN -- a call whose key argument genuinely cannot be known statically + * (a registrar helper's parameter). Declared in `OPEN_REGISTRATION_SITES` + * with the reason, and each declared file must still contain such a call. + * + * ## Where the population comes from + * + * The AST half reuses `readRegistrationClaims` from + * `scripts/unit-registry-collision.mjs` -- objectui#7134's walker, factored so + * a caller that needs the CLAIMANT rather than the key does not need a second + * walker over the same calls. The data-driven half (`registerAllFields()` + * walks a map; the placeholder registrar walks an array) is not readable from + * a call site at all, so it is taken from `INDIRECT_REGISTRATIONS` and + * `deriveRegistryKeys` in `scripts/check-doc-component-types.mjs` -- the same + * table `regenerate-known-schema-types.mjs` already follows, rather than a + * third private list of collections that would drift. + * + * Tests are OUT of the population on purpose: a registration in a test body + * happens when that test runs, in that worker's registry, and governs nothing + * a host can author. objectui#7134's gate is the one that cares about those. + * + * ## The ledger + * + * `KNOWN_BARE_NAME_COLLISIONS` is SHRINK-ONLY, in the shape + * `check-entry-guard`'s `KNOWN_HAND_TYPED_GUARDS` established: a contested key + * that is not in it fails, a ledger entry whose claimants grew fails, and a + * ledger entry that no longer collides fails as stale so a resolved collision + * cannot leave a licence behind. Entries carry the CLAIMANTS, not just the + * name, because objectui#9256's hold-outs have to be resolvable one at a time + * and a bare key name resolves none of them. + * + * ⛔ Entries are keyed by file and full type, never by line number: a stored + * line address is a key that rots on the next insertion above it (AGENTS.md + * section 5, commandment #11). Line numbers are printed as diagnostics, which + * are computed every run and cannot expire. + * + * ⛔ Adding `skipFallback: true` to a registration to empty this ledger is a + * BEHAVIOUR change -- it decides which renderer a bare name resolves to. Each + * one needs its own ruling; objectui#9256 holds eleven registrations for + * exactly that reason. This gate makes those decidable. It does not decide + * them. + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import { deriveRegistryKeys, INDIRECT_REGISTRATIONS } from './check-doc-component-types.mjs'; +import { isEntrypoint } from './invoked-as.mjs'; +import { collectFiles, readRegistrationClaims } from './unit-registry-collision.mjs'; + +const repoRoot = path.join(path.dirname(fileURLToPath(import.meta.url)), '..'); + +/** Every workspace source root that can register a component. */ +export const INCLUDE_GLOBS = [ + 'packages/**/*.ts', + 'packages/**/*.tsx', + 'apps/**/*.ts', + 'apps/**/*.tsx', + 'examples/**/*.ts', + 'examples/**/*.tsx', +]; + +/** + * Build OUTPUT, excluded from the population — and this is not housekeeping. + * + * Measured while landing this gate: the same sweep read 1633 source files + * before `turbo run build` and 1639 after it. The six were the framework + * caches `apps/site/.next` and `apps/site/.source` plus a generated + * `plugin.d.ts`. A population that moves with whether someone has built is a + * population no two runs agree on, and objectui#9256's table records the + * neighbouring version of this hazard: an unbuilt tree answered `any` and + * `any` read as "reads neither". + * + * A dot-segment is how every generator here hides its output; `.d.ts` is + * emitted type, and no declaration file carries a registration CALL. The + * sibling `walkFiles` in `check-doc-component-types.mjs` skips dot-directories + * for the same reason, which is why its file count did not move. + */ +export function isGeneratedPath(rel) { + return rel.split('/').some((segment) => segment.startsWith('.')) || /\.d\.tsx?$/.test(rel); +} + +/** + * Test sources, excluded from the population. The spelling mirrors the + * `isTestFile` predicate in `check-doc-component-types.mjs`, widened by the + * `test/` and `e2e/` directories this repo also uses, so the two gates read + * the same tree. + */ +export function isTestPath(rel) { + return ( + rel.includes('/__tests__/') || + rel.includes('/test/') || + rel.includes('/e2e/') || + /\.(test|spec)\.[tj]sx?$/.test(rel) + ); +} + +/** + * Registration calls whose key argument cannot be known from the call site, + * declared by FILE with the reason. Each declared file must still contain such + * a call, and a file that produces one without being declared here is a + * finding -- the population must never shrink quietly. + */ +export const OPEN_REGISTRATION_SITES = { + 'packages/components/src/renderers/placeholders.tsx': + '`registerPlaceholder(type)` takes the key as a parameter. Its keys are read from ' + + 'PROTOCOL_COMPONENTS through INDIRECT_REGISTRATIONS, and every one of them is GUARDED by the ' + + "registrar's own `if (!ComponentRegistry.get(type))`.", + 'packages/fields/src/index.tsx': + '`registerField(fieldType)` and the retired-field tombstone registrar both take the key as a ' + + 'parameter. The live keys are read from fieldWidgetMap through INDIRECT_REGISTRATIONS; the ' + + 'tombstone registrar declares `skipFallback: true` at its call site, so it claims no bare key.', +}; + +/** + * Indirect sites whose registrations are GUARDED by an existence check. The + * quoted guard is re-read every run: if it goes, the entry is a finding rather + * than a silent downgrade of those claims to "cannot contest". + */ +export const GUARDED_INDIRECT_SITES = { + 'packages/components/src/renderers/placeholders.tsx': 'if (!ComponentRegistry.get(type))', +}; + +/** + * Controls. A sweep that reports nothing is indistinguishable from a sweep + * that sees nothing, so the cases this repository has ALREADY solved must come + * back visible, in both directions: + * + * - `standDown` -- names whose bare key was deliberately declined with + * `skipFallback: true`. If the instrument cannot see these, its silence + * about everything else means nothing. + * - `agreed` -- bare keys claimed by two or more registrations that name ONE + * full type. These are the live, multi-claimant keys the gate must NOT + * report; if they vanish, the sweep has stopped seeing claimants at all + * and every "no collision" answer below it is empty. + */ +export const CONTROLS = { + standDown: ['text', 'image', 'avatar', 'html', 'grid', 'line_items'], + agreed: ['object-map', 'object-kanban'], +}; + +/** + * Floors. Every population here is one a silent zero would make GREEN, which + * is the failure mode this gate exists to refuse one level down, so it is + * refused here too. Move one deliberately, never to make a red run green. + */ +export const FLOORS = { + populationFiles: 1000, + filesWithRegistrations: 100, + claims: 250, + bareKeysClaimed: 150, + standDownNames: 20, +}; + +/** Floors that were not met, as human-readable lines. Empty means non-vacuous. */ +export function checkFloors(counts, floors = FLOORS) { + const out = []; + for (const [name, floor] of Object.entries(floors)) { + const got = counts[name]; + if (typeof got !== 'number') out.push(`${name}: NOT MEASURED (floor ${floor})`); + else if (got < floor) out.push(`${name}: found ${got}, floor is ${floor}`); + } + return out; +} + +/** The workspace a source file belongs to, for reading the table by package. */ +export function packageOf(rel) { + const parts = rel.split('/'); + return parts.length >= 2 ? `${parts[0]}/${parts[1]}` : parts[0]; +} + +/** + * A claimant's stable identity in the ledger: WHERE it is declared and WHAT it + * declares. No line number -- see the header. + */ +export function claimantId(claim) { + return `${claim.file} · ${claim.fullType} · ${claim.method}`; +} + +/** + * Every registration claim this repository's shipped source makes. + * `unresolved` and `findings` are returned rather than dropped. + */ +export function collectClaims(root = repoRoot, options = {}) { + const indirect = options.indirectRegistrations ?? INDIRECT_REGISTRATIONS; + const openSites = options.openRegistrationSites ?? OPEN_REGISTRATION_SITES; + const guardedSites = options.guardedIndirectSites ?? GUARDED_INDIRECT_SITES; + const claims = []; + const findings = []; + const unresolved = []; + const counters = { populationFiles: 0, filesWithRegistrations: 0, claims: 0, indirectClaims: 0 }; + + const files = collectFiles(root, options.includeGlobs ?? INCLUDE_GLOBS).filter( + (f) => !isTestPath(f) && !isGeneratedPath(f), + ); + counters.populationFiles = files.length; + + for (const rel of files) { + const source = fs.readFileSync(path.join(root, rel), 'utf8'); + if (!/\.register(Lazy)?\s*\(/.test(source)) continue; + counters.filesWithRegistrations++; + const read = readRegistrationClaims(source, rel, { resolveLoops: true }); + for (const claim of read.claims) claims.push({ ...claim, file: rel, package: packageOf(rel) }); + for (const site of read.unresolved) unresolved.push({ ...site, file: rel }); + } + + // Declared-open sites: each must still produce an unresolvable call, and an + // undeclared one is reported rather than forgiven. + const openSeen = new Set(unresolved.map((u) => u.file)); + for (const file of openSeen) { + if (!openSites[file]) { + findings.push({ + reason: 'undeclared-open-registration', + site: file, + detail: + 'this file registers with a key that is not statically knowable and is not declared in ' + + 'OPEN_REGISTRATION_SITES. Its keys are invisible to this gate, so declare the site with ' + + 'the reason, or teach the walker the form.', + }); + } + } + for (const file of Object.keys(openSites)) { + if (!openSeen.has(file)) { + findings.push({ + reason: 'stale-open-registration', + site: file, + detail: + 'OPEN_REGISTRATION_SITES names this file, but no unresolvable registration call was found ' + + 'in it. Re-confirm the site or delete the entry.', + }); + } + } + + // The data-driven half, taken from the table the doc gate already follows. + const derived = deriveRegistryKeys(root, { indirectRegistrations: indirect }); + for (const finding of derived.findings) { + findings.push({ reason: `doc-gate:${finding.reason}`, site: finding.site, detail: finding.detail }); + } + for (const entry of indirect) { + const prefix = `${entry.namespace}:`; + const at = [...derived.keys.entries()].filter(([, sites]) => sites.includes(entry.site)).map(([key]) => key); + const names = at.filter((key) => key.startsWith(prefix)).map((key) => key.slice(prefix.length)); + const bare = new Set(at.filter((key) => !key.startsWith(prefix))); + const guard = guardedSites[entry.site] ?? null; + if (guard) { + const source = fs.readFileSync(path.join(root, entry.site), 'utf8'); + if (!source.includes(guard)) { + findings.push({ + reason: 'stale-guard-declaration', + site: entry.site, + detail: + `GUARDED_INDIRECT_SITES says this file guards its registrations with ${JSON.stringify(guard)}, ` + + 'and that text is gone. Its claims would silently become uncontested; re-confirm the guard ' + + 'or delete the entry.', + }); + } + } + for (const type of names) { + counters.indirectClaims++; + claims.push({ + type, + namespace: entry.namespace, + fullType: `${entry.namespace}:${type}`, + skipFallback: !bare.has(type), + claimsBare: bare.has(type), + guarded: Boolean(guard), + method: 'register', + receiver: 'ComponentRegistry', + line: null, + origin: 'indirect', + file: entry.site, + package: packageOf(entry.site), + }); + } + } + + counters.claims = claims.length; + return { claims, unresolved, findings, counters, files }; +} + +/** + * Group claims by the BARE key each one writes or declines, and say what + * governs it. + */ +export function groupBareKeys(claims) { + const groups = new Map(); + const at = (key) => { + if (!groups.has(key)) groups.set(key, { key, claimants: [], guarded: [], standDowns: [] }); + return groups.get(key); + }; + for (const claim of claims) { + if (claim.claimsBare) { + if (claim.guarded) at(claim.type).guarded.push(claim); + else at(claim.type).claimants.push(claim); + } else if (claim.namespace && claim.skipFallback) { + at(claim.type).standDowns.push(claim); + } + } + for (const group of groups.values()) { + const owners = [...new Set(group.claimants.map((c) => c.fullType))].sort(); + group.owners = owners; + group.verdict = + owners.length > 1 ? 'contested' : group.claimants.length > 1 ? 'agreed' : owners.length === 1 ? 'sole' : 'declined'; + group.handled = group.standDowns.length > 0; + } + return groups; +} + +/** + * The ledger. SHRINK-ONLY: every entry is a bare key whose claimants name two + * or more different full types today, with the claimants that make it so. + * + * ⛔ Nothing may be ADDED here to make a red run green. A new contested key is + * a decision about which renderer answers an authored node, and it belongs in + * a card, not in this constant. + */ +export const KNOWN_BARE_NAME_COLLISIONS = [ + { + key: 'dashboard', + claimants: [ + 'apps/console/src/preview-gallery.tsx · plugin-dashboard:dashboard · registerLazy', + 'apps/console/src/register-plugins.ts · plugin-dashboard:dashboard · registerLazy', + 'packages/plugin-dashboard/src/index.tsx · view:dashboard · register', + ], + note: + 'The objectui#6416 shape, still live. The console lazy stubs declare `plugin-dashboard:dashboard` ' + + 'and the plugin itself registers `view:dashboard`, so bare `dashboard` declares one namespace ' + + 'before the chunk loads and the other after. ⛔ Not resolvable here: picking a spelling decides ' + + 'which declaration governs an authored `{ "type": "dashboard" }` node, and objectui#9256 holds ' + + 'registrations for exactly that class of ruling.', + }, +]; + +/** Every finding the gate has, as human-readable records. */ +export function evaluate(root = repoRoot, options = {}) { + const ledger = options.ledger ?? KNOWN_BARE_NAME_COLLISIONS; + const controls = options.controls ?? CONTROLS; + const floors = options.floors ?? FLOORS; + const collected = collectClaims(root, options); + const groups = groupBareKeys(collected.claims); + const findings = [...collected.findings]; + + const counts = { + ...collected.counters, + bareKeysClaimed: [...groups.values()].filter((g) => g.claimants.length > 0).length, + standDownNames: [...groups.values()].filter((g) => g.standDowns.length > 0).length, + }; + + for (const line of checkFloors(counts, floors)) { + findings.push({ + reason: 'vacuous-sweep', + site: 'population', + detail: `${line} — the sweep found too little to be a measurement, so its silence is not a pass.`, + }); + } + + // Controls, both directions. + for (const key of controls.standDown) { + const group = groups.get(key); + if (!group || group.standDowns.length === 0) { + findings.push({ + reason: 'control-missing', + site: `control:standDown:${key}`, + detail: + `the bare key ${JSON.stringify(key)} is declined by a \`skipFallback: true\` registration in ` + + 'this repository, and this sweep did not see that. An instrument blind to a solved case ' + + 'cannot be trusted about an unsolved one.', + }); + } + } + for (const key of controls.agreed) { + const group = groups.get(key); + if (!group || group.claimants.length < 2 || group.owners.length !== 1) { + findings.push({ + reason: 'control-missing', + site: `control:agreed:${key}`, + detail: + `the bare key ${JSON.stringify(key)} is claimed by two or more registrations naming ONE full ` + + 'type. The sweep no longer sees that, so it is not seeing claimants — every "no collision" ' + + 'answer below it would be empty.', + }); + } + } + + const contested = [...groups.values()].filter((g) => g.verdict === 'contested'); + findings.push(...judgeAgainstLedger(groups, ledger)); + + return { + findings, + groups, + counts, + claims: collected.claims, + files: collected.files, + unresolved: collected.unresolved, + contested, + }; +} + +/** + * The judgement, kept pure so it can be exercised on PLANTED populations: a + * gate whose failure path is only ever reached by breaking the real tree is a + * gate nobody has seen fail. + */ +export function judgeAgainstLedger(groups, ledger = KNOWN_BARE_NAME_COLLISIONS) { + const findings = []; + const contested = [...groups.values()].filter((g) => g.verdict === 'contested'); + const byKey = new Map(ledger.map((entry) => [entry.key, entry])); + for (const group of contested) { + const entry = byKey.get(group.key); + const claimants = group.claimants.map(claimantId).sort(); + if (!entry) { + findings.push({ + reason: 'new-bare-name-collision', + site: `bare key ${JSON.stringify(group.key)}`, + detail: + `claimed by ${group.owners.length} different full types, so import order decides which ` + + `declaration governs an authored \`{ "type": ${JSON.stringify(group.key)} }\` node:\n` + + group.claimants + .map((c) => ` ${c.fullType.padEnd(32)} ${c.method.padEnd(13)} ${c.file}${c.line ? `:${c.line}` : ''}`) + .join('\n') + + '\n Give the registration that should NOT own the bare name `skipFallback: true` — that ' + + 'is a behaviour decision and needs a ruling — or, if both should agree, converge them on one ' + + 'full type the way objectui#6416 did for `plugin-report`.', + }); + continue; + } + const known = new Set(entry.claimants); + const added = claimants.filter((c) => !known.has(c)); + if (added.length) { + findings.push({ + reason: 'ledger-entry-grew', + site: `bare key ${JSON.stringify(group.key)}`, + detail: `a new claimant joined a known collision:\n${added.map((c) => ` ${c}`).join('\n')}`, + }); + } + } + const contestedKeys = new Set(contested.map((g) => g.key)); + for (const entry of ledger) { + if (!contestedKeys.has(entry.key)) { + findings.push({ + reason: 'stale-ledger-entry', + site: `bare key ${JSON.stringify(entry.key)}`, + detail: + 'this key is in KNOWN_BARE_NAME_COLLISIONS and no longer has two claimants naming different ' + + 'full types. The ledger is shrink-only: delete the entry in the same change that resolved it.', + }); + } + } + return findings; +} + +/** The measured population, as the table objectui#9264 asks to be published. */ +export function formatTable(result) { + const lines = []; + const order = { contested: 0, agreed: 1, sole: 2, declined: 3 }; + const groups = [...result.groups.values()].sort( + (a, b) => order[a.verdict] - order[b.verdict] || a.key.localeCompare(b.key), + ); + for (const verdict of ['contested', 'agreed', 'sole', 'declined']) { + const rows = groups.filter((g) => g.verdict === verdict); + lines.push(`\n## ${verdict} — ${rows.length} bare key(s)`); + for (const group of rows) { + if (verdict === 'sole' && !group.handled && group.guarded.length === 0) continue; + lines.push(` ${group.key}${group.handled ? ' [handled: a skipFallback stand-down exists]' : ''}`); + for (const claim of group.claimants) { + lines.push( + ` CLAIMS ${claim.fullType.padEnd(32)} ${claim.method.padEnd(13)} ` + + `${claim.file}${claim.line ? `:${claim.line}` : ''}`, + ); + } + for (const claim of group.guarded) { + lines.push( + ` GUARDED ${claim.fullType.padEnd(32)} ${claim.method.padEnd(13)} ` + + `${claim.file}${claim.line ? `:${claim.line}` : ''}`, + ); + } + for (const claim of group.standDowns) { + lines.push( + ` STOOD DOWN ${claim.fullType.padEnd(29)} ${claim.method.padEnd(13)} ` + + `${claim.file}${claim.line ? `:${claim.line}` : ''}`, + ); + } + } + } + return lines.join('\n'); +} + +export function formatFindings(findings) { + return findings.map((f) => ` [${f.reason}] ${f.site}\n ${f.detail}`).join('\n'); +} + +export function main(argv = process.argv.slice(2)) { + const result = evaluate(); + const wantsTable = argv.includes('--table'); + const wantsJson = argv.includes('--json'); + + if (wantsJson) { + const groups = [...result.groups.values()].map((g) => ({ + key: g.key, + verdict: g.verdict, + handled: g.handled, + owners: g.owners, + claimants: g.claimants.map((c) => ({ ...c })), + guarded: g.guarded.map((c) => ({ ...c })), + standDowns: g.standDowns.map((c) => ({ ...c })), + })); + process.stdout.write( + `${JSON.stringify({ counts: result.counts, unresolved: result.unresolved, findings: result.findings, groups }, null, 2)}\n`, + ); + } else if (wantsTable) { + process.stdout.write(`${formatTable(result)}\n`); + } + + // With `--json` the summary goes to stderr: stdout must stay parseable, or a + // caller piping it reads a truncation as a malformed measurement. + const counts = result.counts; + const say = wantsJson ? (t) => process.stderr.write(t) : (t) => process.stdout.write(t); + say( + `Scanned ${counts.populationFiles} source file(s); ${counts.filesWithRegistrations} register; ` + + `${counts.claims} registration claim(s) (${counts.indirectClaims} from declared collections); ` + + `${counts.bareKeysClaimed} bare key(s) claimed, ${counts.standDownNames} declined with skipFallback; ` + + `${result.contested.length} contested; ledger holds ${KNOWN_BARE_NAME_COLLISIONS.length}.\n`, + ); + if (result.unresolved.length) { + say( + `${result.unresolved.length} registration call(s) have a key this gate cannot resolve; every one ` + + 'is in a file declared in OPEN_REGISTRATION_SITES.\n', + ); + } + + if (result.findings.length) { + process.stderr.write( + `\n✗ registry bare-name collisions: ${result.findings.length} finding(s)\n${formatFindings(result.findings)}\n`, + ); + return 1; + } + say('✓ no new bare-name collision; every contested key is a declared, unresolved decision.\n'); + return 0; +} + +if (isEntrypoint(import.meta.url)) { + process.exit(main()); +} diff --git a/scripts/unit-registry-collision.mjs b/scripts/unit-registry-collision.mjs index 033a51eddf..596906b236 100644 --- a/scripts/unit-registry-collision.mjs +++ b/scripts/unit-registry-collision.mjs @@ -63,8 +63,22 @@ export const SINGLETON = 'ComponentRegistry'; /** Directory names never walked when collecting the project population. */ const SKIP_DIRS = new Set(['node_modules', 'dist', 'coverage', '.git', 'cypress', 'e2e']); +/** + * The script kind is chosen from the FILE NAME, and that is load-bearing rather + * than tidy: parsing a `.tsx` source as plain TS does not fail, it mis-parses. + * The JSX in a renderer's inline component argument swallows the rest of the + * argument list, so a registration's meta object literal is simply not there — + * and a call carrying `skipFallback: true` then reads as one that claims the + * bare key. Silent, and in the direction that manufactures false findings + * (objectui#9264, measured on the `ui:calendar` registration, which is + * `skipFallback: true` and read as a bare-name claimant before this line). + * + * Inert for objectui#7134's own population: the `unit` project holds no `.tsx` + * file at all, which `unit-registry-absence-collision.test.ts` pins. + */ function parse(sourceText, fileName = 'file.ts') { - return ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, ts.ScriptKind.TS); + const kind = /\.tsx$/i.test(fileName) ? ts.ScriptKind.TSX : ts.ScriptKind.TS; + return ts.createSourceFile(fileName, sourceText, ts.ScriptTarget.Latest, true, kind); } function lineOf(sf, node) { @@ -251,25 +265,174 @@ export function readAbsenceAssertions(sourceText, fileName = 'file.ts') { } /** - * Keys the file itself writes into the singleton from a test body — the - * registrations no import-closure measurement can see, because they only - * happen when the test runs. + * String-literal arrays declared in the file, for resolving a registration key + * that arrives through a loop variable rather than as a literal argument. + * `new Set([...])` is read too — the same declaration wearing a different + * constructor. + */ +function localStringArrays(sf) { + const arrays = new Map(); + const literals = (expr) => { + if (!expr) return null; + let target = expr; + // `as const`, `satisfies T` and parentheses wrap the array without changing + // it. Both dynamic-tag factories in `@object-ui/components` write `as + // const`, so skipping this unwrap loses 44 registrations and reports them + // as unresolvable instead. + for (;;) { + if (ts.isAsExpression(target) || ts.isParenthesizedExpression(target) + || (ts.isSatisfiesExpression && ts.isSatisfiesExpression(target)) + || ts.isTypeAssertionExpression?.(target)) { + target = target.expression; + continue; + } + break; + } + if (ts.isNewExpression(target) && ts.isIdentifier(target.expression) && target.expression.text === 'Set' + && target.arguments && target.arguments.length) { + target = target.arguments[0]; + } + if (!ts.isArrayLiteralExpression(target)) return null; + const out = []; + for (const el of target.elements) if (ts.isStringLiteralLike(el)) out.push(el.text); + return out.length ? out : null; + }; + const visit = (node) => { + if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)) { + const values = literals(node.initializer); + if (values) arrays.set(node.name.text, values); + } + ts.forEachChild(node, visit); + }; + visit(sf); + return { arrays, literalsOf: literals }; +} + +/** + * The names a loop variable used as a registration key can take, or `null` + * when the loop is not statically knowable. * - * `register(type, c, { namespace: n })` writes `n:type` AND the bare `type` - * fallback unless `skipFallback` is set (`Registry.register`), so both are - * reported. + * Two shapes, both live in this repository's console registration module: + * `for (const variant of ['chart', 'bar-chart'])` and the same loop over a + * named array. `forEach` is read as well because the array-driven form is one + * refactor away from it. + * + * ⛔ OFF by default. `readOwnRegistrations` must keep answering exactly what it + * answered before this reader existed — objectui#7134's gate floors the sites + * it reports UNRESOLVED, and quietly resolving more of them would shrink a + * population that gate reads as a measurement. */ -export function readOwnRegistrations(sourceText, fileName = 'file.ts') { +function resolveLoopNames(identifier, arrays, literalsOf) { + const name = identifier.text; + let cur = identifier.parent; + while (cur) { + if (ts.isForOfStatement(cur) && cur.initializer && ts.isVariableDeclarationList(cur.initializer)) { + const decl = cur.initializer.declarations[0]; + if (decl && ts.isIdentifier(decl.name) && decl.name.text === name) { + const inline = literalsOf(cur.expression); + if (inline) return inline; + if (ts.isIdentifier(cur.expression) && arrays.has(cur.expression.text)) return arrays.get(cur.expression.text); + return null; + } + } + if ((ts.isArrowFunction(cur) || ts.isFunctionExpression(cur)) && cur.parent && ts.isCallExpression(cur.parent) + && ts.isPropertyAccessExpression(cur.parent.expression) && cur.parent.expression.name.text === 'forEach') { + const param = cur.parameters[0]; + if (param && ts.isIdentifier(param.name) && param.name.text === name) { + const subject = cur.parent.expression.expression; + const inline = literalsOf(subject); + if (inline) return inline; + if (ts.isIdentifier(subject) && arrays.has(subject.text)) return arrays.get(subject.text); + return null; + } + } + cur = cur.parent; + } + return null; +} + +/** + * Is this registration call PRE-EMPTED by an existence check — the + * `if (!ComponentRegistry.get(type)) { ... }` shape the placeholder registrar + * in `@object-ui/components` uses? + * + * It matters for bare-name ownership: a guarded registration writes the bare + * key only when nothing holds it, so it cannot take a key from another + * claimant. Reported rather than filtered, because "did not contest" and "was + * not seen" must not collapse into one answer. + */ +function isGuardedByExistenceCheck(node, receivers) { + let cur = node.parent; + let child = node; + while (cur) { + if (ts.isIfStatement(cur) && cur.thenStatement && cur.thenStatement.pos <= child.pos + && child.end <= cur.thenStatement.end) { + const condition = cur.expression; + let hit = false; + const look = (n) => { + if (hit) return; + if (ts.isCallExpression(n) && ts.isPropertyAccessExpression(n.expression) + && ts.isIdentifier(n.expression.expression) && receivers.has(n.expression.expression.text) + && REGISTRY_READERS.has(n.expression.name.text)) { + hit = true; + return; + } + ts.forEachChild(n, look); + }; + look(condition); + if (hit) return true; + } + child = cur; + cur = cur.parent; + } + return false; +} + +/** + * Every registration CLAIM the file makes, one record per (call site, key + * name) pair — the shared walker underneath `readOwnRegistrations`, exported + * so a caller that needs the CLAIMANT rather than the key can have it without + * a second AST walker over the same calls (objectui#9264). + * + * A claim carries what `Registry.register`'s own fallback branch reads: the + * namespace, the `skipFallback` opt-out, and therefore whether this + * registration claims the BARE key as well as the namespaced one. `method` + * separates `register` from `registerLazy`, which matters because only the + * former carries the runtime collision warning. + * + * Options, all defaulting to the behaviour `readOwnRegistrations` has always + * had, so that function's answer is unchanged by this factoring: + * - `receivers` — receiver identifiers whose `.register(` counts. + * - `resolveLoops` — follow a key that arrives as a loop variable. + * + * Call sites whose key expression stays unresolved are REPORTED, never + * dropped: a silent zero is the failure mode every population in this file is + * written to refuse. + */ +export function readRegistrationClaims(sourceText, fileName = 'file.ts', options = {}) { + const receivers = new Set(options.receivers ?? [SINGLETON]); + const resolveLoops = options.resolveLoops ?? false; const sf = parse(sourceText, fileName); const consts = localStringConsts(sf); - const keys = []; + const { arrays, literalsOf } = localStringArrays(sf); + const claims = []; const unresolved = []; const visit = (node) => { if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression) - && ts.isIdentifier(node.expression.expression) && node.expression.expression.text === SINGLETON + && ts.isIdentifier(node.expression.expression) && receivers.has(node.expression.expression.text) && (node.expression.name.text === 'register' || node.expression.name.text === 'registerLazy')) { - const type = resolveKeyExpression(node.arguments[0], consts); - if (type === null) { + const arg = node.arguments[0] ?? null; + const single = resolveKeyExpression(arg, consts); + let names = single === null ? null : [single]; + let origin = 'literal'; + if (names === null && resolveLoops && arg && ts.isIdentifier(arg)) { + const looped = resolveLoopNames(arg, arrays, literalsOf); + if (looped) { + names = looped; + origin = 'loop'; + } + } + if (names === null) { unresolved.push({ line: lineOf(sf, node), text: node.getText(sf).replace(/\s+/g, ' ').slice(0, 100) }); } else { const meta = node.arguments.find((a) => ts.isObjectLiteralExpression(a)); @@ -283,17 +446,53 @@ export function readOwnRegistrations(sourceText, fileName = 'file.ts') { if (name === 'skipFallback') skipFallback = prop.initializer.kind === ts.SyntaxKind.TrueKeyword; } } - if (namespace) { - keys.push(`${namespace}:${type}`); - if (!skipFallback) keys.push(type); - } else { - keys.push(type); + const guarded = isGuardedByExistenceCheck(node, receivers); + for (const type of names) { + claims.push({ + type, + namespace, + fullType: namespace ? `${namespace}:${type}` : type, + skipFallback, + claimsBare: !namespace || !skipFallback, + guarded, + method: node.expression.name.text, + receiver: node.expression.expression.text, + line: lineOf(sf, node), + origin, + }); } } } ts.forEachChild(node, visit); }; visit(sf); + return { claims, unresolved }; +} + +/** + * Keys the file itself writes into the singleton from a test body — the + * registrations no import-closure measurement can see, because they only + * happen when the test runs. + * + * `register(type, c, { namespace: n })` writes `n:type` AND the bare `type` + * fallback unless `skipFallback` is set (`Registry.register`), so both are + * reported. + * + * A thin projection of `readRegistrationClaims` at its DEFAULT options, which + * is what keeps this function's answer identical to the one objectui#7134's + * gate has been reading. + */ +export function readOwnRegistrations(sourceText, fileName = 'file.ts') { + const { claims, unresolved } = readRegistrationClaims(sourceText, fileName); + const keys = []; + for (const claim of claims) { + if (claim.namespace) { + keys.push(claim.fullType); + if (!claim.skipFallback) keys.push(claim.type); + } else { + keys.push(claim.type); + } + } return { keys: [...new Set(keys)], unresolved }; }