@avi2dg/checks 0.16.0 → 0.18.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.
@@ -89,29 +89,42 @@ function statusProblem(outline: Outline): Violation | undefined {
89
89
  return { line: opening?.line ?? status.heading.line, message: `\`## Status\` opens with ${found} where one of ${ADR_STATUSES.join(", ")} goes` };
90
90
  }
91
91
 
92
- function adrProblems(path: string, outline: Outline, records: readonly string[]): readonly Violation[] {
93
- const violations: Violation[] = [];
94
- const filed = recordNumber(path);
95
- if (filed === undefined) {
96
- violations.push({ line: 1, message: `is not named as a record, a four-digit number and a kebab-case name directly in ${ADR_DIRECTORY}` });
97
- }
92
+ function recordTitleProblem(outline: Outline, filed: number | undefined): Violation | undefined {
98
93
  const title = titleOf(outline);
99
- const titled = title === undefined ? undefined : RECORD_TITLE.exec(title.title)?.[1];
100
- if (title !== undefined && titled === undefined) {
101
- violations.push({ line: title.line, message: `${marked(1, title.title)} does not open with the record's number, as in \`# 7. The decision\`` });
102
- }
103
- if (title !== undefined && titled !== undefined && filed !== undefined && Number(titled) !== filed) {
104
- violations.push({ line: title.line, message: `${marked(1, title.title)} carries number ${titled}, and the file name ${filed}` });
94
+ if (title === undefined) return undefined;
95
+ const titled = RECORD_TITLE.exec(title.title)?.[1];
96
+ if (titled === undefined) {
97
+ return { line: title.line, message: `${marked(1, title.title)} does not open with the record's number, as in \`# 7. The decision\`` };
105
98
  }
106
- const dated = firstText(outline.lead);
107
- if (!isDate(DATE_LINE.exec(dated?.text.trim() ?? "")?.[1])) {
108
- violations.push({ line: dated?.line ?? 1, message: "does not follow its title with a `Date: YYYY-MM-DD` line" });
109
- }
110
- const status = statusProblem(outline);
111
- if (status !== undefined) violations.push(status);
112
- const sharing = records.filter((other) => other !== path && filed !== undefined && recordNumber(other) === filed);
113
- if (sharing.length > 0) violations.push({ line: 1, message: `shares number ${filed} with ${sharing.join(", ")}` });
114
- return violations;
99
+ if (filed === undefined || Number(titled) === filed) return undefined;
100
+ return { line: title.line, message: `${marked(1, title.title)} carries number ${titled}, and the file name ${filed}` };
101
+ }
102
+
103
+ function recordDateProblem({ lead }: Outline): Violation | undefined {
104
+ const dated = firstText(lead);
105
+ if (isDate(DATE_LINE.exec(dated?.text.trim() ?? "")?.[1])) return undefined;
106
+ return { line: dated?.line ?? 1, message: "does not follow its title with a `Date: YYYY-MM-DD` line" };
107
+ }
108
+
109
+ function sharedNumberProblem(path: string, filed: number | undefined, records: readonly string[]): Violation | undefined {
110
+ if (filed === undefined) return undefined;
111
+ const sharing = records.filter((other) => other !== path && recordNumber(other) === filed);
112
+ return sharing.length === 0 ? undefined : { line: 1, message: `shares number ${filed} with ${sharing.join(", ")}` };
113
+ }
114
+
115
+ function adrProblems(path: string, outline: Outline, records: readonly string[]): readonly Violation[] {
116
+ const filed = recordNumber(path);
117
+ const misnamed: Violation | undefined =
118
+ filed === undefined
119
+ ? { line: 1, message: `is not named as a record, a four-digit number and a kebab-case name directly in ${ADR_DIRECTORY}` }
120
+ : undefined;
121
+ return [
122
+ misnamed,
123
+ recordTitleProblem(outline, filed),
124
+ recordDateProblem(outline),
125
+ statusProblem(outline),
126
+ sharedNumberProblem(path, filed, records),
127
+ ].filter((violation) => violation !== undefined);
115
128
  }
116
129
 
117
130
  export const RELEASED = /^Released (\S+)\.$/;
package/scripts/docs.ts CHANGED
@@ -3,8 +3,8 @@ import { Console, Effect } from "effect";
3
3
  import { rootsOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
4
4
  import { ADR_DIRECTORY, judge, placementOf, placementProblem, type Placement } from "./doc-rules.ts";
5
5
  import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
6
- import { changedLines, changedPaths, git, pathsAt, rangeEnds } from "./git.ts";
7
- import { runMain, Usage } from "./main.ts";
6
+ import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "./git.ts";
7
+ import { runMain } from "./main.ts";
8
8
  import { isLivingDoc, proseFindings, readerOf } from "./prose-matchers.ts";
9
9
  import { readQuality } from "./quality-file.ts";
10
10
 
@@ -152,8 +152,7 @@ export function report({ held, edited, living, findings, advisory, brokenBefore
152
152
  }
153
153
 
154
154
  const docs = Effect.gen(function* () {
155
- const [first, second, ...extra] = process.argv.slice(2);
156
- if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
155
+ const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
157
156
 
158
157
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
159
158
  const { base, head } = yield* rangeEnds(first, second, root);
@@ -2,8 +2,9 @@
2
2
  import { parse } from "@swc/core";
3
3
  import { Console, Effect, Path, Schema } from "effect";
4
4
  import { changedPaths, git, pathsAt, rangeEnds, type Change } from "./git.ts";
5
- import { runMain, Usage } from "./main.ts";
6
- import { PROOF_DIRECTORY, readQuality, type Feature } from "./quality-file.ts";
5
+ import { runMain } from "./main.ts";
6
+ import { PROOF_DIRECTORY, type Feature } from "./quality-file.ts";
7
+ import { rangeGateInputs } from "./range-gate.ts";
7
8
 
8
9
  type Unproven = {
9
10
  readonly feature: string;
@@ -120,17 +121,13 @@ function signalReport(touched: readonly Touch[]): string {
120
121
  }
121
122
 
122
123
  const owners = Effect.gen(function* () {
123
- const [first, second, ...extra] = process.argv.slice(2);
124
- if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
125
-
126
- const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
127
- const { source, quality } = yield* readQuality(root);
124
+ const { refs, root, source, quality } = yield* rangeGateInputs(USAGE);
128
125
  const features = quality.features ?? [];
129
126
  if (features.length === 0) {
130
127
  yield* Console.log(`${NAME}: ${source} declares no feature`);
131
128
  return true;
132
129
  }
133
- const { base, head } = yield* rangeEnds(first, second, root);
130
+ const { base, head } = yield* rangeEnds(refs.first, refs.second, root);
134
131
  const found = yield* unproven(root, head, features);
135
132
  yield* Console.log(proofReport(features, found));
136
133
  if (quality.changeSignal === "advisory") {
package/scripts/git.ts CHANGED
@@ -87,37 +87,37 @@ function newPathOf(line: string): string | undefined {
87
87
 
88
88
  const HUNK = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
89
89
 
90
+ type HunkPosition = {
91
+ readonly line: number;
92
+ readonly oldLeft: number;
93
+ readonly newLeft: number;
94
+ };
95
+
96
+ function hunkStart(row: string): HunkPosition | undefined {
97
+ const hunk = HUNK.exec(row);
98
+ return hunk === null ? undefined : { oldLeft: Number(hunk[1] ?? 1), line: Number(hunk[2]), newLeft: Number(hunk[3] ?? 1) };
99
+ }
100
+
101
+ function pastHunkRow(row: string, { line, oldLeft, newLeft }: HunkPosition): HunkPosition {
102
+ if (row.startsWith("+")) return { line: line + 1, oldLeft, newLeft: newLeft - 1 };
103
+ if (row.startsWith("-")) return { line, oldLeft: oldLeft - 1, newLeft };
104
+ if (row.startsWith(" ")) return { line: line + 1, oldLeft: oldLeft - 1, newLeft: newLeft - 1 };
105
+ return { line, oldLeft, newLeft };
106
+ }
107
+
90
108
  // A hunk's header counts its lines, so an added line reading `++ x` is not taken for the next file's `+++` header.
91
109
  export function parseAddedLines(diff: string): Map<string, Set<number>> {
92
110
  const added = new Map<string, Set<number>>();
93
111
  let path: string | undefined;
94
- let line = 0;
95
- let oldLeft = 0;
96
- let newLeft = 0;
112
+ let at: HunkPosition = { line: 0, oldLeft: 0, newLeft: 0 };
97
113
  for (const row of diff.split("\n")) {
98
- if (oldLeft > 0 || newLeft > 0) {
99
- if (row.startsWith("+")) {
100
- if (path !== undefined) added.set(path, (added.get(path) ?? new Set<number>()).add(line));
101
- line += 1;
102
- newLeft -= 1;
103
- } else if (row.startsWith("-")) {
104
- oldLeft -= 1;
105
- } else if (row.startsWith(" ")) {
106
- line += 1;
107
- oldLeft -= 1;
108
- newLeft -= 1;
109
- }
110
- continue;
111
- }
112
- if (row.startsWith("+++ ")) {
114
+ if (at.oldLeft > 0 || at.newLeft > 0) {
115
+ if (row.startsWith("+") && path !== undefined) added.set(path, (added.get(path) ?? new Set<number>()).add(at.line));
116
+ at = pastHunkRow(row, at);
117
+ } else if (row.startsWith("+++ ")) {
113
118
  path = newPathOf(row);
114
- continue;
115
- }
116
- const hunk = HUNK.exec(row);
117
- if (hunk !== null) {
118
- oldLeft = Number(hunk[1] ?? 1);
119
- line = Number(hunk[2]);
120
- newLeft = Number(hunk[3] ?? 1);
119
+ } else {
120
+ at = hunkStart(row) ?? at;
121
121
  }
122
122
  }
123
123
  return added;
@@ -161,9 +161,14 @@ export const rangeEnds = Effect.fn("rangeEnds")(function* (first: string, second
161
161
  return { base: (yield* git(["merge-base", first, second], cwd)).trim(), head: second };
162
162
  });
163
163
 
164
- export const rangeFromArgs = Effect.fn("rangeFromArgs")(function* (args: readonly string[], usage: string, cwd?: string) {
164
+ export const refArgs = Effect.fn("refArgs")(function* (args: readonly string[], usage: string) {
165
165
  const [first, second, ...extra] = args;
166
166
  if (first === undefined || extra.length > 0) return yield* new Usage({ message: usage });
167
+ return { first, second };
168
+ });
169
+
170
+ export const rangeFromArgs = Effect.fn("rangeFromArgs")(function* (args: readonly string[], usage: string, cwd?: string) {
171
+ const { first, second } = yield* refArgs(args, usage);
167
172
  return yield* rangeEnds(first, second, cwd);
168
173
  });
169
174
 
@@ -97,59 +97,70 @@ function targetOf(destination: string): string {
97
97
  return trimmed.split(/\s/, 1)[0] ?? "";
98
98
  }
99
99
 
100
+ type Masking = { readonly raw: string; readonly out: string[]; readonly code: string[]; readonly links: string[]; inComment: boolean };
101
+
102
+ function hide(masking: Masking, from: number, to: number, fill: string): void {
103
+ for (let at = from; at < to; at += 1) masking.out[at] = fill;
104
+ }
105
+
106
+ // A step returns an index past `at`, or undefined when its token does not start there.
107
+ function commentStep(masking: Masking, at: number): number | undefined {
108
+ const { raw } = masking;
109
+ if (!masking.inComment) {
110
+ if (!raw.startsWith("<!--", at)) return undefined;
111
+ hide(masking, at, at + 4, BLANK);
112
+ masking.inComment = true;
113
+ return at + 4;
114
+ }
115
+ const close = raw.indexOf("-->", at);
116
+ const end = close < 0 ? raw.length : close + 3;
117
+ hide(masking, at, end, BLANK);
118
+ masking.inComment = close < 0;
119
+ return end;
120
+ }
121
+
122
+ function codeSpanStep(masking: Masking, at: number): number | undefined {
123
+ const { raw } = masking;
124
+ if (raw.charAt(at) !== "`") return undefined;
125
+ const run = runOf(raw, at, "`");
126
+ const close = closingRun(raw, at + run, run);
127
+ if (close < 0) return at + run;
128
+ masking.code.push(raw.slice(at + run, close).trim());
129
+ hide(masking, at, close + run, HIDDEN);
130
+ return close + run;
131
+ }
132
+
133
+ function linkStep(masking: Masking, at: number): number | undefined {
134
+ const { raw } = masking;
135
+ const end = raw.startsWith("](", at) ? destinationEnd(raw, at + 1) : -1;
136
+ if (end < 0) return undefined;
137
+ masking.links.push(targetOf(raw.slice(at + 2, end)));
138
+ hide(masking, at + 1, end + 1, HIDDEN);
139
+ return end + 1;
140
+ }
141
+
142
+ function hiddenStep(masking: Masking, at: number): number | undefined {
143
+ const hidden = hiddenAt(masking.raw, at, masking.raw.slice(at));
144
+ if (hidden === undefined) return undefined;
145
+ hide(masking, at, at + hidden.length, hidden.fill);
146
+ return at + hidden.length;
147
+ }
148
+
149
+ const MASK_STEPS = [commentStep, codeSpanStep, linkStep, hiddenStep];
150
+
151
+ function maskStep(masking: Masking, at: number): number {
152
+ for (const step of MASK_STEPS) {
153
+ const next = step(masking, at);
154
+ if (next !== undefined) return next;
155
+ }
156
+ return at + 1;
157
+ }
158
+
100
159
  function mask(raw: string, startsInComment: boolean): Masked {
101
- const out = raw.split("");
102
- const code: string[] = [];
103
- const links: string[] = [];
104
- const hide = (from: number, to: number, fill: string): void => {
105
- for (let at = from; at < to; at += 1) out[at] = fill;
106
- };
107
- let inComment = startsInComment;
160
+ const masking: Masking = { raw, out: raw.split(""), code: [], links: [], inComment: startsInComment };
108
161
  let at = 0;
109
- while (at < raw.length) {
110
- if (inComment) {
111
- const close = raw.indexOf("-->", at);
112
- const end = close < 0 ? raw.length : close + 3;
113
- hide(at, end, BLANK);
114
- inComment = close < 0;
115
- at = end;
116
- continue;
117
- }
118
- const rest = raw.slice(at);
119
- if (rest.startsWith("<!--")) {
120
- hide(at, at + 4, BLANK);
121
- inComment = true;
122
- at += 4;
123
- continue;
124
- }
125
- if (rest.startsWith("`")) {
126
- const run = runOf(raw, at, "`");
127
- const close = closingRun(raw, at + run, run);
128
- if (close >= 0) {
129
- code.push(raw.slice(at + run, close).trim());
130
- hide(at, close + run, HIDDEN);
131
- at = close + run;
132
- } else at += run;
133
- continue;
134
- }
135
- if (rest.startsWith("](")) {
136
- const end = destinationEnd(raw, at + 1);
137
- if (end >= 0) {
138
- links.push(targetOf(raw.slice(at + 2, end)));
139
- hide(at + 1, end + 1, HIDDEN);
140
- at = end + 1;
141
- continue;
142
- }
143
- }
144
- const hidden = hiddenAt(raw, at, rest);
145
- if (hidden !== undefined) {
146
- hide(at, at + hidden.length, hidden.fill);
147
- at += hidden.length;
148
- continue;
149
- }
150
- at += 1;
151
- }
152
- return { prose: out.join(""), code, links, inComment };
162
+ while (at < raw.length) at = maskStep(masking, at);
163
+ return { prose: masking.out.join(""), code: masking.code, links: masking.links, inComment: masking.inComment };
153
164
  }
154
165
 
155
166
  const FENCE = /^\s*(`{3,}|~{3,})(.*)$/;
@@ -191,42 +202,41 @@ function settext(lines: MarkdownLine[]): MarkdownLine[] {
191
202
  });
192
203
  }
193
204
 
205
+ function frontMatterLength(raws: readonly string[]): number {
206
+ if (raws[0] !== "---") return 0;
207
+ const close = raws.findIndex((raw, index) => index > 0 && (raw === "---" || raw === "..."));
208
+ return close < 0 ? raws.length : close + 1;
209
+ }
210
+
211
+ type Scanning = { fence: string | undefined; inComment: boolean; inHtmlBlock: boolean };
212
+
213
+ function scanLine(line: number, raw: string, scanning: Scanning): MarkdownLine {
214
+ if (scanning.fence !== undefined) {
215
+ if (fenceCloses(raw, scanning.fence)) scanning.fence = undefined;
216
+ return unread(line, raw, "code");
217
+ }
218
+ const opener = scanning.inComment ? undefined : fenceOpener(raw);
219
+ if (opener !== undefined) {
220
+ scanning.fence = opener;
221
+ return unread(line, raw, "code");
222
+ }
223
+ const definition = scanning.inComment ? null : DEFINITION.exec(raw);
224
+ if (definition !== null) return { ...unread(line, raw, "definition"), links: [targetOf(definition[1] ?? "")] };
225
+ const masked = mask(raw, scanning.inComment);
226
+ scanning.inComment = masked.inComment;
227
+ const kind = kindOf(raw, masked.prose);
228
+ // An HTML block runs to the next blank line, whatever its later lines open with.
229
+ scanning.inHtmlBlock = kind === "html" || (scanning.inHtmlBlock && kind !== "blank");
230
+ return { line, kind: scanning.inHtmlBlock ? "html" : kind, raw, prose: masked.prose, code: masked.code, links: masked.links };
231
+ }
232
+
194
233
  export function scanMarkdown(text: string): readonly MarkdownLine[] {
195
234
  const raws = text.split("\n").map((raw) => raw.replace(/\r$/, ""));
235
+ const frontMatter = frontMatterLength(raws);
236
+ const scanning: Scanning = { fence: undefined, inComment: false, inHtmlBlock: false };
196
237
  const lines: MarkdownLine[] = [];
197
- let fence: string | undefined;
198
- let inComment = false;
199
- let inHtmlBlock = false;
200
- let frontMatter = raws[0] === "---";
201
238
  for (const [index, raw] of raws.entries()) {
202
- const line = index + 1;
203
- if (frontMatter) {
204
- frontMatter = index === 0 || (raw !== "---" && raw !== "...");
205
- lines.push(unread(line, raw, "front-matter"));
206
- continue;
207
- }
208
- if (fence !== undefined) {
209
- if (fenceCloses(raw, fence)) fence = undefined;
210
- lines.push(unread(line, raw, "code"));
211
- continue;
212
- }
213
- const opener = inComment ? undefined : fenceOpener(raw);
214
- if (opener !== undefined) {
215
- fence = opener;
216
- lines.push(unread(line, raw, "code"));
217
- continue;
218
- }
219
- const definition = inComment ? null : DEFINITION.exec(raw);
220
- if (definition !== null) {
221
- lines.push({ ...unread(line, raw, "definition"), links: [targetOf(definition[1] ?? "")] });
222
- continue;
223
- }
224
- const masked = mask(raw, inComment);
225
- inComment = masked.inComment;
226
- const kind = kindOf(raw, masked.prose);
227
- // An HTML block runs to the next blank line, whatever its later lines open with.
228
- inHtmlBlock = kind === "html" || (inHtmlBlock && kind !== "blank");
229
- lines.push({ line, kind: inHtmlBlock ? "html" : kind, raw, prose: masked.prose, code: masked.code, links: masked.links });
239
+ lines.push(index < frontMatter ? unread(index + 1, raw, "front-matter") : scanLine(index + 1, raw, scanning));
230
240
  }
231
241
  return settext(lines);
232
242
  }
@@ -1,16 +1,21 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import kitOxlint from "../oxlintrc.json" with { type: "json" };
4
+ import kitManifest from "../package.json" with { type: "json" };
4
5
  import effectLanguageService from "../presets/effect.language-service.json" with { type: "json" };
5
6
  import effectOxlint from "../presets/effect.oxlint.json" with { type: "json" };
6
7
  import { git } from "./git.ts";
8
+ import { DEFAULT_BRANCH } from "./gates.ts";
7
9
  import { runMain, Usage } from "./main.ts";
8
- import { readQuality, renderJson, type Quality } from "./quality-file.ts";
10
+ import { QualityUnreadable, readQuality, renderJson, type Quality } from "./quality-file.ts";
11
+ import { invokes, plainCommand } from "./shell-command.ts";
9
12
 
10
13
  // oxlint resolves an override's files, and the language service an override's include, against the
11
14
  // directory of the config that holds it, so a fragment anywhere but the root matches nothing there.
12
15
  export const OXLINT_FRAGMENT = "oxlintrc.quality.json";
13
16
  export const TSCONFIG_FRAGMENT = "tsconfig.quality.json";
17
+ export const SUITE_WORKFLOW = ".github/workflows/ci.yml";
18
+ export const COMMITLINT_WORKFLOW = ".github/workflows/commitlint.yml";
14
19
 
15
20
  const NAME = "checks-quality";
16
21
  const USAGE = `usage: ${NAME} --check | generate`;
@@ -25,6 +30,16 @@ export type Fragment = {
25
30
  readonly content: unknown;
26
31
  };
27
32
 
33
+ export type GeneratedWorkflow = {
34
+ readonly file: typeof SUITE_WORKFLOW | typeof COMMITLINT_WORKFLOW;
35
+ readonly content: string;
36
+ };
37
+
38
+ export type WorkflowRecipe = {
39
+ readonly commitlintConfig: string;
40
+ readonly bunVersionFile: boolean;
41
+ };
42
+
28
43
  class NativeConfigUnreadable extends Schema.TaggedError<NativeConfigUnreadable>()("NativeConfigUnreadable", {
29
44
  message: Schema.String,
30
45
  }) {}
@@ -51,14 +66,86 @@ function tsconfigFragment({ paths, exempt = [] }: EffectSources): unknown {
51
66
  }
52
67
 
53
68
  const FRAGMENTS = [
54
- { file: OXLINT_FRAGMENT, extendedBy: ".oxlintrc.json", reader: "oxlint", build: oxlintFragment },
55
- { file: TSCONFIG_FRAGMENT, extendedBy: "tsconfig.json", reader: "the language service", build: tsconfigFragment },
69
+ {
70
+ file: OXLINT_FRAGMENT,
71
+ extendedBy: ".oxlintrc.json",
72
+ reader: "oxlint",
73
+ kitConfig: "./node_modules/@avi2dg/checks/oxlintrc.json",
74
+ kitRepositoryConfig: "./oxlintrc.json",
75
+ kitConfigReason: "so the kit's oxlint rules are not loaded",
76
+ build: oxlintFragment,
77
+ },
78
+ {
79
+ file: TSCONFIG_FRAGMENT,
80
+ extendedBy: "tsconfig.json",
81
+ reader: "the language service",
82
+ kitConfig: "@avi2dg/checks/tsconfig.effect.json",
83
+ kitRepositoryConfig: "./tsconfig.effect.json",
84
+ kitConfigReason: "the one accepted spelling of the kit's Effect config",
85
+ build: tsconfigFragment,
86
+ },
56
87
  ] as const;
57
88
 
58
89
  export function fragmentsFor(quality: Quality): readonly Fragment[] {
59
90
  const effect = quality.sources?.effect;
60
91
  if (effect === undefined) return [];
61
- return FRAGMENTS.map(({ build, ...fragment }) => ({ ...fragment, content: build(effect) }));
92
+ return FRAGMENTS.map(({ file, extendedBy, reader, build }) => ({ file, extendedBy, reader, content: build(effect) }));
93
+ }
94
+
95
+ const OWN_COMMITLINT_CONFIG = "./commitlint.config.js";
96
+ const KIT_COMMITLINT_CONFIG = "./node_modules/@avi2dg/checks/commitlint.config.js";
97
+
98
+ function titleLint(commitlintConfig: string): string {
99
+ return `./node_modules/.bin/commitlint --config ${commitlintConfig} --edit "$RUNNER_TEMP/pr-title"`;
100
+ }
101
+
102
+ function runsInTitleLint(gate: string, commitlintConfig: string): boolean {
103
+ const words = plainCommand(gate);
104
+ return words !== undefined && invokes(plainCommand(titleLint(commitlintConfig)), words);
105
+ }
106
+
107
+ function runScalar(command: string): string {
108
+ return /(^[\s#&*!|>@`'"%{[-])|:\s|\s#|:$|[\n\\]/.test(command) ? JSON.stringify(command) : command;
109
+ }
110
+
111
+ export function commitlintWorkflow(commitlintConfig: string): string {
112
+ return `on:
113
+ pull_request:
114
+ types: [opened, edited, synchronize, reopened]
115
+ jobs:
116
+ commitlint:
117
+ runs-on: ubuntu-latest
118
+ steps:
119
+ - uses: actions/checkout@v5
120
+ - uses: oven-sh/setup-bun@v2
121
+ - run: bun install --frozen-lockfile
122
+ # Only the title is linted: it is what a squash merge lands, with GitHub appending " (#N)" to it.
123
+ - run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
124
+ env:
125
+ PR_TITLE: \${{ github.event.pull_request.title }}
126
+ - run: ${titleLint(commitlintConfig)}
127
+ `;
128
+ }
129
+
130
+ export function suiteWorkflow(defaultBranch: string, gates: readonly string[], bunVersionFile: boolean): string {
131
+ const setup = bunVersionFile
132
+ ? " - uses: oven-sh/setup-bun@v2\n with:\n bun-version-file: .bun-version\n"
133
+ : " - uses: oven-sh/setup-bun@v2\n";
134
+ const steps = gates.map((gate) => ` - run: ${runScalar(gate)}\n`).join("");
135
+ return `name: ci\non:\n push:\n branches: [${defaultBranch}]\n pull_request:\n types: [opened, edited, synchronize, reopened]\njobs:\n checks:\n runs-on: ubuntu-latest\n steps:\n # The head, not GitHub's merge ref, so the tree the gates read is the commit the range ends at.\n # The whole history, since the range starts where the head branched from the base branch.\n - uses: actions/checkout@v5\n with:\n ref: \${{ github.event.pull_request.head.sha || github.sha }}\n fetch-depth: 0\n${setup} - run: bun install --frozen-lockfile\n${steps}`;
136
+ }
137
+
138
+ export function workflowsFor(quality: Quality, recipe: WorkflowRecipe): readonly GeneratedWorkflow[] {
139
+ const commitlint: GeneratedWorkflow = {
140
+ file: COMMITLINT_WORKFLOW,
141
+ content: commitlintWorkflow(recipe.commitlintConfig),
142
+ };
143
+ if (quality.gates?.ci === undefined) return [commitlint];
144
+ const suite = quality.gates.ci.filter((gate) => !runsInTitleLint(gate, recipe.commitlintConfig));
145
+ return [
146
+ { file: SUITE_WORKFLOW, content: suiteWorkflow(quality.defaultBranch ?? DEFAULT_BRANCH, suite, recipe.bunVersionFile) },
147
+ commitlint,
148
+ ];
62
149
  }
63
150
 
64
151
  const NativeConfig = Schema.Struct({
@@ -94,11 +181,24 @@ const sameJson = (text: string, content: unknown): Effect.Effect<boolean> =>
94
181
  Effect.orElseSucceed(() => false),
95
182
  );
96
183
 
184
+ const decodePackageName = Schema.decodeUnknownEffect(Schema.fromJsonString(Schema.Struct({ name: Schema.optionalKey(Schema.String) })));
185
+
186
+ const isKit = Effect.fn("isKit")(function* (root: string) {
187
+ const fs = yield* FileSystem.FileSystem;
188
+ const manifest = (yield* Path.Path).join(root, "package.json");
189
+ if (!(yield* fs.exists(manifest))) return false;
190
+ const { name } = yield* decodePackageName(yield* fs.readFileString(manifest)).pipe(
191
+ Effect.mapError((cause) => new QualityUnreadable({ message: `package.json: ${cause.message}` })),
192
+ );
193
+ return name === kitManifest.name;
194
+ });
195
+
97
196
  const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string, source: string, expected: readonly Fragment[]) {
98
197
  const fs = yield* FileSystem.FileSystem;
99
198
  const path = yield* Path.Path;
100
199
  const problems: string[] = [];
101
- for (const { file } of FRAGMENTS) {
200
+ const kitRepository = yield* isKit(root);
201
+ for (const { file, extendedBy, reader, kitConfig, kitRepositoryConfig, kitConfigReason } of FRAGMENTS) {
102
202
  const target = path.join(root, file);
103
203
  const fragment = expected.find((candidate) => candidate.file === file);
104
204
  const present = yield* fs.exists(target);
@@ -111,8 +211,13 @@ const fragmentProblems = Effect.fn("fragmentProblems")(function* (root: string,
111
211
  } else if (!(yield* sameJson(yield* fs.readFileString(target), fragment.content))) {
112
212
  problems.push(`${file} is stale against ${source} and the kit's presets; run ${GENERATE}`);
113
213
  }
114
- if (!(yield* extendsOf(root, fragment.extendedBy)).includes(file)) {
115
- problems.push(`${fragment.extendedBy} does not extend ./${file}, so ${fragment.reader} never reads it`);
214
+ const configured = yield* extendsOf(root, extendedBy);
215
+ if (!configured.includes(file)) {
216
+ problems.push(`${extendedBy} does not extend ./${file}, so ${reader} never reads it`);
217
+ }
218
+ const requiredKitConfig = kitRepository ? kitRepositoryConfig : kitConfig;
219
+ if (!configured.includes(path.normalize(requiredKitConfig))) {
220
+ problems.push(`${extendedBy} does not extend ${requiredKitConfig}, ${kitConfigReason}`);
116
221
  }
117
222
  }
118
223
  return problems;
@@ -135,18 +240,54 @@ const unmatchedPaths = Effect.fn("unmatchedPaths")(function* (root: string, qual
135
240
  return problems;
136
241
  });
137
242
 
243
+ const recipeOf = Effect.fn("recipeOf")(function* (root: string) {
244
+ const fs = yield* FileSystem.FileSystem;
245
+ const path = yield* Path.Path;
246
+ return {
247
+ // The kit never installs itself, so its own tree lints with its root config.
248
+ commitlintConfig: (yield* isKit(root)) ? OWN_COMMITLINT_CONFIG : KIT_COMMITLINT_CONFIG,
249
+ bunVersionFile: yield* fs.exists(path.join(root, ".bun-version")),
250
+ } satisfies WorkflowRecipe;
251
+ });
252
+
253
+ const workflowProblems = Effect.fn("workflowProblems")(function* (
254
+ root: string,
255
+ source: string,
256
+ expected: readonly GeneratedWorkflow[],
257
+ ) {
258
+ const fs = yield* FileSystem.FileSystem;
259
+ const path = yield* Path.Path;
260
+ const problems: string[] = [];
261
+ for (const { file, content } of expected) {
262
+ const target = path.join(root, file);
263
+ if (!(yield* fs.exists(target))) {
264
+ problems.push(`${file} is missing; run ${GENERATE}`);
265
+ } else if ((yield* fs.readFileString(target)) !== content) {
266
+ problems.push(`${file} is stale against ${source} and the kit recipe; run ${GENERATE}`);
267
+ }
268
+ }
269
+ return problems;
270
+ });
271
+
138
272
  const check = Effect.fn("check")(function* (root: string) {
139
273
  const { source, quality } = yield* readQuality(root);
140
274
  const expected = fragmentsFor(quality);
141
- const problems = [...(yield* fragmentProblems(root, source, expected)), ...(yield* unmatchedPaths(root, quality))];
275
+ const workflows = workflowsFor(quality, yield* recipeOf(root));
276
+ const problems = [
277
+ ...(yield* fragmentProblems(root, source, expected)),
278
+ ...(yield* workflowProblems(root, source, workflows)),
279
+ ...(yield* unmatchedPaths(root, quality)),
280
+ ];
142
281
  if (problems.length > 0) {
143
282
  yield* Console.error([`${NAME}: ${problems.length} problem(s) with what ${source} declares:`, ...problems.map((line) => ` ${line}`)].join("\n"));
144
283
  return false;
145
284
  }
285
+ const held = [...expected.map((fragment) => fragment.file), ...workflows.map((workflow) => workflow.file)];
286
+ const verb = held.length === 1 ? "holds" : "hold";
146
287
  yield* Console.log(
147
288
  expected.length === 0
148
- ? `${NAME}: no sources.effect is declared, so nothing is generated`
149
- : `${NAME}: ${expected.map((fragment) => fragment.file).join(" and ")} hold what ${source} declares`,
289
+ ? `${NAME}: no sources.effect is declared, so no fragment is generated; ${held.join(" and ")} ${verb} the kit recipe`
290
+ : `${NAME}: ${held.join(" and ")} ${verb} what ${source} declares`,
150
291
  );
151
292
  return true;
152
293
  });
@@ -167,6 +308,12 @@ const generate = Effect.fn("generate")(function* (root: string) {
167
308
  yield* Console.log(`${NAME}: removed ${file}`);
168
309
  }
169
310
  }
311
+ const workflows = workflowsFor(quality, yield* recipeOf(root));
312
+ yield* fs.makeDirectory(path.join(root, ".github", "workflows"), { recursive: true });
313
+ for (const { file, content } of workflows) {
314
+ yield* fs.writeFileString(path.join(root, file), content);
315
+ yield* Console.log(`${NAME}: wrote ${file}`);
316
+ }
170
317
  return yield* check(root);
171
318
  });
172
319
 
@@ -0,0 +1,9 @@
1
+ import { Effect } from "effect";
2
+ import { git, refArgs } from "./git.ts";
3
+ import { readQuality } from "./quality-file.ts";
4
+
5
+ export const rangeGateInputs = Effect.fn("rangeGateInputs")(function* (usage: string) {
6
+ const refs = yield* refArgs(process.argv.slice(2), usage);
7
+ const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
8
+ return { refs, root, ...(yield* readQuality(root)) };
9
+ });