@avi2dg/checks 0.6.0 → 0.9.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.
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env bun
2
- import { comments, refused, SYNTAXES } from "./comments.ts";
2
+ import { Console, Effect, Option } from "effect";
3
+ import { comments, refused, syntaxOf } from "./comments.ts";
4
+ import { git } from "./git.ts";
5
+ import { runMain, Usage } from "./main.ts";
3
6
 
4
7
  type Landed = {
5
8
  readonly sha: string;
@@ -13,37 +16,20 @@ const USAGE = "usage: backtest.ts [commit-count]";
13
16
 
14
17
  const EXCLUDED = ["generated/", "vendor/", "repos/", "node_modules/", "dist/"];
15
18
 
16
- function die(message: string): never {
17
- console.error(message);
18
- process.exit(2);
19
- }
20
-
21
- function git(...args: readonly string[]): string {
22
- const result = Bun.spawnSync(["git", ...args], { stdout: "pipe", stderr: "pipe" });
23
- if (!result.success) {
24
- die(`backtest: git ${args.join(" ")}: ${result.stderr.toString().trim()}`);
25
- }
26
- return result.stdout.toString();
27
- }
28
-
29
19
  function readable(path: string): boolean {
30
20
  if (EXCLUDED.some((prefix) => path.startsWith(prefix))) return false;
31
- const extension = path.slice(path.lastIndexOf(".") + 1);
32
- return extension in SYNTAXES;
21
+ return syntaxOf(path) !== undefined;
33
22
  }
34
23
 
35
- async function at(sha: string, path: string): Promise<string | undefined> {
36
- const result = Bun.spawnSync(["git", "show", `${sha}:${path}`], { stdout: "pipe", stderr: "pipe" });
37
- return result.success ? result.stdout.toString() : undefined;
38
- }
24
+ const at = (sha: string, path: string) => git(["show", `${sha}:${path}`]).pipe(Effect.option);
39
25
 
40
26
  function withoutLine(refusal: string): string {
41
27
  return refusal.replace(/^(\S+?):\d+ /, "$1 ");
42
28
  }
43
29
 
44
- export async function backtest(count: string): Promise<string> {
30
+ export const backtest = Effect.fn("backtest")(function* (count: string) {
45
31
  const lines: string[] = [];
46
- const commits = git("log", "--first-parent", "--format=%H%x09%P%x09%s", "-n", count)
32
+ const commits = (yield* git(["log", "--first-parent", "--format=%H%x09%P%x09%s", "-n", count]))
47
33
  .trim()
48
34
  .split("\n")
49
35
  .map((row) => {
@@ -55,13 +41,13 @@ export async function backtest(count: string): Promise<string> {
55
41
  const landed: Landed[] = [];
56
42
 
57
43
  for (const { sha, parent, subject } of commits) {
58
- const changed = git("diff", "--name-only", "--diff-filter=d", parent, sha)
44
+ const changed = (yield* git(["diff", "--name-only", "--diff-filter=d", parent, sha]))
59
45
  .trim()
60
46
  .split("\n")
61
47
  .filter((path) => path !== "" && readable(path));
62
48
  if (changed.length === 0) continue;
63
49
 
64
- const numstat = git("diff", "--numstat", parent, sha, "--", ...changed).trim();
50
+ const numstat = (yield* git(["diff", "--numstat", parent, sha, "--", ...changed])).trim();
65
51
  const added =
66
52
  numstat === ""
67
53
  ? 0
@@ -71,17 +57,17 @@ export async function backtest(count: string): Promise<string> {
71
57
  const refusals: string[] = [];
72
58
 
73
59
  for (const path of changed) {
74
- const after = await at(sha, path);
75
- if (after === undefined) continue;
76
- const before = (await at(parent, path)) ?? "";
60
+ const after = yield* at(sha, path);
61
+ if (Option.isNone(after)) continue;
62
+ const before = Option.getOrElse(yield* at(parent, path), () => "");
77
63
 
78
- const wasRefused = new Set(refused(path, before).map(withoutLine));
79
- for (const refusal of refused(path, after)) {
64
+ const wasRefused = new Set((yield* refused(path, before)).map(withoutLine));
65
+ for (const refusal of yield* refused(path, after.value)) {
80
66
  if (!wasRefused.has(withoutLine(refusal))) refusals.push(refusal);
81
67
  }
82
68
 
83
- const wasComment = new Set(comments(path, before).map((one) => one.text));
84
- for (const one of comments(path, after)) {
69
+ const wasComment = new Set((yield* comments(path, before)).map((one) => one.text));
70
+ for (const one of yield* comments(path, after.value)) {
85
71
  if (!wasComment.has(one.text)) commentLines += one.text.split("\n").length;
86
72
  }
87
73
  }
@@ -108,10 +94,13 @@ export async function backtest(count: string): Promise<string> {
108
94
  for (const refusal of one.refusals) lines.push(` ${refusal}`);
109
95
  }
110
96
  return lines.join("\n");
111
- }
97
+ });
112
98
 
113
- if (import.meta.main) {
99
+ const main = Effect.gen(function* () {
114
100
  const [count = "60", ...extra] = process.argv.slice(2);
115
- if (extra.length > 0) die(USAGE);
116
- console.log(await backtest(count));
117
- }
101
+ if (extra.length > 0) return yield* new Usage({ message: USAGE });
102
+ yield* Console.log(yield* backtest(count));
103
+ return true;
104
+ });
105
+
106
+ if (import.meta.main) runMain("backtest", main);
@@ -1,6 +1,16 @@
1
1
  #!/usr/bin/env bun
2
- import { existsSync, readdirSync, readFileSync } from "node:fs";
3
- import { join } from "node:path";
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { git } from "./git.ts";
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";
4
14
 
5
15
  export type Command = readonly string[];
6
16
 
@@ -12,6 +22,7 @@ export type Gate = {
12
22
  export type Declaration = {
13
23
  readonly gates: readonly Gate[];
14
24
  readonly defaultBranch: string;
25
+ readonly lintGates: readonly KitGate[];
15
26
  };
16
27
 
17
28
  export type Workflow = {
@@ -24,8 +35,15 @@ export type BlockedInvocation = {
24
35
  readonly blocker: string;
25
36
  };
26
37
 
38
+ export type Omission = {
39
+ readonly gate: string;
40
+ readonly content: string;
41
+ readonly files: readonly [string, ...string[]];
42
+ };
43
+
27
44
  export type Gap = {
28
45
  readonly gate: string;
46
+ readonly entryPoint?: string;
29
47
  readonly blocked: readonly BlockedInvocation[];
30
48
  };
31
49
 
@@ -35,10 +53,11 @@ type RunStep = {
35
53
  readonly script: string;
36
54
  };
37
55
 
38
- export class WiringError extends Error {}
56
+ export class WiringError extends Schema.TaggedError<WiringError>()("WiringError", {
57
+ message: Schema.String,
58
+ }) {}
39
59
 
40
60
  const WORKFLOWS = ".github/workflows";
41
- const DEFAULT_BRANCH = "main";
42
61
  // Without these a pull_request workflow never sees the commits a pull request pushes.
43
62
  const GATING_TYPES = ["opened", "synchronize"];
44
63
  const CONSTANTS = new Map([
@@ -226,19 +245,32 @@ function runSteps(workflows: readonly Workflow[], branch: string): readonly RunS
226
245
  return steps;
227
246
  }
228
247
 
248
+ function entryPointCommand(gate: Command, lintGates: readonly KitGate[]): Command | undefined {
249
+ const index = gate.findIndex((word) => lintGates.some((kitGate) => kitGate.bin === word));
250
+ return index === -1 ? undefined : [...gate.slice(0, index), ENTRY_POINT.bin];
251
+ }
252
+
229
253
  export function findGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
230
254
  const steps = runSteps(workflows, declaration.defaultBranch);
231
255
  return declaration.gates.flatMap((gate) => {
232
- const alone = `the step runs more than ${gate.command}; give it its own step with nothing else in it`;
256
+ const entryPoint = entryPointCommand(gate.words, declaration.lintGates);
257
+ const commands = [
258
+ { command: gate.command, words: gate.words },
259
+ ...(entryPoint === undefined ? [] : [{ command: entryPoint.join(" "), words: entryPoint }]),
260
+ ];
233
261
  const invoking = steps.flatMap(({ location, blocker, script }) => {
234
- if (invokes(plainCommand(script), gate.words)) return [{ location, blocker }];
235
- return mentions(script, gate.words) ? [{ location, blocker: blocker ?? alone }] : [];
262
+ const plain = plainCommand(script);
263
+ if (commands.some(({ words }) => invokes(plain, words))) return [{ location, blocker }];
264
+ const mentioned = commands.find(({ words }) => mentions(script, words));
265
+ if (mentioned === undefined) return [];
266
+ const alone = `the step runs more than ${mentioned.command}; give it its own step with nothing else in it`;
267
+ return [{ location, blocker: blocker ?? alone }];
236
268
  });
237
269
  if (invoking.some((step) => step.blocker === undefined)) return [];
238
270
  const blocked = invoking.flatMap(({ location, blocker }) =>
239
271
  blocker === undefined ? [] : [{ location, blocker }],
240
272
  );
241
- return [{ gate: gate.command, blocked }];
273
+ return [{ gate: gate.command, entryPoint: entryPoint?.join(" "), blocked }];
242
274
  });
243
275
  }
244
276
 
@@ -248,80 +280,124 @@ export function formatReport(declaration: Declaration, gaps: readonly Gap[]): st
248
280
  const lines = [`ci-wiring: ${gaps.length} of ${declaration.gates.length} gate(s) do not run on ${target}:`];
249
281
  for (const gap of gaps) {
250
282
  lines.push(` ${gap.gate}`);
251
- if (gap.blocked.length === 0) lines.push(" no run step invokes it");
283
+ if (gap.blocked.length === 0) {
284
+ lines.push(gap.entryPoint === undefined ? " no run step invokes it" : ` no run step invokes it or ${gap.entryPoint}`);
285
+ }
252
286
  for (const { location, blocker } of gap.blocked) lines.push(` ${location}: ${blocker}`);
253
287
  }
254
288
  return lines.join("\n");
255
289
  }
256
290
 
257
- export function parseDeclaration(manifest: unknown, source: string): Declaration {
291
+ const decodeWiring = Schema.decodeUnknownEffect(LintWiring);
292
+
293
+ export const parseDeclaration = Effect.fnUntraced(function* (
294
+ manifest: unknown,
295
+ source: string,
296
+ ): Effect.fn.Return<Declaration, WiringError> {
258
297
  const configured = isRecord(manifest) && isRecord(manifest["ciWiring"]) ? manifest["ciWiring"] : {};
259
- const listed = configured["gates"];
298
+ const listed: unknown = configured["gates"];
260
299
  if (!Array.isArray(listed) || listed.length === 0) {
261
- throw new WiringError(`ci-wiring: ${source} sets no ciWiring.gates, a non-empty array of commands`);
300
+ return yield* new WiringError({ message: `${source} sets no ciWiring.gates, a non-empty array of commands` });
262
301
  }
263
- const gates = listed.map((command: unknown): Gate => {
302
+ const commands: readonly unknown[] = listed;
303
+ const gates: Gate[] = [];
304
+ for (const command of commands) {
264
305
  const words = typeof command === "string" ? plainCommand(command) : undefined;
265
306
  if (typeof command !== "string" || words === undefined) {
266
- throw new WiringError(`ci-wiring: ${source} ciWiring gate ${JSON.stringify(command)} is not one plain command`);
307
+ return yield* new WiringError({
308
+ message: `${source} ciWiring gate ${JSON.stringify(command)} is not one plain command`,
309
+ });
267
310
  }
268
- return { command, words };
269
- });
270
- const defaultBranch = configured["defaultBranch"] ?? DEFAULT_BRANCH;
271
- if (typeof defaultBranch !== "string" || defaultBranch === "") {
272
- throw new WiringError(`ci-wiring: ${source} ciWiring.defaultBranch is not a branch name`);
311
+ gates.push({ command, words });
273
312
  }
274
- return { gates, defaultBranch };
275
- }
313
+ const { ciWiring } = yield* decodeWiring(manifest).pipe(
314
+ Effect.mapError((cause) => new WiringError({ message: `${source}: ${cause.message}` })),
315
+ );
316
+ return {
317
+ gates,
318
+ defaultBranch: ciWiring?.defaultBranch ?? DEFAULT_BRANCH,
319
+ lintGates: selectedGates(ciWiring?.lintGates),
320
+ };
321
+ });
276
322
 
277
- export function parseWorkflow(path: string, text: string): Workflow {
278
- try {
279
- return { path, document: Bun.YAML.parse(text) };
280
- } catch (error) {
281
- throw new WiringError(`ci-wiring: cannot parse ${path}: ${String(error)}`);
282
- }
323
+ function omittedFrom(lintGates: readonly KitGate[]): readonly KitGate[] {
324
+ return KIT_GATES.filter((gate) => !lintGates.includes(gate));
283
325
  }
284
326
 
285
- export function readDeclaration(root: string): Declaration {
286
- const path = join(root, "package.json");
287
- let manifest: unknown;
288
- try {
289
- manifest = JSON.parse(readFileSync(path, "utf8"));
290
- } catch {
291
- throw new WiringError(`ci-wiring: cannot read ${path} as JSON`);
327
+ export const findOmissions = Effect.fn("findOmissions")(function* (root: string, lintGates: readonly KitGate[]) {
328
+ const omissions: Omission[] = [];
329
+ for (const gate of omittedFrom(lintGates)) {
330
+ // LintGates refuses a selection that leaves out a gate every repository runs.
331
+ if (gate.appliesTo === EVERY_REPOSITORY) continue;
332
+ const { pathspecs, content } = gate.appliesTo;
333
+ const [first, ...rest] = (yield* git(["ls-files", "-z", "--", ...pathspecs], root)).split("\0").filter(Boolean);
334
+ if (first !== undefined) omissions.push({ gate: gate.bin, content, files: [first, ...rest] });
292
335
  }
293
- return parseDeclaration(manifest, path);
294
- }
336
+ return omissions;
337
+ });
295
338
 
296
- export function readWorkflows(root: string): readonly Workflow[] {
297
- const directory = join(root, WORKFLOWS);
298
- if (!existsSync(directory)) return [];
299
- return readdirSync(directory)
300
- .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))
301
- .sort()
302
- .map((name) => parseWorkflow(`${WORKFLOWS}/${name}`, readFileSync(join(directory, name), "utf8")));
339
+ function sample([first, ...rest]: Omission["files"]): string {
340
+ return rest.length === 0 ? first : `${first} and ${rest.length} more`;
303
341
  }
304
342
 
305
- function repositoryRoot(): string {
306
- const result = Bun.spawnSync(["git", "rev-parse", "--show-toplevel"], { stdout: "pipe", stderr: "pipe" });
307
- if (!result.success) {
308
- throw new WiringError(`ci-wiring: git rev-parse --show-toplevel: ${result.stderr.toString().trim()}`);
343
+ export function formatOmissions(lintGates: readonly KitGate[], omissions: readonly Omission[]): string | undefined {
344
+ const omitted = omittedFrom(lintGates);
345
+ if (omitted.length === 0) return undefined;
346
+ if (omissions.length === 0) {
347
+ const bins = omitted.map((gate) => gate.bin).join(", ");
348
+ return `ci-wiring: ciWiring.lintGates leaves out ${bins}, none of which this repository's contents make applicable`;
309
349
  }
310
- return result.stdout.toString().trim();
350
+ return [
351
+ `ci-wiring: ciWiring.lintGates leaves out ${omissions.length} gate(s) this repository's contents make applicable:`,
352
+ ...omissions.map(({ gate, content, files }) => ` ${gate}: the repository tracks ${content} (${sample(files)})`),
353
+ ].join("\n");
311
354
  }
312
355
 
313
- if (import.meta.main) {
314
- try {
315
- const root = repositoryRoot();
316
- const declaration = readDeclaration(root);
317
- const gaps = findGaps(declaration, readWorkflows(root));
318
- if (gaps.length > 0) {
319
- console.error(formatReport(declaration, gaps));
320
- process.exit(1);
321
- }
322
- console.log(formatReport(declaration, gaps));
323
- } catch (error) {
324
- console.error(error instanceof WiringError ? error.message : `ci-wiring: ${String(error)}`);
325
- process.exit(2);
356
+ export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
357
+ Effect.try({
358
+ try: () => ({ path, document: Bun.YAML.parse(text) }),
359
+ catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
360
+ });
361
+
362
+ const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
363
+
364
+ export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
365
+ const fs = yield* FileSystem.FileSystem;
366
+ const path = (yield* Path.Path).join(root, "package.json");
367
+ const manifest = yield* fs.readFileString(path).pipe(
368
+ Effect.flatMap(parseJson),
369
+ Effect.mapError((cause) => new WiringError({ message: `cannot read ${path} as JSON: ${cause.message}` })),
370
+ );
371
+ return yield* parseDeclaration(manifest, path);
372
+ });
373
+
374
+ export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string) {
375
+ const fs = yield* FileSystem.FileSystem;
376
+ const path = yield* Path.Path;
377
+ const directory = path.join(root, WORKFLOWS);
378
+ if (!(yield* fs.exists(directory))) return [];
379
+ const files = (yield* fs.readDirectory(directory))
380
+ .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))
381
+ .sort();
382
+ return yield* Effect.forEach(files, (name) =>
383
+ fs
384
+ .readFileString(path.join(directory, name))
385
+ .pipe(Effect.flatMap((text) => parseWorkflow(`${WORKFLOWS}/${name}`, text))),
386
+ );
387
+ });
388
+
389
+ const wiring = Effect.gen(function* () {
390
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
391
+ const declaration = yield* readDeclaration(root);
392
+ const gaps = findGaps(declaration, yield* readWorkflows(root));
393
+ const omissions = yield* findOmissions(root, declaration.lintGates);
394
+ const gapReport = formatReport(declaration, gaps);
395
+ yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
396
+ const omissionReport = formatOmissions(declaration.lintGates, omissions);
397
+ if (omissionReport !== undefined) {
398
+ yield* omissions.length > 0 ? Console.error(omissionReport) : Console.log(omissionReport);
326
399
  }
327
- }
400
+ return gaps.length === 0 && omissions.length === 0;
401
+ });
402
+
403
+ if (import.meta.main) runMain("ci-wiring", wiring);
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env bun
2
- import { refused, SYNTAXES } from "./comments.ts";
2
+ import { Console, Effect, Option } from "effect";
3
+ import { refused, syntaxOf } from "./comments.ts";
4
+ import { git, parentOrEmptyTree } from "./git.ts";
5
+ import { runMain, Usage } from "./main.ts";
3
6
 
4
7
  export type GateResult = {
5
8
  readonly files: number;
@@ -9,21 +12,8 @@ export type GateResult = {
9
12
 
10
13
  const USAGE = "usage: comment-gate.ts <ref> | <base-ref> <head-ref>";
11
14
 
12
- function die(message: string): never {
13
- console.error(message);
14
- process.exit(2);
15
- }
16
-
17
- function git(root: string, ...args: readonly string[]): string {
18
- const result = Bun.spawnSync(["git", ...args], { cwd: root, stdout: "pipe", stderr: "pipe" });
19
- if (!result.success) {
20
- die(`comment-gate: git ${args.join(" ")}: ${result.stderr.toString().trim()}`);
21
- }
22
- return result.stdout.toString();
23
- }
24
-
25
15
  function readable(path: string): boolean {
26
- return path.slice(path.lastIndexOf(".") + 1) in SYNTAXES;
16
+ return syntaxOf(path) !== undefined;
27
17
  }
28
18
 
29
19
  function newPathOf(line: string): string | undefined {
@@ -68,29 +58,23 @@ export function parseAddedLines(diff: string): Map<string, Set<number>> {
68
58
  return added;
69
59
  }
70
60
 
71
- function show(rev: string, path: string, root: string): string | undefined {
72
- const result = Bun.spawnSync(["git", "show", `${rev}:${path}`], {
73
- cwd: root,
74
- stdout: "pipe",
75
- stderr: "pipe",
76
- });
77
- return result.success ? result.stdout.toString() : undefined;
78
- }
61
+ const show = (rev: string, path: string, root: string) =>
62
+ git(["show", `${rev}:${path}`], root).pipe(Effect.option);
79
63
 
80
- export function runRange(root: string, base: string, head: string): GateResult {
81
- const diff = git(root, "-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--no-prefix", base, head);
64
+ export const runRange = Effect.fn("runRange")(function* (root: string, base: string, head: string) {
65
+ const diff = yield* git(["-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--no-prefix", base, head], root);
82
66
  const added = parseAddedLines(diff);
83
67
  const violations: string[] = [];
84
68
  let addedLines = 0;
85
69
  for (const [path, lines] of added) {
86
70
  addedLines += lines.size;
87
71
  if (!readable(path)) continue;
88
- const source = show(head, path, root);
89
- if (source === undefined) continue;
90
- violations.push(...refused(path, source, lines));
72
+ const source = yield* show(head, path, root);
73
+ if (Option.isNone(source)) continue;
74
+ violations.push(...(yield* refused(path, source.value, lines)));
91
75
  }
92
76
  return { files: added.size, addedLines, violations };
93
- }
77
+ });
94
78
 
95
79
  export function report({ files, addedLines, violations }: GateResult): string {
96
80
  if (violations.length === 0) {
@@ -99,18 +83,16 @@ export function report({ files, addedLines, violations }: GateResult): string {
99
83
  return [`comment-gate: ${violations.length} violation(s):`, ...violations.map((one) => ` ${one}`)].join("\n");
100
84
  }
101
85
 
102
- function parentOf(root: string, rev: string): string {
103
- return git(root, "rev-parse", "--verify", `${rev}^`).trim();
104
- }
105
-
106
- if (import.meta.main) {
86
+ const gate = Effect.gen(function* () {
107
87
  const [first, second, ...extra] = process.argv.slice(2);
108
- if (first === undefined || extra.length > 0) die(USAGE);
88
+ if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
109
89
 
110
- const root = git(process.cwd(), "rev-parse", "--show-toplevel").trim();
111
- const result =
112
- second !== undefined ? runRange(root, first, second) : runRange(root, parentOf(root, first), first);
90
+ const root = (yield* git(["rev-parse", "--show-toplevel"], process.cwd())).trim();
91
+ const base = second === undefined ? yield* parentOrEmptyTree(first, root) : first;
92
+ const result = yield* runRange(root, base, second ?? first);
113
93
 
114
- console.log(report(result));
115
- process.exit(result.violations.length === 0 ? 0 : 1);
116
- }
94
+ yield* Console.log(report(result));
95
+ return result.violations.length === 0;
96
+ });
97
+
98
+ if (import.meta.main) runMain("comment-gate", gate);