@avi2dg/checks 0.17.0 → 0.19.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -1
- package/bunfig.toml +1 -1
- package/dependency-cruiser.config.js +2 -0
- package/dist/feature-rules.js +22 -5
- package/docs/configs/commit-messages.md +2 -18
- package/docs/configs/quality-file.md +3 -2
- package/docs/gates/checks-lint.md +2 -1
- package/docs/gates/checks-mutation-compare.md +20 -11
- package/docs/gates/checks-quality.md +23 -8
- package/docs/gates/checks-quarantine-clock.md +54 -0
- package/docs/gates/checks-test-layout.md +9 -3
- package/docs/gates/checks-vendor.md +97 -0
- package/package.json +11 -2
- package/quality.schema.json +56 -4
- package/scripts/backtest.ts +57 -41
- package/scripts/ci-wiring.ts +2 -63
- package/scripts/comment-gate.ts +3 -4
- package/scripts/comment-matchers.ts +54 -57
- package/scripts/commit-identity.ts +3 -4
- package/scripts/doc-outline.ts +34 -16
- package/scripts/doc-rules.ts +34 -21
- package/scripts/docs.ts +3 -4
- package/scripts/feature-owners.ts +5 -8
- package/scripts/gates.ts +1 -0
- package/scripts/git.ts +41 -26
- package/scripts/lint.ts +75 -35
- package/scripts/mutation-compare.ts +179 -70
- package/scripts/prose-matchers.ts +93 -83
- package/scripts/quality-file.ts +30 -3
- package/scripts/quality.ts +157 -10
- package/scripts/quarantine-clock.ts +153 -0
- package/scripts/range-gate.ts +9 -0
- package/scripts/repetition.ts +40 -19
- package/scripts/shell-command.ts +74 -0
- package/scripts/size-budget.ts +51 -28
- package/scripts/suppressions-ratchet.ts +22 -28
- package/scripts/test-layout.ts +65 -52
- package/scripts/test-report.ts +18 -13
- package/scripts/vendor.ts +337 -0
package/scripts/repetition.ts
CHANGED
|
@@ -4,22 +4,22 @@ import { changedPaths, checkoutFiles, collect, git, pathsAt, rangeFromArgs } fro
|
|
|
4
4
|
import { runMain } from "./main.ts";
|
|
5
5
|
import { readQuality } from "./quality-file.ts";
|
|
6
6
|
|
|
7
|
-
type Fragment = {
|
|
7
|
+
export type Fragment = {
|
|
8
8
|
readonly file: string;
|
|
9
9
|
readonly start: number;
|
|
10
10
|
readonly end: number;
|
|
11
11
|
};
|
|
12
12
|
|
|
13
|
-
type Clone = readonly [Fragment, Fragment];
|
|
13
|
+
export type Clone = readonly [Fragment, Fragment];
|
|
14
14
|
|
|
15
|
-
type Rise = {
|
|
15
|
+
export type Rise = {
|
|
16
16
|
readonly file: string;
|
|
17
17
|
readonly before: number;
|
|
18
18
|
readonly after: number;
|
|
19
19
|
readonly clones: readonly Clone[];
|
|
20
20
|
};
|
|
21
21
|
|
|
22
|
-
type Held = {
|
|
22
|
+
export type Held = {
|
|
23
23
|
readonly measured: number;
|
|
24
24
|
readonly rises: readonly Rise[];
|
|
25
25
|
readonly advisory: ReadonlyMap<string, number>;
|
|
@@ -86,7 +86,7 @@ const scan = Effect.fn("scan")(
|
|
|
86
86
|
Effect.scoped,
|
|
87
87
|
);
|
|
88
88
|
|
|
89
|
-
function clonesOf(file: string, clones: readonly Clone[]): readonly Clone[] {
|
|
89
|
+
export function clonesOf(file: string, clones: readonly Clone[]): readonly Clone[] {
|
|
90
90
|
return clones
|
|
91
91
|
.flatMap(([first, second]): Clone[] => {
|
|
92
92
|
if (first.file === file) return [[first, second]];
|
|
@@ -95,6 +95,36 @@ function clonesOf(file: string, clones: readonly Clone[]): readonly Clone[] {
|
|
|
95
95
|
.toSorted(([a], [b]) => a.start - b.start);
|
|
96
96
|
}
|
|
97
97
|
|
|
98
|
+
// Decides which held file repeats more lines than the same path, or the path it was renamed
|
|
99
|
+
// from, repeated at the base.
|
|
100
|
+
export function risesOf(
|
|
101
|
+
held: readonly string[],
|
|
102
|
+
before: ReadonlyMap<string, number>,
|
|
103
|
+
after: ReadonlyMap<string, number>,
|
|
104
|
+
formerPath: ReadonlyMap<string, string>,
|
|
105
|
+
clones: readonly Clone[],
|
|
106
|
+
): readonly Rise[] {
|
|
107
|
+
return held
|
|
108
|
+
.flatMap((file): Rise[] => {
|
|
109
|
+
const was = before.get(formerPath.get(file) ?? file) ?? 0;
|
|
110
|
+
const is = after.get(file) ?? 0;
|
|
111
|
+
return is > was ? [{ file, before: was, after: is, clones: clonesOf(file, clones) }] : [];
|
|
112
|
+
})
|
|
113
|
+
.toSorted((a, b) => a.file.localeCompare(b.file));
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// Tallies every held file that did not rise, plus every file outside the held set, as advisory.
|
|
117
|
+
export function heldOf(
|
|
118
|
+
measured: number,
|
|
119
|
+
rises: readonly Rise[],
|
|
120
|
+
after: ReadonlyMap<string, number>,
|
|
121
|
+
othersRepeated: ReadonlyMap<string, number>,
|
|
122
|
+
): Held {
|
|
123
|
+
const risen = new Set(rises.map((rise) => rise.file));
|
|
124
|
+
const advisory = [...after].filter(([file]) => !risen.has(file)).concat([...othersRepeated]);
|
|
125
|
+
return { measured, rises, advisory: new Map(advisory.toSorted(([a], [b]) => a.localeCompare(b))) };
|
|
126
|
+
}
|
|
127
|
+
|
|
98
128
|
const runHold = Effect.fn("runHold")(function* (root: string, production: readonly string[], base: string, head: string) {
|
|
99
129
|
const pathspecs = [...production.map((glob) => `:(glob)${glob}`), DECLARATIONS];
|
|
100
130
|
const held = yield* pathsAt(head, pathspecs, root);
|
|
@@ -107,28 +137,19 @@ const runHold = Effect.fn("runHold")(function* (root: string, production: readon
|
|
|
107
137
|
const before = repeatedLines(yield* scan(root, base, yield* pathsAt(base, pathspecs, root)));
|
|
108
138
|
const clones = yield* scan(root, head, held);
|
|
109
139
|
const after = repeatedLines(clones);
|
|
110
|
-
const rises = held
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
return is > was ? [{ file, before: was, after: is, clones: clonesOf(file, clones) }] : [];
|
|
114
|
-
});
|
|
115
|
-
const risen = new Set(rises.map((rise) => rise.file));
|
|
116
|
-
const advisory = [...after].filter(([file]) => !risen.has(file)).concat([...repeatedLines(yield* scan(root, head, others))]);
|
|
117
|
-
return {
|
|
118
|
-
measured: held.length,
|
|
119
|
-
rises: rises.toSorted((a, b) => a.file.localeCompare(b.file)),
|
|
120
|
-
advisory: new Map(advisory.toSorted(([a], [b]) => a.localeCompare(b))),
|
|
121
|
-
} satisfies Held;
|
|
140
|
+
const rises = risesOf(held, before, after, formerPath, clones);
|
|
141
|
+
const othersRepeated = repeatedLines(yield* scan(root, head, others));
|
|
142
|
+
return heldOf(held.length, rises, after, othersRepeated) satisfies Held;
|
|
122
143
|
});
|
|
123
144
|
|
|
124
|
-
function describe({ file, before, after, clones }: Rise): readonly string[] {
|
|
145
|
+
export function describe({ file, before, after, clones }: Rise): readonly string[] {
|
|
125
146
|
return [
|
|
126
147
|
` ${file}: ${after} repeated line(s), up from ${before}`,
|
|
127
148
|
...clones.map(([own, other]) => ` ${own.file}:${own.start}-${own.end} repeats ${other.file}:${other.start}-${other.end}`),
|
|
128
149
|
];
|
|
129
150
|
}
|
|
130
151
|
|
|
131
|
-
function report({ measured, rises, advisory }: Held): string {
|
|
152
|
+
export function report({ measured, rises, advisory }: Held): string {
|
|
132
153
|
const verdict =
|
|
133
154
|
rises.length === 0
|
|
134
155
|
? [`${NAME}: ${measured} production file(s) repeat no more lines than where the range starts, ${MEASURE}`]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
export type Command = readonly string[];
|
|
2
|
+
|
|
3
|
+
type Span = {
|
|
4
|
+
readonly text: string;
|
|
5
|
+
readonly end: number;
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
const VARIABLE = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/;
|
|
9
|
+
const UNPLAIN = new Set(["|", "&", ";", "<", ">", "(", ")", "`", "\\", "#", "\n"]);
|
|
10
|
+
|
|
11
|
+
function variable(line: string, from: number): Span | undefined {
|
|
12
|
+
const match = VARIABLE.exec(line.slice(from));
|
|
13
|
+
return match === null ? undefined : { text: match[0], end: from + match[0].length };
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function singleQuoted(line: string, open: number): Span | undefined {
|
|
17
|
+
const close = line.indexOf("'", open + 1);
|
|
18
|
+
return close === -1 ? undefined : { text: line.slice(open + 1, close), end: close + 1 };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function doubleQuoted(line: string, open: number): Span | undefined {
|
|
22
|
+
let text = "";
|
|
23
|
+
let index = open + 1;
|
|
24
|
+
while (line.charAt(index) !== '"') {
|
|
25
|
+
const inner = line.charAt(index);
|
|
26
|
+
if (inner === "" || inner === "\\" || inner === "`") return undefined;
|
|
27
|
+
const part = inner === "$" ? variable(line, index) : { text: inner, end: index + 1 };
|
|
28
|
+
if (part === undefined) return undefined;
|
|
29
|
+
text += part.text;
|
|
30
|
+
index = part.end;
|
|
31
|
+
}
|
|
32
|
+
return { text, end: index + 1 };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function wordPart(line: string, index: number): Span | undefined {
|
|
36
|
+
const char = line.charAt(index);
|
|
37
|
+
if (char === "'") return singleQuoted(line, index);
|
|
38
|
+
if (char === '"') return doubleQuoted(line, index);
|
|
39
|
+
if (char === "$") return variable(line, index);
|
|
40
|
+
return UNPLAIN.has(char) ? undefined : { text: char, end: index + 1 };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// A script counts only when it is one line of plain words: any shell control, redirection or
|
|
44
|
+
// substitution can run the gate without its failure failing the step.
|
|
45
|
+
export function plainCommand(script: string): Command | undefined {
|
|
46
|
+
const line = script.trim();
|
|
47
|
+
const words: string[] = [];
|
|
48
|
+
let word: string | undefined;
|
|
49
|
+
let index = 0;
|
|
50
|
+
while (index < line.length) {
|
|
51
|
+
const char = line.charAt(index);
|
|
52
|
+
if (char === " " || char === "\t") {
|
|
53
|
+
if (word !== undefined) words.push(word);
|
|
54
|
+
word = undefined;
|
|
55
|
+
index += 1;
|
|
56
|
+
} else {
|
|
57
|
+
const part = wordPart(line, index);
|
|
58
|
+
if (part === undefined) return undefined;
|
|
59
|
+
word = (word ?? "") + part.text;
|
|
60
|
+
index = part.end;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (word !== undefined) words.push(word);
|
|
64
|
+
return words.length === 0 ? undefined : words;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export function invokes(command: Command | undefined, gate: Command): boolean {
|
|
68
|
+
return command !== undefined && gate.length <= command.length && gate.every((word, index) => command[index] === word);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function mentions(script: string, gate: Command): boolean {
|
|
72
|
+
const tokens = script.split(/[\s|&;<>()`]+/);
|
|
73
|
+
return tokens.some((_, start) => invokes(tokens.slice(start), gate));
|
|
74
|
+
}
|
package/scripts/size-budget.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import { changedPaths, collect, git, pathsAt, rangeEnds, type Change } from "./git.ts";
|
|
4
|
-
import { runMain
|
|
5
|
-
import {
|
|
4
|
+
import { runMain } from "./main.ts";
|
|
5
|
+
import { renderJson } from "./quality-file.ts";
|
|
6
|
+
import { rangeGateInputs } from "./range-gate.ts";
|
|
6
7
|
import {
|
|
7
8
|
budgetOf,
|
|
8
9
|
budgetsOf,
|
|
@@ -19,9 +20,9 @@ import {
|
|
|
19
20
|
type SizeRule,
|
|
20
21
|
} from "./size-rules.ts";
|
|
21
22
|
|
|
22
|
-
type Held = { readonly path: string; readonly from: string };
|
|
23
|
+
export type Held = { readonly path: string; readonly from: string };
|
|
23
24
|
|
|
24
|
-
type Site = {
|
|
25
|
+
export type Site = {
|
|
25
26
|
readonly file: string;
|
|
26
27
|
readonly line: number | undefined;
|
|
27
28
|
readonly rule: SizeRule["rule"];
|
|
@@ -29,7 +30,7 @@ type Site = {
|
|
|
29
30
|
readonly message: string;
|
|
30
31
|
};
|
|
31
32
|
|
|
32
|
-
type Growth = {
|
|
33
|
+
export type Growth = {
|
|
33
34
|
readonly file: string;
|
|
34
35
|
readonly rule: SizeRule["rule"];
|
|
35
36
|
readonly base: number;
|
|
@@ -37,7 +38,7 @@ type Growth = {
|
|
|
37
38
|
readonly sites: readonly Site[];
|
|
38
39
|
};
|
|
39
40
|
|
|
40
|
-
type Verdict =
|
|
41
|
+
export type Verdict =
|
|
41
42
|
| { readonly applies: "all"; readonly held: number; readonly overruns: readonly Site[]; readonly advisory: readonly Site[] }
|
|
42
43
|
| { readonly applies: "ratchet"; readonly held: number; readonly growths: readonly Growth[]; readonly advisory: readonly Site[] };
|
|
43
44
|
|
|
@@ -68,7 +69,17 @@ const Diagnostic = Schema.Struct({
|
|
|
68
69
|
|
|
69
70
|
const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ diagnostics: Schema.Array(Diagnostic) })));
|
|
70
71
|
|
|
71
|
-
|
|
72
|
+
type Rules = Readonly<Record<string, unknown>>;
|
|
73
|
+
|
|
74
|
+
export type SizeConfig = {
|
|
75
|
+
readonly plugins: readonly string[];
|
|
76
|
+
readonly jsPlugins: readonly string[];
|
|
77
|
+
readonly categories: Readonly<Record<string, string>>;
|
|
78
|
+
readonly rules: Rules;
|
|
79
|
+
readonly overrides: readonly { readonly files: readonly string[]; readonly rules: Rules }[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
function rulesOf(budget: Budget): Rules {
|
|
72
83
|
return Object.fromEntries(
|
|
73
84
|
SIZE_RULES.map((entry) => {
|
|
74
85
|
const max = budget[entry.key];
|
|
@@ -77,7 +88,7 @@ function rulesOf(budget: Budget): Record<string, unknown> {
|
|
|
77
88
|
);
|
|
78
89
|
}
|
|
79
90
|
|
|
80
|
-
function sizeConfig({ production, tests }: Budgets, plugin: string):
|
|
91
|
+
export function sizeConfig({ production, tests }: Budgets, plugin: string): SizeConfig {
|
|
81
92
|
return {
|
|
82
93
|
plugins: [],
|
|
83
94
|
jsPlugins: [plugin],
|
|
@@ -87,7 +98,7 @@ function sizeConfig({ production, tests }: Budgets, plugin: string): unknown {
|
|
|
87
98
|
};
|
|
88
99
|
}
|
|
89
100
|
|
|
90
|
-
function heldByChange(changes: readonly Change[]): readonly Held[] {
|
|
101
|
+
export function heldByChange(changes: readonly Change[]): readonly Held[] {
|
|
91
102
|
return changes.flatMap((change) => {
|
|
92
103
|
if (change.kind === "written") return [{ path: change.path, from: change.path }];
|
|
93
104
|
if (change.kind === "renamed" && change.edited) return [{ path: change.path, from: change.from }];
|
|
@@ -117,7 +128,9 @@ const materializeBase = Effect.fn("materializeBase")(function* (root: string, ba
|
|
|
117
128
|
yield* git(["checkout-index", "--all", `--prefix=${tree}/`], root, { env });
|
|
118
129
|
});
|
|
119
130
|
|
|
120
|
-
const siteOf = (
|
|
131
|
+
export const siteOf = (
|
|
132
|
+
budgets: Budgets,
|
|
133
|
+
): ((diagnostic: typeof Diagnostic.Type) => Effect.Effect<readonly Site[], OxlintUnreadable>) =>
|
|
121
134
|
Effect.fn("siteOf")(function* ({ code, message, filename, labels }: typeof Diagnostic.Type) {
|
|
122
135
|
const rule = SIZE_RULES.find((candidate) => code === diagnosticCode(candidate));
|
|
123
136
|
if (rule === undefined) return [];
|
|
@@ -165,7 +178,8 @@ function total(sites: readonly Site[]): number {
|
|
|
165
178
|
return sites.reduce((sum, { overrun }) => sum + overrun, 0);
|
|
166
179
|
}
|
|
167
180
|
|
|
168
|
-
|
|
181
|
+
// Sums each rule per file separately, so a shrink in one rule or file never offsets a growth in another.
|
|
182
|
+
export function growthsOf(head: readonly Site[], base: readonly Site[]): readonly Growth[] {
|
|
169
183
|
const before = byFileAndRule(base);
|
|
170
184
|
return [...byFileAndRule(head).entries()].flatMap(([group, sites]) => {
|
|
171
185
|
const [first] = sites;
|
|
@@ -174,6 +188,24 @@ function growthsOf(head: readonly Site[], base: readonly Site[]): readonly Growt
|
|
|
174
188
|
});
|
|
175
189
|
}
|
|
176
190
|
|
|
191
|
+
// Decides the verdict from sites oxlint already measured: which are held to the range or the
|
|
192
|
+
// whole tree, and, under ratchet, which grew past what the base measured for the same file and rule.
|
|
193
|
+
export function verdictOf(
|
|
194
|
+
applies: Applies,
|
|
195
|
+
held: number,
|
|
196
|
+
holds: ReadonlySet<string>,
|
|
197
|
+
sites: readonly Site[],
|
|
198
|
+
baseSites: readonly Site[],
|
|
199
|
+
): Verdict {
|
|
200
|
+
const heldSites = sites.filter((site) => holds.has(site.file));
|
|
201
|
+
if (applies !== "ratchet") {
|
|
202
|
+
return { applies, held, overruns: heldSites, advisory: sites.filter((site) => !holds.has(site.file)) };
|
|
203
|
+
}
|
|
204
|
+
const growths = growthsOf(heldSites, baseSites);
|
|
205
|
+
const failing = new Set(growths.flatMap((growth) => growth.sites));
|
|
206
|
+
return { applies, held, growths, advisory: sites.filter((site) => !failing.has(site)) };
|
|
207
|
+
}
|
|
208
|
+
|
|
177
209
|
const heldFiles = Effect.fn("heldFiles")(function* (root: string, applies: Applies, pathspecs: readonly string[], base: string, head: string) {
|
|
178
210
|
if (applies === "all") return (yield* pathsAt(head, pathspecs, root)).map((path): Held => ({ path, from: path }));
|
|
179
211
|
return heldByChange(yield* changedPaths(base, head, pathspecs, root));
|
|
@@ -195,25 +227,20 @@ const runBudget = Effect.fn("runBudget")(
|
|
|
195
227
|
if (held.length + others.length > 0) yield* materializeHead(root, head, [...holds, ...others], headTree);
|
|
196
228
|
const plugin = path.join(import.meta.dir, "..", "dist", "index.js");
|
|
197
229
|
const sites = yield* measure(headTree, budgets, plugin);
|
|
198
|
-
|
|
199
|
-
if (applies !== "ratchet") {
|
|
200
|
-
return { applies, held: held.length, overruns: heldSites, advisory: sites.filter((site) => !holds.has(site.file)) } satisfies Verdict;
|
|
201
|
-
}
|
|
230
|
+
if (applies !== "ratchet") return verdictOf(applies, held.length, holds, sites, []);
|
|
202
231
|
|
|
203
232
|
const baseTree = path.join(scratch, "base");
|
|
204
233
|
if (held.length > 0) yield* materializeBase(root, base, held, baseTree);
|
|
205
|
-
|
|
206
|
-
const failing = new Set(growths.flatMap((growth) => growth.sites));
|
|
207
|
-
return { applies, held: held.length, growths, advisory: sites.filter((site) => !failing.has(site)) } satisfies Verdict;
|
|
234
|
+
return verdictOf(applies, held.length, holds, sites, yield* measure(baseTree, budgets, plugin));
|
|
208
235
|
},
|
|
209
236
|
Effect.scoped,
|
|
210
237
|
);
|
|
211
238
|
|
|
212
|
-
function describe({ file, line, message }: Site, indent = " "): string {
|
|
239
|
+
export function describe({ file, line, message }: Site, indent = " "): string {
|
|
213
240
|
return `${indent}${file}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
214
241
|
}
|
|
215
242
|
|
|
216
|
-
function verdictLines(verdict: Verdict): readonly string[] {
|
|
243
|
+
export function verdictLines(verdict: Verdict): readonly string[] {
|
|
217
244
|
const scope = SCOPE[verdict.applies];
|
|
218
245
|
if (verdict.applies === "ratchet") {
|
|
219
246
|
if (verdict.growths.length === 0) return [`${NAME}: ${verdict.held} file(s), ${scope}, raise no overrun past the base`];
|
|
@@ -229,7 +256,7 @@ function verdictLines(verdict: Verdict): readonly string[] {
|
|
|
229
256
|
return [`${NAME}: ${verdict.overruns.length} overrun(s) of the budget in ${scope}:`, ...verdict.overruns.map((site) => describe(site))];
|
|
230
257
|
}
|
|
231
258
|
|
|
232
|
-
function report(verdict: Verdict): string {
|
|
259
|
+
export function report(verdict: Verdict): string {
|
|
233
260
|
const { advisory } = verdict;
|
|
234
261
|
const notice =
|
|
235
262
|
advisory.length === 0
|
|
@@ -238,21 +265,17 @@ function report(verdict: Verdict): string {
|
|
|
238
265
|
return [...verdictLines(verdict), ...notice].join("\n");
|
|
239
266
|
}
|
|
240
267
|
|
|
241
|
-
function passes(verdict: Verdict): boolean {
|
|
268
|
+
export function passes(verdict: Verdict): boolean {
|
|
242
269
|
return verdict.applies === "ratchet" ? verdict.growths.length === 0 : verdict.overruns.length === 0;
|
|
243
270
|
}
|
|
244
271
|
|
|
245
272
|
const budget = Effect.gen(function* () {
|
|
246
|
-
const
|
|
247
|
-
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
248
|
-
|
|
249
|
-
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
250
|
-
const { source, quality } = yield* readQuality(root);
|
|
273
|
+
const { refs, root, source, quality } = yield* rangeGateInputs(USAGE);
|
|
251
274
|
if (quality.size === undefined) {
|
|
252
275
|
yield* Console.log(`${NAME}: ${source} declares no size budget`);
|
|
253
276
|
return true;
|
|
254
277
|
}
|
|
255
|
-
const { base, head } = yield* rangeEnds(first, second, root);
|
|
278
|
+
const { base, head } = yield* rangeEnds(refs.first, refs.second, root);
|
|
256
279
|
const verdict = yield* runBudget(root, quality.size, quality.sources?.production ?? [], base, head);
|
|
257
280
|
|
|
258
281
|
yield* Console.log(report(verdict));
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Schema } from "effect";
|
|
3
|
-
import { git, parentOrEmptyTree } from "./git.ts";
|
|
4
|
-
import { runMain
|
|
3
|
+
import { git, commitOf, parentOrEmptyTree, refArgs } from "./git.ts";
|
|
4
|
+
import { runMain } from "./main.ts";
|
|
5
5
|
|
|
6
6
|
export const SUPPRESSIONS = "oxlint-suppressions.json";
|
|
7
7
|
|
|
@@ -49,28 +49,27 @@ export const parseSuppressions = Effect.fn("parseSuppressions")(function* (
|
|
|
49
49
|
return suppressions;
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
type Count = {
|
|
53
|
+
readonly file: string;
|
|
54
|
+
readonly rule: string;
|
|
55
|
+
readonly count: number;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
function countsOf(suppressions: Suppressions): readonly Count[] {
|
|
59
|
+
return [...suppressions].flatMap(([file, rules]) => [...rules].map(([rule, count]) => ({ file, rule, count })));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function riseOf({ file, rule, count }: Count, before: number | undefined): readonly Rise[] {
|
|
63
|
+
if (before === undefined) return count > 0 ? [{ kind: "appeared", file, rule, head: count }] : [];
|
|
64
|
+
return count > before ? [{ kind: "rose", file, rule, base: before, head: count }] : [];
|
|
65
|
+
}
|
|
66
|
+
|
|
52
67
|
export function compareSuppressions(base: Suppressions, head: Suppressions): Ratchet {
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
for (const [rule, count] of rules) {
|
|
57
|
-
counted += 1;
|
|
58
|
-
const before = base.get(file)?.get(rule);
|
|
59
|
-
if (before === undefined) {
|
|
60
|
-
if (count > 0) rises.push({ kind: "appeared", file, rule, head: count });
|
|
61
|
-
} else if (count > before) {
|
|
62
|
-
rises.push({ kind: "rose", file, rule, base: before, head: count });
|
|
63
|
-
}
|
|
64
|
-
}
|
|
65
|
-
}
|
|
66
|
-
let lowered = 0;
|
|
67
|
-
for (const [file, rules] of base) {
|
|
68
|
-
for (const [rule, count] of rules) {
|
|
69
|
-
if ((head.get(file)?.get(rule) ?? 0) < count) lowered += 1;
|
|
70
|
-
}
|
|
71
|
-
}
|
|
68
|
+
const counts = countsOf(head);
|
|
69
|
+
const rises = counts.flatMap((counted) => riseOf(counted, base.get(counted.file)?.get(counted.rule)));
|
|
70
|
+
const lowered = countsOf(base).filter(({ file, rule, count }) => (head.get(file)?.get(rule) ?? 0) < count).length;
|
|
72
71
|
rises.sort((a, b) => a.file.localeCompare(b.file) || a.rule.localeCompare(b.rule));
|
|
73
|
-
return { counted, lowered, rises };
|
|
72
|
+
return { counted: counts.length, lowered, rises };
|
|
74
73
|
}
|
|
75
74
|
|
|
76
75
|
function describeRise(rise: Rise): string {
|
|
@@ -88,10 +87,6 @@ export function report({ counted, lowered, rises }: Ratchet): string {
|
|
|
88
87
|
].join("\n");
|
|
89
88
|
}
|
|
90
89
|
|
|
91
|
-
const commitOf = Effect.fn("commitOf")(function* (rev: string) {
|
|
92
|
-
return (yield* git(["rev-parse", "--verify", `${rev}^{commit}`])).trim();
|
|
93
|
-
});
|
|
94
|
-
|
|
95
90
|
const mergeBase = Effect.fn("mergeBase")(function* (base: string, head: string) {
|
|
96
91
|
const commit = yield* git(["merge-base", base, head]).pipe(
|
|
97
92
|
Effect.mapError(() => new SuppressionsError({ message: `${base} and ${head} share no commit` })),
|
|
@@ -118,8 +113,7 @@ export const runTip = Effect.fn("runTip")(function* (tip: string) {
|
|
|
118
113
|
});
|
|
119
114
|
|
|
120
115
|
const ratchet = Effect.gen(function* () {
|
|
121
|
-
const
|
|
122
|
-
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
116
|
+
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
123
117
|
|
|
124
118
|
const result = second !== undefined ? yield* runRange(first, second) : yield* runTip(first);
|
|
125
119
|
|
package/scripts/test-layout.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { parse } from "@swc/core";
|
|
|
3
3
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
4
4
|
import { git } from "./git.ts";
|
|
5
5
|
import { runMain } from "./main.ts";
|
|
6
|
-
import { ENTRY_POINT, TEST_ENTRY_POINT } from "./gates.ts";
|
|
6
|
+
import { ENTRY_POINT, QUALITY_FILE, TEST_ENTRY_POINT } from "./gates.ts";
|
|
7
|
+
import { readQuality } from "./quality-file.ts";
|
|
7
8
|
|
|
8
9
|
export type Violation = {
|
|
9
10
|
readonly file: string;
|
|
@@ -16,7 +17,7 @@ const E2E = "tests/e2e/";
|
|
|
16
17
|
const HELPER_DIRS = ["tests/lib/", "tests/fixtures/"] as const;
|
|
17
18
|
const DATA_DIR = "tests/fixtures/";
|
|
18
19
|
|
|
19
|
-
const TEST_FILE = /(?:[.](?:test|spec)|_test)[.](tsx?)$/;
|
|
20
|
+
export const TEST_FILE = /(?:[.](?:test|spec)|_test)[.](tsx?)$/;
|
|
20
21
|
const TARGET_FILE = /^tests\/(?:[^/]+\/)*[^/]+[.]test[.]tsx?$/;
|
|
21
22
|
const TYPESCRIPT = /[.]tsx?$/;
|
|
22
23
|
|
|
@@ -39,9 +40,14 @@ const BANNED_BUN_NAMES: readonly string[] = [
|
|
|
39
40
|
];
|
|
40
41
|
const BANNED_GLOBAL_CALLS: readonly string[] = ["fetch"];
|
|
41
42
|
|
|
42
|
-
const
|
|
43
|
-
|
|
44
|
-
|
|
43
|
+
const IGNORES_KEY = "pathIgnorePatterns";
|
|
44
|
+
const QUARANTINE = "**/tests/quarantine/**";
|
|
45
|
+
const VENDORED = "repos/**";
|
|
46
|
+
const PRESET_IGNORES: readonly string[] = [QUARANTINE, VENDORED];
|
|
47
|
+
|
|
48
|
+
function acceptedIgnores(vendors: boolean): readonly (readonly string[])[] {
|
|
49
|
+
return vendors ? [PRESET_IGNORES] : [PRESET_IGNORES, [QUARANTINE]];
|
|
50
|
+
}
|
|
45
51
|
export const LAYOUT_CHECK_MARK = "scripts/test-layout.ts";
|
|
46
52
|
export const LAYOUT_CHECK_BIN = "checks-test-layout";
|
|
47
53
|
const OWN_ENTRY_POINT = `scripts/${ENTRY_POINT.script}`;
|
|
@@ -147,45 +153,47 @@ function importedNames(node: Record<string, unknown>): readonly string[] {
|
|
|
147
153
|
return names;
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
|
|
151
|
-
const type = node["type"];
|
|
156
|
+
type UseCheck = (node: Record<string, unknown>) => string | undefined;
|
|
152
157
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
return undefined;
|
|
163
|
-
}
|
|
158
|
+
function moduleUse(node: Record<string, unknown>): string | undefined {
|
|
159
|
+
const source = stringValue(node["source"]);
|
|
160
|
+
if (source === undefined) return undefined;
|
|
161
|
+
const banned = bannedModule(source);
|
|
162
|
+
if (banned !== undefined) return `imports node:${banned}`;
|
|
163
|
+
if (source !== "bun") return undefined;
|
|
164
|
+
const name = importedNames(node).find((candidate) => BANNED_BUN_NAMES.includes(candidate));
|
|
165
|
+
return name === undefined ? undefined : `imports ${name} from bun`;
|
|
166
|
+
}
|
|
164
167
|
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
}
|
|
168
|
+
function bunMemberUse(node: Record<string, unknown>): string | undefined {
|
|
169
|
+
if (identifierName(node["object"]) !== "Bun") return undefined;
|
|
170
|
+
const property = identifierName(node["property"]);
|
|
171
|
+
return property !== undefined && BANNED_BUN_NAMES.includes(property) ? `uses Bun.${property}` : undefined;
|
|
172
|
+
}
|
|
171
173
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
174
|
+
function callUse(node: Record<string, unknown>): string | undefined {
|
|
175
|
+
const callee = node["callee"];
|
|
176
|
+
const calleeName = identifierName(callee);
|
|
177
|
+
if (calleeName !== undefined && BANNED_GLOBAL_CALLS.includes(calleeName)) return `calls ${calleeName}`;
|
|
178
|
+
const argument = Array.isArray(node["arguments"]) ? node["arguments"][0] : undefined;
|
|
179
|
+
const specifier = isRecord(argument) ? stringValue(argument["expression"]) : undefined;
|
|
180
|
+
const banned = specifier === undefined ? undefined : bannedModule(specifier);
|
|
181
|
+
if (banned === undefined) return undefined;
|
|
182
|
+
if (isRecord(callee) && callee["type"] === "Import") return `imports node:${banned}`;
|
|
183
|
+
return calleeName === "require" ? `requires node:${banned}` : undefined;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const USE_CHECKS = new Map<string, UseCheck>([
|
|
187
|
+
["ImportDeclaration", moduleUse],
|
|
188
|
+
["ExportNamedDeclaration", moduleUse],
|
|
189
|
+
["ExportAllDeclaration", moduleUse],
|
|
190
|
+
["MemberExpression", bunMemberUse],
|
|
191
|
+
["CallExpression", callUse],
|
|
192
|
+
]);
|
|
187
193
|
|
|
188
|
-
|
|
194
|
+
function outOfProcessUse(node: Record<string, unknown>): string | undefined {
|
|
195
|
+
const type = node["type"];
|
|
196
|
+
return typeof type === "string" ? USE_CHECKS.get(type)?.(node) : undefined;
|
|
189
197
|
}
|
|
190
198
|
|
|
191
199
|
export const isolationViolations = Effect.fn("isolationViolations")(function* (file: string, source: string) {
|
|
@@ -243,7 +251,7 @@ export function scriptViolations(manifest: unknown): readonly Violation[] {
|
|
|
243
251
|
return violations;
|
|
244
252
|
}
|
|
245
253
|
|
|
246
|
-
export function bunfigViolations(consumer: unknown, preset: unknown): readonly Violation[] {
|
|
254
|
+
export function bunfigViolations(consumer: unknown, preset: unknown, vendors: boolean): readonly Violation[] {
|
|
247
255
|
const file = "bunfig.toml";
|
|
248
256
|
const copy = "bun has no bunfig extends, so copy node_modules/@avi2dg/checks/bunfig.toml";
|
|
249
257
|
if (consumer === undefined) {
|
|
@@ -253,18 +261,21 @@ export function bunfigViolations(consumer: unknown, preset: unknown): readonly V
|
|
|
253
261
|
if (!isRecord(presetTest)) {
|
|
254
262
|
return [{ file, line: undefined, message: "the shipped bunfig preset has no [test] table" }];
|
|
255
263
|
}
|
|
256
|
-
const expected = { ...presetTest, ...REQUIRED_TEST_TABLE };
|
|
257
264
|
const consumerTest = isRecord(consumer) ? consumer["test"] : undefined;
|
|
265
|
+
const found = (key: string): unknown => (isRecord(consumerTest) ? consumerTest[key] : undefined);
|
|
266
|
+
const drifted = (key: string, wanted: string, fix: string): Violation => ({
|
|
267
|
+
file,
|
|
268
|
+
line: undefined,
|
|
269
|
+
message: `[test].${key} must be ${wanted}, found ${JSON.stringify(found(key) ?? null)}; ${fix}`,
|
|
270
|
+
});
|
|
258
271
|
const violations: Violation[] = [];
|
|
259
|
-
for (const [key, value] of Object.entries(
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
});
|
|
267
|
-
}
|
|
272
|
+
for (const [key, value] of Object.entries(presetTest)) {
|
|
273
|
+
if (key !== IGNORES_KEY && !Bun.deepEquals(found(key), value)) violations.push(drifted(key, JSON.stringify(value), copy));
|
|
274
|
+
}
|
|
275
|
+
const accepted = acceptedIgnores(vendors);
|
|
276
|
+
if (!accepted.some((ignores) => Bun.deepEquals(found(IGNORES_KEY), ignores))) {
|
|
277
|
+
const wanted = accepted.map((ignores) => JSON.stringify(ignores)).join(" or ");
|
|
278
|
+
violations.push(drifted(IGNORES_KEY, wanted, `the check pins it, requiring ${VENDORED} where ${QUALITY_FILE} declares sources.libraries`));
|
|
268
279
|
}
|
|
269
280
|
return violations;
|
|
270
281
|
}
|
|
@@ -319,7 +330,9 @@ export const run = Effect.fn("run")(function* (root: string, presetPath: string)
|
|
|
319
330
|
|
|
320
331
|
const bunfig = path.join(root, "bunfig.toml");
|
|
321
332
|
const consumerBunfig = (yield* fs.exists(bunfig)) ? yield* parsedToml(bunfig) : undefined;
|
|
322
|
-
|
|
333
|
+
const { quality } = yield* readQuality(root);
|
|
334
|
+
const vendors = (quality.sources?.libraries ?? []).length > 0;
|
|
335
|
+
violations.push(...bunfigViolations(consumerBunfig, yield* parsedToml(presetPath), vendors));
|
|
323
336
|
|
|
324
337
|
return { files: files.length, violations };
|
|
325
338
|
});
|
package/scripts/test-report.ts
CHANGED
|
@@ -52,26 +52,31 @@ function attributesOf(source: string): Readonly<Record<string, string>> {
|
|
|
52
52
|
return attributes;
|
|
53
53
|
}
|
|
54
54
|
|
|
55
|
+
const malformed = (reason: string) => new ReportError({ message: `the junit report is malformed: ${reason}` });
|
|
56
|
+
|
|
57
|
+
const takeTag = Effect.fnUntraced(function* (open: Element[], match: RegExpExecArray): Effect.fn.Return<void, ReportError> {
|
|
58
|
+
const [, closing, tag, attributes, selfClosing] = match;
|
|
59
|
+
if (tag === undefined) return;
|
|
60
|
+
const parent = open.at(-1);
|
|
61
|
+
if (parent === undefined) return yield* malformed(`<${tag}> after the root closed`);
|
|
62
|
+
if (closing === "/") {
|
|
63
|
+
if (parent.tag !== tag) return yield* malformed(`</${tag}> closes <${parent.tag}>`);
|
|
64
|
+
open.pop();
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
const element: Element = { tag, attributes: attributesOf(attributes ?? ""), children: [] };
|
|
68
|
+
parent.children.push(element);
|
|
69
|
+
if (selfClosing !== "/") open.push(element);
|
|
70
|
+
});
|
|
71
|
+
|
|
55
72
|
const parseElements = Effect.fnUntraced(function* (xml: string): Effect.fn.Return<Element, ReportError> {
|
|
56
|
-
const malformed = (reason: string) => new ReportError({ message: `the junit report is malformed: ${reason}` });
|
|
57
73
|
const document: Element = { tag: "", attributes: {}, children: [] };
|
|
58
74
|
const open: Element[] = [document];
|
|
59
75
|
let consumed = 0;
|
|
60
76
|
for (const match of xml.matchAll(MARKUP)) {
|
|
61
77
|
if (xml.slice(consumed, match.index).includes("<")) return yield* malformed(`stray < before offset ${match.index}`);
|
|
62
78
|
consumed = match.index + match[0].length;
|
|
63
|
-
|
|
64
|
-
if (tag === undefined) continue;
|
|
65
|
-
const parent = open.at(-1);
|
|
66
|
-
if (parent === undefined) return yield* malformed(`<${tag}> after the root closed`);
|
|
67
|
-
if (closing === "/") {
|
|
68
|
-
if (parent.tag !== tag) return yield* malformed(`</${tag}> closes <${parent.tag}>`);
|
|
69
|
-
open.pop();
|
|
70
|
-
continue;
|
|
71
|
-
}
|
|
72
|
-
const element: Element = { tag, attributes: attributesOf(attributes ?? ""), children: [] };
|
|
73
|
-
parent.children.push(element);
|
|
74
|
-
if (selfClosing !== "/") open.push(element);
|
|
79
|
+
yield* takeTag(open, match);
|
|
75
80
|
}
|
|
76
81
|
if (xml.slice(consumed).includes("<")) return yield* malformed("stray < after the last element");
|
|
77
82
|
if (open.length !== 1) return yield* malformed(`<${open.at(-1)?.tag}> never closes`);
|