@avi2dg/checks 0.21.0 → 0.23.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.
Files changed (57) hide show
  1. package/CHANGELOG.md +66 -40
  2. package/CONTRIBUTING.md +14 -10
  3. package/README.md +15 -24
  4. package/bunfig.toml +1 -1
  5. package/dist/effect-channel/index.js +122 -0
  6. package/dist/{index.js → readability/index.js} +8 -120
  7. package/docs/configs/commit-messages.md +5 -1
  8. package/docs/configs/dependency-rules.md +5 -2
  9. package/docs/configs/effect-rules.md +32 -33
  10. package/docs/configs/native-settings.md +74 -0
  11. package/docs/configs/typescript-rules.md +4 -0
  12. package/docs/design.md +26 -27
  13. package/docs/gates/checks-backtest.md +4 -0
  14. package/docs/gates/checks-ci-wiring.md +30 -93
  15. package/docs/gates/checks-comment-gate.md +4 -0
  16. package/docs/gates/checks-commit-identity.md +23 -27
  17. package/docs/gates/checks-docs.md +23 -21
  18. package/docs/gates/checks-flake.md +4 -10
  19. package/docs/gates/checks-lint-coverage.md +5 -1
  20. package/docs/gates/checks-lint.md +22 -104
  21. package/docs/gates/checks-mutation-compare.md +4 -0
  22. package/docs/gates/checks-quarantine-clock.md +4 -0
  23. package/docs/gates/checks-repetition.md +27 -41
  24. package/docs/gates/checks-subsumed-tests.md +4 -0
  25. package/docs/gates/checks-suppressions-ratchet.md +4 -0
  26. package/docs/gates/checks-test-layout.md +16 -8
  27. package/docs/gates/checks-test.md +68 -36
  28. package/docs/gates/checks-vendor.md +20 -13
  29. package/oxlintrc.json +1 -1
  30. package/package.json +10 -21
  31. package/scripts/ci-wiring.ts +28 -97
  32. package/scripts/commit-identity.ts +32 -4
  33. package/scripts/doc-rules.ts +26 -11
  34. package/scripts/doc-templates.ts +2 -1
  35. package/scripts/docs.ts +4 -7
  36. package/scripts/gates.ts +0 -29
  37. package/scripts/git.ts +24 -1
  38. package/scripts/lint.ts +15 -34
  39. package/scripts/range-gate.ts +1 -2
  40. package/scripts/repetition.ts +51 -40
  41. package/scripts/shell-command.ts +7 -1
  42. package/scripts/swc.ts +46 -0
  43. package/scripts/test-layout.ts +40 -56
  44. package/scripts/test-skips.ts +180 -0
  45. package/scripts/test.ts +33 -38
  46. package/scripts/vendor.ts +55 -11
  47. package/dist/feature-rules.js +0 -354
  48. package/docs/configs/quality-file.md +0 -103
  49. package/docs/gates/checks-feature-owners.md +0 -113
  50. package/docs/gates/checks-quality.md +0 -111
  51. package/docs/gates/checks-size-budget.md +0 -107
  52. package/quality.schema.json +0 -514
  53. package/scripts/feature-owners.ts +0 -139
  54. package/scripts/quality-file.ts +0 -353
  55. package/scripts/quality.ts +0 -363
  56. package/scripts/size-budget.ts +0 -285
  57. package/scripts/size-rules.ts +0 -126
