diff --git a/packages/compiler/src/backend/cc-cache.test.ts b/packages/compiler/src/backend/cc-cache.test.ts index 84b03356a..4daa70880 100644 --- a/packages/compiler/src/backend/cc-cache.test.ts +++ b/packages/compiler/src/backend/cc-cache.test.ts @@ -2420,6 +2420,54 @@ test("frontend-generated same-output builds no-op only while output and dependen } }); +test("fresh processes preserve output-local dependency coverage after source edits", async () => { + const dir = await mkdtemp(join(tmpdir(), "scriptc-local-artifact-process-")); + scratch.push(dir); + const cacheRoot = join(dir, "cache"); + const cPath = join(dir, "program.c"); + const outPath = join(dir, "program"); + const helperPath = join(dir, "compile.mjs"); + const childEnv: NodeJS.ProcessEnv = { ...process.env, SCRIPTC_CACHE_DIR: cacheRoot }; + delete childEnv["SCRIPTC_NO_CACHE"]; + delete childEnv["SCRIPTC_TEST_STABLE_TOOLCHAIN"]; + delete childEnv["SCRIPTC_TEST_TRUST_COMPILER_WRAPPER"]; + + await writeFile( + helperPath, + `import { compileC } from ${JSON.stringify(new URL("./cc.ts", import.meta.url).href)};\n` + + `await compileC({ cPath: process.argv[2], outPath: process.argv[3], cacheIdentity: "scriptc-generated-v1" });\n`, + ); + const compileInFreshProcess = (): void => { + execFileSync(process.execPath, ["--import", "tsx", helperPath, cPath, outPath], { + env: childEnv, + stdio: "pipe", + }); + }; + const stampPath = join( + cacheRoot, + "local", + createHash("sha256").update(outPath).digest("hex"), + ); + const dependencyPaths = async (): Promise => { + const stamp = JSON.parse(await readFile(stampPath, "utf8")) as { + dependencies: { path: string }[]; + }; + return stamp.dependencies.map((dependency) => dependency.path); + }; + + await writeFile(cPath, '#include \nint main(void) { puts("one"); return 0; }\n'); + compileInFreshProcess(); + const firstPaths = await dependencyPaths(); + expect(firstPaths.length).toBeGreaterThan(2); + + // The metadata fingerprints are restored from cache files in this second + // Node process; their in-memory fingerprint-to-path map starts empty. + await writeFile(cPath, '#include \nint main(void) { puts("two"); return 0; }\n'); + compileInFreshProcess(); + expect(execFileSync(outPath, { encoding: "utf8" }).trim()).toBe("two"); + expect(await dependencyPaths()).toEqual(firstPaths); +}); + test("native metadata snapshots survive source edits and repair after tampering", async () => { const dir = await mkdtemp(join(tmpdir(), "scriptc-native-metadata-")); scratch.push(dir); diff --git a/packages/compiler/src/backend/cc.ts b/packages/compiler/src/backend/cc.ts index b8542e00d..134437207 100644 --- a/packages/compiler/src/backend/cc.ts +++ b/packages/compiler/src/backend/cc.ts @@ -4469,6 +4469,7 @@ export async function compileC(opts: CcOptions): Promise { : effectiveLinkInvocationArgs.filter((arg) => arg !== `-L${curlStubDir}`); let implicitLinker: string | null = null; let preBuildDependencies: LocalArtifactDependency[] | null = null; + let localArtifactDependencyPaths: string[] | null = null; let linkMetadataStamp: NativeMetadataStamp | null = null; const linkMetadataKey = root === null || process.env["SCRIPTC_TEST_TRUST_COMPILER_WRAPPER"] === "1" @@ -4531,6 +4532,18 @@ export async function compileC(opts: CcOptions): Promise { fingerprintDependencyPaths.get(implicitLinker!) ?? []), ...(fingerprintDependencyPaths.get(programDependencies) ?? []), ]); + if (localArtifact !== null) { + // Native metadata stamps persist their dependency paths across CLI + // processes; fingerprintDependencyPaths deliberately does not. Build + // the output-local stamp from this complete validated snapshot so a + // source edit in a fresh process cannot replace it with only the two + // process-local fallback paths. + localArtifactDependencyPaths = [ + localArtifact.compilerPath, + dirname(resolve(opts.cPath)), + ...preBuildDependencies.map((dependency) => dependency.path), + ]; + } } catch { // Program-header discovery and linker tracing are both required for a // complete hit. Runtime objects remain safely cacheable if either probe @@ -4590,17 +4603,12 @@ export async function compileC(opts: CcOptions): Promise { // cache entry populated by a less restrictive shell must not widen access. await chmod(tmpOut, 0o777 & ~process.umask()); await rename(tmpOut, opts.outPath); - if (localArtifact !== null) { + if (localArtifact !== null && localArtifactDependencyPaths !== null) { await publishLocalArtifactStamp( localArtifact.stampPath, opts.outPath, localArtifact.key, - [ - localArtifact.compilerPath, - dirname(resolve(opts.cPath)), - ...(fingerprintDependencyPaths.get(implicitToolchain!) ?? []), - ...(fingerprintDependencyPaths.get(implicitLinker!) ?? []), - ], + localArtifactDependencyPaths, ).catch(() => undefined); } return; // hit: the program/runtime payload compile and link were skipped @@ -4706,17 +4714,17 @@ export async function compileC(opts: CcOptions): Promise { /* publishing is best-effort */ } } - if (localArtifact !== null && cacheCompleteArtifact && cacheInputsStable) { + if ( + localArtifact !== null && + localArtifactDependencyPaths !== null && + cacheCompleteArtifact && + cacheInputsStable + ) { await publishLocalArtifactStamp( localArtifact.stampPath, opts.outPath, localArtifact.key, - [ - localArtifact.compilerPath, - dirname(resolve(opts.cPath)), - ...(fingerprintDependencyPaths.get(implicitToolchain!) ?? []), - ...(fingerprintDependencyPaths.get(implicitLinker!) ?? []), - ], + localArtifactDependencyPaths, ).catch(() => undefined); } } finally { diff --git a/packages/compiler/src/frontend/types.ts b/packages/compiler/src/frontend/types.ts index ee9892471..c78f8d2eb 100644 --- a/packages/compiler/src/frontend/types.ts +++ b/packages/compiler/src/frontend/types.ts @@ -686,9 +686,11 @@ function genChannels( * fallback for it. */ const MAP_TYPE_MAX_DEPTH = 64; let mapTypeDepth = 0; -/** Bumped whenever mapType resolves a type through CONTEXT-SENSITIVE hooks - * (a generic body's type parameter, a mixin instantiation) — mappings that - * make the same ts.Type answer differently across instantiation contexts. +/** Bumped whenever mapType resolves a type through CONTEXT-SENSITIVE or + * collection-sensitive hooks (a generic body's type parameter, a mixin + * instantiation, a generic class instance) — mappings that can make the same + * ts.Type answer differently across instantiation contexts or collection + * phases. * The recursive-shape machinery keys shape identity by checker type, which * is sound only for context-FREE mappings: a recursive frame that observes * a bump between entry and exit stays fenced (recursive generic-open types @@ -1190,7 +1192,14 @@ function mapTypeInner(type: ts.Type, ctx: TypeMapperCtx): IrType | null { // INSTANTIATION's class (`Box%0`), registered on demand — the Lowerer // hook owns the instance table (monomorphization by flow). if (classDecl.typeParameters) { - return ctx.genericClassInstance ? ctx.genericClassInstance(classDecl, widened) : null; + const instance = ctx.genericClassInstance + ? ctx.genericClassInstance(classDecl, widened) + : null; + // Before the generic declaration's collection turn, the hook can only + // return its family shell; after collection the same checker type names + // a concrete registered instance. Never memoize either phase's answer. + if (instance !== null) contextResolutions++; + return instance; } return { kind: "object", className: classNamer(classDecl) }; } diff --git a/tests/corpus/1951-generic-classes-basics.ts b/tests/corpus/1951-generic-classes-basics.ts index 9c515c747..b1fbbe29d 100644 --- a/tests/corpus/1951-generic-classes-basics.ts +++ b/tests/corpus/1951-generic-classes-basics.ts @@ -1,6 +1,18 @@ // Generic classes, monomorphized per instantiation: fields, methods, // accessors, inference and explicit type arguments, instanceof against the // generic name (one runtime class in Node — the family interval here). +// The early reference pins collection-order independence: its checker type is +// first mapped before Box's family has collected, then must remap to Box%N. +class EarlyHolder { + readonly box: Box; + constructor(box: Box) { + this.box = box; + } + read(): number { + return this.box.get(); + } +} + class Box { v: T; constructor(v: T) { @@ -20,6 +32,7 @@ class Box { const a = new Box(41); // inferred Box a.set(a.get() + 1); console.log(a.get(), a.describe()); +console.log(new EarlyHolder(a).read()); const b = new Box("hi"); // explicit console.log(b.get().toUpperCase(), b.describe());