@avi2dg/checks 0.8.0 → 0.10.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,309 +1,21 @@
1
1
  import { Effect, Schema } from "effect";
2
+ import { commentsIn, refusalsIn, syntaxOf, unreadable, type Syntax } from "./comment-matchers.ts";
2
3
 
3
- export type Comment = { line: number; text: string };
4
-
5
- type Marker = { readonly token: string; readonly afterAWordBreak: boolean };
6
- type Quote = { readonly token: string; readonly backslashEscapes: boolean; readonly opensMidWord: boolean };
7
-
8
- type Syntax = {
9
- readonly line: readonly Marker[];
10
- readonly block: readonly (readonly [open: string, close: string])[];
11
- readonly quotes: readonly Quote[];
12
- readonly regexLiterals: boolean;
13
- };
14
-
15
- const anywhere = (token: string): Marker => ({ token, afterAWordBreak: false });
16
- const startingAWord = (token: string): Marker => ({ token, afterAWordBreak: true });
17
- const escaping = (token: string): Quote => ({ token, backslashEscapes: true, opensMidWord: true });
18
- const literal = (token: string): Quote => ({ token, backslashEscapes: false, opensMidWord: true });
19
- const outsideAWord = (quote: Quote): Quote => ({ ...quote, opensMidWord: false });
20
-
21
- const SLASHES: readonly (readonly [string, string])[] = [["/*", "*/"]];
22
- const SLASH_SLASH = [startingAWord("//")];
23
- const DASHES = [anywhere("--")];
24
- const STRINGS = [escaping('"'), escaping("'")];
25
-
26
- const JS: Syntax = {
27
- line: SLASH_SLASH,
28
- block: SLASHES,
29
- quotes: [escaping('"'), outsideAWord(escaping("'")), escaping("`")],
30
- regexLiterals: true,
31
- };
32
- const CURLY: Syntax = { ...JS, quotes: [...STRINGS, escaping("`")], regexLiterals: false };
33
- const PHP: Syntax = { line: [...SLASH_SLASH, anywhere("#")], block: SLASHES, quotes: STRINGS, regexLiterals: false };
34
- const HASH: Syntax = {
35
- line: [startingAWord("#")],
36
- block: [],
37
- quotes: [escaping('"'), literal("'")],
38
- regexLiterals: false,
39
- };
40
- const YAML: Syntax = { ...HASH, quotes: [escaping('"'), outsideAWord(literal("'"))] };
41
- const SQL: Syntax = { line: DASHES, block: SLASHES, quotes: STRINGS, regexLiterals: false };
42
- const LUA: Syntax = { line: DASHES, block: [["--[[", "]]"]], quotes: STRINGS, regexLiterals: false };
43
- const HASKELL: Syntax = { line: DASHES, block: [["{-", "-}"]], quotes: [escaping('"')], regexLiterals: false };
44
- const ML: Syntax = { line: [], block: [["(*", "*)"]], quotes: [escaping('"')], regexLiterals: false };
45
-
46
- export const SYNTAXES: Readonly<Record<string, Syntax>> = {
47
- ts: JS,
48
- tsx: JS,
49
- mts: JS,
50
- cts: JS,
51
- js: JS,
52
- jsx: JS,
53
- mjs: JS,
54
- cjs: JS,
55
- go: CURLY,
56
- rs: CURLY,
57
- java: CURLY,
58
- kt: CURLY,
59
- swift: CURLY,
60
- scala: CURLY,
61
- c: CURLY,
62
- h: CURLY,
63
- cc: CURLY,
64
- cpp: CURLY,
65
- hpp: CURLY,
66
- cs: CURLY,
67
- php: PHP,
68
- py: HASH,
69
- rb: HASH,
70
- ex: HASH,
71
- exs: HASH,
72
- sh: HASH,
73
- bash: HASH,
74
- zsh: HASH,
75
- nix: HASH,
76
- sql: SQL,
77
- lua: LUA,
78
- hs: HASKELL,
79
- yaml: YAML,
80
- yml: YAML,
81
- toml: HASH,
82
- ml: ML,
83
- };
4
+ export { SYNTAXES, syntaxOf, type Comment } from "./comment-matchers.ts";
84
5
 
