From ae3abd706952d133ccf84073881d8eb2cabed68b Mon Sep 17 00:00:00 2001 From: Chris Tate Date: Thu, 30 Jul 2026 21:11:09 -0500 Subject: [PATCH] Refuse unresolved subscriptions exports - Track whether subscriptions_export is explicitly declared and reject dangling names at the profile. - Preserve optional omission semantics and cover both paths with contract regressions. --- packages/compiler/src/library/profile.ts | 9 +++++++++ packages/compiler/src/library/sidecar.ts | 12 +++++++++++- tests/harness/library-contract.test.ts | 21 +++++++++++++++++++-- tests/harness/library-profile.test.ts | 1 + 4 files changed, 40 insertions(+), 3 deletions(-) diff --git a/packages/compiler/src/library/profile.ts b/packages/compiler/src/library/profile.ts index 4bf763161..7127dc5f0 100644 --- a/packages/compiler/src/library/profile.ts +++ b/packages/compiler/src/library/profile.ts @@ -151,6 +151,10 @@ export interface LibrarySidecarConfig { initExport: string; updateExport: string; subscriptionsExport: string; + /** True only when `sidecar.subscriptions_export` is present in the + * profile. Omission keeps subscriptions optional; a declared name is a + * reference that must resolve to an exported function. */ + subscriptionsExportDeclared: boolean; /** The profile-selected source-tree hashing contract. v1 implements * exactly one: "module-graph" (wyhash-64, seed 0xc0de5eed, over the * length-prefixed sorted module graph — canonical root-relative POSIX @@ -175,6 +179,9 @@ export interface LibrarySidecarConfig { export interface LibraryProfile { profileFormat: 1; name: string; + /** The profile file that produced this configuration. Semantic + * designation errors anchor here, where the offending name is written. */ + profilePath: string; /** Resolved absolute path of the ONE entry module. */ entry: string; /** The pinned emission — no fallback concept exists on the library path. */ @@ -432,6 +439,7 @@ export function loadLibraryProfile( initExport: nameField("init_export", "init"), updateExport: nameField("update_export", "update"), subscriptionsExport: nameField("subscriptions_export", "subscriptions"), + subscriptionsExportDeclared: s["subscriptions_export"] !== undefined, sourceHash: "module-graph", integerSlots, }; @@ -562,6 +570,7 @@ export function loadLibraryProfile( profile: { profileFormat: 1, name, + profilePath, entry, emission, prefix, diff --git a/packages/compiler/src/library/sidecar.ts b/packages/compiler/src/library/sidecar.ts index b2dba034e..9979e40d6 100644 --- a/packages/compiler/src/library/sidecar.ts +++ b/packages/compiler/src/library/sidecar.ts @@ -1456,6 +1456,16 @@ export function buildSidecar(input: SidecarBuildInput): SidecarBuildResult { const helperNames = new Set(helpers.map((h) => h.name)); // Shape flags from the designated entries' declared signatures. + const subscriptionsFn = facts.functions.find((f) => f.name === config.subscriptionsExport); + if (config.subscriptionsExportDeclared && subscriptionsFn === undefined) { + throw new SidecarRefusal( + libSidecarDiag( + `'sidecar.subscriptions_export' designates export '${config.subscriptionsExport}', but the entry module exports no function by that name`, + { file: profile.profilePath, start: 0, end: 0 }, + `export a function named '${config.subscriptionsExport}' from the entry module, or omit 'sidecar.subscriptions_export' when the contract has no subscriptions entry`, + ), + ); + } const returnsCmd = (which: "init" | "update", exportName: string): boolean => { const fn = facts.functions.find((f) => f.name === exportName); if (fn === undefined) { @@ -1473,7 +1483,7 @@ export function buildSidecar(input: SidecarBuildInput): SidecarBuildResult { }; const initReturnsCmd = returnsCmd("init", config.initExport); const updateReturnsCmd = returnsCmd("update", config.updateExport); - const hasSubscriptions = facts.functions.some((f) => f.name === config.subscriptionsExport); + const hasSubscriptions = subscriptionsFn !== undefined; // Unbound lists: model fields or helper entries (helpers are bindable // surface — the clarified rule) on the model side, arm names on the diff --git a/tests/harness/library-contract.test.ts b/tests/harness/library-contract.test.ts index 31a9eaeb1..3d7157033 100644 --- a/tests/harness/library-contract.test.ts +++ b/tests/harness/library-contract.test.ts @@ -837,7 +837,7 @@ async function sidecarRefusal( source: string, sidecarPatch: Record = {}, extraFiles: Record = {}, -): Promise<{ code: string; message: string; hint?: string }[]> { +): Promise<{ code: string; message: string; file: string; hint?: string }[]> { const outDir = join(cacheDir, `refusal-${refusalCounter++}`); mkdirSync(outDir, { recursive: true }); const entry = join(outDir, "lib.ts"); @@ -872,7 +872,11 @@ async function sidecarRefusal( const result = await compileLibrary({ profilePath, outDir }); expect(result.ok).toBe(false); if (result.ok) throw new Error("unreachable"); - return result.diagnostics.map((d) => (d.hint === undefined ? { code: d.code, message: d.message } : { code: d.code, message: d.message, hint: d.hint })); + return result.diagnostics.map((d) => ( + d.hint === undefined + ? { code: d.code, message: d.message, file: d.loc.file } + : { code: d.code, message: d.message, file: d.loc.file, hint: d.hint } + )); } const REFUSAL_BASE = `export interface Model { count: number; } @@ -985,6 +989,19 @@ export function boot(): number { }); describe("SC4009: contract sidecar refusals", () => { + test("an explicit subscriptions export naming nothing refuses at the profile", async () => { + const diags = await sidecarRefusal(REFUSAL_BASE, { subscriptions_export: "watch" }); + expect(diags[0]!.code).toBe("SC4009"); + expect(diags[0]!.message).toContain("'sidecar.subscriptions_export'"); + expect(diags[0]!.message).toContain("'watch'"); + expect(diags[0]!.file).toMatch(/[/\\]profile\.json$/); + }); + + test("an omitted subscriptions export remains optional", async () => { + const doc = await sidecarProjection(REFUSAL_BASE); + expect(doc.has_subscriptions).toBe(false); + }); + test("a model designation naming nothing refuses", async () => { const diags = await sidecarRefusal(REFUSAL_BASE, { model: "Nope" }); expect(diags[0]!.code).toBe("SC4009"); diff --git a/tests/harness/library-profile.test.ts b/tests/harness/library-profile.test.ts index b6d384da1..9179a0f1a 100644 --- a/tests/harness/library-profile.test.ts +++ b/tests/harness/library-profile.test.ts @@ -239,6 +239,7 @@ describe("library profile sidecar section", () => { initExport: "init", updateExport: "update", subscriptionsExport: "subscriptions", + subscriptionsExportDeclared: false, sourceHash: "module-graph", integerSlots: [], });