@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.
@@ -18,6 +18,8 @@ It passes over a file with any other extension.
18
18
  `scripts/comment-matchers.ts` holds the scanner, the comment syntaxes and a synchronous `refused()`, and imports nothing.
19
19
  A host such as a hook bundle can therefore copy it alone into a directory with no `node_modules` and import it as `@avi2dg/checks/scripts/comment-matchers.ts`.
20
20
  The kit's own dependency cruise fails when that file gains an import.
21
+ `REFUSED_DIRECTIVES` in that file owns the refused directive names, and the checker builds its directive pattern from that list, so the two cannot disagree.
22
+ A consumer reads the same contract by importing `REFUSED_DIRECTIVES` from `@avi2dg/checks/scripts/comment-matchers.ts`.
21
23
  `scripts/comments.ts` wraps the same matchers in Effect for the gate and for [checks-backtest](checks-backtest.md).
22
24
 
23
25
  ## Arguments
@@ -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
 
@@ -13,7 +13,7 @@ It holds production and test files to the size budget, and lists every other fil
13
13
 
14
14
  A production file is one under `sources.production`, and a test file is a tracked `.ts` or `.tsx` file under `tests/`.
15
15
  A test file keeps to the tests budget, and every other file keeps to the production budget.
16
- It runs oxlint with a configuration of five rules and nothing else, one for each limit.
16
+ It runs oxlint with a configuration of five rules, one for each limit, and the kit's own plugin bundle, which holds the complexity rule.
17
17
  The kit sets each limit:
18
18
 
19
19
  <!-- generated size-limits: bun run build writes it from SIZE_RULES and SIZE_DEFAULTS in scripts/size-rules.ts and scripts/doc-blocks.ts -->
@@ -23,7 +23,7 @@ The kit sets each limit:
23
23
  | `fileLines` | The most lines a file may hold, blank and comment lines counted | `max-lines` | 400 | 600 |
24
24
  | `functionLines` | The most lines a function may span, blank and comment lines counted | `max-lines-per-function` | 100 | none |
25
25
  | `statements` | The most statements a function may hold | `max-statements` | 30 | 50 |
26
- | `complexity` | The highest cyclomatic complexity a function may reach, a switch counted once | `complexity` | 15 | 15 |
26
+ | `complexity` | The highest cognitive complexity a function may reach, a switch counted once | `effect-channel/cognitive-complexity` | 15 | 15 |
27
27
  | `depth` | The deepest a block may nest inside a function | `max-depth` | 4 | 4 |
28
28
 
29
29
  <!-- end generated size-limits -->
@@ -88,8 +88,8 @@ With one it is that commit against its parent, or against the empty tree for a r
88
88
  size-budget: 2 overrun(s) grew past the base in the production and test files the range adds or changes:
89
89
  src/billing/invoice.ts: max-lines over by 31 in total, up from 19
90
90
  src/billing/invoice.ts: File has too many lines (431). Maximum allowed is 400.
91
- src/billing/ledger.ts: complexity over by 3 in total, up from 0
92
- src/billing/ledger.ts:12: function `settle` has a complexity of 18. Maximum allowed is 15.
91
+ src/billing/ledger.ts: cognitive-complexity over by 3 in total, up from 0
92
+ src/billing/ledger.ts:12: function `settle` has a cognitive complexity of 18. Maximum allowed is 15.
93
93
  size-budget: advisory, 1 overrun(s) where the budget does not hold yet:
94
94
  tests/e2e/billing.test.ts: File has too many lines (612). Maximum allowed is 600.
95
95
  ```
package/oxlintrc.json CHANGED
@@ -16,6 +16,7 @@
16
16
  "eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "ignoreRestSiblings": true }],
17
17
  "typescript/no-unsafe-type-assertion": "error",
18
18
  "typescript/no-non-null-assertion": "error",
19
+ "typescript/no-deprecated": "error",
19
20
  "typescript/consistent-return": "off"
20
21
  }
21
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.16.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",
@@ -178,7 +178,7 @@
178
178
  "complexity": {
179
179
  "type": "integer",
180
180
  "exclusiveMinimum": 0,
181
- "description": "The highest cyclomatic complexity a function may reach, a switch counted once; 15 when absent"
181
+ "description": "The highest cognitive complexity a function may reach, a switch counted once; 15 when absent"
182
182
  },
183
183
  "depth": {
184
184
  "type": "integer",
@@ -205,7 +205,7 @@
205
205
  "complexity": {
206
206
  "type": "integer",
207
207
  "exclusiveMinimum": 0,
208
- "description": "The highest cyclomatic complexity a function may reach, a switch counted once; 15 when absent"
208
+ "description": "The highest cognitive complexity a function may reach, a switch counted once; 15 when absent"
209
209
  },
210
210
  "depth": {
211
211
  "type": "integer",
@@ -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;
@@ -244,6 +241,24 @@ function openingBlock(found: readonly Comment[], source: string): Comment[] {
244
241
  return block;
245
242
  }
246
243
 
244
+ export const REFUSED_DIRECTIVES = [
245
+ "@ts-expect-error",
246
+ "@ts-ignore",
247
+ "biome-ignore",
248
+ "eslint-disable",
249
+ "prettier-ignore",
250
+ ] as const satisfies readonly string[];
251
+
252
+ const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
253
+
254
+ const directiveSource = (name: string): string => {
255
+ const escaped = escapeRegExp(name);
256
+ if (name === "eslint-disable") return `\\b${escaped}[\\w-]*`;
257
+ return /^[\w]/.test(name) ? `\\b${escaped}\\b` : escaped;
258
+ };
259
+
260
+ const DIRECTIVE = new RegExp(REFUSED_DIRECTIVES.map(directiveSource).join("|"));
261
+
247
262
  type Check = {
248
263
  readonly find: RegExp;
249
264
  readonly refusal: (match: string) => string;
@@ -251,7 +266,7 @@ type Check = {
251
266
 
252
267
  const CHECKS: readonly Check[] = [
253
268
  {
254
- find: /@ts-expect-error|@ts-ignore|\bprettier-ignore\b|\beslint-disable[\w-]*|\bbiome-ignore\b/,
269
+ find: DIRECTIVE,
255
270
  refusal: (match) =>
256
271
  `carries the machine-read directive \`${match}\`. Fix what the tool is reporting, or stop running the tool on this file`,
257
272
  },
@@ -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) });