Files
rea/tests/boundary/filesystem/staticTaskCache.test.ts
YsopwnableandKaoru0822 eb44921c4c fix(ci): invalidate GitHub entrypoint and package-scope caches (#3522)
Track GitHub package scopes and Knip workflow/action entrypoints in static-check cache keys. Preserve the upstream action-code ownership fix and restrict TypeScript to its actual typed inventory.

Real cold/warm regression tests retain cache reuse for unrelated inputs while rejecting changed source, declarations, package scopes, manifests and reachable module dependencies. Existing lint inventory and report transport coverage remain unchanged.

Fixes #3510

Co-authored-by: Kaoru0822 <208281322+kaoru0822-kitauji@users.noreply.github.com>
2026-10-14 23:15:04 +08:00

615 lines
21 KiB
TypeScript

import { spawnSync } from "node:child_process";
import { mkdir, readFile, rm, symlink, writeFile } from "node:fs/promises";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
import { workflowGit } from "../../support/workflowGit.js";
const repository = fileURLToPath(new URL("../../../", import.meta.url));
const turbo = join(repository, "node_modules/turbo/bin/turbo");
const reportScript = join(repository, "scripts/ci/static-check-report.mjs");
const rootConfigSchema = z.object({
globalDependencies: z.array(z.string()).optional(),
globalPassThroughEnv: z.array(z.string()).optional(),
tasks: z.record(z.string(), z.record(z.string(), z.unknown())),
});
const dryRunSchema = z.object({
tasks: z.array(z.object({ taskId: z.string(), hash: z.string() })),
});
const execute = (command: string, args: string[], cwd: string) =>
spawnSync(process.execPath, [command, ...args], {
cwd,
encoding: "utf8",
timeout: 30_000,
maxBuffer: 16 * 1024 * 1024,
env: process.env,
});
const readRootConfig = async () =>
rootConfigSchema.parse(
JSON.parse(await readFile(join(repository, "turbo.json"), "utf8")),
);
const writeTaskFixture = async (
root: string,
taskNames: string[],
scripts: Record<string, string>,
imports?: Record<string, string>,
) => {
const config = await readRootConfig();
const tasks = Object.fromEntries(
taskNames.map((name) => {
const task = config.tasks[name];
if (task === undefined) throw new Error(`Missing Turbo task ${name}`);
// Measure each task's own input ownership without dependency hashes.
const { dependsOn: _dependsOn, ...definition } = task;
return [name, definition];
}),
);
await writeFile(
join(root, "turbo.json"),
JSON.stringify(
{
...(config.globalDependencies === undefined
? {}
: { globalDependencies: config.globalDependencies }),
...(config.globalPassThroughEnv === undefined
? {}
: { globalPassThroughEnv: config.globalPassThroughEnv }),
tasks,
},
null,
2,
),
);
await writeFile(
join(root, "package.json"),
JSON.stringify({
name: "rea-static-task-cache-fixture",
version: "1.0.0",
private: true,
type: "module",
packageManager: "npm@11.16.0",
scripts,
...(imports === undefined ? {} : { imports }),
}),
);
await writeFile(
join(root, "package-lock.json"),
JSON.stringify({
name: "rea-static-task-cache-fixture",
version: "1.0.0",
lockfileVersion: 3,
packages: {
"": { name: "rea-static-task-cache-fixture", version: "1.0.0" },
},
}),
);
};
const runTurbo = (
root: string,
task: string,
cacheDirectory: string,
dryRun = false,
) =>
execute(
turbo,
[
"run",
task,
...(dryRun ? ["--dry=json"] : ["--output-logs=errors-only"]),
"--cache=local:rw,remote:r",
"--cache-dir",
cacheDirectory,
],
root,
);
const taskHash = (root: string, task: string, cacheDirectory: string) => {
const result = runTurbo(root, task, cacheDirectory, true);
expect(result.error).toBeUndefined();
expect(result.status, result.stderr).toBe(0);
const { tasks } = dryRunSchema.parse(JSON.parse(result.stdout));
const entry = tasks.find(({ taskId }) => taskId === task);
if (entry === undefined) throw new Error(`Turbo omitted task ${task}`);
return entry.hash;
};
it("restores a real lint report across cold, warm, and fresh consumer checkouts", async () => {
const producer = await createTestTempDirectory("rea-static-cache-producer-");
const consumer = await createTestTempDirectory("rea-static-cache-consumer-");
const cache = await createTestTempDirectory("rea-static-cache-store-");
const script = "node scripts/ci/static-check-report.mjs lint";
for (const root of [producer, consumer]) {
await mkdir(join(root, "scripts/ci"), { recursive: true });
await mkdir(join(root, "src"), { recursive: true });
await symlink(
join(repository, "node_modules"),
join(root, "node_modules"),
"junction",
);
await writeTaskFixture(root, ["lint"], { lint: script });
await writeFile(
join(root, ".nvmrc"),
await readFile(join(repository, ".nvmrc")),
);
await writeFile(join(root, ".gitignore"), ".cache/\nnode_modules/\n");
await writeFile(
join(root, ".oxlintrc.json"),
JSON.stringify({ rules: { "no-debugger": "error" } }),
);
await writeFile(join(root, "src/check.js"), "export const ready = true;\n");
await writeFile(
join(root, "scripts/ci/static-check-report.mjs"),
await readFile(reportScript, "utf8"),
);
await workflowGit(root, ["init", "--initial-branch=main"]);
await workflowGit(root, ["add", "."]);
await workflowGit(root, ["commit", "-m", "test: seed static task cache"]);
}
const cold = runTurbo(producer, "lint", cache);
expect(cold.error).toBeUndefined();
expect(cold.status, cold.stderr).toBe(0);
expect(cold.stdout).toMatch(/Cached:\s+0 cached/u);
const report = join(producer, ".cache/static-checks/oxlint.json");
const cleanReport = await readFile(report, "utf8");
await rm(join(producer, ".cache"), { recursive: true });
const warm = runTurbo(producer, "lint", cache);
expect(warm.error).toBeUndefined();
expect(warm.status, warm.stderr).toBe(0);
expect(warm.stdout).toMatch(/Cached:\s+1 cached/u);
expect(await readFile(report, "utf8")).toBe(cleanReport);
const freshConsumer = runTurbo(consumer, "lint", cache);
expect(freshConsumer.error).toBeUndefined();
expect(freshConsumer.status, freshConsumer.stderr).toBe(0);
expect(freshConsumer.stdout).toMatch(/Cached:\s+1 cached/u);
expect(
await readFile(join(consumer, ".cache/static-checks/oxlint.json"), "utf8"),
).toBe(cleanReport);
await writeFile(join(consumer, "src/check.js"), "debugger;\n");
const invalid = runTurbo(consumer, "lint", cache);
expect(invalid.error).toBeUndefined();
expect(invalid.status).not.toBe(0);
expect(invalid.stdout).toMatch(/Cached:\s+0 cached/u);
const { diagnostics } = z
.object({
diagnostics: z.array(
z.object({ code: z.string().optional(), severity: z.string() }),
),
})
.parse(
JSON.parse(
await readFile(
join(consumer, ".cache/static-checks/oxlint.json"),
"utf8",
),
),
);
expect(diagnostics).toContainEqual(
expect.objectContaining({
code: "eslint(no-debugger)",
severity: "error",
}),
);
});
it.each(["workflow", "action manifest"])(
"invalidates Knip for GitHub %s entrypoints and module contents",
async (kind) => {
const root = await createTestTempDirectory("rea-knip-github-inputs-");
const cache = await createTestTempDirectory("rea-knip-github-cache-");
for (const directory of [
"src",
".github/workflows",
".github/actions/probe",
])
await mkdir(join(root, directory), { recursive: true });
await symlink(
join(repository, "node_modules"),
join(root, "node_modules"),
"junction",
);
await writeTaskFixture(root, ["knip"], {
knip: "node node_modules/knip/bin/knip.js",
});
await writeFile(
join(root, ".gitignore"),
"node_modules/\n.cache/\n.turbo/\n",
);
await writeFile(
join(root, "knip.json"),
JSON.stringify({ entry: ["src/main.js"], project: ["src/**/*.js"] }),
);
await writeFile(join(root, "src/main.js"), 'console.log("ready");\n');
const modulePath = join(root, ".github/actions/fault.mjs");
const invalidModule = 'import "rea-ci-controlled-yaml-dependency";\n';
await writeFile(modulePath, invalidModule);
const workflow = (entry: string) =>
JSON.stringify({
name: "Probe",
on: "workflow_dispatch",
jobs: {
probe: {
"runs-on": "ubuntu-latest",
steps: [{ run: `node ${entry}` }],
},
},
});
const action = (entry: string) =>
JSON.stringify({
name: "Probe",
runs: { using: "node24", main: entry },
});
await writeFile(
join(root, ".github/workflows/ci.yml"),
workflow("src/main.js"),
);
if (kind === "action manifest")
await writeFile(
join(root, ".github/actions/probe/action.yml"),
action("../../../src/main.js"),
);
await workflowGit(root, ["init", "--initial-branch=main"]);
await workflowGit(root, ["add", "."]);
await workflowGit(root, ["commit", "-m", "test: seed Knip GitHub inputs"]);
const cold = runTurbo(root, "knip", cache);
expect(cold.status, cold.stdout + cold.stderr).toBe(0);
expect(cold.stdout).toMatch(/Cached:\s+0 cached/u);
await writeFile(join(root, ".github/README.md"), "# Unrelated notes\n");
await writeFile(
join(root, ".github/actions/notes.yml"),
"# Unrelated notes\n",
);
const warm = runTurbo(root, "knip", cache);
expect(warm.status, warm.stdout + warm.stderr).toBe(0);
expect(warm.stdout).toMatch(/Cached:\s+1 cached/u);
await writeFile(
join(
root,
kind === "workflow"
? ".github/workflows/ci.yml"
: ".github/actions/probe/action.yml",
),
kind === "workflow"
? workflow(".github/actions/fault.mjs")
: action("../fault.mjs"),
);
const invalid = runTurbo(root, "knip", cache);
expect(invalid.error).toBeUndefined();
expect(invalid.status, invalid.stdout + invalid.stderr).not.toBe(0);
expect(invalid.stdout).toMatch(/Cached:\s+0 cached/u);
expect(invalid.stdout + invalid.stderr).toContain(
"rea-ci-controlled-yaml-dependency",
);
// A reachable module can change dependencies without another manifest edit.
await writeFile(modulePath, 'console.log("ready");\n');
const reachable = runTurbo(root, "knip", cache);
expect(reachable.status, reachable.stdout + reachable.stderr).toBe(0);
const reused = runTurbo(root, "knip", cache);
expect(reused.status, reused.stdout + reused.stderr).toBe(0);
expect(reused.stdout).toMatch(/Cached:\s+1 cached/u);
await writeFile(modulePath, invalidModule);
const changedModule = runTurbo(root, "knip", cache);
expect(changedModule.error).toBeUndefined();
expect(
changedModule.status,
changedModule.stdout + changedModule.stderr,
).not.toBe(0);
expect(changedModule.stdout).toMatch(/Cached:\s+0 cached/u);
expect(changedModule.stdout + changedModule.stderr).toContain(
"rea-ci-controlled-yaml-dependency",
);
},
);
it.each([
{
label: "imported documentation helper",
path: "docs/.vitepress/helper.ts",
content: 'export const value: number = "invalid";\n',
diagnostic: "TS2322",
invalidatesCompile: false,
},
{
label: "documentation package scope",
path: "docs/package.json",
content: '{"type":"commonjs"}\n',
diagnostic: "CommonJS",
invalidatesCompile: false,
},
{
label: "source package scope",
path: "src/package.json",
content: '{"type":"commonjs"}\n',
diagnostic: "CommonJS",
invalidatesCompile: true,
},
{
label: "test package scope",
path: "tests/package.json",
content: '{"type":"commonjs"}\n',
diagnostic: "CommonJS",
invalidatesCompile: false,
},
{
label: "declaration package scope",
path: "scripts/package.json",
content: '{"imports":{"#compiler-scope":"./shadow-types.d.mts"}}\n',
diagnostic: "TS2322",
invalidatesCompile: false,
},
{
label: "source declaration",
path: "src/types.d.mts",
content: "export type SourceValue = 2;\n",
diagnostic: "TS2322",
invalidatesCompile: true,
},
{
label: "test declaration",
path: "tests/types.d.cts",
content: 'export type TestValue = "shadow";\n',
diagnostic: "TS2322",
invalidatesCompile: false,
},
{
label: "script declaration",
path: "scripts/value.d.cts",
content: 'export type ScriptValue = "shadow";\n',
diagnostic: "TS2322",
invalidatesCompile: false,
},
{
label: "GitHub action source",
path: ".github/actions/helper.ts",
content: "export const githubValue = 2;\n",
diagnostic: "TS2322",
invalidatesCompile: false,
},
{
label: "GitHub action declaration",
path: ".github/actions/api.d.mts",
content: "export type GithubValue = 2;\n",
diagnostic: "TS2322",
invalidatesCompile: false,
},
{
label: "GitHub action package scope",
path: ".github/actions/package.json",
content: '{"type":"commonjs"}\n',
diagnostic: "CommonJS",
invalidatesCompile: false,
},
])(
"rejects a changed $label after warming real compiler tasks",
async ({ path, content, diagnostic, invalidatesCompile }) => {
const root = await createTestTempDirectory("rea-compiler-cache-inputs-");
const cache = await createTestTempDirectory("rea-compiler-cache-store-");
await mkdir(join(root, "docs/.vitepress"), { recursive: true });
await mkdir(join(root, ".github/actions"), { recursive: true });
for (const directory of ["src", "tests", "scripts"])
await mkdir(join(root, directory));
await symlink(
join(repository, "node_modules"),
join(root, "node_modules"),
"junction",
);
await writeTaskFixture(
root,
["compile", "typecheck"],
{
// Exercise the real compiler phase with the production task inputs.
compile: "tsc -p tsconfig.build.json",
typecheck: "tsc --noEmit",
},
{ "#compiler-scope": "./scripts/root-types.d.mts" },
);
for (const name of [
"tsconfig.json",
"tsconfig.build.json",
".nvmrc",
".npmrc",
])
await writeFile(join(root, name), await readFile(join(repository, name)));
await writeFile(
join(root, ".gitignore"),
"node_modules/\n.cache/\n.turbo/\ndist/\n",
);
await writeFile(
join(root, "src/main.ts"),
'import type { SourceValue } from "./types.mjs";\nexport const value: SourceValue = 1;\n',
);
await writeFile(
join(root, "src/types.d.mts"),
"export type SourceValue = 1;\n",
);
await writeFile(
join(root, "docs/.vitepress/helper.ts"),
"export const value: number = 1;\n",
);
await writeFile(
join(root, "docs/.vitepress/config.ts"),
'import { value } from "./helper.js";\nexport const configValue: number = value;\n',
);
await writeFile(
join(root, "scripts/root-types.d.mts"),
'export type Scope = "root";\n',
);
await writeFile(
join(root, "scripts/shadow-types.d.mts"),
'export type Scope = "shadow";\n',
);
await writeFile(
join(root, "scripts/api.d.mts"),
'import type { Scope } from "#compiler-scope";\nexport type ApiScope = Scope;\n',
);
await writeFile(
join(root, "tests/use-script.ts"),
'import type { ApiScope } from "../scripts/api.mjs";\nimport type { TestValue } from "./types.cjs";\nimport type { ScriptValue } from "../scripts/value.cjs";\nexport const scope: ApiScope = "root";\nexport const testValue: TestValue = "root";\nexport const scriptValue: ScriptValue = "root";\n',
);
await writeFile(
join(root, ".github/actions/helper.ts"),
"export const githubValue = 1;\n",
);
await writeFile(
join(root, ".github/actions/api.d.mts"),
"export type GithubValue = 1;\n",
);
await writeFile(
join(root, "tests/use-github.ts"),
'import { githubValue } from "../.github/actions/helper.js";\nimport type { GithubValue } from "../.github/actions/api.mjs";\nexport const actionValue: GithubValue = githubValue;\n',
);
await writeFile(
join(root, "tests/types.d.cts"),
'export type TestValue = "root";\n',
);
await writeFile(
join(root, "scripts/value.d.cts"),
'export type ScriptValue = "root";\n',
);
await workflowGit(root, ["init", "--initial-branch=main"]);
await workflowGit(root, ["add", "."]);
await workflowGit(root, ["commit", "-m", "test: seed compiler cache"]);
const tasks = ["compile", "typecheck"];
const hashes = new Map<string, string>();
for (const task of tasks) {
const cold = runTurbo(root, task, cache);
expect(cold.error).toBeUndefined();
expect(cold.status, cold.stdout + cold.stderr).toBe(0);
expect(cold.stdout).toMatch(/Cached:\s+0 cached/u);
hashes.set(task, taskHash(root, task, cache));
}
await writeFile(
join(root, "docs/guide.md"),
"# Documentation-only change\n",
);
for (const directory of ["cache", "dist"]) {
await mkdir(join(root, "docs/.vitepress", directory));
await writeFile(
join(root, "docs/.vitepress", directory, "generated.ts"),
"export const generated = 1;\n",
);
}
for (const task of tasks) {
expect(taskHash(root, task, cache)).toBe(hashes.get(task));
const warm = runTurbo(root, task, cache);
expect(warm.status, warm.stdout + warm.stderr).toBe(0);
expect(warm.stdout).toMatch(/Cached:\s+1 cached/u);
}
await writeFile(join(root, path), content);
for (const task of tasks) {
const invalidates = task === "typecheck" || invalidatesCompile;
const result = runTurbo(root, task, cache);
expect(result.error).toBeUndefined();
if (invalidates) {
expect(result.status, result.stdout + result.stderr).toBeGreaterThan(0);
expect(result.stdout + result.stderr).toContain(diagnostic);
expect(taskHash(root, task, cache)).not.toBe(hashes.get(task));
} else {
expect(result.status, result.stdout + result.stderr).toBe(0);
expect(result.stdout).toMatch(/Cached:\s+1 cached/u);
expect(taskHash(root, task, cache)).toBe(hashes.get(task));
}
}
},
);
it("invalidates checker task hashes for Node, ignore, and VitePress config changes", async () => {
const root = await createTestTempDirectory("rea-static-cache-inputs-");
const cache = await createTestTempDirectory("rea-static-cache-hashes-");
await mkdir(join(root, "docs/.vitepress"), { recursive: true });
await mkdir(join(root, "src"), { recursive: true });
await mkdir(join(root, "tests"), { recursive: true });
await writeTaskFixture(
root,
["jscpd", "knip", "metadata:check", "scan:todos"],
// This fixture probes only Turbo's real task hash; the checker commands
// are intentionally inert so the hash test cannot invoke large suites.
{
jscpd: 'node -e "process.exit(0)"',
knip: 'node -e "process.exit(0)"',
"metadata:check": 'node -e "process.exit(0)"',
"scan:todos": 'node -e "process.exit(0)"',
},
);
await writeFile(join(root, ".nvmrc"), "24.18.0\n");
await writeFile(join(root, ".npmrc"), "engine-strict=true\n");
await writeFile(join(root, ".gitignore"), ".cache/\n");
await writeFile(join(root, ".jscpd.json"), "{}\n");
await writeFile(join(root, "docs/.vitepress/config.ts"), "export {};\n");
await writeFile(
join(root, "vitest.config.ts"),
'import { CiTestSequencer } from "./tests/ciTestSequencer.js";\nexport const sequencer = CiTestSequencer;\n',
);
await writeFile(join(root, "src/input.ts"), "export {};\n");
await writeFile(join(root, "tests/input.ts"), "export {};\n");
const initial = new Map<string, string>();
for (const task of ["jscpd", "knip", "metadata:check", "scan:todos"])
initial.set(task, taskHash(root, task, cache));
await writeFile(join(root, ".nvmrc"), "24.19.0\n");
for (const task of ["jscpd", "metadata:check", "scan:todos"])
expect(
taskHash(root, task, cache),
`${task} must hash the Node pin`,
).not.toBe(initial.get(task));
await writeFile(join(root, ".nvmrc"), "24.18.0\n");
await writeFile(join(root, ".npmrc"), "engine-strict=false\n");
for (const task of ["jscpd", "knip", "metadata:check", "scan:todos"])
expect(
taskHash(root, task, cache),
`${task} must hash npm settings`,
).not.toBe(initial.get(task));
await writeFile(join(root, ".npmrc"), "engine-strict=true\n");
for (const task of ["jscpd", "metadata:check", "scan:todos"])
expect(taskHash(root, task, cache), `${task} restores after Node pin`).toBe(
initial.get(task),
);
await writeFile(join(root, ".gitignore"), ".cache/\ncoverage/\n");
for (const task of ["jscpd", "knip"])
expect(taskHash(root, task, cache), `${task} must hash gitignore`).not.toBe(
initial.get(task),
);
await writeFile(join(root, ".gitignore"), ".cache/\n");
for (const task of ["jscpd", "knip"])
expect(
taskHash(root, task, cache),
`${task} restores after gitignore`,
).toBe(initial.get(task));
await writeFile(
join(root, "docs/.vitepress/config.ts"),
"export const configRevision = 2;\n",
);
expect(
taskHash(root, "knip", cache),
"knip must hash its configured VitePress entry",
).not.toBe(initial.get("knip"));
await writeFile(join(root, "docs/.vitepress/config.ts"), "export {};\n");
await writeFile(join(root, "vitest.config.ts"), "export {};\n");
expect(
taskHash(root, "knip", cache),
"knip must hash vitest.config.ts so unused sequencer imports invalidate the cache",
).not.toBe(initial.get("knip"));
for (const task of ["jscpd", "metadata:check", "scan:todos"])
expect(
taskHash(root, task, cache),
`${task} must ignore vitest.config.ts`,
).toBe(initial.get(task));
});