@avi2dg/checks 0.17.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,18 @@
2
2
 
3
3
  Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
4
4
 
5
+ ## 0.18.0
6
+
7
+ Released 2026-09-26.
8
+
9
+ ### Features
10
+
11
+ - **scripts:** generate commitlint and CI workflows with checks-quality (#54)
12
+
13
+ ### Fixes
14
+
15
+ - **scripts:** make checks-quality refuse a config that drops the kit's extends (#55)
16
+
5
17
  ## 0.17.0
6
18
 
7
19
  Released 2026-09-25.
@@ -10,24 +10,8 @@ It arrives with the kit, since `@commitlint/cli` and `@commitlint/config-convent
10
10
  ## Workflow
11
11
 
12
12
  The lint runs in CI on pull requests, because `jj` never fires a git hook.
13
- A repository adds this workflow:
14
-
15
- ```yaml
16
- on:
17
- pull_request:
18
- types: [opened, edited, synchronize, reopened]
19
- jobs:
20
- commitlint:
21
- runs-on: ubuntu-latest
22
- steps:
23
- - uses: actions/checkout@v5
24
- - uses: oven-sh/setup-bun@v2
25
- - run: bun install --frozen-lockfile
26
- - run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
27
- env:
28
- PR_TITLE: ${{ github.event.pull_request.title }}
29
- - run: ./node_modules/.bin/commitlint --config ./node_modules/@avi2dg/checks/commitlint.config.js --edit "$RUNNER_TEMP/pr-title"
30
- ```
13
+ `checks-quality generate` writes the workflow whole into `.github/workflows/commitlint.yml`, as [checks-quality](../gates/checks-quality.md) says.
14
+ The workflow lints with the installed kit's `commitlint.config.js`, so every repository holds titles to the same rules.
31
15
 
32
16
  ## What it lints
33
17
 
@@ -39,8 +39,8 @@ The kit's bins find the file at the git root and read it there:
39
39
 
40
40
  | Key | Read by | Holds |
41
41
  | --- | --- | --- |
42
- | `defaultBranch` | `checks-lint`, `checks-ci-wiring` | the branch pull requests merge into, `main` when absent |
43
- | `gates.ci` | `checks-ci-wiring` | the commands CI runs on every pull request, as [checks-ci-wiring](../gates/checks-ci-wiring.md) says |
42
+ | `defaultBranch` | `checks-lint`, `checks-ci-wiring`, `checks-quality` | the branch pull requests merge into, `main` when absent |
43
+ | `gates.ci` | `checks-ci-wiring`, `checks-quality` | the commands CI runs on every pull request, as [checks-ci-wiring](../gates/checks-ci-wiring.md) says |
44
44
  | `gates.scheduled` | `checks-ci-wiring` | the commands a schedule runs |
45
45
  | `gates.lint` | `checks-lint`, `checks-ci-wiring` | the gates `checks-lint` runs when not all apply, as [Gate selection](../gates/checks-lint.md#gate-selection) says |
46
46
  | `commitIdentity.authors` | `checks-commit-identity` | the identities allowed to author and commit, as [checks-commit-identity](../gates/checks-commit-identity.md) says |
@@ -1,6 +1,6 @@
1
1
  # checks-quality
2
2
 
3
- `checks-quality` is the bin that writes what `quality.json` declares for oxlint and tsc into generated fragments and checks them, and a reader looks it up when a fragment is stale.
3
+ `checks-quality` is the bin that writes what `quality.json` declares into generated fragments and workflows and checks them, and a reader looks it up when generated text is stale.
4
4
 
5
5
  ## What it checks
6
6
 
@@ -29,11 +29,20 @@ A rule only this repository needs stays in its own `.oxlintrc.json`, whose overr
29
29
  }
30
30
  ```
31
31
 
32
+ GitHub Actions reads its own YAML and nothing else, so `checks-quality generate` also writes the kit recipe workflows whole.
33
+ `.github/workflows/ci.yml` runs every `gates.ci` command but the title lint as its own step after a frozen install.
34
+ `.github/workflows/commitlint.yml` lints the pull request title with the installed kit config.
35
+ A step one repository alone needs lives in another workflow file, never in the recipe.
36
+ All generated workflows are committed.
37
+
32
38
  `checks-quality --check` fails when any of these holds:
33
39
 
34
40
  - A fragment is missing, or differs from what `generate` would write from `quality.json` and the installed kit's presets.
35
41
  - A fragment is left over once `quality.json` stops declaring `sources.effect`.
42
+ - A generated workflow is missing, or differs from what `generate` would write from `quality.json` and the kit recipe.
36
43
  - `.oxlintrc.json` or `tsconfig.json` does not list its fragment in `extends`, so the tool never reads it.
44
+ - `.oxlintrc.json` does not extend `./node_modules/@avi2dg/checks/oxlintrc.json`, so the kit's oxlint rules are not loaded.
45
+ - `tsconfig.json` does not extend `@avi2dg/checks/tsconfig.effect.json`, the one accepted spelling of the kit's Effect config.
37
46
  - A `sources.effect.paths` or `sources.production` glob matches no tracked or untracked file, so it holds nothing.
38
47
 
39
48
  Two details of the fragments are easy to get wrong, so the kit's tests pin both:
@@ -47,7 +56,10 @@ Two details of the fragments are easy to get wrong, so the kit's tests pin both:
47
56
 
48
57
  ## What it reads
49
58
 
50
- It reads the working tree: `quality.json`, the presets of the installed kit, `.oxlintrc.json`, `tsconfig.json` and the two fragments.
59
+ It reads the working tree: `quality.json`, the presets of the installed kit, `.oxlintrc.json`, `tsconfig.json`, the two fragments and the generated workflows.
60
+ It reads the root `package.json` name, since only the kit's own tree lints titles with its root `commitlint.config.js`.
61
+ The name also decides which kit configs `extends` must list, since the kit's own tree extends its root `oxlintrc.json` and `tsconfig.effect.json`.
62
+ It looks for `.bun-version`, and the suite pins its bun to that file when the file exists.
51
63
  It lists the tracked and untracked files to see what each declared glob matches.
52
64
 
53
65
  ## Arguments
@@ -57,15 +69,15 @@ checks-quality generate
57
69
  checks-quality --check
58
70
  ```
59
71
 
60
- `generate` writes the fragments, removes a left-over one, then runs the same check as `--check`.
72
+ `generate` writes the fragments and the workflows, removes a left-over one, then runs the same check as `--check`.
61
73
  `--check` writes nothing.
62
74
 
63
75
  ## Exit codes
64
76
 
65
77
  | Code | When |
66
78
  | --- | --- |
67
- | 0 | the fragments hold what `quality.json` declares |
68
- | 1 | a fragment is stale, missing, left over or not extended, or a declared glob matches no file |
79
+ | 0 | the generated files hold what `quality.json` declares |
80
+ | 1 | a generated file is stale, missing or left over, a fragment is not extended, the kit's config is not extended, or a declared glob matches no file |
69
81
  | 2 | `quality.json` does not decode, or the arguments are neither `generate` nor `--check` |
70
82
 
71
83
  ## Sample output
@@ -76,16 +88,19 @@ checks-quality: 2 problem(s) with what quality.json declares:
76
88
  tsconfig.json does not extend ./tsconfig.quality.json, so the language service never reads it
77
89
  ```
78
90
 
79
- A passing run says what the fragments hold:
91
+ A passing run says what the generated files hold:
80
92
 
81
93
  ```
82
- checks-quality: oxlintrc.quality.json and tsconfig.quality.json hold what quality.json declares
94
+ checks-quality: oxlintrc.quality.json and tsconfig.quality.json and .github/workflows/ci.yml and .github/workflows/commitlint.yml hold what quality.json declares
83
95
  ```
84
96
 
97
+ A stale workflow is reported the same way as a stale fragment.
98
+
85
99
  ## Opting out
86
100
 
87
101
  It runs only in a repository that tracks `quality.json`, and a selection in `quality.json` always keeps it, since the file it sits in is what makes it apply.
88
- A repository that declares no `sources.effect` gets no fragment, and the check then only refuses a left-over one.
102
+ A repository that declares no `sources.effect` gets no fragment, and the check then refuses a left-over one.
103
+ Every repository gets the title lint workflow, and one with `gates.ci` gets the suite with it.
89
104
 
90
105
  ## Related topics
91
106
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.17.0",
3
+ "version": "0.18.0",
4
4
  "description": "Deterministic checks shared across the captain's TypeScript repos",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -32,6 +32,7 @@
32
32
  "scripts/commit-identity.ts",
33
33
  "scripts/mutation-compare.ts",
34
34
  "scripts/ci-wiring.ts",
35
+ "scripts/shell-command.ts",
35
36
  "scripts/comment-matchers.ts",
36
37
  "scripts/prose-matchers.ts",
37
38
  "scripts/comments.ts",
@@ -41,6 +42,7 @@
41
42
  "scripts/quality-file.ts",
42
43
  "scripts/quality.ts",
43
44
  "scripts/size-budget.ts",
45
+ "scripts/range-gate.ts",
44
46
  "scripts/size-rules.ts",
45
47
  "scripts/repetition.ts",
46
48
  "scripts/feature-owners.ts",
@@ -27,65 +27,72 @@ function withoutLine(refusal: string): string {
27
27
  return refusal.replace(/^(\S+?):\d+ /, "$1 ");
28
28
  }
29
29
 
30
- export const backtest = Effect.fn("backtest")(function* (count: string) {
31
- const lines: string[] = [];
32
- const commits = (yield* git(["log", "--first-parent", "--format=%H%x09%P%x09%s", "-n", count]))
30
+ type Commit = {
31
+ readonly sha: string;
32
+ readonly parent: string;
33
+ readonly subject: string;
34
+ };
35
+
36
+ const firstParentCommits = Effect.fn("firstParentCommits")(function* (count: string) {
37
+ return (yield* git(["log", "--first-parent", "--format=%H%x09%P%x09%s", "-n", count]))
33
38
  .trim()
34
39
  .split("\n")
35
- .map((row) => {
40
+ .map((row): Commit => {
36
41
  const [sha = "", parents = "", subject = ""] = row.split("\t");
37
42
  return { sha, parent: parents.split(" ")[0] ?? "", subject };
38
43
  })
39
44
  .filter((commit) => commit.sha !== "" && commit.parent !== "");
45
+ });
40
46
 
41
- const landed: Landed[] = [];
47
+ const addedLines = Effect.fn("addedLines")(function* ({ sha, parent }: Commit, changed: readonly string[]) {
48
+ const numstat = (yield* git(["diff", "--numstat", parent, sha, "--", ...changed])).trim();
49
+ return numstat === "" ? 0 : numstat.split("\n").reduce((sum, row) => sum + Number(row.split("\t")[0] ?? 0), 0);
50
+ });
51
+
52
+ const pathLanded = Effect.fn("pathLanded")(function* ({ sha, parent }: Commit, path: string) {
53
+ const after = yield* at(sha, path);
54
+ if (Option.isNone(after)) return { comments: 0, refusals: [] };
55
+ const before = Option.getOrElse(yield* at(parent, path), () => "");
56
+
57
+ const wasRefused = new Set((yield* refused(path, before)).map(withoutLine));
58
+ const refusals = (yield* refused(path, after.value)).filter((refusal) => !wasRefused.has(withoutLine(refusal)));
42
59
 
43
- for (const { sha, parent, subject } of commits) {
44
- const changed = (yield* git(["diff", "--name-only", "--diff-filter=d", parent, sha]))
45
- .trim()
46
- .split("\n")
47
- .filter((path) => path !== "" && readable(path));
48
- if (changed.length === 0) continue;
49
-
50
- const numstat = (yield* git(["diff", "--numstat", parent, sha, "--", ...changed])).trim();
51
- const added =
52
- numstat === ""
53
- ? 0
54
- : numstat.split("\n").reduce((sum, row) => sum + Number(row.split("\t")[0] ?? 0), 0);
55
-
56
- let commentLines = 0;
57
- const refusals: string[] = [];
58
-
59
- for (const path of changed) {
60
- const after = yield* at(sha, path);
61
- if (Option.isNone(after)) continue;
62
- const before = Option.getOrElse(yield* at(parent, path), () => "");
63
-
64
- const wasRefused = new Set((yield* refused(path, before)).map(withoutLine));
65
- for (const refusal of yield* refused(path, after.value)) {
66
- if (!wasRefused.has(withoutLine(refusal))) refusals.push(refusal);
67
- }
68
-
69
- const wasComment = new Set((yield* comments(path, before)).map((one) => one.text));
70
- for (const one of yield* comments(path, after.value)) {
71
- if (!wasComment.has(one.text)) commentLines += one.text.split("\n").length;
72
- }
73
- }
74
-
75
- landed.push({ sha: sha.slice(0, 7), subject, added, comments: commentLines, refusals });
60
+ const wasComment = new Set((yield* comments(path, before)).map((one) => one.text));
61
+ const added = (yield* comments(path, after.value)).filter((one) => !wasComment.has(one.text));
62
+ return { comments: added.reduce((sum, one) => sum + one.text.split("\n").length, 0), refusals };
63
+ });
64
+
65
+ const commitLanded = Effect.fn("commitLanded")(function* (commit: Commit) {
66
+ const changed = (yield* git(["diff", "--name-only", "--diff-filter=d", commit.parent, commit.sha]))
67
+ .trim()
68
+ .split("\n")
69
+ .filter((path) => path !== "" && readable(path));
70
+ if (changed.length === 0) return Option.none<Landed>();
71
+
72
+ const added = yield* addedLines(commit, changed);
73
+ let commentLines = 0;
74
+ const refusals: string[] = [];
75
+ for (const path of changed) {
76
+ const one = yield* pathLanded(commit, path);
77
+ commentLines += one.comments;
78
+ refusals.push(...one.refusals);
76
79
  }
80
+ return Option.some<Landed>({ sha: commit.sha.slice(0, 7), subject: commit.subject, added, comments: commentLines, refusals });
81
+ });
77
82
 
83
+ function summary(landed: readonly Landed[]): string {
84
+ const lines: string[] = [];
78
85
  const total = (pick: (one: Landed) => number): number => landed.reduce((sum, one) => sum + pick(one), 0);
79
- const addedLines = total((one) => one.added);
86
+ const added = total((one) => one.added);
80
87
  const commentLines = total((one) => one.comments);
81
88
  const refusalCount = total((one) => one.refusals.length);
82
89
 
83
90
  for (const one of landed) {
84
91
  lines.push(`${one.sha}\t${one.added}\t${one.comments}\t${one.refusals.length}\t${one.subject.slice(0, 58)}`);
85
92
  }
86
- lines.push(`${landed.length} commits touching code, ${addedLines} added lines`);
93
+ lines.push(`${landed.length} commits touching code, ${added} added lines`);
87
94
  lines.push(
88
- `comment lines added: ${commentLines} (${((commentLines / Math.max(addedLines, 1)) * 100).toFixed(1)}% of added lines)`,
95
+ `comment lines added: ${commentLines} (${((commentLines / Math.max(added, 1)) * 100).toFixed(1)}% of added lines)`,
89
96
  );
90
97
  lines.push(`refusals introduced: ${refusalCount}`);
91
98
  for (const one of landed) {
@@ -94,6 +101,15 @@ export const backtest = Effect.fn("backtest")(function* (count: string) {
94
101
  for (const refusal of one.refusals) lines.push(` ${refusal}`);
95
102
  }
96
103
  return lines.join("\n");
104
+ }
105
+
106
+ export const backtest = Effect.fn("backtest")(function* (count: string) {
107
+ const landed: Landed[] = [];
108
+ for (const commit of yield* firstParentCommits(count)) {
109
+ const one = yield* commitLanded(commit);
110
+ if (Option.isSome(one)) landed.push(one.value);
111
+ }
112
+ return summary(landed);
97
113
  });
98
114
 
99
115
  const main = Effect.gen(function* () {
@@ -4,8 +4,9 @@ import { git } from "./git.ts";
4
4
  import { runMain } from "./main.ts";
5
5
  import { DEFAULT_BRANCH, ENTRY_POINT, EVERY_REPOSITORY, KIT_GATES, QUALITY_FILE, selectedGates, type KitGate } from "./gates.ts";
6
6
  import { readQuality, type Quality } from "./quality-file.ts";
7
+ import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
7
8
 
8
- export type Command = readonly string[];
9
+ export type { Command };
9
10
 
10
11
  export type Gate = {
11
12
  readonly command: string;
@@ -61,8 +62,6 @@ const CONSTANTS = new Map([
61
62
  ["false", false],
62
63
  ]);
63
64
  const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
64
- const VARIABLE = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/;
65
- const UNPLAIN = new Set(["|", "&", ";", "<", ">", "(", ")", "`", "\\", "#", "\n"]);
66
65
 
67
66
  function isRecord(value: unknown): value is Record<string, unknown> {
68
67
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -74,66 +73,6 @@ function names(value: unknown): readonly string[] | undefined {
74
73
  return value.filter((entry): entry is string => typeof entry === "string");
75
74
  }
76
75
 
77
- function expansion(text: string, from: number): number | undefined {
78
- const match = VARIABLE.exec(text.slice(from));
79
- return match === null ? undefined : from + match[0].length;
80
- }
81
-
82
- // A script counts only when it is one line of plain words: any shell control, redirection or
83
- // substitution can run the gate without its failure failing the step.
84
- function plainCommand(script: string): Command | undefined {
85
- const line = script.trim();
86
- const words: string[] = [];
87
- let word: string | undefined;
88
- for (let index = 0; index < line.length; index += 1) {
89
- const char = line.charAt(index);
90
- if (char === " " || char === "\t") {
91
- if (word !== undefined) words.push(word);
92
- word = undefined;
93
- } else if (char === "'") {
94
- const close = line.indexOf("'", index + 1);
95
- if (close === -1) return undefined;
96
- word = (word ?? "") + line.slice(index + 1, close);
97
- index = close;
98
- } else if (char === '"') {
99
- let quoted = "";
100
- for (index += 1; line.charAt(index) !== '"'; index += 1) {
101
- const inner = line.charAt(index);
102
- if (inner === "" || inner === "\\" || inner === "`") return undefined;
103
- if (inner === "$") {
104
- const end = expansion(line, index);
105
- if (end === undefined) return undefined;
106
- quoted += line.slice(index, end);
107
- index = end - 1;
108
- } else {
109
- quoted += inner;
110
- }
111
- }
112
- word = (word ?? "") + quoted;
113
- } else if (char === "$") {
114
- const end = expansion(line, index);
115
- if (end === undefined) return undefined;
116
- word = (word ?? "") + line.slice(index, end);
117
- index = end - 1;
118
- } else if (UNPLAIN.has(char)) {
119
- return undefined;
120
- } else {
121
- word = (word ?? "") + char;
122
- }
123
- }
124
- if (word !== undefined) words.push(word);
125
- return words.length === 0 ? undefined : words;
126
- }
127
-
128
- function invokes(command: Command | undefined, gate: Command): boolean {
129
- return command !== undefined && gate.length <= command.length && gate.every((word, index) => command[index] === word);
130
- }
131
-
132
- function mentions(script: string, gate: Command): boolean {
133
- const tokens = script.split(/[\s|&;<>()`]+/);
134
- return tokens.some((_, start) => invokes(tokens.slice(start), gate));
135
- }
136
-
137
76
  function constant(value: unknown): boolean | undefined {
138
77
  if (typeof value === "boolean") return value;
139
78
  if (typeof value !== "string") return undefined;
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Option } from "effect";
3
3
  import { refused, syntaxOf } from "./comments.ts";
4
- import { changedLines, git, parentOrEmptyTree } from "./git.ts";
5
- import { runMain, Usage } from "./main.ts";
4
+ import { changedLines, git, parentOrEmptyTree, refArgs } from "./git.ts";
5
+ import { runMain } from "./main.ts";
6
6
 
7
7
  export type GateResult = {
8
8
  readonly files: number;
@@ -41,8 +41,7 @@ export function report({ files, addedLines, violations }: GateResult): string {
41
41
  }
42
42
 
43
43
  const gate = Effect.gen(function* () {
44
- const [first, second, ...extra] = process.argv.slice(2);
45
- if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
44
+ const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
46
45
 
47
46
  const root = (yield* git(["rev-parse", "--show-toplevel"], process.cwd())).trim();
48
47
  const base = second === undefined ? yield* parentOrEmptyTree(first, root) : first;
@@ -147,70 +147,67 @@ function endOfRegex(source: string, start: number): number {
147
147
  return source.length;
148
148
  }
149
149
 
150
+ type Token = { readonly stop: number; readonly previous: string };
151
+
152
+ function endOfBlockComment(source: string, i: number, syntax: Syntax): number | undefined {
153
+ const block = syntax.block.find(([open]) => source.startsWith(open, i));
154
+ if (block === undefined) return undefined;
155
+ const [open, close] = block;
156
+ const closed = source.indexOf(close, i + open.length);
157
+ return closed < 0 ? source.length : closed + close.length;
158
+ }
159
+
160
+ function endOfLineComment(source: string, i: number, syntax: Syntax): number | undefined {
161
+ const marker = syntax.line.find(
162
+ ({ token, afterAWordBreak }) =>
163
+ source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source.charAt(i - 1))),
164
+ );
165
+ if (marker === undefined) return undefined;
166
+ const newline = source.indexOf("\n", i);
167
+ return newline < 0 ? source.length : newline;
168
+ }
169
+
170
+ function endOfWord(source: string, i: number): number {
171
+ let end = i;
172
+ while (end < source.length && WORD.test(source.charAt(end))) end += 1;
173
+ return end;
174
+ }
175
+
176
+ function codeToken(source: string, i: number, syntax: Syntax, previous: string): Token {
177
+ const quote = syntax.quotes.find(
178
+ ({ token, opensMidWord }) =>
179
+ source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source.charAt(i - 1))),
180
+ );
181
+ if (quote) return { stop: endOfQuoted(source, i, quote), previous: quote.token };
182
+
183
+ const char = source.charAt(i);
184
+ if (syntax.regexLiterals && char === "/" && opensRegex(previous)) return { stop: endOfRegex(source, i), previous: "/" };
185
+ if (WORD.test(char)) {
186
+ const end = endOfWord(source, i);
187
+ return { stop: end, previous: source.slice(i, end) };
188
+ }
189
+ return { stop: i + 1, previous: /\s/.test(char) ? previous : char };
190
+ }
191
+
192
+ function newlinesIn(source: string, from: number, to: number): number {
193
+ let count = 0;
194
+ for (let k = from; k < to; k++) if (source[k] === "\n") count += 1;
195
+ return count;
196
+ }
197
+
150
198
  export function commentsIn(source: string, syntax: Syntax): Comment[] {
151
199
  const found: Comment[] = [];
152
200
  let i = 0;
153
201
  let line = 1;
154
202
  let previous = "";
155
203
 
156
- const advance = (to: number): void => {
157
- for (let k = i; k < to; k++) if (source[k] === "\n") line += 1;
158
- i = to;
159
- };
160
-
161
204
  while (i < source.length) {
162
- const char = source.charAt(i);
163
-
164
- const block = syntax.block.find(([open]) => source.startsWith(open, i));
165
- if (block) {
166
- const [open, close] = block;
167
- const closed = source.indexOf(close, i + open.length);
168
- const stop = closed < 0 ? source.length : closed + close.length;
169
- found.push({ line, text: source.slice(i, stop) });
170
- advance(stop);
171
- previous = "";
172
- continue;
173
- }
174
-
175
- const marker = syntax.line.find(
176
- ({ token, afterAWordBreak }) =>
177
- source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source.charAt(i - 1))),
178
- );
179
- if (marker) {
180
- const newline = source.indexOf("\n", i);
181
- const stop = newline < 0 ? source.length : newline;
182
- found.push({ line, text: source.slice(i, stop) });
183
- advance(stop);
184
- previous = "";
185
- continue;
186
- }
187
-
188
- const quote = syntax.quotes.find(
189
- ({ token, opensMidWord }) =>
190
- source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source.charAt(i - 1))),
191
- );
192
- if (quote) {
193
- advance(endOfQuoted(source, i, quote));
194
- previous = quote.token;
195
- continue;
196
- }
197
-
198
- if (syntax.regexLiterals && char === "/" && opensRegex(previous)) {
199
- advance(endOfRegex(source, i));
200
- previous = "/";
201
- continue;
202
- }
203
-
204
- if (WORD.test(char)) {
205
- let end = i;
206
- while (end < source.length && WORD.test(source.charAt(end))) end += 1;
207
- previous = source.slice(i, end);
208
- advance(end);
209
- continue;
210
- }
211
-
212
- if (!/\s/.test(char)) previous = char;
213
- advance(i + 1);
205
+ const commentStop = endOfBlockComment(source, i, syntax) ?? endOfLineComment(source, i, syntax);
206
+ if (commentStop !== undefined) found.push({ line, text: source.slice(i, commentStop) });
207
+ const token = commentStop === undefined ? codeToken(source, i, syntax, previous) : { stop: commentStop, previous: "" };
208
+ line += newlinesIn(source, i, token.stop);
209
+ i = token.stop;
210
+ previous = token.previous;
214
211
  }
215
212
 
216
213
  return found;
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Schema } from "effect";
3
- import { git } from "./git.ts";
4
- import { runMain, Usage } from "./main.ts";
3
+ import { git, refArgs } from "./git.ts";
4
+ import { runMain } from "./main.ts";
5
5
  import { readQuality, type Identity } from "./quality-file.ts";
6
6
 
7
7
  type Commit = {
@@ -98,8 +98,7 @@ function inspect(commit: Commit, allowed: readonly Identity[]): Offence | undefi
98
98
  }
99
99
 
100
100
  const check = Effect.gen(function* () {
101
- const [first, second, ...extra] = process.argv.slice(2);
102
- if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
101
+ const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
103
102
  const range = second === undefined ? first : `${first}..${second}`;
104
103
  const revisions = second === undefined ? ["-1", range] : [range];
105
104
 
@@ -156,32 +156,50 @@ function missing(slot: Slot, level: number): string {
156
156
  return slot.type === "fixed" ? `lacks ${marked(level, slot.text)}` : `lacks a ${marked(level, slot.placeholder)} section`;
157
157
  }
158
158
 
159
+ type Placement =
160
+ | { readonly kind: "behind"; readonly index: number }
161
+ | { readonly kind: "unplaced" }
162
+ | { readonly kind: "placed"; readonly index: number; readonly slot: Slot; readonly at: number };
163
+
164
+ function placementOf(slots: readonly Slot[], title: string, at: number): Placement {
165
+ const fixed = fixedIndexOf(slots, title);
166
+ if (fixed !== -1 && fixed <= at) return { kind: "behind", index: fixed };
167
+ const open = openIndexFrom(slots, at);
168
+ const index = fixed !== -1 ? fixed : open.index;
169
+ const slot = slots[index];
170
+ if (slot === undefined) return { kind: "unplaced" };
171
+ return { kind: "placed", index, slot, at: fixed !== -1 || open.advances ? index : at };
172
+ }
173
+
174
+ function placedProblems(slot: Slot, { heading, subsections }: Section, level: number): Violation[] {
175
+ const problem = slot.type === "open" ? ruleProblem(slot.rule, heading.title) : undefined;
176
+ return [
177
+ ...(problem === undefined ? [] : [{ line: heading.line, message: `${marked(level, heading.title)} ${problem}` }]),
178
+ ...(slot.subsections === undefined ? [] : matchSections(subsections, slot.subsections, level + 1, heading.line)),
179
+ ];
180
+ }
181
+
159
182
  export function matchSections(sections: readonly Section[], slots: readonly Slot[], level: number, parentLine: number): Violation[] {
160
183
  const order = `the template's order is ${slots.map(slotLabel).join(", ")}`;
161
184
  const violations: Violation[] = [];
162
185
  const found = slots.map(() => false);
163
186
  let at = -1;
164
- for (const { heading, subsections } of sections) {
187
+ for (const section of sections) {
188
+ const { heading } = section;
165
189
  const named = marked(level, heading.title);
166
- const fixed = fixedIndexOf(slots, heading.title);
167
- if (fixed !== -1 && fixed <= at) {
168
- found[fixed] = true;
169
- const problem = fixed === at ? "appears twice" : `is out of order, as ${order}`;
170
- violations.push({ line: heading.line, message: `${named} ${problem}` });
190
+ const placement = placementOf(slots, heading.title, at);
191
+ if (placement.kind === "unplaced") {
192
+ violations.push({ line: heading.line, message: `${named} is not a section the template has there, as ${order}` });
171
193
  continue;
172
194
  }
173
- const open = openIndexFrom(slots, at);
174
- const index = fixed !== -1 ? fixed : open.index;
175
- const slot = slots[index];
176
- if (slot === undefined) {
177
- violations.push({ line: heading.line, message: `${named} is not a section the template has there, as ${order}` });
195
+ found[placement.index] = true;
196
+ if (placement.kind === "behind") {
197
+ const problem = placement.index === at ? "appears twice" : `is out of order, as ${order}`;
198
+ violations.push({ line: heading.line, message: `${named} ${problem}` });
178
199
  continue;
179
200
  }
180
- if (fixed !== -1 || open.advances) at = index;
181
- found[index] = true;
182
- const problem = slot.type === "open" ? ruleProblem(slot.rule, heading.title) : undefined;
183
- if (problem !== undefined) violations.push({ line: heading.line, message: `${named} ${problem}` });
184
- if (slot.subsections !== undefined) violations.push(...matchSections(subsections, slot.subsections, level + 1, heading.line));
201
+ at = placement.at;
202
+ violations.push(...placedProblems(placement.slot, section, level));
185
203
  }
186
204
  slots.forEach((slot, index) => {
187
205
  if (slot.presence.required && found[index] !== true) violations.push({ line: parentLine, message: missing(slot, level) });