Skip to content
Merged
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
231 changes: 231 additions & 0 deletions .github/scripts/pr-hygiene.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
"use strict";

const GENERATED_PREFIXES = [
"gui/dist/",
"dist/",
"coverage/",
".next/",
"node_modules/",
];
const BEHAVIOR_PREFIXES = ["src/", "gui/src/"];

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 Include the published launcher in behavior paths

bin/ocx.mjs is the user-facing npm launcher and contains substantial update, service, and process-launch behavior, but it is outside both behavior prefixes. A PR can therefore change production launcher behavior without any test change or exception label. Include the runtime launcher path in behavior classification.

Useful? React with 👍 / 👎.

const TEST_PREFIXES = ["tests/"];
const TEST_FILE_PATTERN = /(?:^|\/)(?:__tests__\/.*|[^/]+\.(?:test|spec)\.[^.]+)$/;
const SUPPRESSION_PATTERN = /(?:@ts-ignore|@ts-nocheck|eslint-disable|biome-ignore|prettier-ignore)/;

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 Include @ts-expect-error in suppression detection

The suppression pattern omits @ts-expect-error, even though it suppresses TypeScript diagnostics and is already used in this repository. A PR can therefore introduce new @ts-expect-error directives without producing new_suppression or requiring suppression-approved; include this directive in the pattern and its regression cases.

Useful? React with 👍 / 👎.

const FOCUSED_TEST_PATTERN = /\b(?:describe|it|test)\.(?:only|skip)\s*\(/;

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 Detect newly added todo tests

The pattern detects .only( and .skip( but not Bun's test.todo( or it.todo( forms. Consequently, a contributor can add a disabled placeholder test while the advertised focused_or_skipped_test check reports success. Add the todo form while retaining the intentional allowance for conditional platform skips such as skipIf.

Useful? React with 👍 / 👎.


function addedLines(patch) {
if (typeof patch !== "string") return [];
return patch
.split("\n")
.filter((line) => line.startsWith("+") && !line.startsWith("+++"))
.map((line) => line.slice(1));
}

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.
//
// Returned per hunk, never as one flat list. Hunks are disjoint windows onto the
// file, so concatenating them puts unrelated lines next to each other: a hunk
// ending at `} catch (e) {` followed by one starting at `}` reads as an empty
// catch that does not exist anywhere in the file.
function resultLinesByHunk(patch) {
if (typeof patch !== "string") return [];
const hunks = [];
let current = null;
for (const line of patch.split("\n")) {
if (line.startsWith("@@")) {
current = [];
hunks.push(current);
continue;
}
if (current === null) {
// A patch without a hunk header (some API shapes omit it) is one window.
current = [];
hunks.push(current);
}
if ((line.startsWith("+") && !line.startsWith("+++")) || line.startsWith(" ")) {
current.push(line.slice(1));
}
}
return hunks;
}

// Flat form, kept for callers that only need the surviving text of a patch.
function resultLines(patch) {
return resultLinesByHunk(patch).flat();
}

function isGeneratedPath(path) {
return GENERATED_PREFIXES.some((prefix) => path.startsWith(prefix));
}
Comment on lines +46 to +48

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 Detect generated directories below package roots

Most generated prefixes are checked only at the repository root because startsWith("dist/"), startsWith("coverage/"), and startsWith("node_modules/") do not match nested paths. Generated content such as docs-site/dist/, gui/coverage/, or docs-site/node_modules/ therefore bypasses generated_output without an approval label. Match these names as path segments or enumerate every package's generated directories.

Useful? React with 👍 / 👎.


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);
}

// A hunk whose surviving and removed lines are all comments or blank changed no
// behavior, so it cannot owe a regression test. This matters because the review
// standard here asks for dense explanatory comments in the source: a PR that
// only sharpens a comment about WHY something fails closed would otherwise be
// told to add a test for a change it did not make, and the only escape would be
// a maintainer label — which trains contributors to ask for the label instead of
// writing tests, weakening the gate everywhere it actually matters.
//
// Deliberately narrow: a single non-comment line anywhere in the file's patch
// makes the whole file count as behavior again. Block-comment CONTINUATION
// lines are recognized only in the common leading-asterisk form; anything more
// clever than that reads as code and keeps the requirement.
function isCommentOnlyChange(patch) {
if (typeof patch !== "string") return false;
const changed = patch
.split("\n")
.filter(
(line) =>
(line.startsWith("+") && !line.startsWith("+++")) ||
(line.startsWith("-") && !line.startsWith("---")),
)
.map((line) => line.slice(1).trim());
if (changed.length === 0) return false;
return changed.every(
(line) =>
line === "" ||
line.startsWith("//") ||
line.startsWith("/*") ||
line.startsWith("*") ||
line.startsWith("#"),

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 Treat TypeScript private fields as code

Treating every trimmed line beginning with # as a comment misclassifies TypeScript private fields and methods. For example, changing only #verifier = ""—a pattern already used under src/oauth/—makes both changed lines comment-only, so the behavior change passes without a regression test. Restrict hash comments to applicable file types or remove this case for src/ and gui/src/.

AGENTS.md reference: AGENTS.md:L199-L201

Useful? React with 👍 / 👎.

);
}

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])];
// A file whose patch is entirely comments changed no behavior. Renamed-from
// paths carry no patch of their own, so they are judged by the file that
// carries them.
const commentOnlyPaths = new Set(
files
.filter((file) => isCommentOnlyChange(file.patch))
.flatMap((file) =>
file.previous_filename
? [file.filename, file.previous_filename]
: [file.filename],
),
);
const behaviorChanged = allPaths.some(
(path) => isBehaviorPath(path) && !commentOnlyPaths.has(path),
);
// Deleted tests add no coverage and must not satisfy the regression gate.
const testsChanged = allPaths.some(
(path) => isTestPath(path) && !removedFilenames.has(path),
);
Comment on lines +127 to +129

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 Require a surviving runnable test file

