@avi2dg/checks 0.6.0 → 0.8.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,6 +1,8 @@
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 { DEFAULT_BRANCH, ENTRY_POINT, KIT_GATES } from "./gates.ts";
4
6
 
5
7
  export type Command = readonly string[];
6
8
 
@@ -26,6 +28,7 @@ export type BlockedInvocation = {
26
28
 
27
29
  export type Gap = {
28
30
  readonly gate: string;
31
+ readonly entryPoint?: string;
29
32
  readonly blocked: readonly BlockedInvocation[];
30
33
  };
31
34
 
@@ -35,10 +38,11 @@ type RunStep = {
35
38
  readonly script: string;
36
39
  };
37
40
 
38
- export class WiringError extends Error {}
41
+ export class WiringError extends Schema.TaggedError<WiringError>()("WiringError", {
42
+ message: Schema.String,
43
+ }) {}
39
44
 
40
45
  const WORKFLOWS = ".github/workflows";
41
- const DEFAULT_BRANCH = "main";
42
46
  // Without these a pull_request workflow never sees the commits a pull request pushes.
43
47
  const GATING_TYPES = ["opened", "synchronize"];
44
48
  const CONSTANTS = new Map([
@@ -226,19 +230,32 @@ function runSteps(workflows: readonly Workflow[], branch: string): readonly RunS
226
230
  return steps;
227
231
  }
228
232
 
233
+ function entryPointCommand(gate: Command): Command | undefined {
234
+ const index = gate.findIndex((word) => KIT_GATES.some((kitGate) => kitGate.bin === word));
235
+ return index === -1 ? undefined : [...gate.slice(0, index), ENTRY_POINT.bin];
236
+ }
237
+
229
238
  export function findGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
230
239
  const steps = runSteps(workflows, declaration.defaultBranch);
231
240
  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`;
241
+ const entryPoint = entryPointCommand(gate.words);
242
+ const commands = [
243
+ { command: gate.command, words: gate.words },
244
+ ...(entryPoint === undefined ? [] : [{ command: entryPoint.join(" "), words: entryPoint }]),
245
+ ];
233
246
  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 }] : [];
247
+ const plain = plainCommand(script);
248
+ if (commands.some(({ words }) => invokes(plain, words))) return [{ location, blocker }];
249
+ const mentioned = commands.find(({ words }) => mentions(script, words));
250
+ if (mentioned === undefined) return [];
251
+ const alone = `the step runs more than ${mentioned.command}; give it its own step with nothing else in it`;
252
+ return [{ location, blocker: blocker ?? alone }];
236
253
  });
237
254
  if (invoking.some((step) => step.blocker === undefined)) return [];
238
255
  const blocked = invoking.flatMap(({ location, blocker }) =>
239
256
  blocker === undefined ? [] : [{ location, blocker }],
240
257
  );
241
- return [{ gate: gate.command, blocked }];
258
+ return [{ gate: gate.command, entryPoint: entryPoint?.join(" "), blocked }];
242
259
  });
243
260
  }
244
261
 
@@ -248,80 +265,84 @@ export function formatReport(declaration: Declaration, gaps: readonly Gap[]): st
248
265
  const lines = [`ci-wiring: ${gaps.length} of ${declaration.gates.length} gate(s) do not run on ${target}:`];
249
266
  for (const gap of gaps) {
250
267
  lines.push(` ${gap.gate}`);
251
- if (gap.blocked.length === 0) lines.push(" no run step invokes it");
268
+ if (gap.blocked.length === 0) {
269
+ lines.push(gap.entryPoint === undefined ? " no run step invokes it" : ` no run step invokes it or ${gap.entryPoint}`);
270
+ }
252
271
  for (const { location, blocker } of gap.blocked) lines.push(` ${location}: ${blocker}`);
253
272
  }
254
273
  return lines.join("\n");
255
274
  }
256
275
 
257
- export function parseDeclaration(manifest: unknown, source: string): Declaration {
276
+ export const parseDeclaration = Effect.fnUntraced(function* (
277
+ manifest: unknown,
278
+ source: string,
279
+ ): Effect.fn.Return<Declaration, WiringError> {
258
280
  const configured = isRecord(manifest) && isRecord(manifest["ciWiring"]) ? manifest["ciWiring"] : {};
259
- const listed = configured["gates"];
281
+ const listed: unknown = configured["gates"];
260
282
  if (!Array.isArray(listed) || listed.length === 0) {
261
- throw new WiringError(`ci-wiring: ${source} sets no ciWiring.gates, a non-empty array of commands`);
283
+ return yield* new WiringError({ message: `${source} sets no ciWiring.gates, a non-empty array of commands` });
262
284
  }
263
- const gates = listed.map((command: unknown): Gate => {
285
+ const commands: readonly unknown[] = listed;
286
+ const gates: Gate[] = [];
287
+ for (const command of commands) {
264
288
  const words = typeof command === "string" ? plainCommand(command) : undefined;
265
289
  if (typeof command !== "string" || words === undefined) {
266
- throw new WiringError(`ci-wiring: ${source} ciWiring gate ${JSON.stringify(command)} is not one plain command`);
290
+ return yield* new WiringError({
291
+ message: `${source} ciWiring gate ${JSON.stringify(command)} is not one plain command`,
292
+ });
267
293
  }
268
- return { command, words };
269
- });
294
+ gates.push({ command, words });
295
+ }
270
296
  const defaultBranch = configured["defaultBranch"] ?? DEFAULT_BRANCH;
271
297
  if (typeof defaultBranch !== "string" || defaultBranch === "") {
272
- throw new WiringError(`ci-wiring: ${source} ciWiring.defaultBranch is not a branch name`);
298
+ return yield* new WiringError({ message: `${source} ciWiring.defaultBranch is not a branch name` });
273
299
  }
274
300
  return { gates, defaultBranch };
275
- }
276
-
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
- }
283
- }
301
+ });
284
302
 
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`);
292
- }
293
- return parseDeclaration(manifest, path);
294
- }
303
+ export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
304
+ Effect.try({
305
+ try: () => ({ path, document: Bun.YAML.parse(text) }),
306
+ catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
307
+ });
295
308
 
