Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions .github/scripts/pr-hygiene.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
"use strict";

const GENERATED_PREFIXES = [
"gui/dist/",
"dist/",
"coverage/",
".next/",
"node_modules/",
];
const BEHAVIOR_PREFIXES = ["src/", "gui/src/"];
const TEST_PREFIXES = ["tests/"];
const TEST_FILE_PATTERN = /(?:^|\/)(?:__tests__\/.*|[^/]+\.(?:test|spec)\.[^.]+)$/;
const SUPPRESSION_PATTERN = /(?:@ts-ignore|@ts-nocheck|eslint-disable|biome-ignore|prettier-ignore)/;
const FOCUSED_TEST_PATTERN = /\b(?:describe|it|test)\.(?:only|skip)\s*\(/;

function addedLines(patch) {
if (typeof patch !== "string") return [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Fail closed when GitHub omits a patch

The pull-files API does not guarantee a patch string for every file, notably for binary or oversized/truncated diffs. Returning an empty line set in that case silently bypasses suppression, focused-test, and empty-catch checks for the affected file. Since this workflow treats untrusted PR metadata as an enforcement boundary, it should retrieve complete content or fail explicitly when an applicable text file has no inspectable patch.

AGENTS.md reference: .github/AGENTS.md:L15-L17

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Declined as a residual: binary files have no executable suppressions to scan, and truncated patches are an inherent GitHub API limit already noted in the review. Text-file content fallback would add a content fetch per file for marginal coverage; the exception labels cover the remaining judgment cases.

return patch
.split("\n")
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
.map((line) => line.slice(1));
Comment on lines +20 to +21

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Evaluate catch blocks after deletions

Because addedLines discards every deletion and context line, deleting the final statement from an existing catch leaves an empty catch in the PR result but supplies no added braces for hasEmptyCatch, so the supposedly non-bypassable check passes. Conversely, changing only the catch signature and closing brace can make an unchanged nonempty body look empty. Inspect the resulting file or preserve enough hunk context to determine the actual catch body.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Fixed in 7a6982d0: when a hunk deletes lines, the empty-catch scan now includes hunk context (added + context lines) instead of additions only, so deleting a catch body leaves the empty structure visible and is rejected. A nonempty body in a deletion hunk is not flagged. Unit-tested in both directions.

}

function hasDeletions(patch) {
if (typeof patch !== "string") return false;
return patch
.split("\n")
.some((line) => line.startsWith("-") && !line.startsWith("---"));
}

// Lines that survive in the result of a hunk: additions plus context. Used for
// empty-catch detection when the hunk also deletes lines, so deleting a catch
// body cannot bypass the check.
function resultLines(patch) {
if (typeof patch !== "string") return [];
return patch
.split("\n")
.filter(
(line) =>
(line.startsWith("+") && !line.startsWith("+++")) ||
line.startsWith(" "),
)
.map((line) => line.slice(1));
}

function isGeneratedPath(path) {
return GENERATED_PREFIXES.some((prefix) => path.startsWith(prefix));
}

function isBehaviorPath(path) {
return BEHAVIOR_PREFIXES.some((prefix) => path.startsWith(prefix));
}

function isTestPath(path) {
return TEST_PREFIXES.some((prefix) => path.startsWith(prefix)) || TEST_FILE_PATTERN.test(path);
}

function hasEmptyCatch(lines) {
const text = lines.join("\n");
return /catch\s*(?:\([^)]*\))?\s*\{\s*\}/m.test(text);
}

function assessHygiene({ files = [], labels = [] }) {
const labelSet = new Set(labels);
const failures = [];
const filenames = files.map((file) => file.filename);
const removedFilenames = new Set(
files
.filter((file) => file.status === "removed")
.map((file) => file.filename),
);
// Renames are classified on both sides: moving a behavior or generated file
// to a documentation path must not bypass the hygiene gates.
const previousFilenames = files.flatMap((file) =>
file.previous_filename ? [file.previous_filename] : [],
);
const allPaths = [...new Set([...filenames, ...previousFilenames])];
const behaviorChanged = allPaths.some(isBehaviorPath);
// Deleted tests add no coverage and must not satisfy the regression gate.
const testsChanged = allPaths.some(
(path) => isTestPath(path) && !removedFilenames.has(path),
);

if (
behaviorChanged &&
!testsChanged &&
!labelSet.has("test-exception-approved")
Comment on lines +85 to +87

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Revoke stale exception labels after new commits

Once test-exception-approved is applied, later synchronize events retain it and this validator continues bypassing both the regression-test and skipped-test checks for the new head. A contributor can therefore obtain approval for one narrow exception, push additional unreviewed violations, and still receive a passing hygiene result. Bind approvals to the reviewed head SHA or clear exception labels whenever the PR head changes.

AGENTS.md reference: .github/AGENTS.md:L15-L17

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Fixed in 7a6982d0: on synchronize events the workflow revokes all four exception labels before assessment, so approvals are head-specific and a contributor cannot push unreviewed violations under a stale exception. Harness-verified (synchronize revokes + blocks; labeled keeps + passes).

) {
failures.push({ code: "missing_regression_test" });
}

const generated = allPaths.filter(
(path) => isGeneratedPath(path) && !removedFilenames.has(path),
);
if (
generated.length > 0 &&
!labelSet.has("generated-change-approved")
) {
failures.push({ code: "generated_output", paths: generated });
}

if (
filenames.includes("bun.lock") &&
!filenames.includes("package.json") &&
!labelSet.has("dependency-change-approved")
) {
failures.push({ code: "orphan_lockfile" });
}

const suppressions = [];
const focusedTests = [];
const emptyCatches = [];
for (const file of files) {
const lines = addedLines(file.patch);
if (lines.some((line) => SUPPRESSION_PATTERN.test(line))) {
suppressions.push(file.filename);
}
if (lines.some((line) => FOCUSED_TEST_PATTERN.test(line))) {
Comment on lines +114 to +118

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Ignore prohibited tokens inside fixtures and prose

These checks search every added line as raw text, so string literals, regex definitions, and documentation are treated as executable suppressions or focused tests. Feeding this commit's own patches to assessHygiene reports new_suppression, focused_or_skipped_test, and empty_catch because .github/scripts/pr-hygiene.test.cjs contains those constructs as fixture strings. Restrict checks to applicable source syntax or otherwise distinguish literal fixture/prose text.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Declined with rationale: raw-text scanning is the deliberate design (documented in the design record), and the four exception labels are the escape hatch for legitimate fixtures/prose that must mention these tokens. A source parser would be disproportionate for a gate whose failure mode is a maintainer reviewing the flagged line.

focusedTests.push(file.filename);
}
const catchLines = hasDeletions(file.patch) ? resultLines(file.patch) : lines;
if (hasEmptyCatch(catchLines)) emptyCatches.push(file.filename);
}

if (
suppressions.length > 0 &&
!labelSet.has("suppression-approved")
) {
failures.push({ code: "new_suppression", paths: suppressions });
}
if (
focusedTests.length > 0 &&
!labelSet.has("test-exception-approved")
) {
failures.push({ code: "focused_or_skipped_test", paths: focusedTests });
}
if (emptyCatches.length > 0) {
failures.push({ code: "empty_catch", paths: emptyCatches });
}

return failures;
}

module.exports = {
addedLines,
assessHygiene,
hasEmptyCatch,
hasDeletions,
isBehaviorPath,
isGeneratedPath,
isTestPath,
resultLines,
};
136 changes: 136 additions & 0 deletions .github/scripts/pr-hygiene.test.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,136 @@
"use strict";

const { describe, it } = require("node:test");
const assert = require("node:assert/strict");
const { addedLines, assessHygiene, hasEmptyCatch, resultLines } = require("./pr-hygiene.cjs");

describe("patch parsing", () => {
it("returns added content without diff headers", () => {
assert.deepEqual(addedLines("+++ b/a.ts\n+const x = 1;\n-old"), ["const x = 1;"]);
});

it("detects empty catch blocks across added lines", () => {
assert.equal(hasEmptyCatch(["try { work(); } catch (error) {", "}"]), true);
assert.equal(hasEmptyCatch(["catch (error) {", "report(error);", "}"]), false);
});

it("keeps hunk context and added lines for result scanning", () => {
assert.deepEqual(
resultLines(" catch (e) {\n- report(e);\n }"),
["catch (e) {", "}"],
);
});
});

describe("assessHygiene", () => {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Run the hygiene tests in CI

This new test suite is not invoked by any GitHub workflow: .github/workflows/issue-quality-tests.yml neither includes the hygiene files in its path filters nor runs this test, and Cross-platform CI does not trigger for these new paths either. Consequently the privileged gate can regress while repository CI remains green. Add the hygiene script, test, and workflow paths to the automation-test workflow and execute node --test .github/scripts/pr-hygiene.test.cjs there.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[shipping-github] Declined here because it is already implemented later in this stack: #905 wires pr-hygiene.test.cjs into the policy-test workflow (path filters + node --test line). Keeping the CI wiring in #905 avoids duplicating it in #903.

it("requires regression coverage for behavior changes", () => {
const failures = assessHygiene({ files: [{ filename: "src/router.ts", patch: "+change" }] });
assert.equal(failures[0].code, "missing_regression_test");
});

it("accepts behavior changes with tests or approved exception", () => {
assert.deepEqual(assessHygiene({ files: [
{ filename: "src/router.ts", patch: "+change" },
{ filename: "tests/router.test.ts", patch: "+test" },
] }), []);
assert.deepEqual(assessHygiene({
files: [{ filename: "src/router.ts", patch: "+change" }],
labels: ["test-exception-approved"],
}), []);
});

it("classifies renamed behavior files on both sides", () => {
const failures = assessHygiene({ files: [
{ filename: "docs/moved.md", previous_filename: "src/router.ts", patch: "" },
] });
assert.equal(failures[0].code, "missing_regression_test");
});

it("accepts a renamed behavior file when tests are included", () => {
assert.deepEqual(assessHygiene({ files: [
{ filename: "docs/moved.md", previous_filename: "src/router.ts", patch: "" },
{ filename: "tests/moved.test.ts", patch: "+test" },
] }), []);
});

it("classifies renamed generated files on both sides", () => {
const failures = assessHygiene({ files: [
{ filename: "docs/notes.md", previous_filename: "gui/dist/index.js", patch: "" },
] });
assert.equal(failures[0].code, "generated_output");
});

it("blocks added suppressions", () => {
const failures = assessHygiene({ files: [
{ filename: "tests/a.test.ts", patch: "+// @ts-ignore\n+value();" },
] });
assert.equal(failures[0].code, "new_suppression");
});

it("blocks focused or skipped tests", () => {
const failures = assessHygiene({ files: [
{ filename: "tests/a.test.ts", patch: "+test.only(\"x\", () => {});" },
] });
assert.equal(failures[0].code, "focused_or_skipped_test");
});

it("blocks empty catches", () => {
const failures = assessHygiene({ files: [
{ filename: "tests/a.test.ts", patch: "+try {} catch (error) {}" },
] });
assert.equal(failures[0].code, "empty_catch");
});

it("detects a catch emptied by deletion", () => {
const failures = assessHygiene({ files: [
{ filename: "docs/example.ts", patch: " catch (e) {\n- report(e);\n }" },
] });
assert.equal(failures[0].code, "empty_catch");
});

it("does not flag a nonempty catch in a hunk with unrelated deletions", () => {
const failures = assessHygiene({ files: [
{ filename: "docs/example.ts", patch: " catch (e) {\n report(e);\n- old();\n }" },
] });
assert.deepEqual(failures, []);
});

it("blocks generated output and orphan lockfile churn", () => {
const failures = assessHygiene({ files: [
{ filename: "gui/dist/index.js", patch: "+built" },
{ filename: "bun.lock", patch: "+package" },
] });
assert.deepEqual(failures.map((failure) => failure.code), ["generated_output", "orphan_lockfile"]);
});

it("allows removal of generated output", () => {
assert.deepEqual(assessHygiene({ files: [
{ filename: "gui/dist/index.js", status: "removed", patch: "-built" },
] }), []);
});

it("does not count deleted tests as regression coverage", () => {
const failures = assessHygiene({ files: [
{ filename: "src/router.ts", patch: "+change" },
{ filename: "tests/old.test.ts", status: "removed", patch: "-test" },
] });
assert.equal(failures[0].code, "missing_regression_test");
});

it("allows maintainer-approved narrow exceptions", () => {
const failures = assessHygiene({
files: [
{ filename: "src/router.ts", patch: "+// eslint-disable-next-line\n+run();" },
{ filename: "gui/dist/index.js", patch: "+built" },
{ filename: "bun.lock", patch: "+package" },
],
labels: [
"test-exception-approved",
"suppression-approved",
"generated-change-approved",
"dependency-change-approved",
],
});
assert.deepEqual(failures, []);
});
});
Loading
Loading