@avi2dg/checks 0.14.0 → 0.16.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 +17 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +3 -1
- package/dist/feature-rules.js +145 -69
- package/docs/configs/quality-file.md +4 -3
- package/docs/design.md +25 -1
- package/docs/gates/checks-docs.md +83 -6
- package/docs/gates/checks-lint.md +4 -3
- package/docs/gates/checks-quality.md +1 -1
- package/docs/gates/checks-repetition.md +72 -0
- package/docs/gates/checks-size-budget.md +57 -14
- package/package.json +11 -1
- package/quality.schema.json +75 -18
- package/scripts/comment-gate.ts +5 -48
- package/scripts/doc-references.ts +190 -0
- package/scripts/doc-rules.ts +2 -2
- package/scripts/doc-snapshot.ts +70 -0
- package/scripts/docs.ts +100 -24
- package/scripts/gates.ts +1 -0
- package/scripts/git.ts +83 -1
- package/scripts/prose-matchers.ts +380 -0
- package/scripts/quality-file.ts +18 -17
- package/scripts/quality.ts +2 -2
- package/scripts/repetition.ts +161 -0
- package/scripts/size-budget.ts +187 -87
- package/scripts/size-rules.ts +117 -0
package/scripts/size-budget.ts
CHANGED
|
@@ -2,23 +2,43 @@
|
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import { changedPaths, collect, git, pathsAt, rangeEnds, type Change } from "./git.ts";
|
|
4
4
|
import { runMain, Usage } from "./main.ts";
|
|
5
|
-
import { readQuality, renderJson
|
|
5
|
+
import { readQuality, renderJson } from "./quality-file.ts";
|
|
6
|
+
import {
|
|
7
|
+
budgetOf,
|
|
8
|
+
budgetsOf,
|
|
9
|
+
SIZE_DEFAULTS,
|
|
10
|
+
SIZE_RULES,
|
|
11
|
+
TESTS_DIRECTORY,
|
|
12
|
+
type Applies,
|
|
13
|
+
type Budget,
|
|
14
|
+
type Budgets,
|
|
15
|
+
type LimitKey,
|
|
16
|
+
type Size,
|
|
17
|
+
type SizeRule,
|
|
18
|
+
} from "./size-rules.ts";
|
|
6
19
|
|
|
7
|
-
type
|
|
20
|
+
type Held = { readonly path: string; readonly from: string };
|
|
8
21
|
|
|
9
|
-
type
|
|
22
|
+
type Site = {
|
|
10
23
|
readonly file: string;
|
|
11
24
|
readonly line: number | undefined;
|
|
25
|
+
readonly rule: SizeRule["rule"];
|
|
26
|
+
readonly overrun: number;
|
|
12
27
|
readonly message: string;
|
|
13
28
|
};
|
|
14
29
|
|
|
15
|
-
type
|
|
16
|
-
readonly
|
|
17
|
-
readonly
|
|
18
|
-
readonly
|
|
19
|
-
readonly
|
|
30
|
+
type Growth = {
|
|
31
|
+
readonly file: string;
|
|
32
|
+
readonly rule: SizeRule["rule"];
|
|
33
|
+
readonly base: number;
|
|
34
|
+
readonly head: number;
|
|
35
|
+
readonly sites: readonly Site[];
|
|
20
36
|
};
|
|
21
37
|
|
|
38
|
+
type Verdict =
|
|
39
|
+
| { readonly applies: "all"; readonly held: number; readonly overruns: readonly Site[]; readonly advisory: readonly Site[] }
|
|
40
|
+
| { readonly applies: "ratchet"; readonly held: number; readonly growths: readonly Growth[]; readonly advisory: readonly Site[] };
|
|
41
|
+
|
|
22
42
|
class OxlintUnreadable extends Schema.TaggedError<OxlintUnreadable>()("OxlintUnreadable", {
|
|
23
43
|
message: Schema.String,
|
|
24
44
|
}) {}
|
|
@@ -27,115 +47,195 @@ const NAME = "size-budget";
|
|
|
27
47
|
const USAGE = "usage: size-budget.ts <ref> | <base-ref> <head-ref>";
|
|
28
48
|
const DECLARATIONS = ":(exclude,glob)**/*.d.ts";
|
|
29
49
|
const TYPESCRIPT = [":(glob)**/*.ts", ":(glob)**/*.tsx", DECLARATIONS];
|
|
30
|
-
const
|
|
31
|
-
const
|
|
50
|
+
const TESTS = [`:(glob)${TESTS_DIRECTORY}/**/*.ts`, `:(glob)${TESTS_DIRECTORY}/**/*.tsx`];
|
|
51
|
+
const CONFIG = ".size-budget.oxlintrc.json";
|
|
52
|
+
const WHOLE_FILE: LimitKey = "fileLines";
|
|
32
53
|
const OXLINT_FOUND_NOTHING = 0;
|
|
33
54
|
const OXLINT_FOUND_ERRORS = 1;
|
|
55
|
+
const SCOPE = {
|
|
56
|
+
ratchet: "the production and test files the range adds or changes",
|
|
57
|
+
all: "every production and test file",
|
|
58
|
+
} satisfies Record<Applies, string>;
|
|
59
|
+
|
|
60
|
+
const Diagnostic = Schema.Struct({
|
|
61
|
+
code: Schema.String,
|
|
62
|
+
message: Schema.String,
|
|
63
|
+
filename: Schema.String,
|
|
64
|
+
labels: Schema.Array(Schema.Struct({ span: Schema.Struct({ line: Schema.Int }) })),
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
const decodeReport = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ diagnostics: Schema.Array(Diagnostic) })));
|
|
34
68
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
code: Schema.String,
|
|
41
|
-
message: Schema.String,
|
|
42
|
-
filename: Schema.String,
|
|
43
|
-
labels: Schema.Array(Schema.Struct({ span: Schema.Struct({ line: Schema.Int }) })),
|
|
44
|
-
}),
|
|
45
|
-
),
|
|
69
|
+
function rulesOf(budget: Budget): Record<string, unknown> {
|
|
70
|
+
return Object.fromEntries(
|
|
71
|
+
SIZE_RULES.map(({ key, rule, options }) => {
|
|
72
|
+
const max = budget[key];
|
|
73
|
+
return [rule, max === undefined ? "off" : ["error", { max, ...options }]];
|
|
46
74
|
}),
|
|
47
|
-
)
|
|
48
|
-
|
|
75
|
+
);
|
|
76
|
+
}
|
|
49
77
|
|
|
50
|
-
function sizeConfig({
|
|
51
|
-
const counted = { skipBlankLines: false, skipComments: false };
|
|
78
|
+
function sizeConfig({ production, tests }: Budgets): unknown {
|
|
52
79
|
return {
|
|
53
80
|
plugins: [],
|
|
54
81
|
categories: { correctness: "off" },
|
|
55
|
-
rules:
|
|
56
|
-
|
|
57
|
-
"max-lines-per-function": ["error", { max: functionLines, ...counted }],
|
|
58
|
-
},
|
|
82
|
+
rules: rulesOf(production),
|
|
83
|
+
overrides: [{ files: [`${TESTS_DIRECTORY}/**`], rules: rulesOf(tests) }],
|
|
59
84
|
};
|
|
60
85
|
}
|
|
61
86
|
|
|
62
|
-
function heldByChange(changes: readonly Change[]): readonly
|
|
87
|
+
function heldByChange(changes: readonly Change[]): readonly Held[] {
|
|
63
88
|
return changes.flatMap((change) => {
|
|
64
|
-
if (change.kind === "written"
|
|
89
|
+
if (change.kind === "written") return [{ path: change.path, from: change.path }];
|
|
90
|
+
if (change.kind === "renamed" && change.edited) return [{ path: change.path, from: change.from }];
|
|
65
91
|
return [];
|
|
66
92
|
});
|
|
67
93
|
}
|
|
68
94
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const env = { GIT_INDEX_FILE:
|
|
95
|
+
// oxlint reads files from disk, and the working tree need not hold the head: a merge checkout or an uncommitted edit.
|
|
96
|
+
const materializeHead = Effect.fn("materializeHead")(function* (root: string, head: string, files: readonly string[], tree: string) {
|
|
97
|
+
const env = { GIT_INDEX_FILE: `${tree}.index` };
|
|
72
98
|
yield* git(["read-tree", head], root, { env });
|
|
73
|
-
|
|
74
|
-
yield* git(["checkout-index", "-z", "--stdin", `--prefix=${tree}`], root, { env, input: files.map((file) => `${file}\0`).join("") });
|
|
75
|
-
return tree;
|
|
99
|
+
yield* git(["checkout-index", "-z", "--stdin", `--prefix=${tree}/`], root, { env, input: files.map((file) => `${file}\0`).join("") });
|
|
76
100
|
});
|
|
77
101
|
|
|
78
|
-
//
|
|
79
|
-
const
|
|
80
|
-
|
|
81
|
-
|
|
102
|
+
// Each file is written at its head path, so a rename is measured against the budget it is held to now.
|
|
103
|
+
const materializeBase = Effect.fn("materializeBase")(function* (root: string, base: string, held: readonly Held[], tree: string) {
|
|
104
|
+
const env = { GIT_INDEX_FILE: `${tree}.index` };
|
|
105
|
+
const found = (yield* git(["cat-file", "--batch-check=%(objecttype) %(objectname)"], root, {
|
|
106
|
+
input: held.map(({ from }) => `${base}:${from}\n`).join(""),
|
|
107
|
+
})).split("\n");
|
|
108
|
+
const entries = held.flatMap(({ path }, index) => {
|
|
109
|
+
const [type, blob] = (found[index] ?? "").split(" ");
|
|
110
|
+
return type === "blob" ? [`100644 ${blob}\t${path}\0`] : [];
|
|
111
|
+
});
|
|
112
|
+
if (entries.length === 0) return;
|
|
113
|
+
yield* git(["update-index", "-z", "--index-info"], root, { env, input: entries.join("") });
|
|
114
|
+
yield* git(["checkout-index", "--all", `--prefix=${tree}/`], root, { env });
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const siteOf = (budgets: Budgets) =>
|
|
118
|
+
Effect.fn("siteOf")(function* ({ code, message, filename, labels }: typeof Diagnostic.Type) {
|
|
119
|
+
const rule = SIZE_RULES.find((candidate) => code === `eslint(${candidate.rule})`);
|
|
120
|
+
if (rule === undefined) return [];
|
|
121
|
+
const measured = rule.measured.exec(message)?.[1];
|
|
122
|
+
const max = budgetOf(budgets, filename)[rule.key];
|
|
123
|
+
if (measured === undefined || max === undefined) {
|
|
124
|
+
return yield* new OxlintUnreadable({ message: `cannot read ${rule.rule} for ${filename} from oxlint: ${message}` });
|
|
125
|
+
}
|
|
126
|
+
const wholeFile = rule.key === WHOLE_FILE;
|
|
127
|
+
return [
|
|
128
|
+
{
|
|
129
|
+
file: filename,
|
|
130
|
+
line: wholeFile ? undefined : labels[0]?.span.line,
|
|
131
|
+
rule: rule.rule,
|
|
132
|
+
overrun: Number(measured) - max,
|
|
133
|
+
message: wholeFile ? `${message} Maximum allowed is ${max}.` : message,
|
|
134
|
+
} satisfies Site,
|
|
135
|
+
];
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
const measure = Effect.fn("measure")(function* (tree: string, budgets: Budgets) {
|
|
139
|
+
const fs = yield* FileSystem.FileSystem;
|
|
140
|
+
if (!(yield* fs.exists(tree))) return [];
|
|
141
|
+
// oxlint reads an override's glob from the directory of the config that holds it, so the config sits in the tree.
|
|
142
|
+
yield* fs.writeFileString((yield* Path.Path).join(tree, CONFIG), renderJson(sizeConfig(budgets)));
|
|
143
|
+
|
|
144
|
+
const { stdout, stderr, exitCode } = yield* collect("oxlint", ["-c", CONFIG, "-f", "json", "."], tree).pipe(
|
|
145
|
+
Effect.mapError((cause) => new OxlintUnreadable({ message: `cannot run oxlint: ${cause.message}` })),
|
|
146
|
+
);
|
|
147
|
+
if (exitCode !== OXLINT_FOUND_NOTHING && exitCode !== OXLINT_FOUND_ERRORS) {
|
|
148
|
+
return yield* new OxlintUnreadable({ message: `oxlint exited ${exitCode}: ${stderr.trim() || stdout.trim()}` });
|
|
149
|
+
}
|
|
150
|
+
const { diagnostics } = yield* decodeReport(stdout).pipe(
|
|
151
|
+
Effect.mapError((cause) => new OxlintUnreadable({ message: `cannot read oxlint's report: ${cause.message}` })),
|
|
152
|
+
);
|
|
153
|
+
const sites = (yield* Effect.forEach(diagnostics, siteOf(budgets))).flat();
|
|
154
|
+
return sites.toSorted((a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0));
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
function byFileAndRule(sites: readonly Site[]): ReadonlyMap<string, readonly Site[]> {
|
|
158
|
+
return Map.groupBy(sites, ({ file, rule }) => `${file}\0${rule}`);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function total(sites: readonly Site[]): number {
|
|
162
|
+
return sites.reduce((sum, { overrun }) => sum + overrun, 0);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function growthsOf(head: readonly Site[], base: readonly Site[]): readonly Growth[] {
|
|
166
|
+
const before = byFileAndRule(base);
|
|
167
|
+
return [...byFileAndRule(head).entries()].flatMap(([group, sites]) => {
|
|
168
|
+
const [first] = sites;
|
|
169
|
+
const growth = { base: total(before.get(group) ?? []), head: total(sites), sites };
|
|
170
|
+
return first !== undefined && growth.head > growth.base ? [{ file: first.file, rule: first.rule, ...growth }] : [];
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const heldFiles = Effect.fn("heldFiles")(function* (root: string, applies: Applies, pathspecs: readonly string[], base: string, head: string) {
|
|
175
|
+
if (applies === "all") return (yield* pathsAt(head, pathspecs, root)).map((path): Held => ({ path, from: path }));
|
|
176
|
+
return heldByChange(yield* changedPaths(base, head, pathspecs, root));
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
const runBudget = Effect.fn("runBudget")(
|
|
180
|
+
function* (root: string, size: Size, production: readonly string[], base: string, head: string) {
|
|
82
181
|
const fs = yield* FileSystem.FileSystem;
|
|
83
182
|
const path = yield* Path.Path;
|
|
183
|
+
const applies = size.applies ?? SIZE_DEFAULTS.applies;
|
|
184
|
+
const budgets = budgetsOf(size);
|
|
185
|
+
const pathspecs = [...production.map((glob) => `:(glob)${glob}`), ...TESTS, DECLARATIONS];
|
|
186
|
+
const held = yield* heldFiles(root, applies, pathspecs, base, head);
|
|
187
|
+
const holds = new Set(held.map((file) => file.path));
|
|
188
|
+
const others = (yield* pathsAt(head, TYPESCRIPT, root)).filter((file) => !holds.has(file));
|
|
84
189
|
const scratch = yield* fs.makeTempDirectoryScoped({ prefix: "checks-size-budget-" });
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
if (exitCode !== OXLINT_FOUND_NOTHING && exitCode !== OXLINT_FOUND_ERRORS) {
|
|
93
|
-
return yield* new OxlintUnreadable({ message: `oxlint exited ${exitCode}: ${stderr.trim() || stdout.trim()}` });
|
|
190
|
+
|
|
191
|
+
const headTree = path.join(scratch, "head");
|
|
192
|
+
if (held.length + others.length > 0) yield* materializeHead(root, head, [...holds, ...others], headTree);
|
|
193
|
+
const sites = yield* measure(headTree, budgets);
|
|
194
|
+
const heldSites = sites.filter((site) => holds.has(site.file));
|
|
195
|
+
if (applies !== "ratchet") {
|
|
196
|
+
return { applies, held: held.length, overruns: heldSites, advisory: sites.filter((site) => !holds.has(site.file)) } satisfies Verdict;
|
|
94
197
|
}
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
);
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
return [];
|
|
102
|
-
});
|
|
198
|
+
|
|
199
|
+
const baseTree = path.join(scratch, "base");
|
|
200
|
+
if (held.length > 0) yield* materializeBase(root, base, held, baseTree);
|
|
201
|
+
const growths = growthsOf(heldSites, yield* measure(baseTree, budgets));
|
|
202
|
+
const failing = new Set(growths.flatMap((growth) => growth.sites));
|
|
203
|
+
return { applies, held: held.length, growths, advisory: sites.filter((site) => !failing.has(site)) } satisfies Verdict;
|
|
103
204
|
},
|
|
104
205
|
Effect.scoped,
|
|
105
206
|
);
|
|
106
207
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
size.applies === "all" ? yield* pathsAt(head, pathspecs, root) : heldByChange(yield* changedPaths(base, head, pathspecs, root));
|
|
111
|
-
const holds = new Set(held);
|
|
112
|
-
const others = (yield* pathsAt(head, TYPESCRIPT, root)).filter((file) => !holds.has(file));
|
|
113
|
-
const overruns = (yield* measure(root, head, size, [...held, ...others])).toSorted(
|
|
114
|
-
(a, b) => a.file.localeCompare(b.file) || (a.line ?? 0) - (b.line ?? 0),
|
|
115
|
-
);
|
|
116
|
-
return {
|
|
117
|
-
scope: size.applies === "all" ? "every production file" : "the production files the range adds or changes",
|
|
118
|
-
held,
|
|
119
|
-
overruns: overruns.filter((overrun) => holds.has(overrun.file)),
|
|
120
|
-
advisory: overruns.filter((overrun) => !holds.has(overrun.file)),
|
|
121
|
-
} satisfies Measured;
|
|
122
|
-
});
|
|
208
|
+
function describe({ file, line, message }: Site, indent = " "): string {
|
|
209
|
+
return `${indent}${file}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
210
|
+
}
|
|
123
211
|
|
|
124
|
-
function
|
|
125
|
-
|
|
212
|
+
function verdictLines(verdict: Verdict): readonly string[] {
|
|
213
|
+
const scope = SCOPE[verdict.applies];
|
|
214
|
+
if (verdict.applies === "ratchet") {
|
|
215
|
+
if (verdict.growths.length === 0) return [`${NAME}: ${verdict.held} file(s), ${scope}, raise no overrun past the base`];
|
|
216
|
+
return [
|
|
217
|
+
`${NAME}: ${verdict.growths.length} overrun(s) grew past the base in ${scope}:`,
|
|
218
|
+
...verdict.growths.flatMap(({ file, rule, base, head, sites }) => [
|
|
219
|
+
` ${file}: ${rule} over by ${head} in total, up from ${base}`,
|
|
220
|
+
...sites.map((site) => describe(site, " ")),
|
|
221
|
+
]),
|
|
222
|
+
];
|
|
223
|
+
}
|
|
224
|
+
if (verdict.overruns.length === 0) return [`${NAME}: ${verdict.held} file(s), ${scope}, keep within the budget`];
|
|
225
|
+
return [`${NAME}: ${verdict.overruns.length} overrun(s) of the budget in ${scope}:`, ...verdict.overruns.map((site) => describe(site))];
|
|
126
226
|
}
|
|
127
227
|
|
|
128
|
-
function report(
|
|
129
|
-
const
|
|
130
|
-
const verdict =
|
|
131
|
-
overruns.length === 0
|
|
132
|
-
? [`${NAME}: ${held.length} file(s), ${scope}, keep within ${budget}`]
|
|
133
|
-
: [`${NAME}: ${overruns.length} overrun(s) of ${budget} in ${scope}:`, ...overruns.map(describe)];
|
|
228
|
+
function report(verdict: Verdict): string {
|
|
229
|
+
const { advisory } = verdict;
|
|
134
230
|
const notice =
|
|
135
231
|
advisory.length === 0
|
|
136
232
|
? []
|
|
137
|
-
: [`${NAME}: advisory, ${advisory.length} overrun(s) where the budget does not hold yet:`, ...advisory.map(describe)];
|
|
138
|
-
return [...verdict, ...notice].join("\n");
|
|
233
|
+
: [`${NAME}: advisory, ${advisory.length} overrun(s) where the budget does not hold yet:`, ...advisory.map((site) => describe(site))];
|
|
234
|
+
return [...verdictLines(verdict), ...notice].join("\n");
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
function passes(verdict: Verdict): boolean {
|
|
238
|
+
return verdict.applies === "ratchet" ? verdict.growths.length === 0 : verdict.overruns.length === 0;
|
|
139
239
|
}
|
|
140
240
|
|
|
141
241
|
const budget = Effect.gen(function* () {
|
|
@@ -149,10 +249,10 @@ const budget = Effect.gen(function* () {
|
|
|
149
249
|
return true;
|
|
150
250
|
}
|
|
151
251
|
const { base, head } = yield* rangeEnds(first, second, root);
|
|
152
|
-
const
|
|
252
|
+
const verdict = yield* runBudget(root, quality.size, quality.sources?.production ?? [], base, head);
|
|
153
253
|
|
|
154
|
-
yield* Console.log(report(
|
|
155
|
-
return
|
|
254
|
+
yield* Console.log(report(verdict));
|
|
255
|
+
return passes(verdict);
|
|
156
256
|
});
|
|
157
257
|
|
|
158
258
|
if (import.meta.main) runMain(NAME, budget);
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { Schema } from "effect";
|
|
2
|
+
|
|
3
|
+
export const TESTS_DIRECTORY = "tests";
|
|
4
|
+
|
|
5
|
+
const COUNTED = { skipBlankLines: false, skipComments: false };
|
|
6
|
+
|
|
7
|
+
const FILE_LINES = {
|
|
8
|
+
key: "fileLines",
|
|
9
|
+
rule: "max-lines",
|
|
10
|
+
options: COUNTED,
|
|
11
|
+
measured: /has too many lines \((\d+)\)/,
|
|
12
|
+
limits: "The most lines a file may hold, blank and comment lines counted",
|
|
13
|
+
} as const;
|
|
14
|
+
|
|
15
|
+
const FUNCTION_LINES = {
|
|
16
|
+
key: "functionLines",
|
|
17
|
+
rule: "max-lines-per-function",
|
|
18
|
+
options: COUNTED,
|
|
19
|
+
measured: /has too many lines \((\d+)\)/,
|
|
20
|
+
limits: "The most lines a function may span, blank and comment lines counted",
|
|
21
|
+
} as const;
|
|
22
|
+
|
|
23
|
+
const STATEMENTS = {
|
|
24
|
+
key: "statements",
|
|
25
|
+
rule: "max-statements",
|
|
26
|
+
options: {},
|
|
27
|
+
measured: /has too many statements \((\d+)\)/,
|
|
28
|
+
limits: "The most statements a function may hold",
|
|
29
|
+
} as const;
|
|
30
|
+
|
|
31
|
+
const COMPLEXITY = {
|
|
32
|
+
key: "complexity",
|
|
33
|
+
rule: "complexity",
|
|
34
|
+
options: { variant: "modified" },
|
|
35
|
+
measured: /has a complexity of (\d+)/,
|
|
36
|
+
limits: "The highest cyclomatic complexity a function may reach, a switch counted once",
|
|
37
|
+
} as const;
|
|
38
|
+
|
|
39
|
+
const DEPTH = {
|
|
40
|
+
key: "depth",
|
|
41
|
+
rule: "max-depth",
|
|
42
|
+
options: {},
|
|
43
|
+
measured: /nested too deeply \((\d+)\)/,
|
|
44
|
+
limits: "The deepest a block may nest inside a function",
|
|
45
|
+
} as const;
|
|
46
|
+
|
|
47
|
+
export const SIZE_RULES = [FILE_LINES, FUNCTION_LINES, STATEMENTS, COMPLEXITY, DEPTH] as const;
|
|
48
|
+
|
|
49
|
+
export type SizeRule = (typeof SIZE_RULES)[number];
|
|
50
|
+
export type LimitKey = SizeRule["key"];
|
|
51
|
+
|
|
52
|
+
// An absent limit turns its rule off.
|
|
53
|
+
export type Budget = { readonly [K in LimitKey]?: number };
|
|
54
|
+
|
|
55
|
+
export type Budgets = { readonly production: Budget; readonly tests: Budget };
|
|
56
|
+
|
|
57
|
+
const APPLIES = ["ratchet", "all"] as const;
|
|
58
|
+
export type Applies = (typeof APPLIES)[number];
|
|
59
|
+
|
|
60
|
+
export const SIZE_DEFAULTS = {
|
|
61
|
+
applies: "ratchet",
|
|
62
|
+
production: { fileLines: 400, functionLines: 100, statements: 30, complexity: 15, depth: 4 },
|
|
63
|
+
tests: { fileLines: 600, statements: 50, complexity: 15, depth: 4 },
|
|
64
|
+
} as const satisfies Budgets & { readonly applies: Applies };
|
|
65
|
+
|
|
66
|
+
const Limit = Schema.Int.check(Schema.isGreaterThan(0));
|
|
67
|
+
|
|
68
|
+
function limit({ limits }: SizeRule, fallback: number) {
|
|
69
|
+
return Schema.optionalKey(Limit.annotate({ description: `${limits}; ${fallback} when absent` }));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const ProductionBudget = Schema.Struct({
|
|
73
|
+
fileLines: limit(FILE_LINES, SIZE_DEFAULTS.production.fileLines),
|
|
74
|
+
functionLines: limit(FUNCTION_LINES, SIZE_DEFAULTS.production.functionLines),
|
|
75
|
+
statements: limit(STATEMENTS, SIZE_DEFAULTS.production.statements),
|
|
76
|
+
complexity: limit(COMPLEXITY, SIZE_DEFAULTS.production.complexity),
|
|
77
|
+
depth: limit(DEPTH, SIZE_DEFAULTS.production.depth),
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
const TestBudget = Schema.Struct({
|
|
81
|
+
fileLines: limit(FILE_LINES, SIZE_DEFAULTS.tests.fileLines),
|
|
82
|
+
statements: limit(STATEMENTS, SIZE_DEFAULTS.tests.statements),
|
|
83
|
+
complexity: limit(COMPLEXITY, SIZE_DEFAULTS.tests.complexity),
|
|
84
|
+
depth: limit(DEPTH, SIZE_DEFAULTS.tests.depth),
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
export const Size = Schema.Struct({
|
|
88
|
+
applies: Schema.optionalKey(
|
|
89
|
+
Schema.Literals(APPLIES).annotate({
|
|
90
|
+
description: `Which production and test files the budget holds: ratchet, the ones a range adds or changes, to no more overrun per rule than at the range's base; all, every one. ${SIZE_DEFAULTS.applies} when absent. The rest are listed as advisory`,
|
|
91
|
+
message: "Expected ratchet or all, and ratchet replaces changed",
|
|
92
|
+
}),
|
|
93
|
+
),
|
|
94
|
+
production: Schema.optionalKey(
|
|
95
|
+
ProductionBudget.annotate({
|
|
96
|
+
description: `The budget of the files under sources.production, and of the files listed as advisory outside ${TESTS_DIRECTORY}/`,
|
|
97
|
+
}),
|
|
98
|
+
),
|
|
99
|
+
tests: Schema.optionalKey(
|
|
100
|
+
TestBudget.annotate({ description: `The budget of the files under ${TESTS_DIRECTORY}/, which sets no limit on a function's lines` }),
|
|
101
|
+
),
|
|
102
|
+
}).annotate({
|
|
103
|
+
description: "The size budget oxlint holds production and test files to, read by checks-size-budget",
|
|
104
|
+
messageUnexpectedKey: "Expected only applies, production and tests, since each limit is set inside production or tests",
|
|
105
|
+
});
|
|
106
|
+
export type Size = typeof Size.Type;
|
|
107
|
+
|
|
108
|
+
export function budgetsOf(size: Size): Budgets {
|
|
109
|
+
return {
|
|
110
|
+
production: { ...SIZE_DEFAULTS.production, ...size.production },
|
|
111
|
+
tests: { ...SIZE_DEFAULTS.tests, ...size.tests },
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function budgetOf({ production, tests }: Budgets, file: string): Budget {
|
|
116
|
+
return file.startsWith(`${TESTS_DIRECTORY}/`) ? tests : production;
|
|
117
|
+
}
|