296
- export function readWorkflows(root: string): readonly Workflow[] {
297
- const directory = join(root, WORKFLOWS);
298
- if (!existsSync(directory)) return [];
299
- return readdirSync(directory)
309
+ const parseJson = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Unknown));
310
+
311
+ export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
312
+ const fs = yield* FileSystem.FileSystem;
313
+ const path = (yield* Path.Path).join(root, "package.json");
314
+ const manifest = yield* fs.readFileString(path).pipe(
315
+ Effect.flatMap(parseJson),
316
+ Effect.mapError((cause) => new WiringError({ message: `cannot read ${path} as JSON: ${cause.message}` })),
317
+ );
318
+ return yield* parseDeclaration(manifest, path);
319
+ });
320
+
321
+ export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string) {
322
+ const fs = yield* FileSystem.FileSystem;
323
+ const path = yield* Path.Path;
324
+ const directory = path.join(root, WORKFLOWS);
325
+ if (!(yield* fs.exists(directory))) return [];
326
+ const files = (yield* fs.readDirectory(directory))
300
327
  .filter((name) => name.endsWith(".yml") || name.endsWith(".yaml"))
301
- .sort()
302
- .map((name) => parseWorkflow(`${WORKFLOWS}/${name}`, readFileSync(join(directory, name), "utf8")));
303
- }
304
-
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()}`);
328
+ .sort();
329
+ return yield* Effect.forEach(files, (name) =>
330
+ fs
331
+ .readFileString(path.join(directory, name))
332
+ .pipe(Effect.flatMap((text) => parseWorkflow(`${WORKFLOWS}/${name}`, text))),
333
+ );
334
+ });
335
+
336
+ const wiring = Effect.gen(function* () {
337
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
338
+ const declaration = yield* readDeclaration(root);
339
+ const gaps = findGaps(declaration, yield* readWorkflows(root));
340
+ if (gaps.length > 0) {
341
+ yield* Console.error(formatReport(declaration, gaps));
342
+ return false;
309
343
  }
310
- return result.stdout.toString().trim();
311
- }
344
+ yield* Console.log(formatReport(declaration, gaps));
345
+ return true;
346
+ });
312
347
 
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);
326
- }
327
- }
348
+ if (import.meta.main) runMain("ci-wiring", wiring);
@@ -1,5 +1,8 @@
1
1
  #!/usr/bin/env bun
2
+ import { Console, Effect, Option } from "effect";
2
3
  import { refused, SYNTAXES } from "./comments.ts";
4
+ import { git } from "./git.ts";
5
+ import { runMain, Usage } from "./main.ts";
3
6
 
4
7
  export type GateResult = {
5
8
  readonly files: number;
@@ -9,19 +12,6 @@ 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
16
  return path.slice(path.lastIndexOf(".") + 1) in SYNTAXES;
27
17
  }
@@ -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,19 @@ 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
- }
86
+ const parentOf = (root: string, rev: string) =>
87
+ git(["rev-parse", "--verify", `${rev}^`], root).pipe(Effect.map((parent) => parent.trim()));
105
88
 
106
- if (import.meta.main) {
89
+ const gate = Effect.gen(function* () {
107
90
  const [first, second, ...extra] = process.argv.slice(2);
108
- if (first === undefined || extra.length > 0) die(USAGE);
91
+ if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
109
92
 
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);
93
+ const root = (yield* git(["rev-parse", "--show-toplevel"], process.cwd())).trim();
94
+ const base = second === undefined ? yield* parentOf(root, first) : first;
95
+ const result = yield* runRange(root, base, second ?? first);
113
96
 
114
- console.log(report(result));
115
- process.exit(result.violations.length === 0 ? 0 : 1);
116
- }
97
+ yield* Console.log(report(result));
98
+ return result.violations.length === 0;
99
+ });
100
+
101
+ if (import.meta.main) runMain("comment-gate", gate);
@@ -1,3 +1,5 @@
1
+ import { Effect, Schema } from "effect";
2
+
1
3
  export type Comment = { line: number; text: string };
2
4
 
3
5
  type Marker = { readonly token: string; readonly afterAWordBreak: boolean };
@@ -80,15 +82,20 @@ export const SYNTAXES: Readonly<Record<string, Syntax>> = {
80
82
  ml: ML,
81
83
  };
82
84
 
83
- export function syntaxFor(path: string): Syntax {
85
+ export class UnreadableCode extends Schema.TaggedError<UnreadableCode>()("UnreadableCode", {
86
+ message: Schema.String,
87
+ }) {}
88
+
89
+ export function syntaxFor(path: string): Effect.Effect<Syntax, UnreadableCode> {
84
90
  const extension = path.slice(path.lastIndexOf(".") + 1);
85
91
  const syntax = SYNTAXES[extension];
86
- if (!syntax) {
87
- throw new Error(
88
- `${path} is code the comment checks cannot read: add a comment syntax for .${extension} to scripts/comments.ts`,
89
- );
90
- }
91
- return syntax;
92
+ return syntax
93
+ ? Effect.succeed(syntax)
94
+ : Effect.fail(
95
+ new UnreadableCode({
96
+ message: `${path} is code the comment checks cannot read: add a comment syntax for .${extension} to scripts/comments.ts`,
97
+ }),
98
+ );
92
99
  }
93
100
 
94
101
  const WORD = /[\w$]/;
@@ -147,8 +154,8 @@ function endOfRegex(source: string, start: number): number {
147
154
  return source.length;
148
155
  }
149
156
 
150
- export function comments(path: string, source: string): Comment[] {
151
- const syntax = syntaxFor(path);
157
+ export const comments = Effect.fn("comments")(function* (path: string, source: string) {
158
+ const syntax = yield* syntaxFor(path);
152
159
  const found: Comment[] = [];
153
160
  let i = 0;
154
161
  let line = 1;
@@ -215,7 +222,7 @@ export function comments(path: string, source: string): Comment[] {
215
222
  }
216
223
 
217
224
  return found;
218
- }
225
+ });
219
226
 
220
227
  const LICENCE = /copyright|spdx-license-identifier|all rights reserved|licen[cs]ed under|permission is hereby granted/i;
221
228
 
@@ -268,9 +275,13 @@ function spans(from: number, lineCount: number, within: ReadonlySet<number> | un
268
275
  return false;
269
276
  }
270
277
 
271
- export function refused(path: string, source: string, within?: ReadonlySet<number>): string[] {
278
+ export const refused = Effect.fn("refused")(function* (
279
+ path: string,
280
+ source: string,
281
+ within?: ReadonlySet<number>,
282
+ ) {
272
283
  const out: string[] = [];
273
- const found = comments(path, source);
284
+ const found = yield* comments(path, source);
274
285
 
275
286
  const opening = openingBlock(found, source);
276
287
  const openingText = opening.map((comment) => comment.text).join("\n");
@@ -295,4 +306,4 @@ export function refused(path: string, source: string, within?: ReadonlySet<numbe
295
306
  }
296
307
  }
297
308
  return out;
298
- }
309
+ });
@@ -1,7 +1,10 @@
1
1
  #!/usr/bin/env bun
2
- import { existsSync, readFileSync } from "node:fs";
2
+ import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
+ import { git } from "./git.ts";
4
+ import { runMain, Usage } from "./main.ts";
3
5
 
4
- type Identity = { readonly name: string; readonly email: string };
6
+ const Identity = Schema.Struct({ name: Schema.String, email: Schema.String });
7
+ type Identity = typeof Identity.Type;
5
8
 
6
9
  type Commit = {
7
10
  readonly sha: string;
@@ -29,80 +32,77 @@ const RECORD = "\u001e";
29
32
 
30
33
  const USAGE = "usage: commit-identity.ts <ref> | <base-ref> <head-ref>";
31
34
 
32
- function die(message: string): never {
33
- console.error(message);
34
- process.exit(2);
35
- }
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);
36
40
 
37
- function git(...args: readonly string[]): string {
38
- const result = Bun.spawnSync(["git", ...args], { stdout: "pipe", stderr: "pipe" });
39
- if (!result.success) {
40
- die(`commit-identity: git ${args.join(" ")}: ${result.stderr.toString().trim()}`);
41
- }
42
- return result.stdout.toString();
43
- }
41
+ class InvalidAllowlist extends Schema.TaggedError<InvalidAllowlist>()("InvalidAllowlist", {
42
+ message: Schema.String,
43
+ }) {}
44
+
45
+ class UnreadableLog extends Schema.TaggedError<UnreadableLog>()("UnreadableLog", {
46
+ message: Schema.String,
47
+ }) {}
44
48
 
45
49
  function render(identity: Identity): string {
46
50
  return `${identity.name} <${identity.email}>`;
47
51
  }
48
52
 
49
- function isIdentity(value: unknown): value is Identity {
50
- if (typeof value !== "object" || value === null) return false;
51
- const candidate = value as { name?: unknown; email?: unknown };
52
- return typeof candidate.name === "string" && typeof candidate.email === "string";
53
- }
53
+ 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;
54
59
 
55
- function allowedAuthors(): readonly Identity[] {
56
- const root = git("rev-parse", "--show-toplevel").trim();
57
- const manifestPath = `${root}/package.json`;
58
- if (!existsSync(manifestPath)) return DEFAULT_AUTHORS;
59
-
60
- const parsed: unknown = JSON.parse(readFileSync(manifestPath, "utf8"));
61
- const configured = (parsed as { commitIdentity?: unknown }).commitIdentity;
62
- if (configured === undefined) return DEFAULT_AUTHORS;
63
-
64
- const authors = (configured as { authors?: unknown }).authors;
65
- if (!Array.isArray(authors) || authors.length === 0 || !authors.every(isIdentity)) {
66
- die(
67
- `commit-identity: ${manifestPath} sets commitIdentity without a non-empty authors array of {name, email}`,
68
- );
69
- }
70
- return authors;
71
- }
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;
73
+ });
72
74
 
73
- function readCommits(revisions: readonly string[]): readonly Commit[] {
75
+ const readCommits = Effect.fn("readCommits")(function* (revisions: readonly string[]) {
74
76
  const format =
75
77
  ["%H", "%an", "%ae", "%cn", "%ce", "%s", CO_AUTHORED_BY_FORMAT].join(FIELD_FORMAT) +
76
78
  RECORD_FORMAT;
77
- const log = git("log", `--format=${format}`, ...revisions);
78
-
79
- return log
80
- .split(RECORD)
81
- .map((record) => record.replace(/^\n/, ""))
82
- .filter((record) => record !== "")
83
- .map((record) => {
84
- const [sha, authorName, authorEmail, committerName, committerEmail, subject, trailers] =
85
- record.split(FIELD);
86
- if (
87
- sha === undefined ||
88
- authorName === undefined ||
89
- authorEmail === undefined ||
90
- committerName === undefined ||
91
- committerEmail === undefined ||
92
- subject === undefined ||
93
- trailers === undefined
94
- ) {
95
- return die(`commit-identity: cannot parse git log record: ${JSON.stringify(record)}`);
96
- }
97
- return {
98
- sha,
99
- subject,
100
- author: { name: authorName, email: authorEmail },
101
- committer: { name: committerName, email: committerEmail },
102
- coAuthoredBy: trailers.split("\n").filter((line) => line !== ""),
103
- };
79
+ const log = yield* git(["log", `--format=${format}`, ...revisions]);
80
+
81
+ const commits: Commit[] = [];
82
+ for (const record of log.split(RECORD).map((one) => one.replace(/^\n/, ""))) {
83
+ if (record === "") continue;
84
+ const [sha, authorName, authorEmail, committerName, committerEmail, subject, trailers] = record.split(FIELD);
85
+ if (
86
+ sha === undefined ||
87
+ authorName === undefined ||
88
+ authorEmail === undefined ||
89
+ committerName === undefined ||
90
+ committerEmail === undefined ||
91
+ subject === undefined ||
92
+ trailers === undefined
93
+ ) {
94
+ return yield* new UnreadableLog({ message: `cannot parse git log record: ${JSON.stringify(record)}` });
95
+ }
96
+ commits.push({
97
+ sha,
98
+ subject,
99
+ author: { name: authorName, email: authorEmail },
100
+ committer: { name: committerName, email: committerEmail },
101
+ coAuthoredBy: trailers.split("\n").filter((line) => line !== ""),
104
102
  });
105
- }
103
+ }
104
+ return commits;
105
+ });
106
106
 
107
107
  function allows(allowed: readonly Identity[], identity: Identity): boolean {
108
108
  return allowed.some(
@@ -126,28 +126,32 @@ function inspect(commit: Commit, allowed: readonly Identity[]): Offence | undefi
126
126
  return reasons.length === 0 ? undefined : { commit, reasons };
127
127
  }
128
128
 
129
- const [first, second, ...extra] = process.argv.slice(2);
130
- if (first === undefined || extra.length > 0) die(USAGE);
131
- const range = second === undefined ? first : `${first}..${second}`;
132
- const revisions = second === undefined ? ["-1", range] : [range];
133
-
134
- const allowed = allowedAuthors();
135
- const commits = readCommits(revisions);
136
- const offences = commits
137
- .map((commit) => inspect(commit, allowed))
138
- .filter((offence) => offence !== undefined);
139
-
140
- if (offences.length > 0) {
141
- console.error(
142
- `commit-identity: ${offences.length} of ${commits.length} commit(s) in ${range} carry a foreign identity:`,
143
- );
144
- for (const { commit, reasons } of offences) {
145
- console.error(` ${commit.sha.slice(0, 12)} ${commit.subject}`);
146
- for (const reason of reasons) console.error(` ${reason}`);
129
+ const check = Effect.gen(function* () {
130
+ const [first, second, ...extra] = process.argv.slice(2);
131
+ if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
132
+ const range = second === undefined ? first : `${first}..${second}`;
133
+ const revisions = second === undefined ? ["-1", range] : [range];
134
+
135
+ const allowed = yield* allowedAuthors;
136
+ const commits = yield* readCommits(revisions);
137
+ const offences = commits
138
+ .map((commit) => inspect(commit, allowed))
139
+ .filter((offence) => offence !== undefined);
140
+
141
+ if (offences.length > 0) {
142
+ const lines = [`commit-identity: ${offences.length} of ${commits.length} commit(s) in ${range} carry a foreign identity:`];
143
+ for (const { commit, reasons } of offences) {
144
+ lines.push(` ${commit.sha.slice(0, 12)} ${commit.subject}`);
145
+ for (const reason of reasons) lines.push(` ${reason}`);
146
+ }
147
+ lines.push(` allowed: ${allowed.map(render).join(", ")}`);
148
+ lines.push(` allowed as committer only: ${render(SQUASH_COMMITTER)}`);
149
+ yield* Console.error(lines.join("\n"));
150
+ return false;
147
151
  }
148
- console.error(` allowed: ${allowed.map(render).join(", ")}`);
149
- console.error(` allowed as committer only: ${render(SQUASH_COMMITTER)}`);
150
- process.exit(1);
151
- }
152
152
 
153
- console.log(`commit-identity: ${commits.length} commit(s) in ${range} carry only allowed identities`);
153
+ yield* Console.log(`commit-identity: ${commits.length} commit(s) in ${range} carry only allowed identities`);
154
+ return true;
155
+ });
156
+
157
+ runMain("commit-identity", check);
@@ -0,0 +1,21 @@
1
+ export type Program = {
2
+ readonly bin: string;
3
+ readonly script: string;
4
+ };
5
+
6
+ export type KitGate = Program & {
7
+ readonly reads: "tree" | "range";
8
+ };
9
+
10
+ export const ENTRY_POINT: Program = { bin: "checks-lint", script: "lint.ts" };
11
+
12
+ export const DEFAULT_BRANCH = "main";
13
+
14
+ export const KIT_GATES: readonly KitGate[] = [
15
+ { bin: "checks-lint-coverage", script: "lint-coverage.sh", reads: "tree" },
16
+ { bin: "checks-test-layout", script: "test-layout.ts", reads: "tree" },
17
+ { bin: "checks-commit-identity", script: "commit-identity.ts", reads: "range" },
18
+ { bin: "checks-comment-gate", script: "comment-gate.ts", reads: "range" },
19
+ { bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range" },
20
+ { bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree" },
21
+ ];
package/scripts/git.ts ADDED
@@ -0,0 +1,27 @@
1
+ import { Effect, Schema, Stream } from "effect";
2
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
3
+
4
+ export class GitFailure extends Schema.TaggedError<GitFailure>()("GitFailure", {
5
+ message: Schema.String,
6
+ }) {}
7
+
8
+ const text = <E>(bytes: Stream.Stream<Uint8Array, E>): Effect.Effect<string, E> =>
9
+ bytes.pipe(Stream.decodeText(), Stream.mkString);
10
+
11
+ // Both pipes drain while git runs: one left unread fills its buffer and stalls git.
12
+ export const git = Effect.fn("git")(
13
+ function* (args: readonly string[], cwd?: string) {
14
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
15
+ const command = `git ${args.join(" ")}`;
16
+ const failed = (stderr: string): GitFailure => new GitFailure({ message: `${command}: ${stderr.trim()}` });
17
+ const handle = yield* spawner
18
+ .spawn(ChildProcess.make("git", args, { cwd }))
19
+ .pipe(Effect.mapError((cause) => failed(cause.message)));
20
+ const [stdout, stderr, exitCode] = yield* Effect.all([text(handle.stdout), text(handle.stderr), handle.exitCode], {
21
+ concurrency: "unbounded",
22
+ }).pipe(Effect.mapError((cause) => failed(cause.message)));
23
+ if (exitCode !== ChildProcessSpawner.ExitCode(0)) return yield* failed(stderr);
24
+ return stdout;
25
+ },
26
+ Effect.scoped,
27
+ );