@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.
@@ -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, type Quality } from "./quality-file.ts";
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 Size = NonNullable<Quality["size"]>;
20
+ type Held = { readonly path: string; readonly from: string };
8
21
 
9
- type Overrun = {
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 Measured = {
16
- readonly scope: string;
17
- readonly held: readonly string[];
18
- readonly overruns: readonly Overrun[];
19
- readonly advisory: readonly Overrun[];
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 FILE_RULE = "eslint(max-lines)";
31
- const FUNCTION_RULE = "eslint(max-lines-per-function)";
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
- const decodeReport = Schema.decodeUnknownEffect(
36
- Schema.fromJsonString(
37
- Schema.Struct({
38
- diagnostics: Schema.Array(
39
- Schema.Struct({
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({ fileLines, functionLines }: Size): unknown {
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
- "max-lines": ["error", { max: fileLines, ...counted }],
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 string[] {
87
+ function heldByChange(changes: readonly Change[]): readonly Held[] {
63
88
  return changes.flatMap((change) => {
64
- if (change.kind === "written" || (change.kind === "renamed" && change.edited)) return [change.path];
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
- const materialize = Effect.fn("materialize")(function* (root: string, head: string, files: readonly string[], scratch: string) {
70
- const path = yield* Path.Path;
71
- const env = { GIT_INDEX_FILE: path.join(scratch, "index") };
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
- const tree = `${path.join(scratch, "tree")}${path.sep}`;
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
- // oxlint reads files from disk, and the working tree need not hold the head: a merge checkout or an uncommitted edit.
79
- const measure = Effect.fn("measure")(
80
- function* (root: string, head: string, size: Size, files: readonly string[]) {
81
- if (files.length === 0) return [];
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
- const config = path.join(scratch, "size.oxlintrc.json");
86
- yield* fs.writeFileString(config, renderJson(sizeConfig(size)));
87
- const tree = yield* materialize(root, head, files, scratch);
88
-
89
- const { stdout, stderr, exitCode } = yield* collect("oxlint", ["-c", config, "-f", "json", "."], tree).pipe(
90
- Effect.mapError((cause) => new OxlintUnreadable({ message: `cannot run oxlint: ${cause.message}` })),
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
- const { diagnostics } = yield* decodeReport(stdout).pipe(
96
- Effect.mapError((cause) => new OxlintUnreadable({ message: `cannot read oxlint's report: ${cause.message}` })),
97
- );
98
- return diagnostics.flatMap(({ code, message, filename, labels }): Overrun[] => {
99
- if (code === FILE_RULE) return [{ file: filename, line: undefined, message }];
100
- if (code === FUNCTION_RULE) return [{ file: filename, line: labels[0]?.span.line, message }];
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
- const runBudget = Effect.fn("runBudget")(function* (root: string, size: Size, production: readonly string[], base: string, head: string) {
108
- const pathspecs = [...production.map((glob) => `:(glob)${glob}`), DECLARATIONS];
109
- const held =
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 describe({ file, line, message }: Overrun): string {
125
- return ` ${file}${line === undefined ? "" : `:${line}`}: ${message}`;
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({ scope, held, overruns, advisory }: Measured, { fileLines, functionLines }: Size): string {
129
- const budget = `${fileLines} lines per file and ${functionLines} per function`;
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 measured = yield* runBudget(root, quality.size, quality.sources?.production ?? [], base, head);
252
+ const verdict = yield* runBudget(root, quality.size, quality.sources?.production ?? [], base, head);
153
253
 
154
- yield* Console.log(report(measured, quality.size));
155
- return measured.overruns.length === 0;
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
+ }