Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion packages/cli/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ $ npm install -g scriptc

Requires clang on the PATH (Xcode Command Line Tools on macOS, `clang` package on Linux).

Builds use a bounded persistent cache by default. Unchanged executables and library archives skip native code generation and linking after fresh compiler metadata probes, while edited builds reuse stable runtime objects. The compiler remains required so dependency selection is rediscovered on every invocation. FFI builds with archive/object inputs or ambient `system_libraries` relink every time but still reuse runtime objects. Mutable compiler input paths such as `CPATH` and `SDKROOT`, and compiler wrappers, bypass persistent artifacts and objects so same-path dependency edits cannot go stale. Opaque archiver wrappers rebuild library program members and archives while retaining runtime-object reuse. Direct Clang, Apple's system Clang shim, `zig cc`, trusted platform archivers, and `zig ar` retain their applicable persistent tiers. Set `SCRIPTC_NO_CACHE=1` to bypass the cache or `SCRIPTC_CACHE_DIR` to choose its location; an existing POSIX override must already be private, otherwise caching is bypassed without changing its permissions.
Builds use a bounded persistent cache by default. Exact unchanged library builds validate their recorded TypeScript/module-resolution inputs and restore the generated C/LLVM unit before starting the frontend; the native cache then applies its independent toolchain checks. Unchanged executables and library archives skip native code generation and linking after fresh compiler metadata probes, while edited builds reuse stable runtime objects. Experimental provenance-source builds bypass the early frontend tier because their fetched-source registry is process state. FFI builds with archive/object inputs or ambient `system_libraries` relink every time but still reuse runtime objects. Mutable compiler input paths such as `CPATH` and `SDKROOT`, and compiler wrappers, bypass persistent native artifacts and objects so same-path dependency edits cannot go stale. Opaque archiver wrappers rebuild library program members and archives while retaining runtime-object reuse. Direct Clang, Apple's system Clang shim, `zig cc`, trusted platform archivers, and `zig ar` retain their applicable persistent tiers. Set `SCRIPTC_NO_CACHE=1` to bypass every cache or `SCRIPTC_CACHE_DIR` to choose its location; an existing POSIX override must already be private, otherwise caching is bypassed without changing its permissions.

## Commands

Expand Down
25 changes: 25 additions & 0 deletions packages/compiler/src/backend/cc.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2075,6 +2075,31 @@ export function resolveBuildCacheRoot(
: resolve(userHome, ".cache", "scriptc", "build");
}

/** Shared persistent-cache root for compiler-level tiers. The early library
* cache deliberately follows the native cache's activation and hard-disable
* contract, while native compilation retains ownership of toolchain safety. */
export function buildCacheRoot(): string | null {
return resolveBuildCacheRoot();
}

/** Harden/create a compiler-level cache root using the same privacy policy as
* the artifact caches. Failure disables only the optional caller's tier. */
export async function prepareBuildCacheRoot(root: string | null): Promise<string | null> {
if (root === null) return null;
try {
await ensurePrivateCacheRoot(root, process.env["SCRIPTC_CACHE_DIR"] === undefined);
return root;
} catch {
return null;
}
}

/** Register a successful compiler-level cache write with the shared bounded
* LRU policy. */
export async function pruneBuildCache(root: string | null): Promise<void> {
if (root !== null) await pruneCache(root).catch(() => undefined);
}

function cacheRootDir(): string | null {
return resolveBuildCacheRoot();
}
Expand Down
169 changes: 169 additions & 0 deletions packages/compiler/src/frontend/input-tracker.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { chmod, mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, expect, test } from "vitest";
import {
FrontendInputTracker,
frontendInputsStillMatch,
trackedAccessibleEntries,
trackedDirectoryExists,
trackedFileExists,
trackedReadFile,
validFrontendInputSnapshot,
} from "./input-tracker.js";

const scratch: string[] = [];

afterEach(async () => {
await Promise.all(scratch.splice(0).map((path) => rm(path, { recursive: true, force: true })));
});

test("tracked frontend reads invalidate on byte edits", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const file = join(dir, "entry.ts");
await writeFile(file, "export const answer = 1;\n");

const tracker = new FrontendInputTracker();
tracker.run(() => expect(trackedReadFile(file)).toContain("answer"));
const snapshot = tracker.snapshot();
expect(validFrontendInputSnapshot(snapshot)).toBe(true);
expect(frontendInputsStillMatch(snapshot)).toBe(true);

await writeFile(file, "export const answer = 2;\n");
expect(frontendInputsStillMatch(snapshot)).toBe(false);
});

