@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.
- package/README.md +164 -91
- package/package.json +13 -6
- package/scripts/backtest.ts +23 -33
- package/scripts/ci-wiring.ts +86 -65
- package/scripts/comment-gate.ts +23 -38
- package/scripts/comments.ts +24 -13
- package/scripts/commit-identity.ts +91 -87
- package/scripts/gates.ts +21 -0
- package/scripts/git.ts +27 -0
- package/scripts/lint.ts +162 -0
- package/scripts/main.ts +28 -0
- package/scripts/mutation-compare.ts +43 -57
- package/scripts/suppressions-ratchet.ts +59 -76
- package/scripts/test-layout.ts +67 -44
package/scripts/ci-wiring.ts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
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
|
-
|
|
235
|
-
|
|
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)
|
|
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
|
|
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
|
-
|
|
283
|
+
return yield* new WiringError({ message: `${source} sets no ciWiring.gates, a non-empty array of commands` });
|
|
262
284
|
}
|
|
263
|
-
const
|
|
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
|
-
|
|
290
|
+
return yield* new WiringError({
|
|
291
|
+
message: `${source} ciWiring gate ${JSON.stringify(command)} is not one plain command`,
|
|
292
|
+
});
|
|
267
293
|
}
|
|
268
|
-
|
|
269
|
-
}
|
|
294
|
+
gates.push({ command, words });
|
|
295
|
+
}
|
|
270
296
|
const defaultBranch = configured["defaultBranch"] ?? DEFAULT_BRANCH;
|
|
271
297
|
if (typeof defaultBranch !== "string" || defaultBranch === "") {
|
|
272
|
-
|
|
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
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
-
|
|
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);
|
package/scripts/comment-gate.ts
CHANGED
|
@@ -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
|
-
|
|
72
|
-
|
|
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
|
|
81
|
-
const diff = git(
|
|
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
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
86
|
+
const parentOf = (root: string, rev: string) =>
|
|
87
|
+
git(["rev-parse", "--verify", `${rev}^`], root).pipe(Effect.map((parent) => parent.trim()));
|
|
105
88
|
|
|
106
|
-
|
|
89
|
+
const gate = Effect.gen(function* () {
|
|
107
90
|
const [first, second, ...extra] = process.argv.slice(2);
|
|
108
|
-
if (first === undefined || extra.length > 0)
|
|
91
|
+
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
109
92
|
|
|
110
|
-
const root = git(
|
|
111
|
-
const
|
|
112
|
-
|
|
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
|
-
|
|
115
|
-
|
|
116
|
-
}
|
|
97
|
+
yield* Console.log(report(result));
|
|
98
|
+
return result.violations.length === 0;
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (import.meta.main) runMain("comment-gate", gate);
|
package/scripts/comments.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
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
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
const
|
|
52
|
-
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
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
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
const
|
|
136
|
-
const
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
`commit-identity: ${offences.length} of ${commits.length} commit(s) in ${range} carry a foreign identity
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
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
|
-
|
|
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);
|
package/scripts/gates.ts
ADDED
|
@@ -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
|
+
);
|