forked from lidge-jun/opencodex
-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathpr-hygiene.cjs
More file actions
319 lines (297 loc) · 10.7 KB
/
Copy pathpr-hygiene.cjs
File metadata and controls
319 lines (297 loc) · 10.7 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
"use strict";
const { assessSponsoredSurface } = require("./pr-sponsored-surface.cjs");
const { assessCarryAttribution } = require("./pr-carry-attribution.cjs");
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 [];
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));
}
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("#"),
);
}
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),
);
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")
) {
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))) {
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;
}
/**
* Human-readable one-liners for each deterministic hygiene failure code.
* Shared by the hygiene workflow comment and the PR quality gate actions.
*/
const HYGIENE_FAILURE_HINTS = {
missing_regression_test:
"Behavior changed under `src/` or `gui/src/` without a test change. Add focused coverage or obtain `test-exception-approved`.",
generated_output:
"Generated build output is committed. Remove it or obtain `generated-change-approved`.",
orphan_lockfile:
"`bun.lock` changed without `package.json`. Revert accidental churn or obtain `dependency-change-approved`.",
new_suppression:
"A new TypeScript, lint, formatter, or similar suppression was added. Fix the underlying issue or obtain `suppression-approved`.",
focused_or_skipped_test:
"A focused or skipped test was added. Restore the complete suite or obtain `test-exception-approved`.",
empty_catch:
"An empty catch block was added. Handle, report, or deliberately propagate the error.",
unsponsored_surface:
"This changes an authentication, workflow, release-automation, or dependency surface. `MAINTAINERS.md` requires security review for these; ask a maintainer to apply `maintainer-sponsored` once they have reviewed it.",
missing_coauthor_credit:
"This pull request says it reimplements, supersedes, carries, or rebases another author's pull request, but no `Co-authored-by` trailer names that author. Prose in a commit body is not read by anything; the trailer is what GitHub counts. Add it to the description or a commit, or obtain `attribution-approved`.",
};
/**
* Labels that can clear or reinstate a hygiene failure. The quality gate must
* wake on these so READY / DRAFT tracks sponsorship and exception approvals
* without waiting for an unrelated synchronize.
*/
const HYGIENE_GATE_LABELS = [
"intake: hygiene-blocked",
"maintainer-sponsored",
"test-exception-approved",
"suppression-approved",
"generated-change-approved",
"dependency-change-approved",
"attribution-approved",
];
/**
* Combine patch-hygiene and sponsored-surface failures into one list so the
* hygiene workflow and the PR quality gate cannot disagree about Ready.
*/
function collectDeterministicHygieneFailures({
files = [],
labels = [],
authorHasPushPermission = false,
prAuthorLogin = "",
title = "",
body = "",
commits = [],
referencedAuthors = {},
}) {
// Renames must keep the source path: moving a restricted file to a
// non-restricted destination must not drop the sponsorship requirement.
const changedFiles = [
...new Set(
files.flatMap((file) => [
file.filename,
...(file.previous_filename ? [file.previous_filename] : []),
]),
),
];
return [
...assessHygiene({ files, labels }),
...assessSponsoredSurface({
authorHasPushPermission,
changedFiles,
labels,
}),
// Reads the pull request's text rather than its diff: a carry declares
// itself in prose, and the trailer it needs lives in the same place.
...assessCarryAttribution({
prAuthorLogin,
title,
body,
commits,
labels,
referencedAuthors,
}),
];
}
module.exports = {
addedLines,
assessHygiene,
collectDeterministicHygieneFailures,
HYGIENE_FAILURE_HINTS,
HYGIENE_GATE_LABELS,
hasEmptyCatch,
hasDeletions,
isBehaviorPath,
isGeneratedPath,
isTestPath,
resultLines,
resultLinesByHunk,
};