85
6
  export class UnreadableCode extends Schema.TaggedError<UnreadableCode>()("UnreadableCode", {
86
7
  message: Schema.String,
87
8
  }) {}
88
9
 
89
10
  export function syntaxFor(path: string): Effect.Effect<Syntax, UnreadableCode> {
90
- const extension = path.slice(path.lastIndexOf(".") + 1);
91
- const syntax = SYNTAXES[extension];
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
- );
99
- }
100
-
101
- const WORD = /[\w$]/;
102
-
103
- const KEYWORDS = new Set([
104
- "return",
105
- "typeof",
106
- "instanceof",
107
- "in",
108
- "of",
109
- "new",
110
- "delete",
111
- "void",
112
- "throw",
113
- "case",
114
- "do",
115
- "else",
116
- "yield",
117
- "await",
118
- ]);
119
-
120
- const AFTER_A_VALUE = /[)\]}"'`]/;
121
-
122
- function opensRegex(previous: string): boolean {
123
- if (previous === "") return true;
124
- if (WORD.test(previous[0]!)) return KEYWORDS.has(previous);
125
- return !AFTER_A_VALUE.test(previous);
126
- }
127
-
128
- const WORD_BREAK = /[\s;|&(]/;
129
-
130
- function endOfQuoted(source: string, start: number, quote: Quote): number {
131
- let i = start + quote.token.length;
132
- while (i < source.length) {
133
- if (quote.backslashEscapes && source[i] === "\\") i += 2;
134
- else if (source.startsWith(quote.token, i)) return i + quote.token.length;
135
- else i += 1;
136
- }
137
- return start + quote.token.length;
138
- }
139
-
140
- function endOfRegex(source: string, start: number): number {
141
- let i = start + 1;
142
- let inClass = false;
143
- while (i < source.length) {
144
- const char = source[i]!;
145
- if (char === "\\") i += 2;
146
- else if (char === "\n") return i;
147
- else {
148
- if (char === "[") inClass = true;
149
- else if (char === "]") inClass = false;
150
- else if (char === "/" && !inClass) return i + 1;
151
- i += 1;
152
- }
153
- }
154
- return source.length;
11
+ const syntax = syntaxOf(path);
12
+ return syntax ? Effect.succeed(syntax) : Effect.fail(new UnreadableCode({ message: unreadable(path) }));
155
13
  }
156
14
 
157
15
  export const comments = Effect.fn("comments")(function* (path: string, source: string) {
158
- const syntax = yield* syntaxFor(path);
159
- const found: Comment[] = [];
160
- let i = 0;
161
- let line = 1;
162
- let previous = "";
163
-
164
- const advance = (to: number): void => {
165
- for (let k = i; k < to; k++) if (source[k] === "\n") line += 1;
166
- i = to;
167
- };
168
-
169
- while (i < source.length) {
170
- const char = source[i]!;
171
-
172
- const block = syntax.block.find(([open]) => source.startsWith(open, i));
173
- if (block) {
174
- const [open, close] = block;
175
- const closed = source.indexOf(close, i + open.length);
176
- const stop = closed < 0 ? source.length : closed + close.length;
177
- found.push({ line, text: source.slice(i, stop) });
178
- advance(stop);
179
- previous = "";
180
- continue;
181
- }
182
-
183
- const marker = syntax.line.find(
184
- ({ token, afterAWordBreak }) =>
185
- source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source[i - 1]!)),
186
- );
187
- if (marker) {
188
- const newline = source.indexOf("\n", i);
189
- const stop = newline < 0 ? source.length : newline;
190
- found.push({ line, text: source.slice(i, stop) });
191
- advance(stop);
192
- previous = "";
193
- continue;
194
- }
195
-
196
- const quote = syntax.quotes.find(
197
- ({ token, opensMidWord }) =>
198
- source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source[i - 1]!)),
199
- );
200
- if (quote) {
201
- advance(endOfQuoted(source, i, quote));
202
- previous = quote.token;
203
- continue;
204
- }
205
-
206
- if (syntax.regexLiterals && char === "/" && opensRegex(previous)) {
207
- advance(endOfRegex(source, i));
208
- previous = "/";
209
- continue;
210
- }
211
-
212
- if (WORD.test(char)) {
213
- let end = i;
214
- while (end < source.length && WORD.test(source[end]!)) end += 1;
215
- previous = source.slice(i, end);
216
- advance(end);
217
- continue;
218
- }
219
-
220
- if (!/\s/.test(char)) previous = char;
221
- advance(i + 1);
222
- }
223
-
224
- return found;
16
+ return commentsIn(source, yield* syntaxFor(path));
225
17
  });
