@avi2dg/checks 0.18.0 → 0.20.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 +23 -0
- package/README.md +4 -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 +1 -0
- package/docs/configs/quality-file.md +1 -0
- package/docs/gates/checks-lint.md +2 -1
- package/docs/gates/checks-mutation-compare.md +20 -11
- package/docs/gates/checks-quality.md +1 -1
- package/docs/gates/checks-quarantine-clock.md +54 -0
- package/docs/gates/checks-subsumed-tests.md +68 -0
- package/docs/gates/checks-test-layout.md +9 -3
- package/docs/gates/checks-vendor.md +97 -0
- package/package.json +12 -2
- package/quality.schema.json +56 -4
- package/scripts/gates.ts +1 -0
- package/scripts/git.ts +10 -0
- package/scripts/lint.ts +75 -35
- package/scripts/mutation-compare.ts +218 -75
- package/scripts/quality-file.ts +30 -3
- package/scripts/quality.ts +19 -4
- package/scripts/quarantine-clock.ts +153 -0
- package/scripts/repetition.ts +40 -19
- package/scripts/size-budget.ts +46 -20
- package/scripts/subsumed-tests.ts +208 -0
- package/scripts/suppressions-ratchet.ts +1 -5
- package/scripts/test-layout.ts +28 -17
- package/scripts/vendor.ts +337 -0
package/scripts/quality-file.ts
CHANGED
|
@@ -91,10 +91,33 @@ const EffectSources = Schema.Struct({
|
|
|
91
91
|
),
|
|
92
92
|
});
|
|
93
93
|
|
|
94
|
+
const Library = Schema.Struct({
|
|
95
|
+
name: Schema.String.check(
|
|
96
|
+
Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a library name in kebab case" }),
|
|
97
|
+
).annotate({ description: "The link checks-vendor manages under repos/" }),
|
|
98
|
+
package: Schema.NonEmptyString.annotate({ description: "The npm package whose installed version picks the tag" }),
|
|
99
|
+
repository: Schema.NonEmptyString.annotate({ description: "The git remote checks-vendor clones the tag from" }),
|
|
100
|
+
tag: Schema.String.check(Schema.isPattern(/\{version\}/, { expected: "a tag template holding {version}" })).annotate({
|
|
101
|
+
description: "The tag template, with {version} for the installed version",
|
|
102
|
+
}),
|
|
103
|
+
path: Schema.optionalKey(
|
|
104
|
+
FilePath.annotate({ description: "The manifest inside the clone holding the version; package.json when absent" }),
|
|
105
|
+
),
|
|
106
|
+
}).annotate({ identifier: "Library" });
|
|
107
|
+
export type Library = typeof Library.Type;
|
|
108
|
+
|
|
94
109
|
const Sources = Schema.Struct({
|
|
95
110
|
production: Schema.optionalKey(
|
|
96
111
|
Schema.Array(PathGlob).annotate({ description: "The source the repository ships, as against tests and tooling" }),
|
|
97
112
|
),
|
|
113
|
+
libraries: Schema.optionalKey(
|
|
114
|
+
Schema.Array(Library).check(
|
|
115
|
+
Schema.makeFilter((libraries) => {
|
|
116
|
+
const repeated = duplicates(libraries.map((library) => library.name));
|
|
117
|
+
return repeated === undefined || `names ${repeated} more than once`;
|
|
118
|
+
}),
|
|
119
|
+
).annotate({ description: "The libraries checks-vendor pins to a shared read-only clone" }),
|
|
120
|
+
),
|
|
98
121
|
effect: Schema.optionalKey(EffectSources),
|
|
99
122
|
});
|
|
100
123
|
|
|
@@ -124,11 +147,15 @@ function nests(outer: string, inner: string): boolean {
|
|
|
124
147
|
return outer === inner || inner.startsWith(`${outer}/`);
|
|
125
148
|
}
|
|
126
149
|
|
|
150
|
+
function duplicates(names: readonly string[]): string | undefined {
|
|
151
|
+
const repeated = names.filter((name, index) => names.indexOf(name) !== index);
|
|
152
|
+
return repeated.length === 0 ? undefined : [...new Set(repeated)].join(", ");
|
|
153
|
+
}
|
|
154
|
+
|
|
127
155
|
const Features = Schema.Array(Feature).check(
|
|
128
156
|
Schema.makeFilter((features) => {
|
|
129
|
-
const
|
|
130
|
-
|
|
131
|
-
if (repeated.length > 0) return `names ${[...new Set(repeated)].join(", ")} more than once`;
|
|
157
|
+
const repeated = duplicates(features.map((feature) => feature.name));
|
|
158
|
+
if (repeated !== undefined) return `names ${repeated} more than once`;
|
|
132
159
|
for (const outer of features) {
|
|
133
160
|
const inner = features.find((other) => other !== outer && nests(outer.root, other.root));
|
|
134
161
|
if (inner !== undefined) return `gives ${inner.root} to both ${outer.name} and ${inner.name}`;
|
package/scripts/quality.ts
CHANGED
|
@@ -38,6 +38,7 @@ export type GeneratedWorkflow = {
|
|
|
38
38
|
export type WorkflowRecipe = {
|
|
39
39
|
readonly commitlintConfig: string;
|
|
40
40
|
readonly bunVersionFile: boolean;
|
|
41
|
+
readonly nodeVersionFile: boolean;
|
|
41
42
|
};
|
|
42
43
|
|
|
43
44
|
class NativeConfigUnreadable extends Schema.TaggedError<NativeConfigUnreadable>()("NativeConfigUnreadable", {
|
|
@@ -109,7 +110,9 @@ function runScalar(command: string): string {
|
|
|
109
110
|
}
|
|
110
111
|
|
|
111
112
|
export function commitlintWorkflow(commitlintConfig: string): string {
|
|
112
|
-
return `
|
|
113
|
+
return `permissions:
|
|
114
|
+
contents: read
|
|
115
|
+
on:
|
|
113
116
|
pull_request:
|
|
114
117
|
types: [opened, edited, synchronize, reopened]
|
|
115
118
|
jobs:
|
|
@@ -123,16 +126,24 @@ jobs:
|
|
|
123
126
|
- run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
|
|
124
127
|
env:
|
|
125
128
|
PR_TITLE: \${{ github.event.pull_request.title }}
|
|
129
|
+
# A title starting with git's comment character lints as empty without this: commentChar moves off '#'.
|
|
126
130
|
- run: ${titleLint(commitlintConfig)}
|
|
131
|
+
env:
|
|
132
|
+
GIT_CONFIG_COUNT: "1"
|
|
133
|
+
GIT_CONFIG_KEY_0: core.commentChar
|
|
134
|
+
GIT_CONFIG_VALUE_0: "\\x01"
|
|
127
135
|
`;
|
|
128
136
|
}
|
|
129
137
|
|
|
130
|
-
export function suiteWorkflow(defaultBranch: string, gates: readonly string[], bunVersionFile: boolean): string {
|
|
138
|
+
export function suiteWorkflow(defaultBranch: string, gates: readonly string[], bunVersionFile: boolean, nodeVersionFile: boolean): string {
|
|
139
|
+
const node = nodeVersionFile
|
|
140
|
+
? " - uses: actions/setup-node@v5\n with:\n node-version-file: .node-version\n"
|
|
141
|
+
: "";
|
|
131
142
|
const setup = bunVersionFile
|
|
132
143
|
? " - uses: oven-sh/setup-bun@v2\n with:\n bun-version-file: .bun-version\n"
|
|
133
144
|
: " - uses: oven-sh/setup-bun@v2\n";
|
|
134
145
|
const steps = gates.map((gate) => ` - run: ${runScalar(gate)}\n`).join("");
|
|
135
|
-
return `name: ci\non:\n push:\n branches: [${defaultBranch}]\n pull_request:\n types: [opened, edited, synchronize, reopened]\njobs:\n checks:\n runs-on: ubuntu-latest\n steps:\n # The head, not GitHub's merge ref, so the tree the gates read is the commit the range ends at.\n # The whole history, since the range starts where the head branched from the base branch.\n - uses: actions/checkout@v5\n with:\n ref: \${{ github.event.pull_request.head.sha || github.sha }}\n fetch-depth: 0\n${setup} - run: bun install --frozen-lockfile\n${steps}`;
|
|
146
|
+
return `name: ci\non:\n push:\n branches: [${defaultBranch}]\n pull_request:\n types: [opened, edited, synchronize, reopened]\njobs:\n checks:\n runs-on: ubuntu-latest\n steps:\n # The head, not GitHub's merge ref, so the tree the gates read is the commit the range ends at.\n # The whole history, since the range starts where the head branched from the base branch.\n - uses: actions/checkout@v5\n with:\n ref: \${{ github.event.pull_request.head.sha || github.sha }}\n fetch-depth: 0\n${node}${setup} - run: bun install --frozen-lockfile\n${steps}`;
|
|
136
147
|
}
|
|
137
148
|
|
|
138
149
|
export function workflowsFor(quality: Quality, recipe: WorkflowRecipe): readonly GeneratedWorkflow[] {
|
|
@@ -143,7 +154,10 @@ export function workflowsFor(quality: Quality, recipe: WorkflowRecipe): readonly
|
|
|
143
154
|
if (quality.gates?.ci === undefined) return [commitlint];
|
|
144
155
|
const suite = quality.gates.ci.filter((gate) => !runsInTitleLint(gate, recipe.commitlintConfig));
|
|
145
156
|
return [
|
|
146
|
-
{
|
|
157
|
+
{
|
|
158
|
+
file: SUITE_WORKFLOW,
|
|
159
|
+
content: suiteWorkflow(quality.defaultBranch ?? DEFAULT_BRANCH, suite, recipe.bunVersionFile, recipe.nodeVersionFile),
|
|
160
|
+
},
|
|
147
161
|
commitlint,
|
|
148
162
|
];
|
|
149
163
|
}
|
|
@@ -247,6 +261,7 @@ const recipeOf = Effect.fn("recipeOf")(function* (root: string) {
|
|
|
247
261
|
// The kit never installs itself, so its own tree lints with its root config.
|
|
248
262
|
commitlintConfig: (yield* isKit(root)) ? OWN_COMMITLINT_CONFIG : KIT_COMMITLINT_CONFIG,
|
|
249
263
|
bunVersionFile: yield* fs.exists(path.join(root, ".bun-version")),
|
|
264
|
+
nodeVersionFile: yield* fs.exists(path.join(root, ".node-version")),
|
|
250
265
|
} satisfies WorkflowRecipe;
|
|
251
266
|
});
|
|
252
267
|
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect, Schema } from "effect";
|
|
3
|
+
import { commitOf, git, isShallowBoundary, refArgs } from "./git.ts";
|
|
4
|
+
import { runMain } from "./main.ts";
|
|
5
|
+
import { TEST_FILE } from "./test-layout.ts";
|
|
6
|
+
|
|
7
|
+
export const QUARANTINE = "tests/quarantine/";
|
|
8
|
+
export const QUARANTINE_DAYS = 30;
|
|
9
|
+
const DAY_SECONDS = 86400;
|
|
10
|
+
|
|
11
|
+
export class QuarantineError extends Schema.TaggedError<QuarantineError>()("QuarantineError", {
|
|
12
|
+
message: Schema.String,
|
|
13
|
+
}) {}
|
|
14
|
+
|
|
15
|
+
const USAGE = "usage: quarantine-clock.ts <ref> | <base-ref> <head-ref>";
|
|
16
|
+
|
|
17
|
+
export type Entry =
|
|
18
|
+
| { readonly kind: "entered"; readonly sha: string; readonly at: number; readonly day: string }
|
|
19
|
+
| { readonly kind: "truncated" };
|
|
20
|
+
|
|
21
|
+
export type Overdue = {
|
|
22
|
+
readonly file: string;
|
|
23
|
+
readonly day: string;
|
|
24
|
+
readonly days: number;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export type ClockResult = {
|
|
28
|
+
readonly checked: number;
|
|
29
|
+
readonly overdue: readonly Overdue[];
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
const HEADER = /^commit ([0-9a-f]{40}|[0-9a-f]{64}) (\d+) (\S+)$/;
|
|
33
|
+
const TWO_PATHS = /^[RC]/;
|
|
34
|
+
|
|
35
|
+
type Status = readonly [status: string, from: string, to: string];
|
|
36
|
+
|
|
37
|
+
type CommitBlock = {
|
|
38
|
+
readonly sha: string;
|
|
39
|
+
readonly at: number;
|
|
40
|
+
readonly day: string;
|
|
41
|
+
readonly statuses: readonly Status[];
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function parseBlocks(output: string): readonly CommitBlock[] {
|
|
45
|
+
const blocks: { readonly sha: string; readonly at: number; readonly day: string; readonly statuses: Status[] }[] = [];
|
|
46
|
+
const fields = output.split("\0");
|
|
47
|
+
let index = 0;
|
|
48
|
+
while (index < fields.length) {
|
|
49
|
+
const field = (fields[index] ?? "").replace(/^\n/, "");
|
|
50
|
+
index += 1;
|
|
51
|
+
const header = HEADER.exec(field);
|
|
52
|
+
if (header !== null) {
|
|
53
|
+
blocks.push({ sha: header[1] ?? "", at: Number(header[2] ?? "0"), day: (header[3] ?? "").slice(0, "YYYY-MM-DD".length), statuses: [] });
|
|
54
|
+
continue;
|
|
55
|
+
}
|
|
56
|
+
if (field === "") continue;
|
|
57
|
+
const paths = TWO_PATHS.test(field) ? 2 : 1;
|
|
58
|
+
blocks.at(-1)?.statuses.push([field, fields[index] ?? "", fields[index + paths - 1] ?? ""]);
|
|
59
|
+
index += paths;
|
|
60
|
+
}
|
|
61
|
+
return blocks;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function entryIn({ sha, at, day, statuses }: CommitBlock, path: string): Entry | undefined {
|
|
65
|
+
const entered = statuses.some(
|
|
66
|
+
([status, from, to]) =>
|
|
67
|
+
to === path && (status === "A" || status.startsWith("C") || (status.startsWith("R") && !from.startsWith(QUARANTINE))),
|
|
68
|
+
);
|
|
69
|
+
return entered ? { kind: "entered", sha, at, day } : undefined;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function followedPath({ statuses }: CommitBlock, path: string): string {
|
|
73
|
+
const rename = statuses.find(([status, , to]) => status.startsWith("R") && to === path);
|
|
74
|
+
return rename?.[1] ?? path;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function findEntry(output: string, file: string): Entry {
|
|
78
|
+
let current = file;
|
|
79
|
+
for (const block of parseBlocks(output)) {
|
|
80
|
+
const entry = entryIn(block, current);
|
|
81
|
+
if (entry !== undefined) return entry;
|
|
82
|
+
current = followedPath(block, current);
|
|
83
|
+
}
|
|
84
|
+
return { kind: "truncated" };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function overdueOf(
|
|
88
|
+
entries: readonly { readonly file: string; readonly at: number; readonly day: string }[],
|
|
89
|
+
headAt: number,
|
|
90
|
+
): readonly Overdue[] {
|
|
91
|
+
const limit = QUARANTINE_DAYS * DAY_SECONDS;
|
|
92
|
+
return entries
|
|
93
|
+
.flatMap((entry) =>
|
|
94
|
+
headAt - entry.at > limit
|
|
95
|
+
? [{ file: entry.file, day: entry.day, days: Math.floor((headAt - entry.at) / DAY_SECONDS) }]
|
|
96
|
+
: [],
|
|
97
|
+
)
|
|
98
|
+
.toSorted((a, b) => (a.file < b.file ? -1 : 1));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function report({ checked, overdue }: ClockResult): string {
|
|
102
|
+
if (overdue.length === 0) {
|
|
103
|
+
return `quarantine-clock: no test in ${QUARANTINE} is past ${QUARANTINE_DAYS} days (${checked} checked)`;
|
|
104
|
+
}
|
|
105
|
+
const verb = overdue.length === 1 ? "is" : "are";
|
|
106
|
+
return [
|
|
107
|
+
`quarantine-clock: ${overdue.length} test(s) in ${QUARANTINE} ${verb} past ${QUARANTINE_DAYS} days; fix each and move it back, or delete it:`,
|
|
108
|
+
...overdue.map((file) => ` ${file.file} entered quarantine on ${file.day} (${file.days} days ago)`),
|
|
109
|
+
].join("\n");
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const filesAt = Effect.fn("filesAt")(function* (root: string, head: string) {
|
|
113
|
+
const listed = yield* git(["ls-tree", "-r", "-z", "--name-only", head, "--", QUARANTINE], root);
|
|
114
|
+
return listed.split("\0").filter((file) => TEST_FILE.test(file));
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const entryAt = Effect.fn("entryAt")(function* (root: string, head: string, file: string) {
|
|
118
|
+
const output = yield* git(["log", "--follow", "--root", "--name-status", "-z", "--format=commit %H %at %aI", head, "--", file], root);
|
|
119
|
+
const entry = findEntry(output, file);
|
|
120
|
+
if (entry.kind === "truncated" || (yield* isShallowBoundary(entry.sha, root))) {
|
|
121
|
+
return yield* new QuarantineError({
|
|
122
|
+
message: `cannot see ${file} entering ${QUARANTINE}; fetch the whole history, since a shallow clone ends before it`,
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
return { file, at: entry.at, day: entry.day };
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const headAt = Effect.fn("headAt")(function* (root: string, head: string) {
|
|
129
|
+
const shown = yield* git(["show", "-s", "--format=%at %ct", head], root);
|
|
130
|
+
const dates = shown.trim().split(" ").map(Number);
|
|
131
|
+
if (dates.length !== 2 || !dates.every(Number.isInteger)) {
|
|
132
|
+
return yield* new QuarantineError({ message: `cannot read when ${head} was written` });
|
|
133
|
+
}
|
|
134
|
+
return Math.max(...dates);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
export const runHead = Effect.fn("runHead")(function* (root: string, head: string) {
|
|
138
|
+
const files = yield* filesAt(root, head);
|
|
139
|
+
const entries = yield* Effect.forEach(files, (file) => entryAt(root, head, file));
|
|
140
|
+
return { checked: files.length, overdue: overdueOf(entries, yield* headAt(root, head)) } satisfies ClockResult;
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
const clock = Effect.gen(function* () {
|
|
144
|
+
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
145
|
+
if (second !== undefined) yield* commitOf(first);
|
|
146
|
+
const head = yield* commitOf(second ?? first);
|
|
147
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
148
|
+
const result = yield* runHead(root, head);
|
|
149
|
+
yield* Console.log(report(result));
|
|
150
|
+
return result.overdue.length === 0;
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (import.meta.main) runMain("quarantine-clock", clock);
|
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}`]
|
package/scripts/size-budget.ts
CHANGED
|
@@ -20,9 +20,9 @@ import {
|
|
|
20
20
|
type SizeRule,
|
|
21
21
|
} from "./size-rules.ts";
|
|
22
22
|
|
|
23
|
-
type Held = { readonly path: string; readonly from: string };
|
|
23
|
+
export type Held = { readonly path: string; readonly from: string };
|
|
24
24
|
|
|
25
|
-
type Site = {
|
|
25
|
+
export type Site = {
|
|
26
26
|
readonly file: string;
|
|
27
27
|
readonly line: number | undefined;
|
|
28
28
|
readonly rule: SizeRule["rule"];
|
|
@@ -30,7 +30,7 @@ type Site = {
|
|
|
30
30
|
readonly message: string;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
-
type Growth = {
|
|
33
|
+
export type Growth = {
|
|
34
34
|
readonly file: string;
|
|
35
35
|
readonly rule: SizeRule["rule"];
|
|
36
36
|
readonly base: number;
|
|
@@ -38,7 +38,7 @@ type Growth = {
|
|
|
38
38
|
readonly sites: readonly Site[];
|
|
39
39
|
};
|
|
40
40
|
|
|
41
|
-
type Verdict =
|
|
41
|
+
export type Verdict =
|
|
42
42
|
| { readonly applies: "all"; readonly held: number; readonly overruns: readonly Site[]; readonly advisory: readonly Site[] }
|
|
43
43
|
| { readonly applies: "ratchet"; readonly held: number; readonly growths: readonly Growth[]; readonly advisory: readonly Site[] };
|
|
44
44
|
|
|
@@ -69,7 +69,17 @@ const Diagnostic = Schema.Struct({
|
|
|
69
69
|
|
|
70
70
|
const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ diagnostics: Schema.Array(Diagnostic) })));
|
|
71
71
|
|
|
72
|
-
|
|
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 {
|
|
73
83
|
return Object.fromEntries(
|
|
74
84
|
SIZE_RULES.map((entry) => {
|
|
75
85
|
const max = budget[entry.key];
|
|
@@ -78,7 +88,7 @@ function rulesOf(budget: Budget): Record<string, unknown> {
|
|
|
78
88
|
);
|
|
79
89
|
}
|
|
80
90
|
|
|
81
|
-
function sizeConfig({ production, tests }: Budgets, plugin: string):
|
|
91
|
+
export function sizeConfig({ production, tests }: Budgets, plugin: string): SizeConfig {
|
|
82
92
|
return {
|
|
83
93
|
plugins: [],
|
|
84
94
|
jsPlugins: [plugin],
|
|
@@ -88,7 +98,7 @@ function sizeConfig({ production, tests }: Budgets, plugin: string): unknown {
|
|
|
88
98
|
};
|
|
89
99
|
}
|
|
90
100
|
|
|
91
|
-
function heldByChange(changes: readonly Change[]): readonly Held[] {
|
|
101
|
+
export function heldByChange(changes: readonly Change[]): readonly Held[] {
|
|
92
102
|
return changes.flatMap((change) => {
|
|
93
103
|
if (change.kind === "written") return [{ path: change.path, from: change.path }];
|
|
94
104
|
if (change.kind === "renamed" && change.edited) return [{ path: change.path, from: change.from }];
|
|
@@ -118,7 +128,9 @@ const materializeBase = Effect.fn("materializeBase")(function* (root: string, ba
|
|
|
118
128
|
yield* git(["checkout-index", "--all", `--prefix=${tree}/`], root, { env });
|
|
119
129
|
});
|
|
120
130
|
|
|
121
|
-
const siteOf = (
|
|
131
|
+
export const siteOf = (
|
|
132
|
+
budgets: Budgets,
|
|
133
|
+
): ((diagnostic: typeof Diagnostic.Type) => Effect.Effect<readonly Site[], OxlintUnreadable>) =>
|
|
122
134
|
Effect.fn("siteOf")(function* ({ code, message, filename, labels }: typeof Diagnostic.Type) {
|
|
123
135
|
const rule = SIZE_RULES.find((candidate) => code === diagnosticCode(candidate));
|
|
124
136
|
if (rule === undefined) return [];
|
|
@@ -166,7 +178,8 @@ function total(sites: readonly Site[]): number {
|
|
|
166
178
|
return sites.reduce((sum, { overrun }) => sum + overrun, 0);
|
|
167
179
|
}
|
|
168
180
|
|
|
169
|
-
|
|
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[] {
|
|
170
183
|
const before = byFileAndRule(base);
|
|
171
184
|
return [...byFileAndRule(head).entries()].flatMap(([group, sites]) => {
|
|
172
185
|
const [first] = sites;
|
|
@@ -175,6 +188,24 @@ function growthsOf(head: readonly Site[], base: readonly Site[]): readonly Growt
|
|
|
175
188
|
});
|
|
176
189
|
}
|
|
177
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
|
+
|
|
178
209
|
const heldFiles = Effect.fn("heldFiles")(function* (root: string, applies: Applies, pathspecs: readonly string[], base: string, head: string) {
|
|
179
210
|
if (applies === "all") return (yield* pathsAt(head, pathspecs, root)).map((path): Held => ({ path, from: path }));
|
|
180
211
|
return heldByChange(yield* changedPaths(base, head, pathspecs, root));
|
|
@@ -196,25 +227,20 @@ const runBudget = Effect.fn("runBudget")(
|
|
|
196
227
|
if (held.length + others.length > 0) yield* materializeHead(root, head, [...holds, ...others], headTree);
|
|
197
228
|
const plugin = path.join(import.meta.dir, "..", "dist", "index.js");
|
|
198
229
|
const sites = yield* measure(headTree, budgets, plugin);
|
|
199
|
-
|
|
200
|
-
if (applies !== "ratchet") {
|
|
201
|
-
return { applies, held: held.length, overruns: heldSites, advisory: sites.filter((site) => !holds.has(site.file)) } satisfies Verdict;
|
|
202
|
-
}
|
|
230
|
+
if (applies !== "ratchet") return verdictOf(applies, held.length, holds, sites, []);
|
|
203
231
|
|
|
204
232
|
const baseTree = path.join(scratch, "base");
|
|
205
233
|
if (held.length > 0) yield* materializeBase(root, base, held, baseTree);
|
|
206
|
-
|
|
207
|
-
const failing = new Set(growths.flatMap((growth) => growth.sites));
|
|
208
|
-
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));
|
|
209
235
|
},
|
|
210
236
|
Effect.scoped,
|
|
211
237
|
);
|
|
212
238
|
|
|
213
|
-
function describe({ file, line, message }: Site, indent = " "): string {
|
|
239
|
+
export function describe({ file, line, message }: Site, indent = " "): string {
|
|
214
240
|
return `${indent}${file}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
215
241
|
}
|
|
216
242
|
|
|
217
|
-
function verdictLines(verdict: Verdict): readonly string[] {
|
|
243
|
+
export function verdictLines(verdict: Verdict): readonly string[] {
|
|
218
244
|
const scope = SCOPE[verdict.applies];
|
|
219
245
|
if (verdict.applies === "ratchet") {
|
|
220
246
|
if (verdict.growths.length === 0) return [`${NAME}: ${verdict.held} file(s), ${scope}, raise no overrun past the base`];
|
|
@@ -230,7 +256,7 @@ function verdictLines(verdict: Verdict): readonly string[] {
|
|
|
230
256
|
return [`${NAME}: ${verdict.overruns.length} overrun(s) of the budget in ${scope}:`, ...verdict.overruns.map((site) => describe(site))];
|
|
231
257
|
}
|
|
232
258
|
|
|
233
|
-
function report(verdict: Verdict): string {
|
|
259
|
+
export function report(verdict: Verdict): string {
|
|
234
260
|
const { advisory } = verdict;
|
|
235
261
|
const notice =
|
|
236
262
|
advisory.length === 0
|
|
@@ -239,7 +265,7 @@ function report(verdict: Verdict): string {
|
|
|
239
265
|
return [...verdictLines(verdict), ...notice].join("\n");
|
|
240
266
|
}
|
|
241
267
|
|
|
242
|
-
function passes(verdict: Verdict): boolean {
|
|
268
|
+
export function passes(verdict: Verdict): boolean {
|
|
243
269
|
return verdict.applies === "ratchet" ? verdict.growths.length === 0 : verdict.overruns.length === 0;
|
|
244
270
|
}
|
|
245
271
|
|