From 54238e1fdd2aa019bb8ce3471e26906294b6bd1b Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 13:14:58 +0000 Subject: [PATCH 1/3] =?UTF-8?q?feat(spec):=20ratchet=20cross-entry=20dual-?= =?UTF-8?q?source=20exports=20=E2=80=94=20same=20name,=20different=20decla?= =?UTF-8?q?ration,=20judged=20by=20symbol=20identity=20(#4446)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit api-surface.json records every export per entry point, so a name appearing on two entries was VISIBLE — but nothing distinguished the two ways that happens, and only one of them is fine: a re-export (one declaration, two import paths) versus a DUAL-SOURCE (each entry resolving the shared name to its own declaration, so which type a consumer gets depends on nothing but the import path). The dual-source case is the #4411 trap: eleven names declared twice across ./kernel and ./system, where the copy that LOOKED canonical was the dead one — a pick by name compiled and failed later, at an edge value. New pure check `check:dual-source-exports`: - Judged by SYMBOL IDENTITY, not name: every export of all 16 public entries is resolved through its alias chain to the original symbol; a name whose entries resolve to >=2 distinct symbols is dual-source. Name-based counting would drown the signal — the real surface carries 148 legitimately re-exported names next to the 63 real findings. - Shrink-only baseline (dual-source-exports.baseline.json) records the 63 existing dual-sources — including the MetadataFormat ./shared≠./system enum divergence, the ./contracts third-shape interfaces, and two type-vs-const cases (ShareRecipientType, TransformType) the name-level scan could not even see. A NEW dual-source fails with the fix at the declaration (converge + re-export, or rename); a resolved one fails until its line is deleted. The baseline is hand-edited under review, deliberately NOT generated: a `gen:` that rewrites it would admit new dual-sources via "run the fix command" instead of via a maintainer decision. - Self-tests first (the check-exported-any pattern), pinning both edges: a fixture dual-source (incl. type-vs-const) must be flagged, a re-export must not, and count assertions keep a resolution failure from reading as clean. - Wired everywhere a new check must be: package.json, the check:generated reconciliation ledger (NO_GENERATOR — it would fail the run unclassified), lint.yml's TypeScript Type Check job after the build step, and the AGENTS.md pure-checks paragraph. Also fixes a fresh flake this work kept tripping over: the #4491 parity tests spawn a tsx subprocess that loads the whole spec surface (~4.5s alone, 5-7s under turbo's parallel load) against vitest's 5s default timeout — three consecutive full-suite runs failed a DIFFERENT test of that file each time, every one a timeout, while the file alone stayed green. The six spawning tests now carry an explicit 60s timeout: a timeout there should mean "the script hung", not "the runner was busy". Closes #4446. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL --- .changeset/dual-source-export-ratchet.md | 37 +++ .github/workflows/lint.yml | 11 + AGENTS.md | 13 +- .../spec/dual-source-exports.baseline.json | 68 ++++ packages/spec/package.json | 1 + .../spec/scripts/check-dual-source-exports.ts | 293 ++++++++++++++++++ packages/spec/scripts/check-generated.ts | 9 + ...ck-react-blocks-declaration-parity.test.ts | 22 +- 8 files changed, 444 insertions(+), 10 deletions(-) create mode 100644 .changeset/dual-source-export-ratchet.md create mode 100644 packages/spec/dual-source-exports.baseline.json create mode 100644 packages/spec/scripts/check-dual-source-exports.ts diff --git a/.changeset/dual-source-export-ratchet.md b/.changeset/dual-source-export-ratchet.md new file mode 100644 index 0000000000..8b9363c9c9 --- /dev/null +++ b/.changeset/dual-source-export-ratchet.md @@ -0,0 +1,37 @@ +--- +"@objectstack/spec": patch +--- + +feat(spec): ratchet cross-entry dual-source exports — same name, different declaration, caught by symbol identity (#4446) + +`api-surface.json` records every export per entry point, so a name appearing on +two entries was VISIBLE — but nothing distinguished a re-export (one +declaration, two import paths — fine) from a **dual-source** (each entry +resolving the shared name to its OWN declaration, so which type a consumer gets +depends on nothing but the import path). The dual-source case is the #4411 +trap: spec carried two differently-shaped `MetadataWatchEvent`s plus ten more +pairs, and the copy that *looked* canonical was the dead one — an auto-import +or model completion picking by name compiled fine and failed later, at an edge +value. + +New pure check `check:dual-source-exports` (lint.yml, after the build step): + +- **Judged by symbol identity, not name.** Every export of all 16 public + entries is resolved through its alias chain to the original symbol; a name + whose entries resolve to ≥2 distinct symbols is dual-source. Name-based + counting would drown the signal — the real surface carries 148 legitimate + re-exported names. +- **Shrink-only baseline** (`dual-source-exports.baseline.json`): the 63 + existing dual-source names are recorded (including the `MetadataFormat` + `./shared`≠`./system` enum divergence, the `./contracts` third-shape + interfaces, and two type-vs-const cases `ShareRecipientType` / + `TransformType`). A NEW dual-source fails the gate with the fix at the + declaration (converge + re-export, or rename); a resolved one fails until its + line is deleted. The baseline is hand-edited under review, deliberately not + generated — a `gen:` would admit new dual-sources via "run the fix command". +- **Self-tests first** (like `check:exported-any`): a fixture proves the + detector still flags a true dual-source (incl. type-vs-const) and still + passes re-exports, so a resolution failure can never read as "clean". + +No runtime code changes; no export changes. The 63 baseline entries are +pre-existing debt, now visible and non-growing. diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 1a4a90394f..54fbebf852 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -440,6 +440,17 @@ jobs: - name: Check no exported spec type resolves to `any` run: pnpm --filter @objectstack/spec run check:exported-any + # Third axis on the same surface: api-surface.json shows a name on two + # entries but not whether the two are ONE declaration re-exported (fine) + # or TWO declarations sharing a name — the #4411 trap, where which type a + # consumer gets depends on nothing but the import path and the copy that + # LOOKS canonical can be the dead one. Judged by symbol identity against + # the built dist; existing dual-sources live in a shrink-only baseline + # (dual-source-exports.baseline.json), so only a NEW one fails (#4446). + # Self-tests first, like exported-any. + - name: Check no new same-name dual-source spec exports + run: pnpm --filter @objectstack/spec run check:dual-source-exports + # Anti-drift for the skill EXAMPLES, not just the skill reference indexes # (#3094). The TypeScript in skills/ is the first thing an AI copies when # authoring metadata, yet nothing type-checked it — so it rotted silently diff --git a/AGENTS.md b/AGENTS.md index 80e19f8f1a..57307ca3a5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -342,10 +342,15 @@ removals" this way while writing this section; `check:generated` now prints this inline when that gate is the one failing.) `check:liveness`, `check:empty-state`, `check:skill-examples`, -`check:react-declaration-parity` and `check:exported-any` are pure checks with no -generator — a failure there is a real finding to fix, not an artifact to regenerate. -`check:generated` names them as deliberately not run, so its "all up to date" never reads -as "everything passed". +`check:react-declaration-parity`, `check:exported-any` and `check:dual-source-exports` are +pure checks with no generator — a failure there is a real finding to fix, not an artifact +to regenerate. `check:generated` names them as deliberately not run, so its "all up to +date" never reads as "everything passed". The last one asks the third question about the +export surface (#4446): `api-surface.json` shows a name on two entries but not whether +that is one declaration re-exported (fine) or two declarations sharing a name — the #4411 +trap, judged by symbol identity against the built dist, with the accepted cases in the +shrink-only `dual-source-exports.baseline.json` (hand-edited under review, never +generated: a `gen:` would admit a new dual-source via "run the fix command"). ⚠️ **`check:react-declaration-parity` compares two DECLARATIONS, not a declaration against an implementation.** Left: the props a block's spec zod schema declares. Right: the inputs diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json new file mode 100644 index 0000000000..60e38039ed --- /dev/null +++ b/packages/spec/dual-source-exports.baseline.json @@ -0,0 +1,68 @@ +{ + "_comment": "Accepted cross-entry DUAL-SOURCE exports of @objectstack/spec (#4446): names that two or more public entry points export for DIFFERENT declarations, so which type a consumer gets depends on the import path — the #4411 trap. Shrink-only ratchet, judged by symbol identity (a re-export of one declaration from many entries is fine and not listed). A NEW name here fails check:dual-source-exports: converge on one declaration and re-export it, or rename one side — growing this list needs maintainer sign-off and shows up as this file in the diff. An entry that stops being dual-source fails until its line is deleted. Regenerate with: tsx scripts/check-dual-source-exports.ts --update (after pnpm build).", + "entries": [ + "ActionLocationSchema — [./studio (const)] ≠ [./ui (const)]", + "ActivationEventSchema — [./kernel (const)] ≠ [./studio (const)]", + "AnalyticsQuery — [./contracts (interface)] ≠ [./data (type)]", + "CacheStrategy — [./shared (type)] ≠ [./system (type)]", + "ConflictResolution — [./automation (type)] ≠ [./integration (type)] ≠ [./ui (type)]", + "ConflictResolutionSchema — [./automation (const)] ≠ [./integration (const)] ≠ [./ui (const)]", + "Connector — [./automation (type)] ≠ [./integration (type)]", + "ConnectorSchema — [./automation (const)] ≠ [./integration (const)]", + "ConnectorTriggerSchema — [./automation (const)] ≠ [./integration (const)]", + "ConsumerConfig — [./integration (type)] ≠ [./system (type)]", + "ConsumerConfigSchema — [./integration (const)] ≠ [./system (const)]", + "DataSyncConfig — [./automation (type)] ≠ [./integration (type)]", + "DataSyncConfigSchema — [./automation (const)] ≠ [./integration (const)]", + "DatabaseProvider — [./integration (type)] ≠ [./system (type)]", + "DatabaseProviderSchema — [./integration (const)] ≠ [./system (const)]", + "DriverCapabilities — [./contracts (interface)] ≠ [./data (type)]", + "EnvironmentArtifact — [./cloud (type)] ≠ [./system (type)]", + "EnvironmentArtifactInput — [./cloud (type)] ≠ [./system (type)]", + "EnvironmentArtifactSchema — [./cloud (const)] ≠ [./system (const)]", + "EventSchema — [./automation (const)] ≠ [./kernel (const)]", + "FieldMapping — [./data (type)] ≠ [./integration (type)] ≠ [./shared (type)]", + "FieldMappingSchema — [./data (const)] ≠ [./integration (const)] ≠ [./shared (const)]", + "HealthStatus — [./contracts (interface)] ≠ [./kernel (type)]", + "HttpMethod — [./api, ./shared (type)] ≠ [./ui (type)]", + "HttpRequest — [./shared (type)] ≠ [./ui (type)]", + "JobExecution — [./contracts (interface)] ≠ [./system (type)]", + "JobSchedule — [./contracts (interface)] ≠ [./system (type)]", + "MessageQueueProvider — [./integration (type)] ≠ [./system (type)]", + "MessageQueueProviderSchema — [./integration (const)] ≠ [./system (const)]", + "MetadataBulkRegisterRequestSchema — [./api (const)] ≠ [./kernel (const)]", + "MetadataEvent — [./api (type)] ≠ [./kernel (type)]", + "MetadataEventSchema — [./api (const)] ≠ [./kernel (const)]", + "MetadataExportOptions — [./contracts (interface)] ≠ [./system (type)]", + "MetadataFormat — [./shared (type)] ≠ [./system (type)]", + "MetadataFormatSchema — [./shared (const)] ≠ [./system (const)]", + "MetadataImportOptions — [./contracts (interface)] ≠ [./system (type)]", + "MultipartUploadConfig — [./integration (type)] ≠ [./system (type)]", + "MultipartUploadConfigSchema — [./integration (const)] ≠ [./system (const)]", + "Notification — [./api (type)] ≠ [./ui (type)]", + "NotificationChannel — [./contracts (type)] ≠ [./system (type)]", + "NotificationConfig — [./system (type)] ≠ [./ui (type)]", + "NotificationConfigSchema — [./system (const)] ≠ [./ui (const)]", + "NotificationSchema — [./api (const)] ≠ [./ui (const)]", + "PackageDependency — [./cloud (type)] ≠ [./kernel (type)]", + "PackageDependencySchema — [./cloud (const)] ≠ [./kernel (const)]", + "PluginStartupResult — [./contracts (interface)] ≠ [./kernel (type)]", + "RateLimitConfig — [./integration (type)] ≠ [./shared (type)]", + "RateLimitConfigSchema — [./integration (const)] ≠ [./shared (const)]", + "RetryPolicy — [./automation (type)] ≠ [./system (type)]", + "RetryPolicySchema — [./automation (const)] ≠ [./system (const)]", + "Session — [./api (type)] ≠ [./identity (type)]", + "SessionSchema — [./api (const)] ≠ [./identity (const)]", + "ShareRecipientType — [./contracts (type)] ≠ [./security (const)]", + "StartupOptions — [./contracts (interface)] ≠ [./kernel (type)]", + "TenantPlan — [./cloud (type)] ≠ [./system (type)]", + "TenantPlanSchema — [./cloud (const)] ≠ [./system (const)]", + "TransformType — [./data (const)] ≠ [./shared (type)]", + "ValidationResult — [./contracts (interface)] ≠ [./kernel (type)]", + "WebhookConfig — [./api (type)] ≠ [./integration (type)]", + "WebhookConfigSchema — [./api (const)] ≠ [./integration (const)]", + "WebhookEvent — [./api (type)] ≠ [./integration (type)]", + "WebhookEventSchema — [./api (const)] ≠ [./integration (const)]", + "suggestFieldType — [., ./shared (function)] ≠ [./data (function)]" + ] +} diff --git a/packages/spec/package.json b/packages/spec/package.json index 3c56f0214c..0c2e663482 100644 --- a/packages/spec/package.json +++ b/packages/spec/package.json @@ -199,6 +199,7 @@ "gen:api-surface": "tsx scripts/build-api-surface.ts", "check:api-surface": "tsx scripts/build-api-surface.ts --check", "check:exported-any": "tsx scripts/check-exported-any.ts --self-test && tsx scripts/check-exported-any.ts", + "check:dual-source-exports": "tsx scripts/check-dual-source-exports.ts --self-test && tsx scripts/check-dual-source-exports.ts", "check:authorable-surface": "OS_EAGER_SCHEMAS=1 tsx scripts/build-schemas.ts --check", "gen:spec-changes": "tsx scripts/build-spec-changes.ts", "check:spec-changes": "tsx scripts/build-spec-changes.ts --check", diff --git a/packages/spec/scripts/check-dual-source-exports.ts b/packages/spec/scripts/check-dual-source-exports.ts new file mode 100644 index 0000000000..251b288c4d --- /dev/null +++ b/packages/spec/scripts/check-dual-source-exports.ts @@ -0,0 +1,293 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * check-dual-source-exports.ts — no two entry points of @objectstack/spec may + * export the same name for DIFFERENT declarations. + * + * `api-surface.json` records every `name (kind)` per entry point, so a name + * appearing on two entries is VISIBLE there — but nothing distinguishes the two + * ways that can happen, and only one of them is fine: + * + * - re-export: both entries resolve to the SAME declaration. One symbol, + * two import paths. Harmless, common (root `.` re-exports the domains). + * - dual-source: each entry resolves to its OWN declaration under a shared + * name. Which type you get depends on nothing but the import path. + * + * The dual-source case is the #4411 trap. Spec carried two differently-shaped + * `MetadataWatchEvent`s on `./kernel` and `./system` — plus ten more pairs in + * the same file — and the naming intuition pointed the WRONG way: the copy that + * looked canonical (normalized enums, required fields, a `.describe()` per + * property) was the dead one. An auto-import or a model completion picking by + * name, or by which copy reads as more rigorous, picked the dead one; because + * the shapes overlapped heavily, the wrong pick compiled and failed later, at + * an edge value (`add` vs `added`) or on a field one copy made required. No + * human review catches this: each file is locally reasonable. + * + * So the distinction is drawn where it exists — SYMBOL IDENTITY, not name. + * Every export of every public entry is resolved through its alias chain to + * the original symbol; a name whose entries resolve to two or more distinct + * symbols is dual-source. Judging by name alone would drown the signal in + * ~80 legitimate re-exports. + * + * The existing dual-sources are recorded in `dual-source-exports.baseline.json` + * — a shrink-only ratchet. A NEW dual-source name fails this gate; an entry + * that stops being dual-source (converged or renamed) fails until its baseline + * line is deleted, so the ledger cannot quietly stop ratcheting. Fix a new + * finding by NOT introducing the second declaration: import the existing one + * and re-export it, or pick a different name. Growing the baseline is a + * deliberate act that shows up in review as a baseline diff. + * + * ## Usage + * + * pnpm --filter @objectstack/spec check:dual-source-exports # self-test + audit + * tsx scripts/check-dual-source-exports.ts --update # rewrite baseline (review the diff!) + * tsx scripts/check-dual-source-exports.ts --self-test # fixture check only + * + * Reads the built dist — run after `pnpm --filter @objectstack/spec build`. + * The declaration bundler emits each source module into exactly one output + * chunk, so distinct dist declarations imply distinct source declarations; the + * self-test pins the detector itself, and the count assertions keep a silent + * resolution failure from reading as "clean". + */ +import ts from 'typescript'; +import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'; +import { join, resolve } from 'node:path'; +import { tmpdir } from 'node:os'; +import { fileURLToPath } from 'node:url'; + +const PKG_DIR = resolve(fileURLToPath(new URL('.', import.meta.url)), '..'); +const BASELINE_PATH = resolve(PKG_DIR, 'dual-source-exports.baseline.json'); +const SELF_TEST = process.argv.includes('--self-test'); +const UPDATE = process.argv.includes('--update'); + +/** Public entry points → their built CJS `.d.ts`, read from the exports map. */ +function collectEntries(): Record { + const pkg = JSON.parse(readFileSync(resolve(PKG_DIR, 'package.json'), 'utf8')); + const entries: Record = {}; + for (const [sub, val] of Object.entries(pkg.exports ?? {})) { + if (!sub.startsWith('.')) continue; + const dts = val?.require?.types ?? val?.import?.types; + if (typeof dts === 'string' && dts.endsWith('.d.ts')) entries[sub] = resolve(PKG_DIR, dts); + } + return entries; +} + +function kindOf(flags: ts.SymbolFlags): string { + if (flags & ts.SymbolFlags.Function) return 'function'; + if (flags & ts.SymbolFlags.Class) return 'class'; + if (flags & ts.SymbolFlags.Enum) return 'enum'; + if (flags & ts.SymbolFlags.Interface) return 'interface'; + if (flags & ts.SymbolFlags.TypeAlias) return 'type'; + if (flags & ts.SymbolFlags.Variable) return 'const'; + if (flags & ts.SymbolFlags.Namespace) return 'namespace'; + return 'other'; +} + +type ScanResult = { + /** Stable line per dual-source name: `Name — [./a, ./b (kind)] ≠ [./c (kind)]`. */ + findings: string[]; + /** Total distinct export names seen across all entries. */ + names: number; + /** Names on ≥2 entries that resolved to ONE symbol — the benign re-exports. */ + reExports: number; +}; + +/** + * Group every entry's exports by name, then partition each name's entries by + * the ORIGINAL symbol they resolve to. One partition = re-export; two or more + * = dual-source. The finding line encodes the partition (which entries share a + * declaration), not declaration positions — chunk file names carry content + * hashes and would churn the baseline on every build. + */ +function scan(program: ts.Program, entries: Record): ScanResult { + const checker = program.getTypeChecker(); + const unalias = (s: ts.Symbol): ts.Symbol => + s.getFlags() & ts.SymbolFlags.Alias ? checker.getAliasedSymbol(s) : s; + + // name → (original symbol → entries exporting it under that name) + const byName = new Map>(); + + for (const [sub, file] of Object.entries(entries)) { + const sf = program.getSourceFile(file); + const moduleSym = sf && checker.getSymbolAtLocation(sf); + if (!moduleSym) throw new Error(`Could not resolve module symbol for ${sub} (${file}). Is the package built?`); + for (const exported of checker.getExportsOfModule(moduleSym)) { + const name = exported.getName(); + const original = unalias(exported); + let groups = byName.get(name); + if (!groups) byName.set(name, (groups = new Map())); + let subs = groups.get(original); + if (!subs) groups.set(original, (subs = [])); + subs.push(sub); + } + } + + const result: ScanResult = { findings: [], names: byName.size, reExports: 0 }; + for (const [name, groups] of byName) { + const multiEntry = [...groups.values()].some((subs) => subs.length > 1) || groups.size > 1; + if (groups.size === 1) { + if (multiEntry) result.reExports++; + continue; + } + const parts = [...groups.entries()] + .map(([sym, subs]) => `[${subs.sort().join(', ')} (${kindOf(sym.getFlags())})]`) + .sort(); + result.findings.push(`${name} — ${parts.join(' ≠ ')}`); + } + result.findings.sort(); + return result; +} + +function makeProgram(files: string[], extra: ts.CompilerOptions = {}): ts.Program { + return ts.createProgram(files, { + module: ts.ModuleKind.NodeNext, + moduleResolution: ts.ModuleResolutionKind.NodeNext, + skipLibCheck: true, + noEmit: true, + ...extra, + }); +} + +// ── Self-test ──────────────────────────────────────────────────────────────── + +/** + * Pin both edges: a true dual-source must be flagged (a false negative makes + * the gate dormant — green forever, indistinguishable from clean), and a + * re-export must NOT be (a false positive drowns the signal in the ~80 + * legitimate re-exports the real surface carries). + */ +function selfTest(): never { + const fail = (msg: string): never => { + console.error(`✗ self-test: ${msg}`); + process.exit(1); + }; + + const dir = mkdtempSync(join(tmpdir(), 'spec-dual-source-')); + try { + // shared.ts — the single-source declarations both entries re-export. + writeFileSync(join(dir, 'shared.ts'), [ + `export type SharedType = { a: string };`, + `export const sharedConst = 1;`, + ].join('\n'), 'utf8'); + // Entry A: re-exports shared, declares its own TrueDup + Mixed (type). + writeFileSync(join(dir, 'a.ts'), [ + `export { SharedType, sharedConst } from './shared';`, + `export type TrueDup = { fromA: true };`, + `export type Mixed = { a: string };`, + `export type OnlyA = { onlyA: true };`, + ].join('\n'), 'utf8'); + // Entry B: re-exports shared, declares its own TrueDup + Mixed (const) — + // the type-vs-const face of the same trap. + writeFileSync(join(dir, 'b.ts'), [ + `export type { SharedType } from './shared';`, + `export { sharedConst } from './shared';`, + `export type TrueDup = { fromB: true };`, + `export const Mixed = { a: 'b' };`, + `export type OnlyB = { onlyB: true };`, + ].join('\n'), 'utf8'); + + const entries = { './a': join(dir, 'a.ts'), './b': join(dir, 'b.ts') }; + const program = makeProgram(Object.values(entries)); + const syntactic = program.getSyntacticDiagnostics(); + if (syntactic.length > 0) fail(`fixture does not parse: ${ts.flattenDiagnosticMessageText(syntactic[0].messageText, ' ')}`); + + const { findings, names, reExports } = scan(program, entries); + const flagged = new Set(findings.map((f) => f.split(' — ')[0])); + + // 6 distinct names (SharedType, sharedConst, TrueDup, Mixed, OnlyA, OnlyB). + // Fewer means exports are not resolving, and every assertion below would + // pass vacuously — the exact way a gate goes dormant. + if (names !== 6) fail(`saw ${names} export names, expected 6 — the fixture's modules are not resolving`); + if (reExports !== 2) fail(`saw ${reExports} re-exported names, expected 2 (SharedType, sharedConst) — alias resolution is broken`); + + for (const name of ['TrueDup', 'Mixed']) { + if (!flagged.has(name)) fail(`missed \`${name}\` — two declarations share the name and the gate is DORMANT`); + } + for (const name of ['SharedType', 'sharedConst', 'OnlyA', 'OnlyB']) { + if (flagged.has(name)) fail(`false positive on \`${name}\` — only same-name DIFFERENT-declaration exports may be flagged`); + } + } finally { + rmSync(dir, { recursive: true, force: true }); + } + + console.log('✅ self-test: flags same-name different-declaration exports, and nothing else.'); + process.exit(0); +} + +if (SELF_TEST) selfTest(); + +// ── Audit ──────────────────────────────────────────────────────────────────── + +const entries = collectEntries(); +const { findings, names, reExports } = scan(makeProgram(Object.values(entries)), entries); + +interface Baseline { _comment: string; entries: string[] } + +const BASELINE_COMMENT = + 'Accepted cross-entry DUAL-SOURCE exports of @objectstack/spec (#4446): names that two or more ' + + 'public entry points export for DIFFERENT declarations, so which type a consumer gets depends on ' + + 'the import path — the #4411 trap. Shrink-only ratchet, judged by symbol identity (a re-export of ' + + 'one declaration from many entries is fine and not listed). A NEW name here fails ' + + 'check:dual-source-exports: converge on one declaration and re-export it, or rename one side — ' + + 'growing this list needs maintainer sign-off and shows up as this file in the diff. An entry that ' + + 'stops being dual-source fails until its line is deleted. Regenerate with: ' + + 'tsx scripts/check-dual-source-exports.ts --update (after pnpm build).'; + +if (UPDATE) { + const doc: Baseline = { _comment: BASELINE_COMMENT, entries: findings }; + writeFileSync(BASELINE_PATH, JSON.stringify(doc, null, 2) + '\n'); + console.log(`Wrote ${findings.length} dual-source entr${findings.length === 1 ? 'y' : 'ies'} to dual-source-exports.baseline.json — review the diff before committing.`); + process.exit(0); +} + +let baseline: Baseline; +try { + baseline = JSON.parse(readFileSync(BASELINE_PATH, 'utf8')); +} catch { + console.error(`No baseline at ${BASELINE_PATH}. Run \`tsx scripts/check-dual-source-exports.ts --update\` after a build and commit it.`); + process.exit(1); +} + +const known = new Set(baseline.entries); +const current = new Set(findings); +const fresh = findings.filter((f) => !known.has(f)); +const stale = baseline.entries.filter((e) => !current.has(e)); + +if (fresh.length === 0 && stale.length === 0) { + console.log( + `✅ no new dual-source exports: ${names} names across ${Object.keys(entries).length} entry points — ` + + `${reExports} re-exported (single declaration), ${findings.length} accepted dual-source (baseline).`, + ); + process.exit(0); +} + +if (fresh.length > 0) { + console.error(`❌ ${fresh.length} NEW dual-source export name(s) — two entry points now export the same name for different declarations:\n`); + for (const f of fresh) console.error(` • ${f}`); + console.error( + '\nWhich type a consumer gets now depends on nothing but the import path. An auto-import or a\n' + + 'model completion resolves this by coin-flip, and because such shapes usually overlap, the wrong\n' + + 'pick compiles and fails later at an edge value — the #4411 trap this gate exists to prevent\n' + + '(eleven names were declared twice across ./kernel and ./system, and the copy that LOOKED\n' + + 'canonical was the dead one).\n\n' + + 'Fix it at the declaration, not the ledger:\n' + + ' - if both should be one concept: keep ONE declaration and re-export it from the other entry\n' + + ' (the MetadataManagerConfig pattern — system re-exports kernel’s; a re-export is not flagged);\n' + + ' - if they are genuinely different concepts: one of them is misnamed — rename it.\n\n' + + 'If a maintainer decides a new dual-source must stand, add the line to\n' + + 'dual-source-exports.baseline.json — deliberately, in review, with the reason in the PR.', + ); +} + +if (stale.length > 0) { + console.error(`\n❌ ${stale.length} stale baseline entr${stale.length === 1 ? 'y' : 'ies'} — no longer dual-source, delete the line(s):\n`); + for (const e of stale) console.error(` • ${e}`); + console.error( + '\nThe baseline is shrink-only. A stale line stays available to cover the NEXT same-name collision\n' + + "under the last one's justification, which is how a ratchet quietly stops ratcheting. (If the\n" + + 'partition merely changed shape, the new form is reported above as a new finding — replace the\n' + + 'line, deliberately.)', + ); +} + +process.exit(1); diff --git a/packages/spec/scripts/check-generated.ts b/packages/spec/scripts/check-generated.ts index dd83f94078..50c2565faa 100644 --- a/packages/spec/scripts/check-generated.ts +++ b/packages/spec/scripts/check-generated.ts @@ -88,6 +88,15 @@ const NO_GENERATOR: ReadonlyArray<{ check: string; why: string }> = [ check: 'check:exported-any', why: 'audits the built .d.ts for exported types/schemas that resolve to `any` — no artifact (needs a fresh `pnpm build`)', }, + // Reads the built dist like exported-any. Its baseline + // (dual-source-exports.baseline.json) is a shrink-only ledger edited by hand + // under review — deliberately NOT a generated artifact, because a `gen:` that + // rewrites it would admit a new dual-source via "run the fix command" instead + // of via a maintainer decision (#4446). + { + check: 'check:dual-source-exports', + why: 'audits the built .d.ts for same-name exports resolving to DIFFERENT declarations across entry points — baseline is hand-ratcheted, not generated (needs a fresh `pnpm build`)', + }, ]; /** diff --git a/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts b/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts index 012a5a0ac6..a052722d32 100644 --- a/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts +++ b/packages/spec/scripts/check-react-blocks-declaration-parity.test.ts @@ -46,6 +46,16 @@ const manifestFor = (type: string, inputs: string[]): Manifest => ({ components: { [type]: { type, inputs: inputs.map((name) => ({ name })) } }, }); +/** + * Every `run()` spawns a fresh tsx process that loads the whole spec schema + * surface — ~4.5s alone and 5–7s under turbo's parallel test load, which is + * ON the 5s vitest default. Which test crossed the line varied run to run: + * three consecutive `pnpm test` sweeps failed a different `it` of this file + * each time, every one a timeout, while the file alone stayed green. A + * timeout here should mean "the script hung", not "the runner was busy". + */ +const SPAWN_TIMEOUT_MS = 60_000; + /** Run the real script against a synthetic manifest; return stdout+stderr. */ function run(manifest: Manifest, args: string[] = []): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'react-parity-')); @@ -73,17 +83,17 @@ const SCHEMA_PROP = 'layout'; const NOT_A_SCHEMA_PROP = 'zzzNotInTheFormViewSchema'; describe('check:react-declaration-parity — the signals it can see', () => { - it('reports a registry-declared input the spec does not declare as registry-only', () => { + it('reports a registry-declared input the spec does not declare as registry-only', { timeout: SPAWN_TIMEOUT_MS }, () => { const out = run(manifestFor('object-form', [SCHEMA_PROP, NOT_A_SCHEMA_PROP])); expect(out).toMatch(/registry declares, spec does not: .*zzzNotInTheFormViewSchema/); }); - it('reports a spec-declared prop the registry does not declare as spec-only', () => { + it('reports a spec-declared prop the registry does not declare as spec-only', { timeout: SPAWN_TIMEOUT_MS }, () => { const out = run(manifestFor('object-form', [])); expect(out).toMatch(new RegExp(`spec declares, registry does not: .*${SCHEMA_PROP}`)); }); - it('reports a block absent from the manifest as missing', () => { + it('reports a block absent from the manifest as missing', { timeout: SPAWN_TIMEOUT_MS }, () => { const out = run(manifestFor('something-else', [])); expect(out).toMatch(/ \(object-form\): NO component in the manifest/); }); @@ -100,7 +110,7 @@ describe('check:react-declaration-parity — the blind spot is stated, every run * The assertion is therefore not "it catches this" (it cannot) but "it says so * while reporting the agreement". */ - it('calls agreeing declarations agreement — and prints the caveat alongside it', () => { + it('calls agreeing declarations agreement — and prints the caveat alongside it', { timeout: SPAWN_TIMEOUT_MS }, () => { const out = run(manifestFor('object-form', [SCHEMA_PROP])); expect(out).toMatch(/declared by both/); expect(out).not.toMatch(/registry declares, spec does not/); @@ -109,7 +119,7 @@ describe('check:react-declaration-parity — the blind spot is stated, every run expect(out).toMatch(/#4413/); }); - it('carries the caveat on a clean baseline ratchet too, where it is easiest to over-read', () => { + it('carries the caveat on a clean baseline ratchet too, where it is easiest to over-read', { timeout: SPAWN_TIMEOUT_MS }, () => { const baseline = path.join(PKG, 'react-declaration-parity.baseline.json'); // Every baselined block must be present, or the run reports them vanished // instead of clean. Each declares only spec props, so registry-only is empty @@ -141,7 +151,7 @@ describe('check:react-declaration-parity — the retired claim stays retired (Pr */ const CLAIM_WORDS = /\b(actually implements?|conforms? to|conformance)\b/i; - it('the report never claims the frontend implements anything', () => { + it('the report never claims the frontend implements anything', { timeout: SPAWN_TIMEOUT_MS }, () => { const out = run(manifestFor('object-form', [SCHEMA_PROP, NOT_A_SCHEMA_PROP])); expect(out).not.toMatch(CLAIM_WORDS); }); From 9273bb98bd28a345d3f6da35e93acfdc1178a9aa Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 13:38:48 +0000 Subject: [PATCH 2/3] chore(spec): shrink the dual-source baseline by the 8 pairs #4500 resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First contact with reality, one merge in: #4500 removed the connector "template" cluster, deleting the ./integration copies of ConsumerConfig, DatabaseProvider, MessageQueueProvider and MultipartUploadConfig (type + Schema each). Those 8 names are no longer dual-source, and the gate's stale-entry leg refused to pass until their baseline lines were deleted — the shrink-only ratchet ratcheting down exactly as designed. 63 → 55. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL --- packages/spec/dual-source-exports.baseline.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json index 60e38039ed..fe78e48ca3 100644 --- a/packages/spec/dual-source-exports.baseline.json +++ b/packages/spec/dual-source-exports.baseline.json @@ -10,12 +10,8 @@ "Connector — [./automation (type)] ≠ [./integration (type)]", "ConnectorSchema — [./automation (const)] ≠ [./integration (const)]", "ConnectorTriggerSchema — [./automation (const)] ≠ [./integration (const)]", - "ConsumerConfig — [./integration (type)] ≠ [./system (type)]", - "ConsumerConfigSchema — [./integration (const)] ≠ [./system (const)]", "DataSyncConfig — [./automation (type)] ≠ [./integration (type)]", "DataSyncConfigSchema — [./automation (const)] ≠ [./integration (const)]", - "DatabaseProvider — [./integration (type)] ≠ [./system (type)]", - "DatabaseProviderSchema — [./integration (const)] ≠ [./system (const)]", "DriverCapabilities — [./contracts (interface)] ≠ [./data (type)]", "EnvironmentArtifact — [./cloud (type)] ≠ [./system (type)]", "EnvironmentArtifactInput — [./cloud (type)] ≠ [./system (type)]", @@ -28,8 +24,6 @@ "HttpRequest — [./shared (type)] ≠ [./ui (type)]", "JobExecution — [./contracts (interface)] ≠ [./system (type)]", "JobSchedule — [./contracts (interface)] ≠ [./system (type)]", - "MessageQueueProvider — [./integration (type)] ≠ [./system (type)]", - "MessageQueueProviderSchema — [./integration (const)] ≠ [./system (const)]", "MetadataBulkRegisterRequestSchema — [./api (const)] ≠ [./kernel (const)]", "MetadataEvent — [./api (type)] ≠ [./kernel (type)]", "MetadataEventSchema — [./api (const)] ≠ [./kernel (const)]", @@ -37,8 +31,6 @@ "MetadataFormat — [./shared (type)] ≠ [./system (type)]", "MetadataFormatSchema — [./shared (const)] ≠ [./system (const)]", "MetadataImportOptions — [./contracts (interface)] ≠ [./system (type)]", - "MultipartUploadConfig — [./integration (type)] ≠ [./system (type)]", - "MultipartUploadConfigSchema — [./integration (const)] ≠ [./system (const)]", "Notification — [./api (type)] ≠ [./ui (type)]", "NotificationChannel — [./contracts (type)] ≠ [./system (type)]", "NotificationConfig — [./system (type)] ≠ [./ui (type)]", From d0e9c06f8053bcd06ccdd2807109ed6aa1edc569 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 1 Aug 2026 14:10:27 +0000 Subject: [PATCH 3/3] chore(spec): shrink the dual-source baseline by the 3 pairs #4503 resolved MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Second catch in one afternoon, this time inside the merge queue: the queue built this branch against a main that had just landed #4503 (trigger-registry Connector cluster removal), which deleted the ./automation copies of Connector, ConnectorSchema and ConnectorTriggerSchema — and the gate refused the queue build until their baseline lines were gone. 55 → 52. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01M9uWvoEp9CoLzYjNExj9sL --- packages/spec/dual-source-exports.baseline.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/spec/dual-source-exports.baseline.json b/packages/spec/dual-source-exports.baseline.json index fe78e48ca3..7f8d8673de 100644 --- a/packages/spec/dual-source-exports.baseline.json +++ b/packages/spec/dual-source-exports.baseline.json @@ -7,9 +7,6 @@ "CacheStrategy — [./shared (type)] ≠ [./system (type)]", "ConflictResolution — [./automation (type)] ≠ [./integration (type)] ≠ [./ui (type)]", "ConflictResolutionSchema — [./automation (const)] ≠ [./integration (const)] ≠ [./ui (const)]", - "Connector — [./automation (type)] ≠ [./integration (type)]", - "ConnectorSchema — [./automation (const)] ≠ [./integration (const)]", - "ConnectorTriggerSchema — [./automation (const)] ≠ [./integration (const)]", "DataSyncConfig — [./automation (type)] ≠ [./integration (type)]", "DataSyncConfigSchema — [./automation (const)] ≠ [./integration (const)]", "DriverCapabilities — [./contracts (interface)] ≠ [./data (type)]",