testsChanged checks both sides of renames and accepts any extension matched by the broad test-name pattern. A source change can therefore pass by renaming tests/x.test.ts to a non-test location, or by adding a non-runnable file such as docs/note.test.md; neither adds regression coverage. Determine coverage from surviving current paths and restrict it to locations and extensions exercised by the repository's test commands.

AGENTS.md reference: AGENTS.md:L199-L201

Useful? React with 👍 / 👎.


if (
behaviorChanged &&
!testsChanged &&
!labelSet.has("test-exception-approved")
) {
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 });
}

// A lockfile that MOVED with no manifest beside it is still orphaned, so both
// sides of a rename count. A lockfile that was DELETED is not: dropping
// `bun.lock` adds no dependency, which is why the generated-output and
// regression-test checks above exclude removals the same way.
if (
allPaths.includes("bun.lock") &&
!removedFilenames.has("bun.lock") &&
!allPaths.includes("package.json") &&
!labelSet.has("dependency-change-approved")
Comment on lines +149 to +152

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 Match lockfiles with manifests in every package

This exact-name check covers only the repository-root lockfile. The repository also contains independent gui/bun.lock and docs-site/bun.lock files, so accidental churn in either one passes even when its sibling package.json is untouched. Apply the orphan-lockfile check to every changed bun.lock, pairing it with the manifest in the same directory.

Useful? React with 👍 / 👎.

) {
failures.push({ code: "orphan_lockfile" });
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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))) {
focusedTests.push(file.filename);
}
// Scan hunk by hunk: an empty catch has to be empty within one window.
const catchWindows = hasDeletions(file.patch)
? resultLinesByHunk(file.patch)
: [lines];
if (catchWindows.some((window) => hasEmptyCatch(window))) {
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,
resultLinesByHunk,
};
Loading
Loading