ci: summarize static checks and retain complete reports (#2242)

Keep static feedback concise while retaining complete diagnostic reports,
visible lint errors, module-boundary checks, and the 3% duplication gate.
Upload complete reports after static failures and restore their outputs
from Turbo caches. Preserve unambiguous, checkout-relative clone locations.

Bind both lint caches to the website, documentation configuration and
experiment sources actually scanned by oxlint, including ignore policy.
Guard this ownership by comparing live checker discovery with resolved
Turbo inputs. A real website syntax error now invalidates a cached pass.

Validation: full current-head CI and the separate Go lane passed. Seven
real-checker regressions passed; failure-status and stale-report mutations
were rejected, and restored source passed. Actual Turbo restored both
reports byte-for-byte; full static/docs/workflow checks passed.

Closes #2238.
Refs #2190.

Assisted-by: Kogasa (gensokyo-skills:kogasa-surprise-testing)
This commit is contained in:
Ysopwnable
2026-10-13 01:02:35 +08:00
committed by GitHub
parent 8cbd57cafa
commit d96550f2f6
7 changed files with 407 additions and 4 deletions
+9
View File
@@ -99,6 +99,15 @@ jobs:
run: npm run test:python
- if: needs.changes.outputs.code != 'true'
run: npm run format:check
- name: Upload complete static diagnostics
if: always() && needs.changes.outputs.code == 'true'
uses: actions/upload-artifact@v4
with:
name: static-check-reports
path: .cache/static-checks/
include-hidden-files: true
if-no-files-found: ignore
retention-days: 7
test-shard:
name: Test shard (${{ matrix.shard }})
-1
View File
@@ -1,5 +1,4 @@
{
"_comment": "Test files remain included so the gate measures duplication across both src/ and tests/. The 3.0% limit leaves headroom for ordinary changes while keeping duplication visible.",
"threshold": 3.0,
"reporters": ["consoleFull"],
"ignore": [
+10
View File
@@ -68,6 +68,16 @@ correctness. Select relevant boundary files and real-provider lanes explicitly.
Source projects also contain large capacity regressions; `test:fast` promises
no compiled-runtime prerequisite, not a fixed time budget.
`npm run lint` prints errors and a warning summary; `npm run jscpd` prints
duplication statistics. Complete reports remain in `.cache/static-checks/` and
CI uploads them as `static-check-reports`, including after a failed static gate.
Use `npm run lint:details` or `npm run jscpd:details` for all console diagnostics.
Both duplicate commands scan production and test code with the same 3% limit;
the summary commands preserve checker failures and restore reports from cached
static tasks. Both lint caches include the website, documentation configuration,
and experiment code scanned by the checker; a regression compares the actual
lint inventory with Turbo's resolved inputs.
Routine iterations and rebases need focused regressions and relevant checks.
Before handing off a PR, run `npm run check` and generated-document checks when
applicable; CI owns the full suite and coverage. Use the full local gate for
+4 -2
View File
@@ -118,11 +118,13 @@
"build:termux:run": "npm run compile:termux && npm run artifacts:skills",
"format": "oxfmt .",
"format:check": "oxfmt --check .",
"lint": "oxlint && npm run verify:module-boundaries",
"lint": "node scripts/ci/static-check-report.mjs lint && npm run verify:module-boundaries",
"lint:details": "oxlint && npm run verify:module-boundaries",
"lint:built": "npm run lint",
"lint:fix": "oxlint --fix && npm run verify:module-boundaries",
"knip": "knip",
"jscpd": "jscpd src/ tests/",
"jscpd": "node scripts/ci/static-check-report.mjs duplicates",
"jscpd:details": "jscpd src/ tests/ --no-tips",
"scan:todos": "node scripts/scan-todos.mjs",
"start": "node scripts/rea.mjs mcp",
"test": "npm run test:with-build",
+149
View File
@@ -0,0 +1,149 @@
import { spawnSync } from "node:child_process";
import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
import { createRequire } from "node:module";
import { dirname, join, relative, sep } from "node:path";
import { z } from "zod";
const require = createRequire(import.meta.url);
const mode = process.argv[2];
const reportRoot = join(".cache", "static-checks");
const extraArguments = process.argv.slice(3);
const lintReport = z.object({
number_of_files: z.number(),
diagnostics: z.array(
z.object({
message: z.string(),
code: z.string().optional(),
severity: z.enum(["error", "warning", "advice"]),
filename: z.string(),
labels: z.array(
z.object({
span: z.object({ line: z.number(), column: z.number() }),
}),
),
}),
),
});
const duplicateReport = z
.object({
duplicates: z.array(
z
.object({
firstFile: z.object({ name: z.string() }).passthrough(),
secondFile: z.object({ name: z.string() }).passthrough(),
})
.passthrough(),
),
statistics: z
.object({
total: z
.object({
sources: z.number(),
clones: z.number(),
percentage: z.number(),
})
.passthrough(),
})
.passthrough(),
})
.passthrough();
const run = (packageName, entrypoint, arguments_) => {
const executable = join(
dirname(require.resolve(`${packageName}/package.json`)),
entrypoint,
);
const result = spawnSync(process.execPath, [executable, ...arguments_], {
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
});
if (result.stderr) process.stderr.write(result.stderr);
if (result.error !== undefined) throw result.error;
if (result.status === null)
throw new Error(`${packageName} terminated by ${result.signal}`);
return result;
};
try {
await mkdir(reportRoot, { recursive: true });
if (mode === "lint") {
const reportPath = join(reportRoot, "oxlint.json");
await rm(reportPath, { force: true });
const result = run("oxlint", "bin/oxlint", [
...extraArguments,
"--format",
"json",
]);
let report;
try {
report = lintReport.parse(JSON.parse(result.stdout));
} catch (cause) {
process.stderr.write(result.stdout);
throw cause;
}
await writeFile(reportPath, result.stdout);
const warnings = report.diagnostics.filter(
({ severity }) => severity === "warning",
);
const errors = report.diagnostics.filter(
({ severity }) => severity === "error",
);
console.log(
`Lint: ${report.number_of_files} files, ${errors.length} errors, ${warnings.length} warnings.`,
);
for (const diagnostic of errors) {
const location = diagnostic.labels[0]?.span;
console.error(
`${diagnostic.filename}:${location?.line ?? 1}:${location?.column ?? 1}: ${diagnostic.code ?? "lint"}: ${diagnostic.message}`,
);
}
const counts = new Map();
for (const { code } of warnings)
counts.set(code ?? "warning", (counts.get(code ?? "warning") ?? 0) + 1);
for (const [rule, count] of [...counts]
.sort(
(left, right) => right[1] - left[1] || left[0].localeCompare(right[0]),
)
.slice(0, 8))
console.log(` ${rule}: ${count} warnings`);
console.log(`Full diagnostics: ${reportPath}; npm run lint:details`);
process.exitCode = result.status;
} else if (mode === "duplicates") {
const output = join(reportRoot, "jscpd");
const reportPath = join(output, "jscpd-report.json");
await rm(reportPath, { force: true });
const result = run("jscpd", "run-jscpd.js", [
"src/",
"tests/",
...extraArguments,
"--reporters",
"json",
"--output",
output,
"--no-colors",
"--no-tips",
"--absolute",
]);
if (result.stdout) process.stdout.write(result.stdout);
const report = duplicateReport.parse(
JSON.parse(await readFile(reportPath, "utf8")),
);
// jscpd otherwise strips each scan root, making src/ and tests/ names
// ambiguous. Normalize absolute producer paths once for portable reports.
for (const clone of report.duplicates)
for (const file of [clone.firstFile, clone.secondFile])
file.name = relative(process.cwd(), file.name).split(sep).join("/");
await writeFile(reportPath, JSON.stringify(report, null, 2));
const { sources, clones, percentage } = report.statistics.total;
console.log(
`Duplication: ${sources} files, ${clones} clones, ${percentage.toFixed(2)}% duplicated lines.`,
);
console.log(`Full diagnostics: ${reportPath}; npm run jscpd:details`);
process.exitCode = result.status;
} else {
throw new Error("Expected lint or duplicates");
}
} catch (cause) {
console.error(cause instanceof Error ? cause.message : String(cause));
process.exitCode = 1;
}
@@ -0,0 +1,214 @@
import { spawnSync } from "node:child_process";
import { access, mkdir, readFile, writeFile } from "node:fs/promises";
import { join, sep } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { z } from "zod";
import { createTestTempDirectory } from "../../fixtures/temporaryDirectory.js";
const script = fileURLToPath(
new URL("../../../scripts/ci/static-check-report.mjs", import.meta.url),
);
const repository = fileURLToPath(new URL("../../../", import.meta.url));
const fixture = async () => {
const root = await createTestTempDirectory("rea-static-report-");
await Promise.all([mkdir(join(root, "src")), mkdir(join(root, "tests"))]);
await writeFile(
join(root, ".oxlintrc.json"),
JSON.stringify({
rules: { "no-debugger": "error", "no-unused-vars": "warn" },
}),
);
await writeFile(
join(root, ".jscpd.json"),
JSON.stringify({ threshold: 3, minLines: 5, minTokens: 50 }),
);
return root;
};
const invoke = (root: string, mode: string, ...arguments_: string[]) =>
spawnSync(process.execPath, [script, mode, ...arguments_], {
cwd: root,
encoding: "utf8",
timeout: 15_000,
});
const lintDiagnostics = async (root: string) =>
z
.object({
diagnostics: z.array(
z.object({
severity: z.string(),
code: z.string().optional(),
filename: z.string(),
}),
),
})
.parse(
JSON.parse(
await readFile(join(root, ".cache/static-checks/oxlint.json"), "utf8"),
),
).diagnostics;
it("includes every actually linted file in both cached lint task inputs", () => {
const scan = spawnSync(
process.execPath,
[join(repository, "node_modules/oxlint/bin/oxlint"), "--debug=files"],
{ cwd: repository, encoding: "utf8", timeout: 15_000 },
);
expect(scan.error).toBeUndefined();
expect(scan.status, scan.stderr).toBe(0);
const files = scan.stdout
.trim()
.split(/\r?\n/u)
.map((path) => path.split(sep).join("/"));
expect(files.length).toBeGreaterThan(0);
const dry = spawnSync(
process.execPath,
[
join(repository, "node_modules/turbo/bin/turbo"),
"run",
"lint",
"lint:built",
"--dry=json",
],
{
cwd: repository,
encoding: "utf8",
timeout: 15_000,
maxBuffer: 16 * 1024 * 1024,
},
);
expect(dry.error).toBeUndefined();
expect(dry.status, dry.stderr).toBe(0);
const { tasks } = z
.object({
tasks: z.array(
z.object({
task: z.string(),
inputs: z.record(z.string(), z.string()),
}),
),
})
.parse(JSON.parse(dry.stdout));
const lintTasks = tasks.filter(({ task }) =>
["lint", "lint:built"].includes(task),
);
expect(lintTasks).toHaveLength(2);
for (const task of lintTasks)
expect(
files.filter((file) => !Object.hasOwn(task.inputs, file)),
`${task.task} omits actual checker inputs`,
).toEqual([]);
});
it("summarizes warnings without losing their complete diagnostic records", async () => {
const root = await fixture();
await writeFile(join(root, "src/warning.js"), "const forgotten = 1;\n");
const result = invoke(root, "lint");
expect(result.error).toBeUndefined();
expect(result.status).toBe(0);
expect(result.stdout).toContain("0 errors, 1 warnings");
expect(result.stdout.split("\n").length).toBeLessThan(10);
expect(await lintDiagnostics(root)).toEqual([
expect.objectContaining({
severity: "warning",
code: "eslint(no-unused-vars)",
filename: "src/warning.js",
}),
]);
});
it("keeps lint errors failing and visible alongside the retained warnings", async () => {
const root = await fixture();
await writeFile(
join(root, "src/fault.js"),
"const forgotten = 1;\ndebugger;\n",
);
const result = invoke(root, "lint");
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stdout).toContain("1 errors, 1 warnings");
expect(result.stderr).toContain("src/fault.js:2:1");
expect(result.stderr).toContain("eslint(no-debugger)");
expect(await lintDiagnostics(root)).toEqual(
expect.arrayContaining([
expect.objectContaining({ severity: "warning" }),
expect.objectContaining({ severity: "error" }),
]),
);
});
it("keeps malformed source failing with its parser diagnostic", async () => {
const root = await fixture();
await writeFile(join(root, "src/malformed.js"), "const value = ;\n");
const result = invoke(root, "lint");
expect(result.error).toBeUndefined();
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("src/malformed.js:1:");
expect(await lintDiagnostics(root)).toContainEqual(
expect.objectContaining({ severity: "error" }),
);
});
it("preserves the duplication threshold and complete clone locations", async () => {
const root = await fixture();
const source = [
"export function calculate(value) {",
...Array.from({ length: 34 }, (_, index) => ` value += ${index + 1};`),
" return value;",
"}",
].join("\n");
await Promise.all([
writeFile(join(root, "src/same.ts"), source),
writeFile(join(root, "tests/same.ts"), source),
]);
const result = invoke(root, "duplicates");
expect(result.error).toBeUndefined();
expect(result.status).toBe(1);
expect(result.stdout).toContain("Duplication:");
expect(result.stdout.split("\n").length).toBeLessThan(15);
const report = z
.object({
statistics: z.object({ total: z.object({ percentage: z.number() }) }),
duplicates: z.array(
z.object({
firstFile: z.object({ name: z.string() }),
secondFile: z.object({ name: z.string() }),
}),
),
})
.parse(
JSON.parse(
await readFile(
join(root, ".cache/static-checks/jscpd/jscpd-report.json"),
"utf8",
),
),
);
expect(report.statistics.total.percentage).toBeGreaterThan(3);
expect(report.duplicates).toHaveLength(1);
expect(
[
report.duplicates[0]?.firstFile.name,
report.duplicates[0]?.secondFile.name,
].sort(),
).toEqual(["src/same.ts", "tests/same.ts"]);
});
it.each([
["lint", "oxlint.json"],
["duplicates", "jscpd/jscpd-report.json"],
])(
"removes stale %s evidence when the checker cannot run",
async (mode, name) => {
const root = await fixture();
const reportPath = join(root, ".cache/static-checks", name);
await mkdir(join(root, ".cache/static-checks/jscpd"), { recursive: true });
await writeFile(reportPath, '{"previous_success":true}');
const result = invoke(root, mode, "--rea-invalid-checker-option");
expect(result.error).toBeUndefined();
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("rea-invalid-checker-option");
await expect(access(reportPath)).rejects.toMatchObject({ code: "ENOENT" });
},
);
+21 -1
View File
@@ -46,7 +46,9 @@
]
},
"lint": {
"outputs": [".cache/static-checks/oxlint.json"],
"inputs": [
".gitignore",
".nvmrc",
".oxlintrc.json",
"package.json",
@@ -54,12 +56,21 @@
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"tests/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"docs/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"website/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"experiments/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"!docs/.vitepress/cache/**",
"!docs/.vitepress/dist/**",
"!website/_site/**",
"!experiments/analysis-efficiency/results/**",
"vitest.config.ts"
]
},
"lint:built": {
"dependsOn": ["compile"],
"outputs": [".cache/static-checks/oxlint.json"],
"inputs": [
".gitignore",
".nvmrc",
".oxlintrc.json",
"package.json",
@@ -67,6 +78,13 @@
"scripts/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"src/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"tests/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"docs/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"website/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"experiments/**/*.{cjs,cts,js,jsx,mjs,mts,ts,tsx}",
"!docs/.vitepress/cache/**",
"!docs/.vitepress/dist/**",
"!website/_site/**",
"!experiments/analysis-efficiency/results/**",
"vitest.config.ts"
]
},
@@ -91,9 +109,11 @@
".jscpd.json",
"package.json",
"package-lock.json",
"scripts/ci/static-check-report.mjs",
"src/**",
"tests/**"
]
],
"outputs": [".cache/static-checks/jscpd/jscpd-report.json"]
},
"scan:todos": {
"inputs": [