@avi2dg/checks 0.11.0 → 0.12.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/README.md +203 -12
- package/dist/feature-rules.js +242 -0
- package/package.json +13 -5
- package/quality.schema.json +143 -1
- package/scripts/feature-owners.ts +142 -0
- package/scripts/gates.ts +2 -0
- package/scripts/git.ts +70 -14
- package/scripts/quality-file.ts +114 -4
- package/scripts/quality.ts +10 -2
- package/scripts/size-budget.ts +158 -0
package/quality.schema.json
CHANGED
|
@@ -44,7 +44,9 @@
|
|
|
44
44
|
"checks-comment-gate",
|
|
45
45
|
"checks-suppressions-ratchet",
|
|
46
46
|
"checks-ci-wiring",
|
|
47
|
-
"checks-quality"
|
|
47
|
+
"checks-quality",
|
|
48
|
+
"checks-size-budget",
|
|
49
|
+
"checks-feature-owners"
|
|
48
50
|
]
|
|
49
51
|
},
|
|
50
52
|
"allOf": [
|
|
@@ -137,6 +139,89 @@
|
|
|
137
139
|
},
|
|
138
140
|
"additionalProperties": false
|
|
139
141
|
},
|
|
142
|
+
"size": {
|
|
143
|
+
"type": "object",
|
|
144
|
+
"properties": {
|
|
145
|
+
"fileLines": {
|
|
146
|
+
"type": "integer",
|
|
147
|
+
"exclusiveMinimum": 0,
|
|
148
|
+
"description": "The most lines a file may hold, blank and comment lines counted"
|
|
149
|
+
},
|
|
150
|
+
"functionLines": {
|
|
151
|
+
"type": "integer",
|
|
152
|
+
"exclusiveMinimum": 0,
|
|
153
|
+
"description": "The most lines a function may span, blank and comment lines counted"
|
|
154
|
+
},
|
|
155
|
+
"applies": {
|
|
156
|
+
"type": "string",
|
|
157
|
+
"enum": [
|
|
158
|
+
"changed",
|
|
159
|
+
"all"
|
|
160
|
+
],
|
|
161
|
+
"description": "Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory"
|
|
162
|
+
}
|
|
163
|
+
},
|
|
164
|
+
"required": [
|
|
165
|
+
"fileLines",
|
|
166
|
+
"functionLines",
|
|
167
|
+
"applies"
|
|
168
|
+
],
|
|
169
|
+
"additionalProperties": false,
|
|
170
|
+
"description": "The line budget oxlint holds production files to, read by checks-size-budget"
|
|
171
|
+
},
|
|
172
|
+
"features": {
|
|
173
|
+
"type": "array",
|
|
174
|
+
"items": {
|
|
175
|
+
"type": "object",
|
|
176
|
+
"properties": {
|
|
177
|
+
"name": {
|
|
178
|
+
"type": "string",
|
|
179
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
|
|
180
|
+
"description": "The owner the dependency rule and the change signal name"
|
|
181
|
+
},
|
|
182
|
+
"root": {
|
|
183
|
+
"$ref": "#/$defs/DirectoryPath"
|
|
184
|
+
},
|
|
185
|
+
"entries": {
|
|
186
|
+
"type": "array",
|
|
187
|
+
"prefixItems": [
|
|
188
|
+
{
|
|
189
|
+
"$ref": "#/$defs/FilePath"
|
|
190
|
+
}
|
|
191
|
+
],
|
|
192
|
+
"minItems": 1,
|
|
193
|
+
"items": {
|
|
194
|
+
"$ref": "#/$defs/FilePath"
|
|
195
|
+
},
|
|
196
|
+
"description": "The files under root that code outside it imports the feature through"
|
|
197
|
+
},
|
|
198
|
+
"allowFrom": {
|
|
199
|
+
"type": "array",
|
|
200
|
+
"items": {
|
|
201
|
+
"$ref": "#/$defs/PathGlob"
|
|
202
|
+
},
|
|
203
|
+
"description": "Files outside root that may import past its entries, such as a CLI or a harness; tests/ always may"
|
|
204
|
+
},
|
|
205
|
+
"proof": {
|
|
206
|
+
"$ref": "#/$defs/ProofPath"
|
|
207
|
+
}
|
|
208
|
+
},
|
|
209
|
+
"required": [
|
|
210
|
+
"name",
|
|
211
|
+
"root",
|
|
212
|
+
"entries",
|
|
213
|
+
"proof"
|
|
214
|
+
],
|
|
215
|
+
"additionalProperties": false
|
|
216
|
+
}
|
|
217
|
+
},
|
|
218
|
+
"changeSignal": {
|
|
219
|
+
"type": "string",
|
|
220
|
+
"enum": [
|
|
221
|
+
"advisory"
|
|
222
|
+
],
|
|
223
|
+
"description": "Report which feature owners a change touches, without failing on it"
|
|
224
|
+
},
|
|
140
225
|
"agentRules": {
|
|
141
226
|
"type": "object",
|
|
142
227
|
"properties": {
|
|
@@ -161,6 +246,49 @@
|
|
|
161
246
|
"additionalProperties": false,
|
|
162
247
|
"title": "quality.json",
|
|
163
248
|
"description": "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection",
|
|
249
|
+
"allOf": [
|
|
250
|
+
{
|
|
251
|
+
"if": {
|
|
252
|
+
"required": [
|
|
253
|
+
"size"
|
|
254
|
+
]
|
|
255
|
+
},
|
|
256
|
+
"then": {
|
|
257
|
+
"required": [
|
|
258
|
+
"sources"
|
|
259
|
+
],
|
|
260
|
+
"properties": {
|
|
261
|
+
"sources": {
|
|
262
|
+
"required": [
|
|
263
|
+
"production"
|
|
264
|
+
],
|
|
265
|
+
"properties": {
|
|
266
|
+
"production": {
|
|
267
|
+
"minItems": 1
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
},
|
|
274
|
+
{
|
|
275
|
+
"if": {
|
|
276
|
+
"required": [
|
|
277
|
+
"changeSignal"
|
|
278
|
+
]
|
|
279
|
+
},
|
|
280
|
+
"then": {
|
|
281
|
+
"required": [
|
|
282
|
+
"features"
|
|
283
|
+
],
|
|
284
|
+
"properties": {
|
|
285
|
+
"features": {
|
|
286
|
+
"minItems": 1
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
],
|
|
164
292
|
"$defs": {
|
|
165
293
|
"Command": {
|
|
166
294
|
"type": "string",
|
|
@@ -189,6 +317,20 @@
|
|
|
189
317
|
"pattern": "^(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+)(?:\\/(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+))*\\/(?:[\\w.@+-]|\\*(?!\\*))*\\.\\w+$",
|
|
190
318
|
"description": "A glob from the repository root that oxlint, the Effect language service and git read alike: a directory first, * within a segment, ** as a whole one, a file name with an extension last, and no braces, ?, [ or leading ./"
|
|
191
319
|
},
|
|
320
|
+
"DirectoryPath": {
|
|
321
|
+
"type": "string",
|
|
322
|
+
"pattern": "^(?!\\.\\.?(?:\\/|$))[\\w.@+-]+(?:\\/(?!\\.\\.?(?:\\/|$))[\\w.@+-]+)*$",
|
|
323
|
+
"description": "The directory the feature owns"
|
|
324
|
+
},
|
|
325
|
+
"FilePath": {
|
|
326
|
+
"type": "string",
|
|
327
|
+
"pattern": "^(?:(?!\\.\\.?(?:\\/|$))[\\w.@+-]+\\/)*[\\w.@+-]*\\.\\w+$"
|
|
328
|
+
},
|
|
329
|
+
"ProofPath": {
|
|
330
|
+
"type": "string",
|
|
331
|
+
"pattern": "^tests\\/e2e\\/(?:(?!\\.\\.?(?:\\/|$))[\\w.@+-]+\\/)*[\\w.@+-]+\\.test\\.tsx?$",
|
|
332
|
+
"description": "The end-to-end test that imports one of entries"
|
|
333
|
+
},
|
|
192
334
|
"RuleName": {
|
|
193
335
|
"type": "string",
|
|
194
336
|
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { parse } from "@swc/core";
|
|
3
|
+
import { Console, Effect, Path, Schema } from "effect";
|
|
4
|
+
import { changedPaths, git, pathsAt, rangeEnds, type Change } from "./git.ts";
|
|
5
|
+
import { runMain, Usage } from "./main.ts";
|
|
6
|
+
import { PROOF_DIRECTORY, readQuality, type Feature } from "./quality-file.ts";
|
|
7
|
+
|
|
8
|
+
type Unproven = {
|
|
9
|
+
readonly feature: string;
|
|
10
|
+
readonly reason: string;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
type Touch = {
|
|
14
|
+
readonly feature: string;
|
|
15
|
+
readonly paths: readonly string[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
class ProofUnparsed extends Schema.TaggedError<ProofUnparsed>()("ProofUnparsed", {
|
|
19
|
+
message: Schema.String,
|
|
20
|
+
}) {}
|
|
21
|
+
|
|
22
|
+
const NAME = "feature-owners";
|
|
23
|
+
const USAGE = "usage: feature-owners.ts <ref> | <base-ref> <head-ref>";
|
|
24
|
+
const RELATIVE = /^\.\.?\//;
|
|
25
|
+
const SCRIPT_EXTENSIONS = new Map([
|
|
26
|
+
[".js", [".ts", ".tsx"]],
|
|
27
|
+
[".jsx", [".tsx"]],
|
|
28
|
+
[".mjs", [".mts"]],
|
|
29
|
+
[".cjs", [".cts"]],
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const runtimeSpecifiers = Effect.fn("runtimeSpecifiers")(function* (file: string, source: string) {
|
|
33
|
+
const module = yield* Effect.tryPromise({
|
|
34
|
+
try: () => parse(source, { syntax: "typescript", tsx: file.endsWith(".tsx"), target: "esnext" }),
|
|
35
|
+
catch: (error) => new ProofUnparsed({ message: `${file} does not parse: ${String(error)}` }),
|
|
36
|
+
});
|
|
37
|
+
return module.body.flatMap((item) => {
|
|
38
|
+
if (item.type === "ImportDeclaration" && !item.typeOnly) return [item.source.value];
|
|
39
|
+
// swc types source as optional, but a local export such as `export { x }` carries null.
|
|
40
|
+
if (item.type === "ExportNamedDeclaration" && !item.typeOnly && item.source) return [item.source.value];
|
|
41
|
+
if (item.type === "ExportAllDeclaration") return [item.source.value];
|
|
42
|
+
return [];
|
|
43
|
+
});
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const candidatesFor = Effect.fn("candidatesFor")(function* (importer: string, specifier: string) {
|
|
47
|
+
if (!RELATIVE.test(specifier)) return [];
|
|
48
|
+
const path = yield* Path.Path;
|
|
49
|
+
const target = path.join(path.dirname(importer), specifier);
|
|
50
|
+
const extension = path.extname(target);
|
|
51
|
+
const sources = SCRIPT_EXTENSIONS.get(extension);
|
|
52
|
+
if (sources !== undefined) return [target, ...sources.map((source) => `${target.slice(0, -extension.length)}${source}`)];
|
|
53
|
+
if (extension !== "") return [target];
|
|
54
|
+
return [`${target}.ts`, `${target}.tsx`, `${target}/index.ts`, `${target}/index.tsx`];
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const proofImportsAnEntry = Effect.fn("proofImportsAnEntry")(function* (root: string, head: string, feature: Feature) {
|
|
58
|
+
const source = yield* git(["cat-file", "blob", `${head}:${feature.proof}`], root);
|
|
59
|
+
const specifiers = yield* runtimeSpecifiers(feature.proof, source);
|
|
60
|
+
const reached = yield* Effect.forEach(specifiers, (specifier) => candidatesFor(feature.proof, specifier));
|
|
61
|
+
return reached.flat().some((path) => feature.entries.includes(path));
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
const unproven = Effect.fn("unproven")(function* (root: string, head: string, features: readonly Feature[]) {
|
|
65
|
+
const declared = features.flatMap((feature) => [feature.proof, ...feature.entries]);
|
|
66
|
+
const present = new Set(yield* pathsAt(head, declared.map((path) => `:(literal)${path}`), root));
|
|
67
|
+
const found: Unproven[] = [];
|
|
68
|
+
for (const feature of features) {
|
|
69
|
+
const refuse = (reason: string) => found.push({ feature: feature.name, reason });
|
|
70
|
+
for (const entry of feature.entries.filter((path) => !present.has(path))) refuse(`entry ${entry} is not in the head commit`);
|
|
71
|
+
if (!present.has(feature.proof)) {
|
|
72
|
+
refuse(`proof ${feature.proof} is not in the head commit`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const reason = yield* proofImportsAnEntry(root, head, feature).pipe(
|
|
76
|
+
Effect.map((imports) => (imports ? undefined : `proof ${feature.proof} imports none of its entries, ${feature.entries.join(", ")}`)),
|
|
77
|
+
Effect.catchTag("ProofUnparsed", (error) => Effect.succeed(`proof ${error.message}`)),
|
|
78
|
+
);
|
|
79
|
+
if (reason !== undefined) refuse(reason);
|
|
80
|
+
}
|
|
81
|
+
return found;
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
function ownerOf(features: readonly Feature[], path: string): string | undefined {
|
|
85
|
+
return features.find((feature) => path.startsWith(`${feature.root}/`) || path === feature.proof)?.name;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function touches(features: readonly Feature[], changes: readonly Change[]): readonly Touch[] {
|
|
89
|
+
const byOwner = new Map<string, string[]>();
|
|
90
|
+
for (const change of changes) {
|
|
91
|
+
for (const path of change.kind === "renamed" ? [change.from, change.path] : [change.path]) {
|
|
92
|
+
const owner = ownerOf(features, path);
|
|
93
|
+
if (owner === undefined) continue;
|
|
94
|
+
byOwner.set(owner, [...(byOwner.get(owner) ?? []), path]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
return features.flatMap((feature) => {
|
|
98
|
+
const paths = byOwner.get(feature.name);
|
|
99
|
+
return paths === undefined ? [] : [{ feature: feature.name, paths }];
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function proofReport(features: readonly Feature[], found: readonly Unproven[]): string {
|
|
104
|
+
if (found.length === 0) {
|
|
105
|
+
return `${NAME}: ${features.length} feature(s) keep a proof under ${PROOF_DIRECTORY} that imports an entry`;
|
|
106
|
+
}
|
|
107
|
+
return [
|
|
108
|
+
`${NAME}: ${found.length} problem(s) with the features' runnable proofs:`,
|
|
109
|
+
...found.map(({ feature, reason }) => ` ${feature}: ${reason}`),
|
|
110
|
+
].join("\n");
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function signalReport(touched: readonly Touch[]): string {
|
|
114
|
+
if (touched.length === 0) return `${NAME}: advisory, the range touches no feature owner`;
|
|
115
|
+
const judge = touched.length > 1 ? "; a reviewer judges whether they make one slice" : "";
|
|
116
|
+
return [
|
|
117
|
+
`${NAME}: advisory, the range touches ${touched.length} feature owner(s)${judge}:`,
|
|
118
|
+
...touched.map(({ feature, paths }) => ` ${feature}: ${paths.join(", ")}`),
|
|
119
|
+
].join("\n");
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const owners = Effect.gen(function* () {
|
|
123
|
+
const [first, second, ...extra] = process.argv.slice(2);
|
|
124
|
+
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
125
|
+
|
|
126
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
127
|
+
const { source, quality } = yield* readQuality(root);
|
|
128
|
+
const features = quality.features ?? [];
|
|
129
|
+
if (features.length === 0) {
|
|
130
|
+
yield* Console.log(`${NAME}: ${source} declares no feature`);
|
|
131
|
+
return true;
|
|
132
|
+
}
|
|
133
|
+
const { base, head } = yield* rangeEnds(first, second, root);
|
|
134
|
+
const found = yield* unproven(root, head, features);
|
|
135
|
+
yield* Console.log(proofReport(features, found));
|
|
136
|
+
if (quality.changeSignal === "advisory") {
|
|
137
|
+
yield* Console.log(signalReport(touches(features, yield* changedPaths(base, head, [], root))));
|
|
138
|
+
}
|
|
139
|
+
return found.length === 0;
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
if (import.meta.main) runMain(NAME, owners);
|
package/scripts/gates.ts
CHANGED
|
@@ -38,6 +38,8 @@ export const KIT_GATES = [
|
|
|
38
38
|
{ bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
|
|
39
39
|
{ bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
|
|
40
40
|
{ bin: "checks-quality", script: "quality.ts", reads: "tree", args: ["--check"], appliesTo: QUALITY_DECLARATION },
|
|
41
|
+
{ bin: "checks-size-budget", script: "size-budget.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
42
|
+
{ bin: "checks-feature-owners", script: "feature-owners.ts", reads: "range", appliesTo: TYPESCRIPT_SOURCE },
|
|
41
43
|
] as const satisfies readonly KitGate[];
|
|
42
44
|
|
|
43
45
|
const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
|
package/scripts/git.ts
CHANGED
|
@@ -8,24 +8,76 @@ export class GitFailure extends Schema.TaggedError<GitFailure>()("GitFailure", {
|
|
|
8
8
|
const text = <E>(bytes: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
|
|
9
9
|
bytes.pipe(Stream.decodeText(), Stream.mkString);
|
|
10
10
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
export type Feed = {
|
|
12
|
+
readonly env?: Readonly<Record<string, string>>;
|
|
13
|
+
readonly input?: string;
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
// Both pipes drain while the program runs: one left unread fills its buffer and stalls it.
|
|
17
|
+
export const collect = Effect.fn("collect")(
|
|
18
|
+
function* (program: string, args: readonly string[], cwd?: string, { env, input }: Feed = {}) {
|
|
14
19
|
const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
|
|
15
|
-
const
|
|
16
|
-
const
|
|
17
|
-
const handle = yield* spawner
|
|
18
|
-
.spawn(ChildProcess.make("git", args, { cwd }))
|
|
19
|
-
.pipe(Effect.mapError((cause) => failed(cause.message)));
|
|
20
|
+
const stdin = input === undefined ? undefined : Stream.make(new TextEncoder().encode(input));
|
|
21
|
+
const handle = yield* spawner.spawn(ChildProcess.make(program, args, { cwd, env, extendEnv: true, stdin }));
|
|
20
22
|
const [stdout, stderr, exitCode] = yield* Effect.all([text(handle.stdout), text(handle.stderr), handle.exitCode], {
|
|
21
23
|
concurrency: "unbounded",
|
|
22
|
-
})
|
|
23
|
-
|
|
24
|
-
return stdout;
|
|
24
|
+
});
|
|
25
|
+
return { stdout, stderr, exitCode };
|
|
25
26
|
},
|
|
26
27
|
Effect.scoped,
|
|
27
28
|
);
|
|
28
29
|
|
|
30
|
+
export const git = Effect.fn("git")(function* (args: readonly string[], cwd?: string, feed?: Feed) {
|
|
31
|
+
const failed = (reason: string): GitFailure => new GitFailure({ message: `git ${args.join(" ")}: ${reason.trim()}` });
|
|
32
|
+
const { stdout, stderr, exitCode } = yield* collect("git", args, cwd, feed).pipe(
|
|
33
|
+
Effect.mapError((cause) => failed(cause.message)),
|
|
34
|
+
);
|
|
35
|
+
if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* failed(stderr);
|
|
36
|
+
return stdout;
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
export type Change =
|
|
40
|
+
| { readonly kind: "written" | "deleted"; readonly path: string }
|
|
41
|
+
| { readonly kind: "renamed"; readonly from: string; readonly path: string; readonly edited: boolean };
|
|
42
|
+
|
|
43
|
+
const UNCHANGED_RENAME = "R100";
|
|
44
|
+
|
|
45
|
+
function parseNameStatus(output: string): readonly Change[] {
|
|
46
|
+
const fields = output.split("\0");
|
|
47
|
+
const changes: Change[] = [];
|
|
48
|
+
let index = 0;
|
|
49
|
+
while (index < fields.length - 1) {
|
|
50
|
+
const status = fields[index] ?? "";
|
|
51
|
+
if (status.startsWith("R")) {
|
|
52
|
+
changes.push({ kind: "renamed", from: fields[index + 1] ?? "", path: fields[index + 2] ?? "", edited: status !== UNCHANGED_RENAME });
|
|
53
|
+
index += 3;
|
|
54
|
+
} else if (status.startsWith("C")) {
|
|
55
|
+
changes.push({ kind: "written", path: fields[index + 2] ?? "" });
|
|
56
|
+
index += 3;
|
|
57
|
+
} else {
|
|
58
|
+
changes.push({ kind: status === "D" ? "deleted" : "written", path: fields[index + 1] ?? "" });
|
|
59
|
+
index += 2;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return changes;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const changedPaths = Effect.fn("changedPaths")(function* (
|
|
66
|
+
base: string,
|
|
67
|
+
head: string,
|
|
68
|
+
pathspecs: readonly string[],
|
|
69
|
+
cwd?: string,
|
|
70
|
+
) {
|
|
71
|
+
return parseNameStatus(yield* git(["diff", "--name-status", "-z", "-M", base, head, "--", ...pathspecs], cwd));
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
const emptyTree = (cwd?: string) => git(["hash-object", "-t", "tree", "/dev/null"], cwd).pipe(Effect.map((sha) => sha.trim()));
|
|
75
|
+
|
|
76
|
+
export const pathsAt = Effect.fn("pathsAt")(function* (rev: string, pathspecs: readonly string[], cwd?: string) {
|
|
77
|
+
const listed = yield* git(["diff", "--name-only", "-z", "--no-renames", yield* emptyTree(cwd), rev, "--", ...pathspecs], cwd);
|
|
78
|
+
return listed.split("\0").filter((path) => path !== "");
|
|
79
|
+
});
|
|
80
|
+
|
|
29
81
|
function namesAParent(commitObject: string): boolean {
|
|
30
82
|
const [headers = ""] = commitObject.split("\n\n", 1);
|
|
31
83
|
return headers.split("\n").some((header) => header.startsWith("parent "));
|
|
@@ -34,8 +86,12 @@ function namesAParent(commitObject: string): boolean {
|
|
|
34
86
|
// A shallow clone's boundary commit reads as parentless to rev-parse and log, and judged against
|
|
35
87
|
// the empty tree it would carry the whole repository; only the commit object still names its parents.
|
|
36
88
|
export const parentOrEmptyTree = Effect.fn("parentOrEmptyTree")(function* (rev: string, cwd?: string) {
|
|
37
|
-
if (!namesAParent(yield* git(["cat-file", "commit", rev], cwd)))
|
|
38
|
-
return (yield* git(["hash-object", "-t", "tree", "/dev/null"], cwd)).trim();
|
|
39
|
-
}
|
|
89
|
+
if (!namesAParent(yield* git(["cat-file", "commit", rev], cwd))) return yield* emptyTree(cwd);
|
|
40
90
|
return (yield* git(["rev-parse", "--verify", `${rev}^`], cwd)).trim();
|
|
41
91
|
});
|
|
92
|
+
|
|
93
|
+
// From the base branch's tip, a range would charge the head with what the base branch changed after it branched off.
|
|
94
|
+
export const rangeEnds = Effect.fn("rangeEnds")(function* (first: string, second: string | undefined, cwd?: string) {
|
|
95
|
+
if (second === undefined) return { base: yield* parentOrEmptyTree(first, cwd), head: first };
|
|
96
|
+
return { base: (yield* git(["merge-base", first, second], cwd)).trim(), head: second };
|
|
97
|
+
});
|
package/scripts/quality-file.ts
CHANGED
|
@@ -20,6 +20,28 @@ const PathGlob = Schema.String.check(
|
|
|
20
20
|
"A glob from the repository root that oxlint, the Effect language service and git read alike: a directory first, * within a segment, ** as a whole one, a file name with an extension last, and no braces, ?, [ or leading ./",
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
const LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
|
|
24
|
+
|
|
25
|
+
const DirectoryPath = Schema.String.check(
|
|
26
|
+
Schema.isPattern(new RegExp(`^${LITERAL_SEGMENT}(?:/${LITERAL_SEGMENT})*$`), {
|
|
27
|
+
expected: "a directory from the repository root such as src/billing, with no glob and no trailing slash",
|
|
28
|
+
}),
|
|
29
|
+
).annotate({ identifier: "DirectoryPath" });
|
|
30
|
+
|
|
31
|
+
const FilePath = Schema.String.check(
|
|
32
|
+
Schema.isPattern(new RegExp(`^(?:${LITERAL_SEGMENT}/)*[\\w.@+-]*\\.\\w+$`), {
|
|
33
|
+
expected: "a file from the repository root such as src/billing/index.ts, with no glob",
|
|
34
|
+
}),
|
|
35
|
+
).annotate({ identifier: "FilePath" });
|
|
36
|
+
|
|
37
|
+
export const PROOF_DIRECTORY = "tests/e2e/";
|
|
38
|
+
|
|
39
|
+
const ProofPath = Schema.String.check(
|
|
40
|
+
Schema.isPattern(new RegExp(`^${PROOF_DIRECTORY}(?:${LITERAL_SEGMENT}/)*[\\w.@+-]+\\.test\\.tsx?$`), {
|
|
41
|
+
expected: `a test file under ${PROOF_DIRECTORY} such as ${PROOF_DIRECTORY}billing.test.ts`,
|
|
42
|
+
}),
|
|
43
|
+
).annotate({ identifier: "ProofPath" });
|
|
44
|
+
|
|
23
45
|
const Command = Schema.NonEmptyString.annotate({ identifier: "Command" });
|
|
24
46
|
|
|
25
47
|
const RuleName = Schema.String.check(
|
|
@@ -65,6 +87,58 @@ const Sources = Schema.Struct({
|
|
|
65
87
|
effect: Schema.optionalKey(EffectSources),
|
|
66
88
|
});
|
|
67
89
|
|
|
90
|
+
const LineBudget = Schema.Int.check(Schema.isGreaterThan(0));
|
|
91
|
+
|
|
92
|
+
const Size = Schema.Struct({
|
|
93
|
+
fileLines: LineBudget.annotate({ description: "The most lines a file may hold, blank and comment lines counted" }),
|
|
94
|
+
functionLines: LineBudget.annotate({
|
|
95
|
+
description: "The most lines a function may span, blank and comment lines counted",
|
|
96
|
+
}),
|
|
97
|
+
applies: Schema.Literals(["changed", "all"]).annotate({
|
|
98
|
+
description:
|
|
99
|
+
"Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory",
|
|
100
|
+
}),
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
const Feature = Schema.Struct({
|
|
104
|
+
name: Schema.String.check(
|
|
105
|
+
Schema.isPattern(/^[a-z0-9]+(?:-[a-z0-9]+)*$/, { expected: "a feature name in kebab case" }),
|
|
106
|
+
).annotate({ description: "The owner the dependency rule and the change signal name" }),
|
|
107
|
+
root: DirectoryPath.annotate({ description: "The directory the feature owns" }),
|
|
108
|
+
entries: Schema.NonEmptyArray(FilePath).annotate({
|
|
109
|
+
description: "The files under root that code outside it imports the feature through",
|
|
110
|
+
}),
|
|
111
|
+
allowFrom: Schema.optionalKey(
|
|
112
|
+
Schema.Array(PathGlob).annotate({
|
|
113
|
+
description: "Files outside root that may import past its entries, such as a CLI or a harness; tests/ always may",
|
|
114
|
+
}),
|
|
115
|
+
),
|
|
116
|
+
proof: ProofPath.annotate({ description: "The end-to-end test that imports one of entries" }),
|
|
117
|
+
}).check(
|
|
118
|
+
Schema.makeFilter(({ root, entries }) => {
|
|
119
|
+
const outside = entries.filter((entry) => !entry.startsWith(`${root}/`));
|
|
120
|
+
return outside.length === 0 || `lists ${outside.join(", ")} among its entries, outside its root ${root}`;
|
|
121
|
+
}),
|
|
122
|
+
);
|
|
123
|
+
export type Feature = typeof Feature.Type;
|
|
124
|
+
|
|
125
|
+
function nests(outer: string, inner: string): boolean {
|
|
126
|
+
return outer === inner || inner.startsWith(`${outer}/`);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const Features = Schema.Array(Feature).check(
|
|
130
|
+
Schema.makeFilter((features) => {
|
|
131
|
+
const names = features.map((feature) => feature.name);
|
|
132
|
+
const repeated = names.filter((name, index) => names.indexOf(name) !== index);
|
|
133
|
+
if (repeated.length > 0) return `names ${[...new Set(repeated)].join(", ")} more than once`;
|
|
134
|
+
for (const outer of features) {
|
|
135
|
+
const inner = features.find((other) => other !== outer && nests(outer.root, other.root));
|
|
136
|
+
if (inner !== undefined) return `gives ${inner.root} to both ${outer.name} and ${inner.name}`;
|
|
137
|
+
}
|
|
138
|
+
return true;
|
|
139
|
+
}),
|
|
140
|
+
);
|
|
141
|
+
|
|
68
142
|
const AgentRules = Schema.Struct({
|
|
69
143
|
on: Schema.optionalKey(Schema.Array(RuleName).annotate({ description: "Catalogued Rules switched on here" })),
|
|
70
144
|
off: Schema.optionalKey(Schema.Array(RuleName).annotate({ description: "Catalogued Rules switched off here" })),
|
|
@@ -83,11 +157,47 @@ export const Quality = Schema.Struct({
|
|
|
83
157
|
gates: Schema.optionalKey(Gates),
|
|
84
158
|
commitIdentity: Schema.optionalKey(CommitIdentity),
|
|
85
159
|
sources: Schema.optionalKey(Sources),
|
|
160
|
+
size: Schema.optionalKey(
|
|
161
|
+
Size.annotate({ description: "The line budget oxlint holds production files to, read by checks-size-budget" }),
|
|
162
|
+
),
|
|
163
|
+
features: Schema.optionalKey(
|
|
164
|
+
Features.annotate({
|
|
165
|
+
description: "The feature owners dependency-cruiser holds to their entries and checks-feature-owners maps a change to",
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
168
|
+
changeSignal: Schema.optionalKey(
|
|
169
|
+
Schema.Literal("advisory").annotate({
|
|
170
|
+
description: "Report which feature owners a change touches, without failing on it",
|
|
171
|
+
}),
|
|
172
|
+
),
|
|
86
173
|
agentRules: Schema.optionalKey(AgentRules),
|
|
87
|
-
})
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
174
|
+
})
|
|
175
|
+
.annotate({
|
|
176
|
+
title: QUALITY_FILE,
|
|
177
|
+
description: "What a repository has opted into from @avi2dg/checks, read by its bins and agent Rule selection",
|
|
178
|
+
})
|
|
179
|
+
.check(
|
|
180
|
+
Schema.makeFilter(
|
|
181
|
+
({ size, sources }) =>
|
|
182
|
+
size === undefined || (sources?.production ?? []).length > 0 || "declares size, which holds nothing without sources.production",
|
|
183
|
+
{
|
|
184
|
+
toJsonSchema: () => ({
|
|
185
|
+
if: { required: ["size"] },
|
|
186
|
+
then: { required: ["sources"], properties: { sources: { required: ["production"], properties: { production: { minItems: 1 } } } } },
|
|
187
|
+
}),
|
|
188
|
+
},
|
|
189
|
+
),
|
|
190
|
+
Schema.makeFilter(
|
|
191
|
+
({ changeSignal, features = [] }) =>
|
|
192
|
+
changeSignal === undefined || features.length > 0 || "declares changeSignal, which maps a change to no owner without features",
|
|
193
|
+
{
|
|
194
|
+
toJsonSchema: () => ({
|
|
195
|
+
if: { required: ["changeSignal"] },
|
|
196
|
+
then: { required: ["features"], properties: { features: { minItems: 1 } } },
|
|
197
|
+
}),
|
|
198
|
+
},
|
|
199
|
+
),
|
|
200
|
+
);
|
|
91
201
|
export type Quality = typeof Quality.Type;
|
|
92
202
|
|
|
93
203
|
const LegacyManifest = Schema.Struct({
|
package/scripts/quality.ts
CHANGED
|
@@ -119,10 +119,18 @@ const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string,
|
|
|
119
119
|
});
|
|
120
120
|
|
|
121
121
|
const unmatchedPaths = Effect.fn("unmatchedPaths")(function* (root: string, quality: Quality) {
|
|
122
|
+
const declared = [
|
|
123
|
+
...(quality.size === undefined ? [] : (quality.sources?.production ?? [])).map((glob) => ({
|
|
124
|
+
glob,
|
|
125
|
+
key: "sources.production",
|
|
126
|
+
holds: "no source to the size budget",
|
|
127
|
+
})),
|
|
128
|
+
...(quality.sources?.effect?.paths ?? []).map((glob) => ({ glob, key: "sources.effect.paths", holds: "nothing to the Effect rules" })),
|
|
129
|
+
];
|
|
122
130
|
const problems: string[] = [];
|
|
123
|
-
for (const glob
|
|
131
|
+
for (const { glob, key, holds } of declared) {
|
|
124
132
|
const matched = yield* git(["ls-files", "--cached", "--others", "--exclude-standard", "--", `:(glob)${glob}`], root);
|
|
125
|
-
if (matched.trim() === "") problems.push(
|
|
133
|
+
if (matched.trim() === "") problems.push(`${key} ${glob} matches no file, so it holds ${holds}`);
|
|
126
134
|
}
|
|
127
135
|
return problems;
|
|
128
136
|
});
|