@@ -1,363 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
- import kitOxlint from "../oxlintrc.json" with { type: "json" };
4
- import kitManifest from "../package.json" with { type: "json" };
5
- import effectLanguageService from "../presets/effect.language-service.json" with { type: "json" };
6
- import effectOxlint from "../presets/effect.oxlint.json" with { type: "json" };
7
- import { git } from "./git.ts";
8
- import { DEFAULT_BRANCH } from "./gates.ts";
9
- import { runMain, Usage } from "./main.ts";
10
- import { QualityUnreadable, readQuality, renderJson, type Quality } from "./quality-file.ts";
11
- import { invokes, plainCommand } from "./shell-command.ts";
12
-
13
- // oxlint resolves an override's files, and the language service an override's include, against the
14
- // directory of the config that holds it, so a fragment anywhere but the root matches nothing there.
15
- export const OXLINT_FRAGMENT = "oxlintrc.quality.json";
16
- export const TSCONFIG_FRAGMENT = "tsconfig.quality.json";
17
- export const SUITE_WORKFLOW = ".github/workflows/ci.yml";
18
- export const COMMITLINT_WORKFLOW = ".github/workflows/commitlint.yml";
19
-
20
- const NAME = "checks-quality";
21
- const USAGE = `usage: ${NAME} --check | generate`;
22
- const GENERATE = `${NAME} generate`;
23
-
24
- type EffectSources = NonNullable<NonNullable<Quality["sources"]>["effect"]>;
25
-
26
- export type Fragment = {
27
- readonly file: typeof OXLINT_FRAGMENT | typeof TSCONFIG_FRAGMENT;
28
- readonly extendedBy: string;
29
- readonly reader: string;
30
- readonly content: unknown;
31
- };
32
-
33
- export type GeneratedWorkflow = {
34
- readonly file: typeof SUITE_WORKFLOW | typeof COMMITLINT_WORKFLOW;
35
- readonly content: string;
36
- };
37
-
38
- export type WorkflowRecipe = {
39
- readonly commitlintConfig: string;
40
- readonly bunVersionFile: boolean;
41
- readonly nodeVersionFile: boolean;
42
- };
43
-
44
- class NativeConfigUnreadable extends Schema.TaggedError<NativeConfigUnreadable>()("NativeConfigUnreadable", {
45
- message: Schema.String,
46
- }) {}
47
-
48
- function oxlintFragment({ paths, exempt = [] }: EffectSources): unknown {
49
- const override = {
50
- files: paths,
51
- ...(exempt.length === 0 ? {} : { excludeFiles: exempt }),
52
- // An override that leaves out any of the kit's plugins turns on the category rules of the ones it adds.
53
- plugins: [...kitOxlint.plugins, ...effectOxlint.plugins.filter((plugin) => !kitOxlint.plugins.includes(plugin))],
54
- rules: effectOxlint.rules,
55
- };
56
- return {
57
- $schema: "./node_modules/oxlint/configuration_schema.json",
58
- // A config without plugins brings oxlint's default plugins into the one extending it.
59
- plugins: kitOxlint.plugins,
60
- overrides: [override],
61
- };
62
- }
63
-
64
- function tsconfigFragment({ paths, exempt = [] }: EffectSources): unknown {
65
- const override = { include: paths, ...(exempt.length === 0 ? {} : { exclude: exempt }), options: effectLanguageService };
66
- return { compilerOptions: { plugins: [{ name: "@effect/language-service", overrides: [override] }] } };
67
- }
68
-
69
- const FRAGMENTS = [
70
- {
71
- file: OXLINT_FRAGMENT,
72
- extendedBy: ".oxlintrc.json",
73
- reader: "oxlint",
74
- kitConfig: "./node_modules/@avi2dg/checks/oxlintrc.json",
75
- kitRepositoryConfig: "./oxlintrc.json",
76
- kitConfigReason: "so the kit's oxlint rules are not loaded",
77
- build: oxlintFragment,
78
- },
79
- {
80
- file: TSCONFIG_FRAGMENT,
81
- extendedBy: "tsconfig.json",
82
- reader: "the language service",
83
- kitConfig: "@avi2dg/checks/tsconfig.effect.json",
84
- kitRepositoryConfig: "./tsconfig.effect.json",
85
- kitConfigReason: "the one accepted spelling of the kit's Effect config",
86
- build: tsconfigFragment,
87
- },
88
- ] as const;
89
-
90
- export function fragmentsFor(quality: Quality): readonly Fragment[] {
91
- const effect = quality.sources?.effect;
92
- if (effect === undefined) return [];
93
- return FRAGMENTS.map(({ file, extendedBy, reader, build }) => ({ file, extendedBy, reader, content: build(effect) }));
94
- }
95
-
96
- const OWN_COMMITLINT_CONFIG = "./commitlint.config.js";
97
- const KIT_COMMITLINT_CONFIG = "./node_modules/@avi2dg/checks/commitlint.config.js";
98
-
99
- function titleLint(commitlintConfig: string): string {
100
- return `./node_modules/.bin/commitlint --config ${commitlintConfig} --edit "$RUNNER_TEMP/pr-title"`;
101
- }
102
-
103
- function runsInTitleLint(gate: string, commitlintConfig: string): boolean {
104
- const words = plainCommand(gate);
105
- return words !== undefined && invokes(plainCommand(titleLint(commitlintConfig)), words);
106
- }
107
-
108
- // YAML 1.2's core schema resolves these plain scalars to a null, a boolean or a number, and YAML 1.1 parsers also read yes, no, on and off as booleans.
109
- const NON_STRING_SCALAR =
110
- /^(?:~|null|Null|NULL|true|True|TRUE|false|False|FALSE|y|Y|yes|Yes|YES|n|N|no|No|NO|on|On|ON|off|Off|OFF|[-+]?(?:\.[0-9]+|[0-9]+(?:\.[0-9]*)?)(?:[eE][-+]?[0-9]+)?|0o[0-7]+|0x[0-9a-fA-F]+|[-+]?\.(?:inf|Inf|INF)|\.(?:nan|NaN|NAN))$/;
111
-
112
- function runScalar(command: string): string {
113
- return /(^[\s#&*!|>@`'"%{[-])|:\s|\s#|:$|[\n\\]/.test(command) || NON_STRING_SCALAR.test(command)
114
- ? JSON.stringify(command)
115
- : command;
116
- }
117
-
118
- function flowScalar(label: string): string {
119
- return /[,[\]{}]/.test(label) ? JSON.stringify(label) : runScalar(label);
120
- }
121
-
122
- function runsOnLine(runsOn: Quality["runsOn"]): string {
123
- if (runsOn === undefined) return "ubuntu-latest";
124
- return `[${runsOn.map(flowScalar).join(", ")}]`;
125
- }
126
-
127
- export function commitlintWorkflow(commitlintConfig: string, runsOn: Quality["runsOn"]): string {
128
- return `permissions:
129
- contents: read
130
- on:
131
- pull_request:
132
- types: [opened, edited, synchronize, reopened]
133
- jobs:
134
- commitlint:
135
- runs-on: ${runsOnLine(runsOn)}
136
- steps:
137
- - uses: actions/checkout@v5
138
- - uses: oven-sh/setup-bun@v2
139
- - run: bun install --frozen-lockfile
140
- # Only the title is linted: it is what a squash merge lands, with GitHub appending " (#N)" to it.
141
- - run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
142
- env:
143
- PR_TITLE: \${{ github.event.pull_request.title }}
144
- # A title starting with git's comment character lints as empty without this: commentChar moves off '#'.
145
- - run: ${titleLint(commitlintConfig)}
146
- env:
147
- GIT_CONFIG_COUNT: "1"
148
- GIT_CONFIG_KEY_0: core.commentChar
149
- GIT_CONFIG_VALUE_0: "\\x01"
150
- `;
151
- }
152
-
153
- export function suiteWorkflow(
154
- defaultBranch: string,
155
- gates: readonly string[],
156
- bunVersionFile: boolean,
157
- nodeVersionFile: boolean,
158
- runsOn: Quality["runsOn"],
159
- ): string {
160
- const node = nodeVersionFile
161
- ? " - uses: actions/setup-node@v5\n with:\n node-version-file: .node-version\n"
162
- : "";
163
- const setup = bunVersionFile
164
- ? " - uses: oven-sh/setup-bun@v2\n with:\n bun-version-file: .bun-version\n"
165
- : " - uses: oven-sh/setup-bun@v2\n";
166
- const steps = gates.map((gate) => ` - run: ${runScalar(gate)}\n`).join("");
167
- return `name: ci\non:\n push:\n branches: [${defaultBranch}]\n pull_request:\n types: [opened, edited, synchronize, reopened]\njobs:\n checks:\n runs-on: ${runsOnLine(runsOn)}\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}`;
168
- }
169
-
170
- export function workflowsFor(quality: Quality, recipe: WorkflowRecipe): readonly GeneratedWorkflow[] {
171
- const commitlint: GeneratedWorkflow = {
172
- file: COMMITLINT_WORKFLOW,
173
- content: commitlintWorkflow(recipe.commitlintConfig, quality.runsOn),
174
- };
175
- if (quality.gates?.ci === undefined) return [commitlint];
176
- const suite = quality.gates.ci.filter((gate) => !runsInTitleLint(gate, recipe.commitlintConfig));
177
- return [
178
- {
179
- file: SUITE_WORKFLOW,
180
- content: suiteWorkflow(quality.defaultBranch ?? DEFAULT_BRANCH, suite, recipe.bunVersionFile, recipe.nodeVersionFile, quality.runsOn),
181
- },
182
- commitlint,
183
- ];
184
- }
185
-
186
- const NativeConfig = Schema.Struct({
187
- extends: Schema.optionalKey(Schema.Union([Schema.String, Schema.Array(Schema.String)])),
188
- });
189
- const decodeNativeConfig = Schema.decodeUnknownEffect(NativeConfig);
190
-
191
- const parseJsonc = (text: string, file: string) =>
192
- Effect.try({
193
- try: (): unknown => Bun.JSONC.parse(text),
194
- catch: (error) => new NativeConfigUnreadable({ message: `cannot parse ${file}: ${String(error)}` }),
195
- });
196
-
197
- const extendsOf = Effect.fn("extendsOf")(function* (root: string, file: string) {
198
- const fs = yield* FileSystem.FileSystem;
199
- const path = yield* Path.Path;
200
- const target = path.join(root, file);
201
- if (!(yield* fs.exists(target))) return [];
202
- const config = yield* fs.readFileString(target).pipe(
203
- Effect.flatMap((text) => parseJsonc(text, file)),
204
- Effect.flatMap(decodeNativeConfig),
205
- Effect.mapError((cause) => new NativeConfigUnreadable({ message: `cannot read extends from ${file}: ${cause.message}` })),
206
- );
207
- const listed = config.extends ?? [];
208
- return (typeof listed === "string" ? [listed] : listed).map((entry) => path.normalize(entry));
209
- });
210
-
211
- const decodeJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
212
-
213
- const sameJson = (text: string, content: unknown): Effect.Effect<boolean> =>
214
- decodeJson(text).pipe(
215
- Effect.map((parsed) => Bun.deepEquals(parsed, content, true)),
216
- Effect.orElseSucceed(() => false),
217
- );
218
-
219
- const decodePackageName = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ name: Schema.optionalKey(Schema.String) })));
220
-
221
- const isKit = Effect.fn("isKit")(function* (root: string) {
222
- const fs = yield* FileSystem.FileSystem;
223
- const manifest = (yield* Path.Path).join(root, "package.json");
224
- if (!(yield* fs.exists(manifest))) return false;
225
- const { name } = yield* decodePackageName(yield* fs.readFileString(manifest)).pipe(
226
- Effect.mapError((cause) => new QualityUnreadable({ message: `package.json: ${cause.message}` })),
227
- );
228
- return name === kitManifest.name;
229
- });
230
-
231
- const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string, source: string, expected: readonly Fragment[]) {
232
- const fs = yield* FileSystem.FileSystem;
233
- const path = yield* Path.Path;
234
- const problems: string[] = [];
235
- const kitRepository = yield* isKit(root);
236
- for (const { file, extendedBy, reader, kitConfig, kitRepositoryConfig, kitConfigReason } of FRAGMENTS) {
237
- const target = path.join(root, file);
238
- const fragment = expected.find((candidate) => candidate.file === file);
239
- const present = yield* fs.exists(target);
240
- if (fragment === undefined) {
241
- if (present) problems.push(`${file} is left over, since no sources.effect is declared; run ${GENERATE}`);
242
- continue;
243
- }
244
- if (!present) {
245
- problems.push(`${file} is missing; run ${GENERATE}`);
246
- } else if (!(yield* sameJson(yield* fs.readFileString(target), fragment.content))) {
247
- problems.push(`${file} is stale against ${source} and the kit's presets; run ${GENERATE}`);
248
- }
249
- const configured = yield* extendsOf(root, extendedBy);
250
- if (!configured.includes(file)) {
251
- problems.push(`${extendedBy} does not extend ./${file}, so ${reader} never reads it`);
252
- }
253
- const requiredKitConfig = kitRepository ? kitRepositoryConfig : kitConfig;
254
- if (!configured.includes(path.normalize(requiredKitConfig))) {
255
- problems.push(`${extendedBy} does not extend ${requiredKitConfig}, ${kitConfigReason}`);
256
- }
257
- }
258
- return problems;
259
- });
260
-
261
- const unmatchedPaths = Effect.fn("unmatchedPaths")(function* (root: string, quality: Quality) {
262
- const declared = [
263
- ...(quality.sources?.production ?? []).map((glob) => ({
264
- glob,
265
- key: "sources.production",
266
- holds: "no source to checks-size-budget or checks-repetition",
267
- })),
268
- ...(quality.sources?.effect?.paths ?? []).map((glob) => ({ glob, key: "sources.effect.paths", holds: "nothing to the Effect rules" })),
269
- ];
270
- const problems: string[] = [];
271
- for (const { glob, key, holds } of declared) {
272
- const matched = yield* git(["ls-files", "--cached", "--others", "--exclude-standard", "--", `:(glob)${glob}`], root);
273
- if (matched.trim() === "") problems.push(`${key} ${glob} matches no file, so it holds ${holds}`);
274
- }
275
- return problems;
276
- });
277
-
278
- const recipeOf = Effect.fn("recipeOf")(function* (root: string) {
279
- const fs = yield* FileSystem.FileSystem;
280
- const path = yield* Path.Path;
281
- return {
282
- // The kit never installs itself, so its own tree lints with its root config.
283
- commitlintConfig: (yield* isKit(root)) ? OWN_COMMITLINT_CONFIG : KIT_COMMITLINT_CONFIG,
284
- bunVersionFile: yield* fs.exists(path.join(root, ".bun-version")),
285
- nodeVersionFile: yield* fs.exists(path.join(root, ".node-version")),
286
- } satisfies WorkflowRecipe;
287
- });
288
-
289
- const workflowProblems = Effect.fn("workflowProblems")(function* (
290
- root: string,
291
- source: string,
292
- expected: readonly GeneratedWorkflow[],
293
- ) {
294
- const fs = yield* FileSystem.FileSystem;
295
- const path = yield* Path.Path;
296
- const problems: string[] = [];
297
- for (const { file, content } of expected) {
298
- const target = path.join(root, file);
299
- if (!(yield* fs.exists(target))) {
300
- problems.push(`${file} is missing; run ${GENERATE}`);
301
- } else if ((yield* fs.readFileString(target)) !== content) {
302
- problems.push(`${file} is stale against ${source} and the kit recipe; run ${GENERATE}`);
303
- }
304
- }
305
- return problems;
306
- });
307
-
308
- const check = Effect.fn("check")(function* (root: string) {
309
- const { source, quality } = yield* readQuality(root);
310
- const expected = fragmentsFor(quality);
311
- const workflows = workflowsFor(quality, yield* recipeOf(root));
312
- const problems = [
313
- ...(yield* fragmentProblems(root, source, expected)),
314
- ...(yield* workflowProblems(root, source, workflows)),
315
- ...(yield* unmatchedPaths(root, quality)),
316
- ];
317
- if (problems.length > 0) {
318
- yield* Console.error([`${NAME}: ${problems.length} problem(s) with what ${source} declares:`, ...problems.map((line) => ` ${line}`)].join("\n"));
319
- return false;
320
- }
321
- const held = [...expected.map((fragment) => fragment.file), ...workflows.map((workflow) => workflow.file)];
322
- const verb = held.length === 1 ? "holds" : "hold";
323
- yield* Console.log(
324
- expected.length === 0
325
- ? `${NAME}: no sources.effect is declared, so no fragment is generated; ${held.join(" and ")} ${verb} the kit recipe`
326
- : `${NAME}: ${held.join(" and ")} ${verb} what ${source} declares`,
327
- );
328
- return true;
329
- });
330
-
331
- const generate = Effect.fn("generate")(function* (root: string) {
332
- const fs = yield* FileSystem.FileSystem;
333
- const path = yield* Path.Path;
334
- const { quality } = yield* readQuality(root);
335
- const expected = fragmentsFor(quality);
336
- for (const { file } of FRAGMENTS) {
337
- const target = path.join(root, file);
338
- const fragment = expected.find((candidate) => candidate.file === file);
339
- if (fragment !== undefined) {
340
- yield* fs.writeFileString(target, renderJson(fragment.content));
341
- yield* Console.log(`${NAME}: wrote ${file}`);
342
- } else if (yield* fs.exists(target)) {
343
- yield* fs.remove(target);
344
- yield* Console.log(`${NAME}: removed ${file}`);
345
- }
346
- }
347
- const workflows = workflowsFor(quality, yield* recipeOf(root));
348
- yield* fs.makeDirectory(path.join(root, ".github", "workflows"), { recursive: true });
349
- for (const { file, content } of workflows) {
350
- yield* fs.writeFileString(path.join(root, file), content);
351
- yield* Console.log(`${NAME}: wrote ${file}`);
352
- }
353
- return yield* check(root);
354
- });
355
-
356
- const main = Effect.gen(function* () {
357
- const [mode, ...extra] = process.argv.slice(2);
358
- if (extra.length > 0 || (mode !== "--check" && mode !== "generate")) return yield* new Usage({ message: USAGE });
359
- const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
360
- return mode === "generate" ? yield* generate(root) : yield* check(root);
361
- });
362
-
363
- if (import.meta.main) runMain(NAME, main);
@@ -1,285 +0,0 @@
1
- #!/usr/bin/env bun
2
- import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
- import { changedPaths, collect, git, pathsAt, rangeEnds, type Change } from "./git.ts";
4
- import { runMain } from "./main.ts";
5
- import { renderJson } from "./quality-file.ts";
6
- import { rangeGateInputs } from "./range-gate.ts";
7
- import {
8
- budgetOf,
9
- budgetsOf,
10
- diagnosticCode,
11
- qualifiedName,
12
- SIZE_DEFAULTS,
13
- SIZE_RULES,
14
- TESTS_DIRECTORY,
15
- type Applies,
16
- type Budget,
17
- type Budgets,
18
- type LimitKey,
19
- type Size,
20
- type SizeRule,
21
- } from "./size-rules.ts";
22
-
23
- export type Held = { readonly path: string; readonly from: string };
24
-
25
- export type Site = {
26
- readonly file: string;
27
- readonly line: number | undefined;
28
- readonly rule: SizeRule["rule"];
29
- readonly overrun: number;
30
- readonly message: string;
31
- };
32
-
33
- export type Growth = {
34
- readonly file: string;
35
- readonly rule: SizeRule["rule"];
36
- readonly base: number;
37
- readonly head: number;
38
- readonly sites: readonly Site[];
39
- };
40
-
41
- export type Verdict =
42
- | { readonly applies: "all"; readonly held: number; readonly overruns: readonly Site[]; readonly advisory: readonly Site[] }
43
- | { readonly applies: "ratchet"; readonly held: number; readonly growths: readonly Growth[]; readonly advisory: readonly Site[] };
44
-
45
- class OxlintUnreadable extends Schema.TaggedError<OxlintUnreadable>()("OxlintUnreadable", {
46
- message: Schema.String,
47
- }) {}
48
-
49
- const NAME = "size-budget";
50
- const USAGE = "usage: size-budget.ts <ref> | <base-ref> <head-ref>";
51
- const DECLARATIONS = ":(exclude,glob)**/*.d.ts";
52
- const TYPESCRIPT = [":(glob)**/*.ts", ":(glob)**/*.tsx", DECLARATIONS];
53
- const TESTS = [`:(glob)${TESTS_DIRECTORY}/**/*.ts`, `:(glob)${TESTS_DIRECTORY}/**/*.tsx`];
54
- const CONFIG = ".size-budget.oxlintrc.json";
55
- const WHOLE_FILE: LimitKey = "fileLines";
56
- const OXLINT_FOUND_NOTHING = 0;
57
- const OXLINT_FOUND_ERRORS = 1;
58
- const SCOPE = {
59
- ratchet: "the production and test files the range adds or changes",
60
- all: "every production and test file",
61
- } satisfies Record<Applies, string>;
62
-
63
- const Diagnostic = Schema.Struct({
64
- code: Schema.String,
65
- message: Schema.String,
66
- filename: Schema.String,
67
- labels: Schema.Array(Schema.Struct({ span: Schema.Struct({ line: Schema.Int }) })),
68
- });
69
-
70
- const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ diagnostics: Schema.Array(Diagnostic) })));
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 {
83
- return Object.fromEntries(
84
- SIZE_RULES.map((entry) => {
85
- const max = budget[entry.key];
86
- return [qualifiedName(entry), max === undefined ? "off" : ["error", { max, ...entry.options }]];
87
- }),
88
- );
89
- }
90
-
91
- export function sizeConfig({ production, tests }: Budgets, plugin: string): SizeConfig {
92
- return {
93
- plugins: [],
94
- jsPlugins: [plugin],
95
- categories: { correctness: "off" },
96
- rules: rulesOf(production),
97
- overrides: [{ files: [`${TESTS_DIRECTORY}/**`], rules: rulesOf(tests) }],
98
- };
99
- }
100
-
101
- export function heldByChange(changes: readonly Change[]): readonly Held[] {
102
- return changes.flatMap((change) => {
103
- if (change.kind === "written") return [{ path: change.path, from: change.path }];
104
- if (change.kind === "renamed" && change.edited) return [{ path: change.path, from: change.from }];
105
- return [];
106
- });
107
- }
108
-
109
- // oxlint reads files from disk, and the working tree need not hold the head: a merge checkout or an uncommitted edit.
110
- const materializeHead = Effect.fn("materializeHead")(function* (root: string, head: string, files: readonly string[], tree: string) {
111
- const env = { GIT_INDEX_FILE: `${tree}.index` };
112
- yield* git(["read-tree", head], root, { env });
113
- yield* git(["checkout-index", "-z", "--stdin", `--prefix=${tree}/`], root, { env, input: files.map((file) => `${file}\0`).join("") });
114
- });
115
-
116
- // Each file is written at its head path, so a rename is measured against the budget it is held to now.
117
- const materializeBase = Effect.fn("materializeBase")(function* (root: string, base: string, held: readonly Held[], tree: string) {
118
- const env = { GIT_INDEX_FILE: `${tree}.index` };
119
- const found = (yield* git(["cat-file", "--batch-check=%(objecttype) %(objectname)"], root, {
120
- input: held.map(({ from }) => `${base}:${from}\n`).join(""),
121
- })).split("\n");
122
- const entries = held.flatMap(({ path }, index) => {
123
- const [type, blob] = (found[index] ?? "").split(" ");
124
- return type === "blob" ? [`100644 ${blob}\t${path}\0`] : [];
125
- });
126
- if (entries.length === 0) return;
127
- yield* git(["update-index", "-z", "--index-info"], root, { env, input: entries.join("") });
128
- yield* git(["checkout-index", "--all", `--prefix=${tree}/`], root, { env });
129
- });
130
-
131
- export const siteOf = (
132
- budgets: Budgets,
133
- ): ((diagnostic: typeof Diagnostic.Type) => Effect.Effect<readonly Site[], OxlintUnreadable>) =>
134
- Effect.fn("siteOf")(function* ({ code, message, filename, labels }: typeof Diagnostic.Type) {
135
- const rule = SIZE_RULES.find((candidate) => code === diagnosticCode(candidate));
136
- if (rule === undefined) return [];
137
- const measured = rule.measured.exec(message)?.[1];
138
- const max = budgetOf(budgets, filename)[rule.key];
139
- if (measured === undefined || max === undefined) {
140
- return yield* new OxlintUnreadable({ message: `cannot read ${rule.rule} for ${filename} from oxlint: ${message}` });
141
- }
142
- const wholeFile = rule.key === WHOLE_FILE;
143
- return [
144
- {
145
- file: filename,
146
- line: wholeFile ? undefined : labels[0]?.span.line,
147
- rule: rule.rule,
148
- overrun: Number(measured) - max,
149
- message: wholeFile ? `${message} Maximum allowed is ${max}.` : message,
150
- } satisfies Site,
151
- ];
152
- });
153
-
154
- const measure = Effect.fn("measure")(function* (tree: string, budgets: Budgets, plugin: string) {
155
- const fs = yield* FileSystem.FileSystem;
156
- if (!(yield* fs.exists(tree))) return [];
157
- // oxlint reads an override's glob from the directory of the config that holds it, so the config sits in the tree.
158
- yield* fs.writeFileString((yield* Path.Path).join(tree, CONFIG), renderJson(sizeConfig(budgets, plugin)));
159
-
160
- const { stdout, stderr, exitCode } = yield* collect("oxlint", ["-c", CONFIG, "-f", "json", "."], tree).pipe(
161
- Effect.mapError((cause) => new OxlintUnreadable({ message: `cannot run oxlint: ${cause.message}` })),
162
- );
163
- if (exitCode !== OXLINT_FOUND_NOTHING && exitCode !== OXLINT_FOUND_ERRORS) {
164
- return yield* new OxlintUnreadable({ message: `oxlint exited ${exitCode}: ${stderr.trim() || stdout.trim()}` });
165
- }
166
- const { diagnostics } = yield* decodeReport(stdout).pipe(
167
- Effect.mapError((cause) => new OxlintUnreadable({ message: `cannot read oxlint's report: ${cause.message}` })),
168
- );
169
- const sites = (yield* Effect.forEach(diagnostics, siteOf(budgets))).flat();
170
- return sites.toSorted((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
171
- });
172
-
173
- function byFileAndRule(sites: readonly Site[]): ReadonlyMap<string, readonly Site[]> {
174
- return Map.groupBy(sites, ({ file, rule }) => `${file}\0${rule}`);
175
- }
176
-
177
- function total(sites: readonly Site[]): number {
178
- return sites.reduce((sum, { overrun }) => sum + overrun, 0);
179
- }
180
-
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[] {
183
- const before = byFileAndRule(base);
184
- return [...byFileAndRule(head).entries()].flatMap(([group, sites]) => {
185
- const [first] = sites;
186
- const growth = { base: total(before.get(group) ?? []), head: total(sites), sites };
187
- return first !== undefined && growth.head > growth.base ? [{ file: first.file, rule: first.rule, ...growth }] : [];
188
- });
189
- }
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
-
209
- const heldFiles = Effect.fn("heldFiles")(function* (root: string, applies: Applies, pathspecs: readonly string[], base: string, head: string) {
210
- if (applies === "all") return (yield* pathsAt(head, pathspecs, root)).map((path): Held => ({ path, from: path }));
211
- return heldByChange(yield* changedPaths(base, head, pathspecs, root));
212
- });
213
-
214
- const runBudget = Effect.fn("runBudget")(
215
- function* (root: string, size: Size, production: readonly string[], base: string, head: string) {
216
- const fs = yield* FileSystem.FileSystem;
217
- const path = yield* Path.Path;
218
- const applies = size.applies ?? SIZE_DEFAULTS.applies;
219
- const budgets = budgetsOf(size);
220
- const pathspecs = [...production.map((glob) => `:(glob)${glob}`), ...TESTS, DECLARATIONS];
221
- const held = yield* heldFiles(root, applies, pathspecs, base, head);
222
- const holds = new Set(held.map((file) => file.path));
223
- const others = (yield* pathsAt(head, TYPESCRIPT, root)).filter((file) => !holds.has(file));
224
- const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "checks-size-budget-" });
225
-
226
- const headTree = path.join(scratch, "head");
227
- if (held.length + others.length > 0) yield* materializeHead(root, head, [...holds, ...others], headTree);
228
- const plugin = path.join(import.meta.dir, "..", "dist", "index.js");
229
- const sites = yield* measure(headTree, budgets, plugin);
230
- if (applies !== "ratchet") return verdictOf(applies, held.length, holds, sites, []);
231
-
232
- const baseTree = path.join(scratch, "base");
233
- if (held.length > 0) yield* materializeBase(root, base, held, baseTree);
234
- return verdictOf(applies, held.length, holds, sites, yield* measure(baseTree, budgets, plugin));
235
- },
236
- Effect.scoped,
237
- );
238
-
239
- export function describe({ file, line, message }: Site, indent = " "): string {
240
- return `${indent}${file}${line === undefined ? "" : `:${line}`}: ${message}`;
241
- }
242
-
243
- export function verdictLines(verdict: Verdict): readonly string[] {
244
- const scope = SCOPE[verdict.applies];
245
- if (verdict.applies === "ratchet") {
246
- if (verdict.growths.length === 0) return [`${NAME}: ${verdict.held} file(s), ${scope}, raise no overrun past the base`];
247
- return [
248
- `${NAME}: ${verdict.growths.length} overrun(s) grew past the base in ${scope}:`,
249
- ...verdict.growths.flatMap(({ file, rule, base, head, sites }) => [
250
- ` ${file}: ${rule} over by ${head} in total, up from ${base}`,
251
- ...sites.map((site) => describe(site, " ")),
252
- ]),
253
- ];
254
- }
255
- if (verdict.overruns.length === 0) return [`${NAME}: ${verdict.held} file(s), ${scope}, keep within the budget`];
256
- return [`${NAME}: ${verdict.overruns.length} overrun(s) of the budget in ${scope}:`, ...verdict.overruns.map((site) => describe(site))];
257
- }
258
-
259
- export function report(verdict: Verdict): string {
260
- const { advisory } = verdict;
261
- const notice =
262
- advisory.length === 0
263
- ? []
264
- : [`${NAME}: advisory, ${advisory.length} overrun(s) where the budget does not hold yet:`, ...advisory.map((site) => describe(site))];
265
- return [...verdictLines(verdict), ...notice].join("\n");
266
- }
267
-
268
- export function passes(verdict: Verdict): boolean {
269
- return verdict.applies === "ratchet" ? verdict.growths.length === 0 : verdict.overruns.length === 0;
270
- }
271
-
272
- const budget = Effect.gen(function* () {
273
- const { refs, root, source, quality } = yield* rangeGateInputs(USAGE);
274
- if (quality.size === undefined) {
275
- yield* Console.log(`${NAME}: ${source} declares no size budget`);
276
- return true;
277
- }
278
- const { base, head } = yield* rangeEnds(refs.first, refs.second, root);
279
- const verdict = yield* runBudget(root, quality.size, quality.sources?.production ?? [], base, head);
280
-
281
- yield* Console.log(report(verdict));
282
- return passes(verdict);
283
- });
284
-
285
- if (import.meta.main) runMain(NAME, budget);