Merge pull request #67 from vercel-labs/sub-export

Refuse unresolved subscriptions exports
This commit is contained in:
Chris Tate
2026-07-30 21:20:47 -05:00
committed by GitHub
4 changed files with 40 additions and 3 deletions
+9
View File
@@ -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,
+11 -1
View File
@@ -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
+19 -2
View File
@@ -837,7 +837,7 @@ async function sidecarRefusal(
source: string,
sidecarPatch: Record<string, unknown> = {},
extraFiles: Record<string, string> = {},
): 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");
+1
View File
@@ -239,6 +239,7 @@ describe("library profile sidecar section", () => {
initExport: "init",
updateExport: "update",
subscriptionsExport: "subscriptions",
subscriptionsExportDeclared: false,
sourceHash: "module-graph",
integerSlots: [],
});