test("failed frontend reads invalidate when the same file becomes readable", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const file = join(dir, "unreadable.ts");
await writeFile(file, "export const repaired = true;\n");
await chmod(file, 0o000);

const tracker = new FrontendInputTracker();
const result = tracker.run(() => trackedReadFile(file));
if (result !== null) {
// Windows and privileged test users may not enforce POSIX mode bits.
await chmod(file, 0o600);
return;
}
const snapshot = tracker.snapshot();
expect(snapshot.probes).toContainEqual({ op: "read-error", path: file });
expect(frontendInputsStillMatch(snapshot)).toBe(true);

await chmod(file, 0o600);
expect(frontendInputsStillMatch(snapshot)).toBe(false);
});

test("failed resolution candidates invalidate when a file appears", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const candidate = join(dir, "dependency.ts");

const tracker = new FrontendInputTracker();
tracker.run(() => expect(trackedFileExists(candidate)).toBe(false));
const snapshot = tracker.snapshot();
expect(frontendInputsStillMatch(snapshot)).toBe(true);

await writeFile(candidate, "export const loaded = true;\n");
expect(frontendInputsStillMatch(snapshot)).toBe(false);
});

test("a candidate appearing during the frontend prevents cache publication", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const candidate = join(dir, "dependency.ts");

const tracker = new FrontendInputTracker();
tracker.run(() => expect(trackedFileExists(candidate)).toBe(false));
await writeFile(candidate, "export const loaded = true;\n");
tracker.run(() => expect(trackedReadFile(candidate)).toContain("loaded"));

const snapshot = tracker.snapshot();
expect(snapshot.stable).toBe(false);
expect(validFrontendInputSnapshot(snapshot)).toBe(false);
expect(frontendInputsStillMatch(snapshot)).toBe(false);
});

test("directory enumeration invalidates workspace discovery", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const packages = join(dir, "packages");
await mkdir(packages);

const tracker = new FrontendInputTracker();
tracker.run(() => expect(trackedAccessibleEntries(packages)?.directories).toEqual([]));
const snapshot = tracker.snapshot();
await mkdir(join(packages, "new-member"));
expect(frontendInputsStillMatch(snapshot)).toBe(false);
});

test("compiler outputs do not invalidate a fresh output directory", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const generatedRoot = join(dir, "generated");
const outDir = join(generatedRoot, "nested");
const generated = join(outDir, "entry.lib.c");

const tracker = new FrontendInputTracker();
tracker.run(() => {
expect(trackedAccessibleEntries(dir)?.directories).toEqual([]);
expect(trackedDirectoryExists(generatedRoot)).toBe(false);
expect(trackedDirectoryExists(outDir)).toBe(false);
expect(trackedAccessibleEntries(outDir)).toBeNull();
});
const snapshot = tracker.snapshot();
const exclusions = {
outputPaths: [generated],
outputDirectories: [dir, generatedRoot, outDir],
};

await mkdir(outDir, { recursive: true });
await writeFile(generated, "/* generated */\n");
expect(frontendInputsStillMatch(snapshot, exclusions)).toBe(true);

await writeFile(join(outDir, "new-source.ts"), "export const appeared = true;\n");
expect(frontendInputsStillMatch(snapshot, exclusions)).toBe(false);
});

test("failed directory enumeration invalidates when the operation starts succeeding", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const packages = join(dir, "packages");
await writeFile(packages, "not a directory\n");

const tracker = new FrontendInputTracker();
tracker.run(() => expect(trackedAccessibleEntries(packages)).toBeNull());
const snapshot = tracker.snapshot();
expect(snapshot.probes).toContainEqual({ op: "entries-error", path: packages });
expect(frontendInputsStillMatch(snapshot)).toBe(true);

await rm(packages);
await mkdir(packages);
expect(frontendInputsStillMatch(snapshot)).toBe(false);
});

test("failed directory enumeration invalidates when access is restored", async () => {
const dir = await mkdtemp(join(tmpdir(), "scriptc-inputs-"));
scratch.push(dir);
const packages = join(dir, "packages");
await mkdir(packages);
await writeFile(join(packages, "member.ts"), "export const member = true;\n");
await chmod(packages, 0o000);

try {
const tracker = new FrontendInputTracker();
const result = tracker.run(() => trackedAccessibleEntries(packages));
if (result !== null) return; // Windows and privileged users may ignore POSIX mode bits.

const snapshot = tracker.snapshot();
expect(snapshot.probes).toContainEqual({ op: "entries-error", path: packages });
expect(frontendInputsStillMatch(snapshot)).toBe(true);

await chmod(packages, 0o700);
expect(frontendInputsStillMatch(snapshot)).toBe(false);
} finally {
await chmod(packages, 0o700);
}
});
Loading
Loading