From d96550f2f61647ffa150597de73363fa90c4e169 Mon Sep 17 00:00:00 2001 From: Ysopwnable <161784452+N0zoM1z0@users.noreply.github.com> Date: Tue, 13 Oct 2026 01:02:35 +0800 Subject: [PATCH] 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) --- .github/workflows/ci.yml | 9 + .jscpd.json | 1 - docs/testing.md | 10 + package.json | 6 +- scripts/ci/static-check-report.mjs | 149 ++++++++++++ .../filesystem/staticCheckReports.test.ts | 214 ++++++++++++++++++ turbo.json | 22 +- 7 files changed, 407 insertions(+), 4 deletions(-) create mode 100644 scripts/ci/static-check-report.mjs create mode 100644 tests/boundary/filesystem/staticCheckReports.test.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ba8c026177..1514de8511 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 }}) diff --git a/.jscpd.json b/.jscpd.json index 8c6cb653f1..c8e89d7914 100644 --- a/.jscpd.json +++ b/.jscpd.json @@ -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": [ diff --git a/docs/testing.md b/docs/testing.md index 7bb14effeb..c7fb109df0 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -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 diff --git a/package.json b/package.json index dd0ef57d80..faefead3b1 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/ci/static-check-report.mjs b/scripts/ci/static-check-report.mjs new file mode 100644 index 0000000000..04460b2c20 --- /dev/null +++ b/scripts/ci/static-check-report.mjs @@ -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; +} diff --git a/tests/boundary/filesystem/staticCheckReports.test.ts b/tests/boundary/filesystem/staticCheckReports.test.ts new file mode 100644 index 0000000000..c2708e1dba --- /dev/null +++ b/tests/boundary/filesystem/staticCheckReports.test.ts @@ -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" }); + }, +); diff --git a/turbo.json b/turbo.json index 8de89672db..e8bfbf06e8 100644 --- a/turbo.json +++ b/turbo.json @@ -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": [