@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,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,59 @@
1
+ import { Schema } from "effect";
2
+
3
+ export type Program = {
4
+ readonly bin: string;
5
+ readonly script: string;
6
+ };
7
+
8
+ type TrackedContent = {
9
+ readonly pathspecs: readonly string[];
10
+ readonly content: string;
11
+ };
12
+
13
+ export const EVERY_REPOSITORY = "every repository";
14
+
15
+ export type KitGate = Program & {
16
+ readonly reads: "tree" | "range";
17
+ readonly appliesTo: typeof EVERY_REPOSITORY | TrackedContent;
18
+ };
19
+
20
+ export const ENTRY_POINT: Program = { bin: "checks-lint", script: "lint.ts" };
21
+
22
+ export const DEFAULT_BRANCH = "main";
23
+
24
+ const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
25
+
26
+ export const KIT_GATES = [
27
+ { bin: "checks-lint-coverage", script: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
28
+ { bin: "checks-test-layout", script: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
29
+ { bin: "checks-commit-identity", script: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
30
+ { bin: "checks-comment-gate", script: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
31
+ { bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
32
+ { bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
33
+ ] as const satisfies readonly KitGate[];
34
+
35
+ const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
36
+
37
+ // A selection without ci-wiring would go unchecked under checks-lint, so a gate every repository runs
38
+ // is refused where checks-lint decodes the selection, not by ci-wiring.
39
+ export const LintGates = Schema.Array(Schema.Literals(KIT_GATES.map((gate) => gate.bin))).check(
40
+ Schema.makeFilter((selected) => {
41
+ const missing = UNCONDITIONAL.filter((bin) => !selected.includes(bin));
42
+ if (missing.length === 0) return true;
43
+ const verb = missing.length === 1 ? "applies" : "apply";
44
+ return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
45
+ }),
46
+ );
47
+
48
+ export const LintWiring = Schema.Struct({
49
+ ciWiring: Schema.optionalKey(
50
+ Schema.Struct({
51
+ defaultBranch: Schema.optionalKey(Schema.NonEmptyString),
52
+ lintGates: Schema.optionalKey(LintGates),
53
+ }),
54
+ ),
55
+ });
56
+
57
+ export function selectedGates(lintGates: typeof LintGates.Type | undefined): readonly KitGate[] {
58
+ return lintGates === undefined ? KIT_GATES : KIT_GATES.filter((gate) => lintGates.includes(gate.bin));
59
+ }
package/scripts/git.ts ADDED
@@ -0,0 +1,41 @@
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
+ );
28
+
29
+ function namesAParent(commitObject: string): boolean {
30
+ const [headers = ""] = commitObject.split("\n\n", 1);
31
+ return headers.split("\n").some((header) => header.startsWith("parent "));
32
+ }
33
+
34
+ // A shallow clone's boundary commit reads as parentless to rev-parse and log, and judged against
35
+ // the empty tree it would carry the whole repository; only the commit object still names its parents.
36
+ export const parentOrEmptyTree = Effect.fn("parentOrEmptyTree")(function* (rev: string, cwd?: string) {
37
+ if (!namesAParent(yield* git(["cat-file", "commit", rev], cwd))) {
38
+ return (yield* git(["hash-object", "-t", "tree", "/dev/null"], cwd)).trim();
39
+ }
40
+ return (yield* git(["rev-parse", "--verify", `${rev}^`], cwd)).trim();
41
+ });
@@ -12,15 +12,21 @@ if ! [ -s "$tmp/expected" ]; then
12
12
  fi
13
13
 
14
14
  # Explicit paths bypass ignore files, so only an unscoped walk proves coverage.
15
- oxlint --debug=files 2>/dev/null | grep -E '\.tsx?$' | LC_ALL=C sort > "$tmp/walked" || true
15
+ if ! oxlint --debug=files > "$tmp/walk" 2> "$tmp/walk-error"; then
16
+ echo "lint-coverage: oxlint could not walk the tree:"
17
+ cat "$tmp/walk" "$tmp/walk-error"
18
+ exit 2
19
+ fi
20
+ grep -E '\.tsx?$' "$tmp/walk" | LC_ALL=C sort > "$tmp/walked" || true
16
21
 
17
22
  expected_count="$(wc -l < "$tmp/expected" | tr -d ' ')"
18
23
  walked_count="$(grep -c . "$tmp/walked" || true)"
19
24
 
20
- missing="$(comm -23 "$tmp/expected" "$tmp/walked")"
21
- if [ -n "$missing" ]; then
22
- echo "lint-coverage: oxlint skips ${walked_count}/${expected_count} tracked .ts/.tsx files; missing:"
23
- printf '%s\n' "$missing"
25
+ comm -23 "$tmp/expected" "$tmp/walked" > "$tmp/missing"
26
+ if [ -s "$tmp/missing" ]; then
27
+ missing_count="$(wc -l < "$tmp/missing" | tr -d ' ')"
28
+ echo "lint-coverage: oxlint skips ${missing_count}/${expected_count} tracked .ts/.tsx files; missing:"
29
+ cat "$tmp/missing"
24
30
  exit 1
25
31
  fi
26
32
 
@@ -0,0 +1,179 @@
1
+ #!/usr/bin/env bun
2
+ import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
+ import { DEFAULT_BRANCH, LintWiring, selectedGates, type KitGate } from "./gates.ts";
5
+ import { git } from "./git.ts";
6
+ import { runMain, Usage } from "./main.ts";
7
+
8
+ type Range = {
9
+ readonly refs: readonly [tip: string] | readonly [base: string, head: string];
10
+ readonly source: string;
11
+ };
12
+
13
+ type Outcome = "passed" | "violated" | "undecided";
14
+
15
+ class RangeUnresolved extends Schema.TaggedError<RangeUnresolved>()("RangeUnresolved", {
16
+ message: Schema.String,
17
+ }) {}
18
+
19
+ class GatesUndecided extends Schema.TaggedError<GatesUndecided>()("GatesUndecided", {
20
+ message: Schema.String,
21
+ }) {}
22
+
23
+ class WiringUnreadable extends Schema.TaggedError<WiringUnreadable>()("WiringUnreadable", {
24
+ message: Schema.String,
25
+ }) {}
26
+
27
+ const NAME = "checks-lint";
28
+ const USAGE = "usage: lint.ts [<base-ref> <head-ref>]";
29
+ const PULL_REQUEST_EVENT = "pull_request";
30
+ const SHALLOW_HINT = "a CI checkout needs actions/checkout fetch-depth: 0";
31
+
32
+ const decodePullRequestEvent = Schema.decodeUnknownEffect(
33
+ Schema.fromJsonString(
34
+ Schema.Struct({
35
+ pull_request: Schema.Struct({
36
+ number: Schema.Int,
37
+ base: Schema.Struct({ ref: Schema.String }),
38
+ head: Schema.Struct({ sha: Schema.String }),
39
+ }),
40
+ }),
41
+ ),
42
+ );
43
+
44
+ const pullRequestEnds = Effect.fn("pullRequestEnds")(function* (eventPath: string) {
45
+ const fs = yield* FileSystem.FileSystem;
46
+ const { pull_request: pullRequest } = yield* fs.readFileString(eventPath).pipe(
47
+ Effect.flatMap(decodePullRequestEvent),
48
+ Effect.mapError(
49
+ (cause) => new RangeUnresolved({ message: `cannot read the pull request from ${eventPath}: ${cause.message}` }),
50
+ ),
51
+ );
52
+ return {
53
+ base: `origin/${pullRequest.base.ref}`,
54
+ head: pullRequest.head.sha,
55
+ source: `pull request #${pullRequest.number} into ${pullRequest.base.ref}`,
56
+ };
57
+ });
58
+
59
+ const decodeWiring = Schema.decodeUnknownEffect(Schema.fromJsonString(LintWiring));
60
+
61
+ const readWiring = Effect.gen(function* () {
62
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
63
+ const manifest = (yield* Path.Path).join(root, "package.json");
64
+ const { ciWiring } = yield* (yield* FileSystem.FileSystem).readFileString(manifest).pipe(
65
+ Effect.flatMap(decodeWiring),
66
+ Effect.mapError((cause) => new WiringUnreadable({ message: `cannot read ciWiring from ${manifest}: ${cause.message}` })),
67
+ );
68
+ return { defaultBranch: ciWiring?.defaultBranch ?? DEFAULT_BRANCH, lintGates: ciWiring?.lintGates };
69
+ });
70
+
71
+ const originEnds = (defaultBranch: string) =>
72
+ git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]).pipe(
73
+ Effect.map((ref) => ref.trim()),
74
+ Effect.catchTag("GitFailure", () => Effect.succeed(`origin/${defaultBranch}`)),
75
+ Effect.map((base) => ({ base, head: "HEAD", source: `HEAD against ${base}` })),
76
+ );
77
+
78
+ // A clone holding any remote-tracking ref but not the default branch is a shallow CI checkout,
79
+ // where judging HEAD alone would pass every commit before it unchecked.
80
+ const localEnds = Effect.fn("localEnds")(function* (defaultBranch: string) {
81
+ const remoteTracking = yield* git(["for-each-ref", "--count=1", "refs/remotes/"]).pipe(
82
+ Effect.mapError((cause) => new RangeUnresolved({ message: cause.message })),
83
+ );
84
+ if (remoteTracking.trim() === "") {
85
+ return { base: "HEAD", head: "HEAD", source: "HEAD alone, as the clone has no remote-tracking refs" };
86
+ }
87
+ return yield* originEnds(defaultBranch);
88
+ });
89
+
90
+ const endsOf = Effect.fn("endsOf")(function* (args: readonly string[], defaultBranch: string) {
91
+ const [base, head, ...extra] = args;
92
+ if (base !== undefined && head !== undefined && extra.length === 0) {
93
+ return { base, head, source: `${head} against ${base}` };
94
+ }
95
+ if (base !== undefined) return yield* new Usage({ message: USAGE });
96
+
97
+ const event = yield* Config.all({
98
+ name: Config.option(Config.String("GITHUB_EVENT_NAME")),
99
+ path: Config.option(Config.String("GITHUB_EVENT_PATH")),
100
+ }).pipe(Effect.mapError((cause) => new RangeUnresolved({ message: cause.message })));
101
+ if (Option.isSome(event.name) && event.name.value === PULL_REQUEST_EVENT) {
102
+ if (Option.isNone(event.path)) {
103
+ return yield* new RangeUnresolved({ message: `${PULL_REQUEST_EVENT} sets no GITHUB_EVENT_PATH` });
104
+ }
105
+ return yield* pullRequestEnds(event.path.value);
106
+ }
107
+ return yield* localEnds(defaultBranch);
108
+ });
109
+
110
+ const commitOf = (ref: string) =>
111
+ git(["rev-parse", "--verify", "--end-of-options", `${ref}^{commit}`]).pipe(
112
+ Effect.map((sha) => sha.trim()),
113
+ Effect.mapError(
114
+ (cause) => new RangeUnresolved({ message: `${ref} is not a commit in this clone; ${SHALLOW_HINT}: ${cause.message}` }),
115
+ ),
116
+ );
117
+
118
+ // From the base branch's tip, every range gate would charge the head with the commits the base
119
+ // branch gained after the head branched off.
120
+ const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[], defaultBranch: string) {
121
+ const ends = yield* endsOf(args, defaultBranch);
122
+ const head = yield* commitOf(ends.head);
123
+ const base = yield* git(["merge-base", yield* commitOf(ends.base), head]).pipe(
124
+ Effect.map((sha) => sha.trim()),
125
+ Effect.mapError(
126
+ () => new RangeUnresolved({ message: `${ends.base} and ${ends.head} share no commit in this clone; ${SHALLOW_HINT}` }),
127
+ ),
128
+ );
129
+ return { refs: base === head ? [head] : [base, head], source: ends.source } satisfies Range;
130
+ });
131
+
132
+ function describe({ refs }: Range): string {
133
+ return refs.length === 1 ? `tip ${refs[0]}` : `range ${refs[0]}..${refs[1]}`;
134
+ }
135
+
136
+ function outcomeOf(exitCode: number): Outcome {
137
+ if (exitCode === 0) return "passed";
138
+ return exitCode === 1 ? "violated" : "undecided";
139
+ }
140
+
141
+ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
142
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
143
+ const script = (yield* Path.Path).join(import.meta.dir, gate.script);
144
+ const program = gate.script.endsWith(".sh") ? "sh" : process.execPath;
145
+ const args = gate.reads === "range" ? [script, ...range.refs] : [script];
146
+ const exitCode = yield* spawner
147
+ .exitCode(ChildProcess.make(program, args, { stdin: "ignore", stdout: "inherit", stderr: "inherit" }))
148
+ .pipe(
149
+ Effect.catch((cause) =>
150
+ Console.error(`${NAME}: cannot run ${gate.bin}: ${cause.message}`).pipe(Effect.as(ChildProcessSpawner.ExitCode(2))),
151
+ ),
152
+ );
153
+ return { gate, outcome: outcomeOf(exitCode) };
154
+ });
155
+
156
+ const lint = Effect.gen(function* () {
157
+ const { defaultBranch, lintGates } = yield* readWiring;
158
+ const range = yield* resolveRange(process.argv.slice(2), defaultBranch);
159
+ yield* Console.log(`${NAME}: ${describe(range)} from ${range.source}`);
160
+ const gates = selectedGates(lintGates);
161
+ if (lintGates !== undefined) {
162
+ yield* Console.log(`${NAME}: ciWiring.lintGates selects ${gates.map((gate) => gate.bin).join(", ")}`);
163
+ }
164
+
165
+ const verdicts = yield* Effect.forEach(gates, (gate) => runGate(gate, range));
166
+ const failed = verdicts.filter((verdict) => verdict.outcome !== "passed");
167
+ if (failed.length === 0) {
168
+ yield* Console.log(`${NAME}: ${gates.length} gate(s) pass`);
169
+ return true;
170
+ }
171
+ const summary = `${failed.length} of ${gates.length} gate(s) failed: ${failed.map((verdict) => verdict.gate.bin).join(", ")}`;
172
+ if (failed.every((verdict) => verdict.outcome === "undecided")) {
173
+ return yield* new GatesUndecided({ message: summary });
174
+ }
175
+ yield* Console.error(`${NAME}: ${summary}`);
176
+ return false;
177
+ });
178
+
179
+ if (import.meta.main) runMain(NAME, lint);
@@ -0,0 +1,28 @@
1
+ import { BunRuntime, BunServices } from "@effect/platform-bun";
2
+ import { Cause, Console, Effect, Exit, Schema } from "effect";
3
+
4
+ const PASSED = 0;
5
+ const VIOLATED = 1;
6
+ const UNDECIDED = 2;
7
+ const INTERRUPTED = 130;
8
+
9
+ export class Usage extends Schema.TaggedError<Usage>()("Usage", {
10
+ message: Schema.String,
11
+ }) {}
12
+
13
+ function statusOf<A, E>(exit: Exit.Exit<A, E>): number {
14
+ if (Exit.isSuccess(exit)) return typeof exit.value === "number" ? exit.value : UNDECIDED;
15
+ return Cause.hasInterruptsOnly(exit.cause) ? INTERRUPTED : UNDECIDED;
16
+ }
17
+
18
+ export function runMain<E extends { readonly message: string }>(
19
+ name: string,
20
+ passes: Effect.Effect<boolean, E, BunServices.BunServices>,
21
+ ): void {
22
+ const program = passes.pipe(
23
+ Effect.map((passed) => (passed ? PASSED : VIOLATED)),
24
+ Effect.catch((failure) => Console.error(`${name}: ${failure.message}`).pipe(Effect.as(UNDECIDED))),
25
+ Effect.provide(BunServices.layer),
26
+ );
27
+ BunRuntime.runMain(program, { teardown: (exit, onExit) => onExit(statusOf(exit)) });
28
+ }