@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.
@@ -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
+ }
@@ -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
- DEFAULT_BRANCH,
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 unknown[],
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 = typeof command === "string" ? plainCommand(command) : undefined;
327
- if (typeof command !== "string" || words === undefined) {
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
- manifest: unknown,
327
+ { defaultBranch, gates: declared }: Quality,
337
328
  source: string,
338
329
  ): Effect.fn.Return<Declaration, WiringError> {
339
- const configured = isRecord(manifest) && isRecord(manifest["ciWiring"]) ? manifest["ciWiring"] : {};
340
- const listed: unknown = configured["gates"];
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: ciWiring?.defaultBranch ?? DEFAULT_BRANCH,
357
- lintGates: selectedGates(ciWiring?.lintGates),
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: ciWiring.lintGates leaves out ${bins}, none of which this repository's contents make applicable`;
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: ciWiring.lintGates leaves out ${omissions.length} gate(s) this repository's contents make applicable:`,
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 fs = yield* FileSystem.FileSystem;
404
- const path = (yield* Path.Path).join(root, "package.json");
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, FileSystem, Path, Schema } from "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 fs = yield* FileSystem.FileSystem;
55
- const path = yield* Path.Path;
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);