226
18
 
227
- const LICENCE = /copyright|spdx-license-identifier|all rights reserved|licen[cs]ed under|permission is hereby granted/i;
228
-
229
- // A single sentence wraps to at most a few lines at a normal width, so a longer run at the
230
- // file's own opening is the shape of a paragraph rather than of one fact.
231
- const A_WRAPPED_SENTENCE_FITS_WITHIN = 3;
232
-
233
- function beginsItsLine(comment: Comment, lines: readonly string[]): boolean {
234
- return lines[comment.line - 1]!.trimStart().startsWith(comment.text.split("\n")[0]!);
235
- }
236
-
237
- function openingBlock(found: readonly Comment[], source: string): Comment[] {
238
- const lines = source.split("\n");
239
- if (found.length === 0 || found[0]!.line !== 1 || !beginsItsLine(found[0]!, lines)) return [];
240
- const block = [found[0]!];
241
- let next = found[0]!.line + found[0]!.text.split("\n").length;
242
- for (let i = 1; i < found.length && found[i]!.line === next && beginsItsLine(found[i]!, lines); i++) {
243
- block.push(found[i]!);
244
- next = found[i]!.line + found[i]!.text.split("\n").length;
245
- }
246
- return block;
247
- }
248
-
249
- type Check = {
250
- readonly find: RegExp;
251
- readonly refusal: (match: string) => string;
252
- };
253
-
254
- const CHECKS: readonly Check[] = [
255
- {
256
- find: /@ts-expect-error|@ts-ignore|\bprettier-ignore\b|\beslint-disable[\w-]*|\bbiome-ignore\b/,
257
- refusal: (match) =>
258
- `carries the machine-read directive \`${match}\`. Fix what the tool is reporting, or stop running the tool on this file`,
259
- },
260
- {
261
- find: /\bADR[\s-]?\d+|\bdocs\/adr\b|\bRFC[\s-]?\d+|(?:^|[\s(["'`])#\d+\b|[\w.-]+\/[\w.-]+#\d+|\/(?:issues|pull)\/\d+/i,
262
- refusal: (match) =>
263
- `points at a record or a ticket (${JSON.stringify(match.trim())}). Drop the pointer: a record is reached by searching docs/adr, and the story of the change goes in the commit message`,
264
- },
265
- {
266
- find: /^\/\*\*/,
267
- refusal: () =>
268
- "opens a doc block. Make the code say it, or put what the types cannot hold on a line comment in the fewest words that carry it",
269
- },
270
- ];
271
-
272
- function spans(from: number, lineCount: number, within: ReadonlySet<number> | undefined): boolean {
273
- if (within === undefined) return true;
274
- for (let line = from; line < from + lineCount; line += 1) if (within.has(line)) return true;
275
- return false;
276
- }
277
-
278
- export const refused = Effect.fn("refused")(function* (
279
- path: string,
280
- source: string,
281
- within?: ReadonlySet<number>,
282
- ) {
283
- const out: string[] = [];
284
- const found = yield* comments(path, source);
285
-
286
- const opening = openingBlock(found, source);
287
- const openingText = opening.map((comment) => comment.text).join("\n");
288
- const openingLines = opening.reduce((sum, comment) => sum + comment.text.split("\n").length, 0);
289
- if (
290
- opening.length > 0 &&
291
- openingLines > A_WRAPPED_SENTENCE_FITS_WITHIN &&
292
- !LICENCE.test(openingText) &&
293
- spans(1, openingLines, within)
294
- ) {
295
- out.push(
296
- `${path}:1 opens with a ${openingLines}-line rationale block. A record this long belongs in docs/adr or the repo's decision log, and the code does not point at it`,
297
- );
298
- }
299
-
300
- for (const comment of found) {
301
- if (LICENCE.test(comment.text)) continue;
302
- if (!spans(comment.line, comment.text.split("\n").length, within)) continue;
303
- for (const check of CHECKS) {
304
- const [match] = check.find.exec(comment.text) ?? [];
305
- if (match !== undefined) out.push(`${path}:${comment.line} ${check.refusal(match)}`);
306
- }
307
- }
308
- return out;
19
+ export const refused = Effect.fn("refused")(function* (path: string, source: string, within?: ReadonlySet<number>) {
20
+ return refusalsIn(path, source, yield* syntaxFor(path), within);
309
21
  });
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env bun
2
+ import { Config, Console, Effect, FileSystem, Option, Path, Random, Schema } from "effect";
3
+ import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
+ import { runMain, Usage } from "./main.ts";
5
+ import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
6
+
7
+ export type Run = {
8
+ readonly seed: number;
9
+ readonly passed: boolean;
10
+ readonly tests: number;
11
+ readonly failed: readonly TestResult[];
12
+ };
13
+
14
+ export type Failure = {
15
+ readonly file: string;
16
+ readonly test: string;
17
+ readonly line: number;
18
+ readonly seeds: readonly number[];
19
+ };
20
+
21
+ export type RunRecord = Omit<Run, "failed"> & { readonly failed: readonly string[] };
22
+
23
+ export type FlakeRecord = {
24
+ readonly runs: readonly RunRecord[];
25
+ readonly failures: readonly Failure[];
26
+ readonly outsideTests: readonly number[];
27
+ };
28
+
29
+ type Plan = { readonly seeds: readonly number[] } | { readonly runs: number };
30
+
31
+ const NAME = "checks-flake";
32
+ const USAGE = `usage: ${NAME} [--runs <count> | --seed <seed>...] [--report <file>]`;
33
+ const DEFAULT_RUNS = 10;
34
+ const MAX_SEED = 2 ** 32 - 1;
35
+
36
+ const wholeBetween = (minimum: number, maximum: number) =>
37
+ Schema.decodeUnknownEffect(Schema.NumberFromString.check(Schema.isInt(), Schema.isBetween({ minimum, maximum })));
38
+ const decodeRuns = wholeBetween(1, 1000);
39
+ const decodeSeed = wholeBetween(0, MAX_SEED);
40
+
41
+ const parseArgs = Effect.fnUntraced(function* (args: readonly string[]) {
42
+ const usage = (reason?: string) => new Usage({ message: reason === undefined ? USAGE : `${reason}; ${USAGE}` });
43
+ let runs: string | undefined;
44
+ let report: string | undefined;
45
+ const seeds: string[] = [];
46
+ for (let index = 0; index < args.length; index += 2) {
47
+ const [flag, value] = [args[index], args[index + 1]];
48
+ if (value === undefined) return yield* usage();
49
+ if (flag === "--runs" && runs === undefined) runs = value;
50
+ else if (flag === "--seed") seeds.push(value);
51
+ else if (flag === "--report" && report === undefined) report = value;
52
+ else return yield* usage();
53
+ }
54
+ if (runs !== undefined && seeds.length > 0) return yield* usage();
55
+ const invalid = (flag: string) => (cause: { readonly message: string }) => usage(`${flag}: ${cause.message}`);
56
+ const plan: Plan =
57
+ seeds.length > 0
58
+ ? { seeds: yield* Effect.forEach(seeds, (seed) => decodeSeed(seed).pipe(Effect.mapError(invalid("--seed")))) }
59
+ : { runs: runs === undefined ? DEFAULT_RUNS : yield* decodeRuns(runs).pipe(Effect.mapError(invalid("--runs"))) };
60
+ return { plan, report };
61
+ });
62
+
63
+ const seedsOf = (plan: Plan) =>
64
+ "seeds" in plan ? Effect.succeed(plan.seeds) : Effect.replicateEffect(Random.nextIntBetween(0, MAX_SEED), plan.runs);
65
+
66
+ const runOnce = Effect.fn("runOnce")(function* (seed: number, outfile: string) {
67
+ const fs = yield* FileSystem.FileSystem;
68
+ const spawner = yield* ChildProcessSpawner.ChildProcessSpawner;
69
+ const args = ["test", "--randomize", `--seed=${seed}`, ...reporterArgs(outfile)];
70
+ const exitCode = yield* spawner.exitCode(
71
+ ChildProcess.make(process.execPath, args, { stdin: "ignore", stdout: "ignore", stderr: "ignore" }),
72
+ );
73
+ const passed = exitCode === ChildProcessSpawner.ExitCode(0);
74
+ if (!(yield* fs.exists(outfile))) {
75
+ if (passed) return yield* new ReportError({ message: `bun test passed with seed ${seed} but wrote no report` });
76
+ return { seed, passed, tests: 0, failed: [] } satisfies Run;
77
+ }
78
+ const results = yield* parseReport(yield* fs.readFileString(outfile));
79
+ return { seed, passed, tests: results.length, failed: results.filter((result) => result.outcome === "failed") } satisfies Run;
80
+ });
81
+
82
+ function outcomeOf(run: Run): string {
83
+ if (run.passed) return "passed";
84
+ return run.failed.length === 0 ? "failed outside any test" : `failed, ${run.failed.length} test(s) failing`;
85
+ }
86
+
87
+ function nameOf(result: TestResult): string {
88
+ return `${result.file}${NAME_SEPARATOR}${result.name}`;
89
+ }
90
+
91
+ export function recordOf(runs: readonly Run[]): FlakeRecord {
92
+ const failures = new Map<string, { file: string; test: string; line: number; seeds: number[] }>();
93
+ for (const run of runs) {
94
+ const failing = new Map(run.failed.map((result) => [nameOf(result), result]));
95
+ for (const [name, result] of failing) {
96
+ const failure = failures.get(name) ?? { file: result.file, test: result.name, line: result.line, seeds: [] };
97
+ failure.seeds.push(run.seed);
98
+ failures.set(name, failure);
99
+ }
100
+ }
101
+ return {
102
+ runs: runs.map(({ seed, passed, tests, failed }) => ({ seed, passed, tests, failed: failed.map(nameOf) })),
103
+ failures: [...failures.values()].sort((a, b) => a.file.localeCompare(b.file) || a.line - b.line),
104
+ outsideTests: runs.filter((run) => !run.passed && run.failed.length === 0).map((run) => run.seed),
105
+ };
106
+ }
107
+
108
+ function cell(text: string): string {
109
+ return text.replaceAll("|", "\\|");
110
+ }
111
+
112
+ export function summary(record: FlakeRecord): string {
113
+ const total = record.runs.length;
114
+ const failedRuns = record.runs.filter((run) => !run.passed).length;
115
+ if (failedRuns === 0) return `${NAME}: ${total} run(s) passed, with seeds ${record.runs.map((run) => run.seed).join(", ")}`;
116
+ const lines = [`${NAME}: ${failedRuns} of ${total} run(s) failed, ${record.failures.length} test(s) failing in them`];
117
+ if (record.failures.length > 0) {
118
+ lines.push("", "| Test | Failed | Seeds |", "| --- | --- | --- |");
119
+ for (const failure of record.failures) {
120
+ const place = cell(`${failure.file}:${failure.line} ${failure.test}`);
121
+ lines.push(`| ${place} | ${failure.seeds.length} of ${total} runs | ${failure.seeds.join(", ")} |`);
122
+ }
123
+ }
124
+ if (record.outsideTests.length > 0) {
125
+ lines.push("", `${record.outsideTests.length} run(s) failed outside any test, with seeds ${record.outsideTests.join(", ")}`);
126
+ }
127
+ lines.push("", "Reproduce a failing run with bun test --randomize --seed=<seed>.");
128
+ return lines.join("\n");
129
+ }
130
+
131
+ const flake = Effect.gen(function* () {
132
+ const fs = yield* FileSystem.FileSystem;
133
+ const path = yield* Path.Path;
134
+ const { plan, report } = yield* parseArgs(process.argv.slice(2));
135
+ const seeds = yield* seedsOf(plan);
136
+ const dir = yield* fs.makeTempDirectoryScoped({ prefix: "checks-flake-" });
137
+
138
+ const runs = yield* Effect.forEach(seeds, (seed, index) =>
139
+ runOnce(seed, path.join(dir, `${index}.xml`)).pipe(
140
+ Effect.tap((run) => Console.log(`${NAME}: run ${index + 1} of ${seeds.length}, seed ${seed}: ${outcomeOf(run)}`)),
141
+ ),
142
+ );
143
+ const record = recordOf(runs);
144
+ const text = summary(record);
145
+ yield* Console.log(text);
146
+
147
+ if (report !== undefined) yield* fs.writeFileString(report, `${JSON.stringify(record, null, 2)}\n`);
148
+ const stepSummary = yield* Config.option(Config.String("GITHUB_STEP_SUMMARY"));
149
+ if (Option.isSome(stepSummary)) yield* fs.writeFileString(stepSummary.value, `${text}\n`, { flag: "a" });
150
+ return runs.every((run) => run.passed);
151
+ }).pipe(Effect.scoped);
152
+
153
+ if (import.meta.main) runMain(NAME, flake);
package/scripts/gates.ts CHANGED
@@ -1,21 +1,61 @@
1
+ import { Schema } from "effect";
2
+
1
3
  export type Program = {
2
4
  readonly bin: string;
3
5
  readonly script: string;
4
6
  };
5
7
 
8
+ type TrackedContent = {
9
+ readonly pathspecs: readonly string[];
10
+ readonly content: string;
11
+ };
12
+
13
+ export const EVERY_REPOSITORY = "every repository";
14
+
6
15
  export type KitGate = Program & {
7
16
  readonly reads: "tree" | "range";
17
+ readonly appliesTo: typeof EVERY_REPOSITORY | TrackedContent;
8
18
  };
9
19
 
10
20
  export const ENTRY_POINT: Program = { bin: "checks-lint", script: "lint.ts" };
11
21
 
22
+ export const TEST_ENTRY_POINT: Program = { bin: "checks-test", script: "test.ts" };
23
+
12
24
  export const DEFAULT_BRANCH = "main";
13
25
 
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
- ];
26
+ const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
27
+
28
+ export const KIT_GATES = [
29
+ { bin: "checks-lint-coverage", script: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
30
+ { bin: "checks-test-layout", script: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
31
+ { bin: "checks-commit-identity", script: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
32
+ { bin: "checks-comment-gate", script: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
33
+ { bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
34
+ { bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
35
+ ] as const satisfies readonly KitGate[];
36
+
37
+ const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
38
+
39
+ // A selection without ci-wiring would go unchecked under checks-lint, so a gate every repository runs
40
+ // is refused where checks-lint decodes the selection, not by ci-wiring.
41
+ export const LintGates = Schema.Array(Schema.Literals(KIT_GATES.map((gate) => gate.bin))).check(
42
+ Schema.makeFilter((selected) => {
43
+ const missing = UNCONDITIONAL.filter((bin) => !selected.includes(bin));
44
+ if (missing.length === 0) return true;
45
+ const verb = missing.length === 1 ? "applies" : "apply";
46
+ return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
47
+ }),
48
+ );
49
+
50
+ export const LintWiring = Schema.Struct({
51
+ ciWiring: Schema.optionalKey(
52
+ Schema.Struct({
53
+ defaultBranch: Schema.optionalKey(Schema.NonEmptyString),
54
+ lintGates: Schema.optionalKey(LintGates),
55
+ }),
56
+ ),
57
+ });
58
+
59
+ export function selectedGates(lintGates: typeof LintGates.Type | undefined): readonly KitGate[] {
60
+ return lintGates === undefined ? KIT_GATES : KIT_GATES.filter((gate) => lintGates.includes(gate.bin));
61
+ }
package/scripts/git.ts CHANGED
@@ -25,3 +25,17 @@ export const git = Effect.fn("git")(
25
25
  },
26
26
  Effect.scoped,
27
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