mirror of
https://github.com/morluto/rea
synced 2026-10-25 22:08:11 -04:00
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:
@@ -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,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": [
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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
@@ -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": [
|
||||
|
||||
Reference in New Issue
Block a user