@avi2dg/checks 0.10.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 +426 -89
- package/dist/feature-rules.js +242 -0
- package/package.json +25 -18
- package/presets/effect.language-service.json +8 -0
- package/presets/effect.oxlint.json +11 -0
- package/quality.schema.json +339 -0
- package/scripts/ci-wiring.ts +16 -43
- package/scripts/commit-identity.ts +4 -33
- package/scripts/feature-owners.ts +142 -0
- package/scripts/gates.ts +17 -15
- package/scripts/git.ts +70 -14
- package/scripts/lint.ts +7 -17
- package/scripts/quality-file.ts +298 -0
- package/scripts/quality.ts +180 -0
- package/scripts/size-budget.ts +158 -0
|
@@ -0,0 +1,339 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"type": "object",
|
|
4
|
+
"properties": {
|
|
5
|
+
"$schema": {
|
|
6
|
+
"type": "string"
|
|
7
|
+
},
|
|
8
|
+
"defaultBranch": {
|
|
9
|
+
"type": "string",
|
|
10
|
+
"minLength": 1,
|
|
11
|
+
"description": "The branch pull requests merge into; main when absent"
|
|
12
|
+
},
|
|
13
|
+
"gates": {
|
|
14
|
+
"type": "object",
|
|
15
|
+
"properties": {
|
|
16
|
+
"ci": {
|
|
17
|
+
"type": "array",
|
|
18
|
+
"prefixItems": [
|
|
19
|
+
{
|
|
20
|
+
"$ref": "#/$defs/Command"
|
|
21
|
+
}
|
|
22
|
+
],
|
|
23
|
+
"minItems": 1,
|
|
24
|
+
"items": {
|
|
25
|
+
"$ref": "#/$defs/Command"
|
|
26
|
+
},
|
|
27
|
+
"description": "The commands CI runs on every pull request to the default branch, each one plain command"
|
|
28
|
+
},
|
|
29
|
+
"scheduled": {
|
|
30
|
+
"type": "array",
|
|
31
|
+
"items": {
|
|
32
|
+
"$ref": "#/$defs/Command"
|
|
33
|
+
},
|
|
34
|
+
"description": "The commands a cron-scheduled workflow runs"
|
|
35
|
+
},
|
|
36
|
+
"lint": {
|
|
37
|
+
"type": "array",
|
|
38
|
+
"items": {
|
|
39
|
+
"type": "string",
|
|
40
|
+
"enum": [
|
|
41
|
+
"checks-lint-coverage",
|
|
42
|
+
"checks-test-layout",
|
|
43
|
+
"checks-commit-identity",
|
|
44
|
+
"checks-comment-gate",
|
|
45
|
+
"checks-suppressions-ratchet",
|
|
46
|
+
"checks-ci-wiring",
|
|
47
|
+
"checks-quality",
|
|
48
|
+
"checks-size-budget",
|
|
49
|
+
"checks-feature-owners"
|
|
50
|
+
]
|
|
51
|
+
},
|
|
52
|
+
"allOf": [
|
|
53
|
+
{
|
|
54
|
+
"contains": {
|
|
55
|
+
"const": "checks-commit-identity"
|
|
56
|
+
}
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
"contains": {
|
|
60
|
+
"const": "checks-comment-gate"
|
|
61
|
+
}
|
|
62
|
+
},
|
|
63
|
+
{
|
|
64
|
+
"contains": {
|
|
65
|
+
"const": "checks-suppressions-ratchet"
|
|
66
|
+
}
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
"contains": {
|
|
70
|
+
"const": "checks-ci-wiring"
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
]
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
"additionalProperties": false
|
|
77
|
+
},
|
|
78
|
+
"commitIdentity": {
|
|
79
|
+
"type": "object",
|
|
80
|
+
"properties": {
|
|
81
|
+
"authors": {
|
|
82
|
+
"type": "array",
|
|
83
|
+
"prefixItems": [
|
|
84
|
+
{
|
|
85
|
+
"$ref": "#/$defs/Identity"
|
|
86
|
+
}
|
|
87
|
+
],
|
|
88
|
+
"minItems": 1,
|
|
89
|
+
"items": {
|
|
90
|
+
"$ref": "#/$defs/Identity"
|
|
91
|
+
},
|
|
92
|
+
"description": "The identities allowed to author and commit, in place of the kit's default owner"
|
|
93
|
+
}
|
|
94
|
+
},
|
|
95
|
+
"required": [
|
|
96
|
+
"authors"
|
|
97
|
+
],
|
|
98
|
+
"additionalProperties": false
|
|
99
|
+
},
|
|
100
|
+
"sources": {
|
|
101
|
+
"type": "object",
|
|
102
|
+
"properties": {
|
|
103
|
+
"production": {
|
|
104
|
+
"type": "array",
|
|
105
|
+
"items": {
|
|
106
|
+
"$ref": "#/$defs/PathGlob"
|
|
107
|
+
},
|
|
108
|
+
"description": "The source the repository ships, as against tests and tooling"
|
|
109
|
+
},
|
|
110
|
+
"effect": {
|
|
111
|
+
"type": "object",
|
|
112
|
+
"properties": {
|
|
113
|
+
"paths": {
|
|
114
|
+
"type": "array",
|
|
115
|
+
"prefixItems": [
|
|
116
|
+
{
|
|
117
|
+
"$ref": "#/$defs/PathGlob"
|
|
118
|
+
}
|
|
119
|
+
],
|
|
120
|
+
"minItems": 1,
|
|
121
|
+
"items": {
|
|
122
|
+
"$ref": "#/$defs/PathGlob"
|
|
123
|
+
},
|
|
124
|
+
"description": "Where source is written in Effect, held to the Effect rules of oxlint and the language service"
|
|
125
|
+
},
|
|
126
|
+
"exempt": {
|
|
127
|
+
"type": "array",
|
|
128
|
+
"items": {
|
|
129
|
+
"$ref": "#/$defs/PathGlob"
|
|
130
|
+
},
|
|
131
|
+
"description": "Files under paths the Effect rules pass over"
|
|
132
|
+
}
|
|
133
|
+
},
|
|
134
|
+
"required": [
|
|
135
|
+
"paths"
|
|
136
|
+
],
|
|
137
|
+
"additionalProperties": false
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
"additionalProperties": false
|
|
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
|
+
},
|
|
225
|
+
"agentRules": {
|
|
226
|
+
"type": "object",
|
|
227
|
+
"properties": {
|
|
228
|
+
"on": {
|
|
229
|
+
"type": "array",
|
|
230
|
+
"items": {
|
|
231
|
+
"$ref": "#/$defs/RuleName"
|
|
232
|
+
},
|
|
233
|
+
"description": "Catalogued Rules switched on here"
|
|
234
|
+
},
|
|
235
|
+
"off": {
|
|
236
|
+
"type": "array",
|
|
237
|
+
"items": {
|
|
238
|
+
"$ref": "#/$defs/RuleName"
|
|
239
|
+
},
|
|
240
|
+
"description": "Catalogued Rules switched off here"
|
|
241
|
+
}
|
|
242
|
+
},
|
|
243
|
+
"additionalProperties": false
|
|
244
|
+
}
|
|
245
|
+
},
|
|
246
|
+
"additionalProperties": false,
|
|
247
|
+
"title": "quality.json",
|
|
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
|
+
],
|
|
292
|
+
"$defs": {
|
|
293
|
+
"Command": {
|
|
294
|
+
"type": "string",
|
|
295
|
+
"minLength": 1
|
|
296
|
+
},
|
|
297
|
+
"Identity": {
|
|
298
|
+
"type": "object",
|
|
299
|
+
"properties": {
|
|
300
|
+
"name": {
|
|
301
|
+
"type": "string",
|
|
302
|
+
"minLength": 1
|
|
303
|
+
},
|
|
304
|
+
"email": {
|
|
305
|
+
"type": "string",
|
|
306
|
+
"minLength": 1
|
|
307
|
+
}
|
|
308
|
+
},
|
|
309
|
+
"required": [
|
|
310
|
+
"name",
|
|
311
|
+
"email"
|
|
312
|
+
],
|
|
313
|
+
"additionalProperties": false
|
|
314
|
+
},
|
|
315
|
+
"PathGlob": {
|
|
316
|
+
"type": "string",
|
|
317
|
+
"pattern": "^(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+)(?:\\/(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+))*\\/(?:[\\w.@+-]|\\*(?!\\*))*\\.\\w+$",
|
|
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 ./"
|
|
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
|
+
},
|
|
334
|
+
"RuleName": {
|
|
335
|
+
"type": "string",
|
|
336
|
+
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
package/scripts/ci-wiring.ts
CHANGED
|
@@ -2,15 +2,8 @@
|
|
|
2
2
|
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import { git } from "./git.ts";
|
|
4
4
|
import { runMain } from "./main.ts";
|
|
5
|
-
import {
|
|
6
|
-
|
|
7
|
-
ENTRY_POINT,
|
|
8
|
-
EVERY_REPOSITORY,
|
|
9
|
-
KIT_GATES,
|
|
10
|
-
LintWiring,
|
|
11
|
-
selectedGates,
|
|
12
|
-
type KitGate,
|
|
13
|
-
} from "./gates.ts";
|
|
5
|
+
import { DEFAULT_BRANCH, ENTRY_POINT, EVERY_REPOSITORY, KIT_GATES, QUALITY_FILE, selectedGates, type KitGate } from "./gates.ts";
|
|
6
|
+
import { readQuality, type Quality } from "./quality-file.ts";
|
|
14
7
|
|
|
15
8
|
export type Command = readonly string[];
|
|
16
9
|
|
|
@@ -303,8 +296,6 @@ function gapLines(gaps: readonly Gap[]): readonly string[] {
|
|
|
303
296
|
return lines;
|
|
304
297
|
}
|
|
305
298
|
|
|
306
|
-
const decodeWiring = Schema.decodeUnknownEffect(LintWiring);
|
|
307
|
-
|
|
308
299
|
export function formatReport(declaration: Declaration, gaps: readonly Gap[]): string {
|
|
309
300
|
const target = `pull requests to ${declaration.defaultBranch}`;
|
|
310
301
|
if (gaps.length === 0) return `ci-wiring: ${declaration.gates.length} gate(s) run on ${target}`;
|
|
@@ -318,13 +309,13 @@ export function formatScheduledReport(declaration: Declaration, gaps: readonly G
|
|
|
318
309
|
}
|
|
319
310
|
|
|
320
311
|
const plainCommands = Effect.fnUntraced(function* (
|
|
321
|
-
listed: readonly
|
|
312
|
+
listed: readonly string[],
|
|
322
313
|
subject: string,
|
|
323
314
|
): Effect.fn.Return<readonly Gate[], WiringError> {
|
|
324
315
|
const gates: Gate[] = [];
|
|
325
316
|
for (const command of listed) {
|
|
326
|
-
const words =
|
|
327
|
-
if (
|
|
317
|
+
const words = plainCommand(command);
|
|
318
|
+
if (words === undefined) {
|
|
328
319
|
return yield* new WiringError({ message: `${subject} ${JSON.stringify(command)} is not one plain command` });
|
|
329
320
|
}
|
|
330
321
|
gates.push({ command, words });
|
|
@@ -333,28 +324,17 @@ const plainCommands = Effect.fnUntraced(function* (
|
|
|
333
324
|
});
|
|
334
325
|
|
|
335
326
|
export const parseDeclaration = Effect.fnUntraced(function* (
|
|
336
|
-
|
|
327
|
+
{ defaultBranch, gates: declared }: Quality,
|
|
337
328
|
source: string,
|
|
338
329
|
): Effect.fn.Return<Declaration, WiringError> {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
if (!Array.isArray(listed) || listed.length === 0) {
|
|
342
|
-
return yield* new WiringError({ message: `${source} sets no ciWiring.gates, a non-empty array of commands` });
|
|
343
|
-
}
|
|
344
|
-
const gates = yield* plainCommands(listed, `${source} ciWiring gate`);
|
|
345
|
-
const scheduledListed = configured["scheduled"] ?? [];
|
|
346
|
-
if (!Array.isArray(scheduledListed)) {
|
|
347
|
-
return yield* new WiringError({ message: `${source} ciWiring.scheduled is not an array of commands` });
|
|
330
|
+
if (declared?.ci === undefined) {
|
|
331
|
+
return yield* new WiringError({ message: `${QUALITY_FILE} declares no gates.ci, a non-empty array of commands` });
|
|
348
332
|
}
|
|
349
|
-
const scheduled = yield* plainCommands(scheduledListed, `${source} ciWiring scheduled command`);
|
|
350
|
-
const { ciWiring } = yield* decodeWiring(manifest).pipe(
|
|
351
|
-
Effect.mapError((cause) => new WiringError({ message: `${source}: ${cause.message}` })),
|
|
352
|
-
);
|
|
353
333
|
return {
|
|
354
|
-
gates,
|
|
355
|
-
scheduled,
|
|
356
|
-
defaultBranch:
|
|
357
|
-
lintGates: selectedGates(
|
|
334
|
+
gates: yield* plainCommands(declared.ci, `${source} gate`),
|
|
335
|
+
scheduled: yield* plainCommands(declared.scheduled ?? [], `${source} scheduled command`),
|
|
336
|
+
defaultBranch: defaultBranch ?? DEFAULT_BRANCH,
|
|
337
|
+
lintGates: selectedGates(declared.lint),
|
|
358
338
|
};
|
|
359
339
|
});
|
|
360
340
|
|
|
@@ -383,10 +363,10 @@ export function formatOmissions(lintGates: readonly KitGate[], omissions: readon
|
|
|
383
363
|
if (omitted.length === 0) return undefined;
|
|
384
364
|
if (omissions.length === 0) {
|
|
385
365
|
const bins = omitted.map((gate) => gate.bin).join(", ");
|
|
386
|
-
return `ci-wiring:
|
|
366
|
+
return `ci-wiring: ${QUALITY_FILE} gates.lint leaves out ${bins}, none of which this repository's contents make applicable`;
|
|
387
367
|
}
|
|
388
368
|
return [
|
|
389
|
-
`ci-wiring:
|
|
369
|
+
`ci-wiring: ${QUALITY_FILE} gates.lint leaves out ${omissions.length} gate(s) this repository's contents make applicable:`,
|
|
390
370
|
...omissions.map(({ gate, content, files }) => ` ${gate}: the repository tracks ${content} (${sample(files)})`),
|
|
391
371
|
].join("\n");
|
|
392
372
|
}
|
|
@@ -397,16 +377,9 @@ export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflo
|
|
|
397
377
|
catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
|
|
398
378
|
});
|
|
399
379
|
|
|
400
|
-
const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
|
|
401
|
-
|
|
402
380
|
export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
|
|
403
|
-
const
|
|
404
|
-
|
|
405
|
-
const manifest = yield* fs.readFileString(path).pipe(
|
|
406
|
-
Effect.flatMap(parseJson),
|
|
407
|
-
Effect.mapError((cause) => new WiringError({ message: `cannot read ${path} as JSON: ${cause.message}` })),
|
|
408
|
-
);
|
|
409
|
-
return yield* parseDeclaration(manifest, path);
|
|
381
|
+
const { source, quality } = yield* readQuality(root);
|
|
382
|
+
return yield* parseDeclaration(quality, source);
|
|
410
383
|
});
|
|
411
384
|
|
|
412
385
|
export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string) {
|
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { Console, Effect,
|
|
2
|
+
import { Console, Effect, Schema } from "effect";
|
|
3
3
|
import { git } from "./git.ts";
|
|
4
4
|
import { runMain, Usage } from "./main.ts";
|
|
5
|
-
|
|
6
|
-
const Identity = Schema.Struct({ name: Schema.String, email: Schema.String });
|
|
7
|
-
type Identity = typeof Identity.Type;
|
|
5
|
+
import { readQuality, type Identity } from "./quality-file.ts";
|
|
8
6
|
|
|
9
7
|
type Commit = {
|
|
10
8
|
readonly sha: string;
|
|
@@ -32,16 +30,6 @@ const RECORD = "\u001e";
|
|
|
32
30
|
|
|
33
31
|
const USAGE = "usage: commit-identity.ts <ref> | <base-ref> <head-ref>";
|
|
34
32
|
|
|
35
|
-
const Manifest = Schema.Struct({
|
|
36
|
-
commitIdentity: Schema.optional(Schema.Struct({ authors: Schema.NonEmptyArray(Identity) })),
|
|
37
|
-
});
|
|
38
|
-
const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
|
|
39
|
-
const decodeManifest = Schema.decodeUnknownEffect(Manifest);
|
|
40
|
-
|
|
41
|
-
class InvalidAllowlist extends Schema.TaggedError<InvalidAllowlist>()("InvalidAllowlist", {
|
|
42
|
-
message: Schema.String,
|
|
43
|
-
}) {}
|
|
44
|
-
|
|
45
33
|
class UnreadableLog extends Schema.TaggedError<UnreadableLog>()("UnreadableLog", {
|
|
46
34
|
message: Schema.String,
|
|
47
35
|
}) {}
|
|
@@ -51,25 +39,8 @@ function render(identity: Identity): string {
|
|
|
51
39
|
}
|
|
52
40
|
|
|
53
41
|
const allowedAuthors = Effect.gen(function* () {
|
|
54
|
-
const
|
|
55
|
-
|
|
56
|
-
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
57
|
-
const manifestPath = path.join(root, "package.json");
|
|
58
|
-
if (!(yield* fs.exists(manifestPath))) return DEFAULT_AUTHORS;
|
|
59
|
-
|
|
60
|
-
const parsed = yield* fs.readFileString(manifestPath).pipe(
|
|
61
|
-
Effect.flatMap(parseJson),
|
|
62
|
-
Effect.mapError((cause) => new InvalidAllowlist({ message: `cannot read ${manifestPath} as JSON: ${cause.message}` })),
|
|
63
|
-
);
|
|
64
|
-
const manifest = yield* decodeManifest(parsed).pipe(
|
|
65
|
-
Effect.mapError(
|
|
66
|
-
() =>
|
|
67
|
-
new InvalidAllowlist({
|
|
68
|
-
message: `${manifestPath} sets commitIdentity without a non-empty authors array of {name, email}`,
|
|
69
|
-
}),
|
|
70
|
-
),
|
|
71
|
-
);
|
|
72
|
-
return manifest.commitIdentity?.authors ?? DEFAULT_AUTHORS;
|
|
42
|
+
const { quality } = yield* readQuality((yield* git(["rev-parse", "--show-toplevel"])).trim());
|
|
43
|
+
return quality.commitIdentity?.authors ?? DEFAULT_AUTHORS;
|
|
73
44
|
});
|
|
74
45
|
|
|
75
46
|
const readCommits = Effect.fn("readCommits")(function* (revisions: readonly string[]) {
|
|
@@ -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);
|