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": [