@avi2dg/checks 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -63,6 +63,8 @@ below. Three of them:
63
63
  `lint-coverage.sh` fails when oxlint silently skips a tracked `.ts` or
64
64
  `.tsx` file, for example through a stray `.gitignore` entry. It compares
65
65
  `git ls-files` against oxlint's own file walk and names the missing files.
66
+ It exits 2 when oxlint cannot walk the tree, as when it is not on `PATH`
67
+ or its config does not parse.
66
68
 
67
69
  `test-layout.ts` decides the test layout described below.
68
70
 
@@ -87,7 +89,9 @@ The registry version is pinned by the consumer's lockfile; bump
87
89
  ## Lint entry point
88
90
 
89
91
  `checks-lint` runs each of the kit's lint gates in turn and names every
90
- one that fails, rather than stopping at the first:
92
+ one that fails, rather than stopping at the first. A repository whose
93
+ tracked files give a gate nothing to check can leave it out through
94
+ `ciWiring.lintGates`; see "Gate selection" under "CI wiring".
91
95
 
92
96
  | Gate | Reads |
93
97
  | --- | --- |
@@ -122,13 +126,29 @@ tip: commits the base branch gained after the head branched off would
122
126
  otherwise count against the head. When the head is the merge base, as
123
127
  on a push to the default branch or a local run on it, the range would be
124
128
  empty, so each range gate is handed that tip commit alone and checks it
125
- against its parent:
129
+ against its parent, or against the empty tree when it is a repository's
130
+ first commit:
126
131
 
127
132
  ```
128
133
  checks-lint: tip 10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
129
134
  ```
130
135
 
131
- It prints the range, each gate's own report, then its verdict:
136
+ A clone with no remote-tracking refs at all, such as a freshly
137
+ initialised repository with no remote or one whose remote was never
138
+ fetched, has no default branch to start from, so each range gate is
139
+ handed `HEAD` alone the same way:
140
+
141
+ ```
142
+ checks-lint: tip 10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD alone, as the clone has no remote-tracking refs
143
+ ```
144
+
145
+ Once any ref sits under `refs/remotes/`, a missing
146
+ `origin/<default branch>` exits 2 instead, since that is the shape of a
147
+ shallow CI checkout, where `HEAD` alone would leave the commits before
148
+ it unchecked.
149
+
150
+ It prints the range, the declared selection if there is one, each
151
+ gate's own report, then its verdict:
132
152
 
133
153
  ```
134
154
  checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed72624120a1542e51bd21ca7c7 from HEAD against origin/main
@@ -136,10 +156,11 @@ checks-lint: range 2504acf098d120e73a8ece3c96f22b934f35c6a8..10ba7d8935b73ed7262
136
156
  checks-lint: 3 of 6 gate(s) failed: checks-commit-identity, checks-comment-gate, checks-suppressions-ratchet
137
157
  ```
138
158
 
139
- It exits 1 when any gate found a violation, and 2 when the range does
140
- not resolve or no failing gate could decide. Every gate runs, so a
141
- repository on `checks-lint` declares `ciWiring.gates` (see "CI wiring")
142
- and holds the test layout.
159
+ It exits 1 when any gate found a violation, and 2 when the range or the
160
+ selection does not resolve, or no failing gate could decide. ci-wiring
161
+ always runs, so a repository on `checks-lint` declares `ciWiring.gates`
162
+ (see "CI wiring"), and it holds the test layout unless its selection
163
+ leaves out `checks-test-layout`.
143
164
 
144
165
  CI runs it through `lint`. The checkout fetches the whole history, which
145
166
  the merge base needs:
@@ -207,7 +228,9 @@ which need the `node`, `promise` and `unicorn` plugins in the override's
207
228
  `plugins`. `unicorn/no-process-exit` passes over any file that opens with
208
229
  a shebang, so a bin also needs `no-restricted-properties` on
209
230
  `process.exit`. Sites standing when the override lands go in oxlint's own
210
- baseline, `oxlint --suppress-all`, so their count can only fall.
231
+ baseline, `oxlint --suppress-all`, so their count can only fall. A later
232
+ override lifts `effect-channel/no-throw` from `scripts/comment-matchers.ts`,
233
+ the one file there a host loads without `node_modules`.
211
234
 
212
235
  The language service holds the same paths to Effect-native IO through
213
236
  `overrides` in the plugin block of `tsconfig.json`. effect-tsgo keeps the
@@ -408,15 +431,23 @@ bun run checks-comment-gate <ref>
408
431
  ```
409
432
 
410
433
  With two arguments it diffs the base against the head. With one it diffs
411
- that commit against its parent, and exits 2 when that parent is not in
412
- the clone rather than widening to the whole tree, so a shallow checkout
413
- running the one-argument form needs `fetch-depth: 2`. Only added lines
434
+ that commit against its parent, or against the empty tree for a
435
+ repository's first commit, which has none. It exits 2 when the parent
436
+ exists but is not in the clone rather than widening to the whole tree,
437
+ so a shallow checkout running the one-argument form needs
438
+ `fetch-depth: 2`. Only added lines
414
439
  are checked, so a violation in a file the diff never touches stays
415
440
  silent, and a refusal counts when any line of the comment carrying it was
416
441
  added. The check refuses a machine-read directive, a record or ticket
417
442
  pointer, a doc block, and a file opening with a rationale block over
418
- three lines, licence headers excepted; `scripts/comments.ts` holds the
419
- scanner the gate and the backtest share.
443
+ three lines, licence headers excepted.
444
+
445
+ `scripts/comment-matchers.ts` holds the scanner, the comment syntaxes
446
+ and a synchronous `refused()`, and imports nothing, so a host such as a
447
+ hook bundle can copy it alone into a directory with no `node_modules`
448
+ and import it as `@avi2dg/checks/scripts/comment-matchers.ts`. The repo's
449
+ cruise fails when it gains an import. `scripts/comments.ts` wraps the
450
+ same matchers in Effect for the gate and the backtest.
420
451
 
421
452
  `checks-lint` runs it over each pull request's range; see "Lint entry point".
422
453
 
@@ -434,8 +465,9 @@ bun run checks-suppressions-ratchet <base-ref> <head-ref>
434
465
  bun run checks-suppressions-ratchet <ref>
435
466
  ```
436
467
 
437
- With one argument it compares that commit with its parent, and exits 2
438
- when the parent is not in the clone. With two it reads the base where
468
+ With one argument it compares that commit with its parent, or with the
469
+ empty tree for a repository's first commit, and exits 2 when the parent
470
+ exists but is not in the clone. With two it reads the base where
439
471
  the head branched off, at their merge-base, so a count the base branch
440
472
  lowered since does not read as a rise at the head. A count that fell and
441
473
  an entry that went both pass. A commit without the file counts as empty,
@@ -527,6 +559,55 @@ It exits 2 when `package.json` declares no gates, a gate is not one
527
559
  plain command, or a workflow does not parse. Whether a workflow is
528
560
  well formed is actionlint's question, not this one's.
529
561
 
562
+ ### Gate selection
563
+
564
+ A repository with no TypeScript source gives `checks-lint-coverage` and
565
+ `checks-test-layout` nothing to check, and test-layout still refuses its
566
+ missing `bun test` script and `bunfig.toml`. It declares the gates
567
+ `checks-lint` runs as `lintGates`, beside `gates`:
568
+
569
+ ```json
570
+ "ciWiring": {
571
+ "gates": ["bun run lint"],
572
+ "lintGates": [
573
+ "checks-commit-identity",
574
+ "checks-comment-gate",
575
+ "checks-suppressions-ratchet",
576
+ "checks-ci-wiring"
577
+ ]
578
+ }
579
+ ```
580
+
581
+ `checks-lint` runs exactly those, in the "Lint entry point" table's
582
+ order, and all six when `lintGates` is absent. A step running
583
+ `checks-lint` then counts only for a declared gate that `lintGates`
584
+ keeps.
585
+
586
+ A selection may leave out only a gate that does not apply:
587
+
588
+ | Gate | Applies when the repository |
589
+ | --- | --- |
590
+ | `checks-lint-coverage` | tracks a `.ts` or `.tsx` file |
591
+ | `checks-test-layout` | tracks a `.ts` or `.tsx` file |
592
+ | `checks-commit-identity` | always |
593
+ | `checks-comment-gate` | always |
594
+ | `checks-suppressions-ratchet` | always |
595
+ | `checks-ci-wiring` | always |
596
+
597
+ Both bins exit 2 on a `lintGates` that names an unknown gate or leaves
598
+ out one that always applies. ci-wiring exits 1 when the
599
+ selection leaves out a gate the repository's tracked files make
600
+ applicable, and names the gate and the files:
601
+
602
+ ```
603
+ ci-wiring: ciWiring.lintGates leaves out 2 gate(s) this repository's contents make applicable:
604
+ checks-lint-coverage: the repository tracks TypeScript source (src/widget.ts)
605
+ checks-test-layout: the repository tracks TypeScript source (src/widget.ts)
606
+ ```
607
+
608
+ It reads the files tracked at the checkout, so the pull request that
609
+ adds the first TypeScript file is the one refused.
610
+
530
611
  ### Limits
531
612
 
532
613
  The check reads workflow files and never runs them, so it deliberately
@@ -655,6 +736,11 @@ The shared Stryker preset's `json` reporter writes
655
736
  the entry point, and `lint-coverage.sh` stays a shell script. The
656
737
  gates run one at a time with their output passed straight through, so
657
738
  each report reads whole and in the table's order.
739
+ - A gate selection is checked against the repository's contents rather
740
+ than trusted, so it cannot skip a gate that applies. ci-wiring does
741
+ that check, which is why a selection without it, or without another
742
+ gate that applies everywhere, is refused as `checks-lint` reads it:
743
+ nothing would check the selection otherwise.
658
744
  - `checks-ci-wiring` runs inside `lint`, not in a workflow of its own:
659
745
  deleting the step that runs a check is the violation it catches, so the
660
746
  local `lint` is where it has to fail.
@@ -695,7 +781,7 @@ a tag off `main` and reruns the build, `dist/` check, lint, typecheck
695
781
  and tests before it publishes:
696
782
 
697
783
  ```sh
698
- git tag v0.8.0 && git push origin v0.8.0
784
+ git tag v0.9.0 && git push origin v0.9.0
699
785
  ```
700
786
 
701
787
  The `release` workflow publishes the tagged version through npm
package/oxlintrc.json CHANGED
@@ -14,8 +14,8 @@
14
14
  "typescript/no-unnecessary-type-parameters": "error",
15
15
  "typescript/use-unknown-in-catch-callback-variable": "error",
16
16
  "eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "ignoreRestSiblings": true }],
17
- "typescript/no-unsafe-type-assertion": "off",
18
- "typescript/no-non-null-assertion": "off",
17
+ "typescript/no-unsafe-type-assertion": "error",
18
+ "typescript/no-non-null-assertion": "error",
19
19
  "typescript/consistent-return": "off"
20
20
  }
21
21
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "description": "Deterministic checks shared across the captain's TypeScript repos",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -24,6 +24,7 @@
24
24
  "scripts/commit-identity.ts",
25
25
  "scripts/mutation-compare.ts",
26
26
  "scripts/ci-wiring.ts",
27
+ "scripts/comment-matchers.ts",
27
28
  "scripts/comments.ts",
28
29
  "scripts/comment-gate.ts",
29
30
  "scripts/suppressions-ratchet.ts",
@@ -43,6 +44,7 @@
43
44
  "./scripts/commit-identity.ts": "./scripts/commit-identity.ts",
44
45
  "./scripts/mutation-compare.ts": "./scripts/mutation-compare.ts",
45
46
  "./scripts/ci-wiring.ts": "./scripts/ci-wiring.ts",
47
+ "./scripts/comment-matchers.ts": "./scripts/comment-matchers.ts",
46
48
  "./scripts/comments.ts": "./scripts/comments.ts",
47
49
  "./scripts/comment-gate.ts": "./scripts/comment-gate.ts",
48
50
  "./scripts/suppressions-ratchet.ts": "./scripts/suppressions-ratchet.ts",
@@ -65,7 +67,7 @@
65
67
  },
66
68
  "scripts": {
67
69
  "build": "bun build effect-channel/index.ts --outdir dist --target node --format esm",
68
- "lint": "oxlint --type-aware && bun scripts/lint.ts && depcruise --config .dependency-cruiser.cjs effect-channel scripts tests commitlint.config.js dependency-cruiser.config.js stryker.preset.js .dependency-cruiser.cjs",
70
+ "lint": "oxlint --type-aware && bun scripts/lint.ts && depcruise --config .dependency-cruiser.cjs .",
69
71
  "typecheck": "tsc --noEmit && effect-tsgo diagnostics --project tsconfig.json --format text --strict",
70
72
  "test": "bun test --randomize"
71
73
  },
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Option } from "effect";
3
- import { comments, refused, SYNTAXES } from "./comments.ts";
3
+ import { comments, refused, syntaxOf } from "./comments.ts";
4
4
  import { git } from "./git.ts";
5
5
  import { runMain, Usage } from "./main.ts";
6
6
 
@@ -18,8 +18,7 @@ const EXCLUDED = ["generated/", "vendor/", "repos/", "node_modules/", "dist/"];
18
18
 
19
19
  function readable(path: string): boolean {
20
20
  if (EXCLUDED.some((prefix) => path.startsWith(prefix))) return false;
21
- const extension = path.slice(path.lastIndexOf(".") + 1);
22
- return extension in SYNTAXES;
21
+ return syntaxOf(path) !== undefined;
23
22
  }
24
23
 
25
24
  const at = (sha: string, path: string) => git(["show", `${sha}:${path}`]).pipe(Effect.option);
@@ -2,7 +2,15 @@
2
2
  import { Console, Effect, FileSystem, Path, Schema } from "effect";
3
3
  import { git } from "./git.ts";
4
4
  import { runMain } from "./main.ts";
5
- import { DEFAULT_BRANCH, ENTRY_POINT, KIT_GATES } from "./gates.ts";
5
+ import {
6
+ DEFAULT_BRANCH,
7
+ ENTRY_POINT,
8
+ EVERY_REPOSITORY,
9
+ KIT_GATES,
10
+ LintWiring,
11
+ selectedGates,
12
+ type KitGate,
13
+ } from "./gates.ts";
6
14
 
7
15
  export type Command = readonly string[];
8
16
 
@@ -14,6 +22,7 @@ export type Gate = {
14
22
  export type Declaration = {
15
23
  readonly gates: readonly Gate[];
16
24
  readonly defaultBranch: string;
25
+ readonly lintGates: readonly KitGate[];
17
26
  };
18
27
 
19
28
  export type Workflow = {
@@ -26,6 +35,12 @@ export type BlockedInvocation = {
26
35
  readonly blocker: string;
27
36
  };
28
37
 
38
+ export type Omission = {
39
+ readonly gate: string;
40
+ readonly content: string;
41
+ readonly files: readonly [string, ...string[]];
42
+ };
43
+
29
44
  export type Gap = {
30
45
  readonly gate: string;
31
46
  readonly entryPoint?: string;
@@ -230,15 +245,15 @@ function runSteps(workflows: readonly Workflow[], branch: string): readonly RunS
230
245
  return steps;
231
246
  }
232
247
 
233
- function entryPointCommand(gate: Command): Command | undefined {
234
- const index = gate.findIndex((word) => KIT_GATES.some((kitGate) => kitGate.bin === word));
248
+ function entryPointCommand(gate: Command, lintGates: readonly KitGate[]): Command | undefined {
249
+ const index = gate.findIndex((word) => lintGates.some((kitGate) => kitGate.bin === word));
235
250
  return index === -1 ? undefined : [...gate.slice(0, index), ENTRY_POINT.bin];
236
251
  }
237
252
 
238
253
  export function findGaps(declaration: Declaration, workflows: readonly Workflow[]): readonly Gap[] {
239
254
  const steps = runSteps(workflows, declaration.defaultBranch);
240
255
  return declaration.gates.flatMap((gate) => {
241
- const entryPoint = entryPointCommand(gate.words);
256
+ const entryPoint = entryPointCommand(gate.words, declaration.lintGates);
242
257
  const commands = [
243
258
  { command: gate.command, words: gate.words },
244
259
  ...(entryPoint === undefined ? [] : [{ command: entryPoint.join(" "), words: entryPoint }]),
@@ -273,6 +288,8 @@ export function formatReport(declaration: Declaration, gaps: readonly Gap[]): st
273
288
  return lines.join("\n");
274
289
  }
275
290
 
291
+ const decodeWiring = Schema.decodeUnknownEffect(LintWiring);
292
+
276
293
  export const parseDeclaration = Effect.fnUntraced(function* (
277
294
  manifest: unknown,
278
295
  source: string,
@@ -293,13 +310,49 @@ export const parseDeclaration = Effect.fnUntraced(function* (
293
310
  }
294
311
  gates.push({ command, words });
295
312
  }
296
- const defaultBranch = configured["defaultBranch"] ?? DEFAULT_BRANCH;
297
- if (typeof defaultBranch !== "string" || defaultBranch === "") {
298
- return yield* new WiringError({ message: `${source} ciWiring.defaultBranch is not a branch name` });
313
+ const { ciWiring } = yield* decodeWiring(manifest).pipe(
314
+ Effect.mapError((cause) => new WiringError({ message: `${source}: ${cause.message}` })),
315
+ );
316
+ return {
317
+ gates,
318
+ defaultBranch: ciWiring?.defaultBranch ?? DEFAULT_BRANCH,
319
+ lintGates: selectedGates(ciWiring?.lintGates),
320
+ };
321
+ });
322
+
323
+ function omittedFrom(lintGates: readonly KitGate[]): readonly KitGate[] {
324
+ return KIT_GATES.filter((gate) => !lintGates.includes(gate));
325
+ }
326
+
327
+ export const findOmissions = Effect.fn("findOmissions")(function* (root: string, lintGates: readonly KitGate[]) {
328
+ const omissions: Omission[] = [];
329
+ for (const gate of omittedFrom(lintGates)) {
330
+ // LintGates refuses a selection that leaves out a gate every repository runs.
331
+ if (gate.appliesTo === EVERY_REPOSITORY) continue;
332
+ const { pathspecs, content } = gate.appliesTo;
333
+ const [first, ...rest] = (yield* git(["ls-files", "-z", "--", ...pathspecs], root)).split("\0").filter(Boolean);
334
+ if (first !== undefined) omissions.push({ gate: gate.bin, content, files: [first, ...rest] });
299
335
  }
300
- return { gates, defaultBranch };
336
+ return omissions;
301
337
  });
302
338
 
339
+ function sample([first, ...rest]: Omission["files"]): string {
340
+ return rest.length === 0 ? first : `${first} and ${rest.length} more`;
341
+ }
342
+
343
+ export function formatOmissions(lintGates: readonly KitGate[], omissions: readonly Omission[]): string | undefined {
344
+ const omitted = omittedFrom(lintGates);
345
+ if (omitted.length === 0) return undefined;
346
+ if (omissions.length === 0) {
347
+ const bins = omitted.map((gate) => gate.bin).join(", ");
348
+ return `ci-wiring: ciWiring.lintGates leaves out ${bins}, none of which this repository's contents make applicable`;
349
+ }
350
+ return [
351
+ `ci-wiring: ciWiring.lintGates leaves out ${omissions.length} gate(s) this repository's contents make applicable:`,
352
+ ...omissions.map(({ gate, content, files }) => ` ${gate}: the repository tracks ${content} (${sample(files)})`),
353
+ ].join("\n");
354
+ }
355
+
303
356
  export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
304
357
  Effect.try({
305
358
  try: () => ({ path, document: Bun.YAML.parse(text) }),
@@ -337,12 +390,14 @@ const wiring = Effect.gen(function* () {
337
390
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
338
391
  const declaration = yield* readDeclaration(root);
339
392
  const gaps = findGaps(declaration, yield* readWorkflows(root));
340
- if (gaps.length > 0) {
341
- yield* Console.error(formatReport(declaration, gaps));
342
- return false;
393
+ const omissions = yield* findOmissions(root, declaration.lintGates);
394
+ const gapReport = formatReport(declaration, gaps);
395
+ yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
396
+ const omissionReport = formatOmissions(declaration.lintGates, omissions);
397
+ if (omissionReport !== undefined) {
398
+ yield* omissions.length > 0 ? Console.error(omissionReport) : Console.log(omissionReport);
343
399
  }
344
- yield* Console.log(formatReport(declaration, gaps));
345
- return true;
400
+ return gaps.length === 0 && omissions.length === 0;
346
401
  });
347
402
 
348
403
  if (import.meta.main) runMain("ci-wiring", wiring);
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Option } from "effect";
3
- import { refused, SYNTAXES } from "./comments.ts";
4
- import { git } from "./git.ts";
3
+ import { refused, syntaxOf } from "./comments.ts";
4
+ import { git, parentOrEmptyTree } from "./git.ts";
5
5
  import { runMain, Usage } from "./main.ts";
6
6
 
7
7
  export type GateResult = {
@@ -13,7 +13,7 @@ export type GateResult = {
13
13
  const USAGE = "usage: comment-gate.ts <ref> | <base-ref> <head-ref>";
14
14
 
15
15
  function readable(path: string): boolean {
16
- return path.slice(path.lastIndexOf(".") + 1) in SYNTAXES;
16
+ return syntaxOf(path) !== undefined;
17
17
  }
18
18
 
19
19
  function newPathOf(line: string): string | undefined {
@@ -83,15 +83,12 @@ export function report({ files, addedLines, violations }: GateResult): string {
83
83
  return [`comment-gate: ${violations.length} violation(s):`, ...violations.map((one) => ` ${one}`)].join("\n");
84
84
  }
85
85
 
86
- const parentOf = (root: string, rev: string) =>
87
- git(["rev-parse", "--verify", `${rev}^`], root).pipe(Effect.map((parent) => parent.trim()));
88
-
89
86
  const gate = Effect.gen(function* () {
90
87
  const [first, second, ...extra] = process.argv.slice(2);
91
88
  if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
92
89
 
93
90
  const root = (yield* git(["rev-parse", "--show-toplevel"], process.cwd())).trim();
94
- const base = second === undefined ? yield* parentOf(root, first) : first;
91
+ const base = second === undefined ? yield* parentOrEmptyTree(first, root) : first;
95
92
  const result = yield* runRange(root, base, second ?? first);
96
93
 
97
94
  yield* Console.log(report(result));
@@ -0,0 +1,309 @@
1
+ export type Comment = { line: number; text: string };
2
+
3
+ type Marker = { readonly token: string; readonly afterAWordBreak: boolean };
4
+ type Quote = { readonly token: string; readonly backslashEscapes: boolean; readonly opensMidWord: boolean };
5
+
6
+ export type Syntax = {
7
+ readonly line: readonly Marker[];
8
+ readonly block: readonly (readonly [open: string, close: string])[];
9
+ readonly quotes: readonly Quote[];
10
+ readonly regexLiterals: boolean;
11
+ };
12
+
13
+ const anywhere = (token: string): Marker => ({ token, afterAWordBreak: false });
14
+ const startingAWord = (token: string): Marker => ({ token, afterAWordBreak: true });
15
+ const escaping = (token: string): Quote => ({ token, backslashEscapes: true, opensMidWord: true });
16
+ const literal = (token: string): Quote => ({ token, backslashEscapes: false, opensMidWord: true });
17
+ const outsideAWord = (quote: Quote): Quote => ({ ...quote, opensMidWord: false });
18
+
19
+ const SLASHES: readonly (readonly [string, string])[] = [["/*", "*/"]];
20
+ const SLASH_SLASH = [startingAWord("//")];
21
+ const DASHES = [anywhere("--")];
22
+ const STRINGS = [escaping('"'), escaping("'")];
23
+
24
+ const JS: Syntax = {
25
+ line: SLASH_SLASH,
26
+ block: SLASHES,
27
+ quotes: [escaping('"'), outsideAWord(escaping("'")), escaping("`")],
28
+ regexLiterals: true,
29
+ };
30
+ const CURLY: Syntax = { ...JS, quotes: [...STRINGS, escaping("`")], regexLiterals: false };
31
+ const PHP: Syntax = { line: [...SLASH_SLASH, anywhere("#")], block: SLASHES, quotes: STRINGS, regexLiterals: false };
32
+ const HASH: Syntax = {
33
+ line: [startingAWord("#")],
34
+ block: [],
35
+ quotes: [escaping('"'), literal("'")],
36
+ regexLiterals: false,
37
+ };
38
+ const YAML: Syntax = { ...HASH, quotes: [escaping('"'), outsideAWord(literal("'"))] };
39
+ const SQL: Syntax = { line: DASHES, block: SLASHES, quotes: STRINGS, regexLiterals: false };
40
+ const LUA: Syntax = { line: DASHES, block: [["--[[", "]]"]], quotes: STRINGS, regexLiterals: false };
41
+ const HASKELL: Syntax = { line: DASHES, block: [["{-", "-}"]], quotes: [escaping('"')], regexLiterals: false };
42
+ const ML: Syntax = { line: [], block: [["(*", "*)"]], quotes: [escaping('"')], regexLiterals: false };
43
+
44
+ export const SYNTAXES: Readonly<Record<string, Syntax>> = {
45
+ ts: JS,
46
+ tsx: JS,
47
+ mts: JS,
48
+ cts: JS,
49
+ js: JS,
50
+ jsx: JS,
51
+ mjs: JS,
52
+ cjs: JS,
53
+ go: CURLY,
54
+ rs: CURLY,
55
+ java: CURLY,
56
+ kt: CURLY,
57
+ swift: CURLY,
58
+ scala: CURLY,
59
+ c: CURLY,
60
+ h: CURLY,
61
+ cc: CURLY,
62
+ cpp: CURLY,
63
+ hpp: CURLY,
64
+ cs: CURLY,
65
+ php: PHP,
66
+ py: HASH,
67
+ rb: HASH,
68
+ ex: HASH,
69
+ exs: HASH,
70
+ sh: HASH,
71
+ bash: HASH,
72
+ zsh: HASH,
73
+ nix: HASH,
74
+ sql: SQL,
75
+ lua: LUA,
76
+ hs: HASKELL,
77
+ yaml: YAML,
78
+ yml: YAML,
79
+ toml: HASH,
80
+ ml: ML,
81
+ };
82
+
83
+ const extensionOf = (path: string): string => path.slice(path.lastIndexOf(".") + 1);
84
+
85
+ export function syntaxOf(path: string): Syntax | undefined {
86
+ const extension = extensionOf(path);
87
+ return Object.hasOwn(SYNTAXES, extension) ? SYNTAXES[extension] : undefined;
88
+ }
89
+
90
+ export function unreadable(path: string): string {
91
+ return `${path} is code the comment checks cannot read: add a comment syntax for .${extensionOf(path)} to scripts/comment-matchers.ts`;
92
+ }
93
+
94
+ const WORD = /[\w$]/;
95
+
96
+ const KEYWORDS = new Set([
97
+ "return",
98
+ "typeof",
99
+ "instanceof",
100
+ "in",
101
+ "of",
102
+ "new",
103
+ "delete",
104
+ "void",
105
+ "throw",
106
+ "case",
107
+ "do",
108
+ "else",
109
+ "yield",
110
+ "await",
111
+ ]);
112
+
113
+ const AFTER_A_VALUE = /[)\]}"'`]/;
114
+
115
+ function opensRegex(previous: string): boolean {
116
+ if (previous === "") return true;
117
+ if (WORD.test(previous.charAt(0))) return KEYWORDS.has(previous);
118
+ return !AFTER_A_VALUE.test(previous);
119
+ }
120
+
121
+ const WORD_BREAK = /[\s;|&(]/;
122
+
123
+ function endOfQuoted(source: string, start: number, quote: Quote): number {
124
+ let i = start + quote.token.length;
125
+ while (i < source.length) {
126
+ if (quote.backslashEscapes && source[i] === "\\") i += 2;
127
+ else if (source.startsWith(quote.token, i)) return i + quote.token.length;
128
+ else i += 1;
129
+ }
130
+ return start + quote.token.length;
131
+ }
132
+
133
+ function endOfRegex(source: string, start: number): number {
134
+ let i = start + 1;
135
+ let inClass = false;
136
+ while (i < source.length) {
137
+ const char = source.charAt(i);
138
+ if (char === "\\") i += 2;
139
+ else if (char === "\n") return i;
140
+ else {
141
+ if (char === "[") inClass = true;
142
+ else if (char === "]") inClass = false;
143
+ else if (char === "/" && !inClass) return i + 1;
144
+ i += 1;
145
+ }
146
+ }
147
+ return source.length;
148
+ }
149
+
150
+ export function commentsIn(source: string, syntax: Syntax): Comment[] {
151
+ const found: Comment[] = [];
152
+ let i = 0;
153
+ let line = 1;
154
+ let previous = "";
155
+
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
+ 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);
214
+ }
215
+
216
+ return found;
217
+ }
218
+
219
+ const LICENCE = /copyright|spdx-license-identifier|all rights reserved|licen[cs]ed under|permission is hereby granted/i;
220
+
221
+ // A single sentence wraps to at most a few lines at a normal width, so a longer run at the
222
+ // file's own opening is the shape of a paragraph rather than of one fact.
223
+ const A_WRAPPED_SENTENCE_FITS_WITHIN = 3;
224
+
225
+ function firstLine(text: string): string {
226
+ const newline = text.indexOf("\n");
227
+ return newline < 0 ? text : text.slice(0, newline);
228
+ }
229
+
230
+ function beginsItsLine(comment: Comment, lines: readonly string[]): boolean {
231
+ const line = lines[comment.line - 1];
232
+ return line !== undefined && line.trimStart().startsWith(firstLine(comment.text));
233
+ }
234
+
235
+ function openingBlock(found: readonly Comment[], source: string): Comment[] {
236
+ const lines = source.split("\n");
237
+ const block: Comment[] = [];
238
+ let next = 1;
239
+ for (const comment of found) {
240
+ if (comment.line !== next || !beginsItsLine(comment, lines)) break;
241
+ block.push(comment);
242
+ next = comment.line + comment.text.split("\n").length;
243
+ }
244
+ return block;
245
+ }
246
+
247
+ type Check = {
248
+ readonly find: RegExp;
249
+ readonly refusal: (match: string) => string;
250
+ };
251
+
252
+ const CHECKS: readonly Check[] = [
253
+ {
254
+ find: /@ts-expect-error|@ts-ignore|\bprettier-ignore\b|\beslint-disable[\w-]*|\bbiome-ignore\b/,
255
+ refusal: (match) =>
256
+ `carries the machine-read directive \`${match}\`. Fix what the tool is reporting, or stop running the tool on this file`,
257
+ },
258
+ {
259
+ find: /\bADR[\s-]?\d+|\bdocs\/adr\b|\bRFC[\s-]?\d+|(?:^|[\s(["'`])#\d+\b|[\w.-]+\/[\w.-]+#\d+|\/(?:issues|pull)\/\d+/i,
260
+ refusal: (match) =>
261
+ `points at a record or a ticket (${JSON.stringify(match.trim())}). Drop the pointer: a record is reached by searching docs/adr, and the story of the change goes in the commit message`,
262
+ },
263
+ {
264
+ find: /^\/\*\*/,
265
+ refusal: () =>
266
+ "opens a doc block. Make the code say it, or put what the types cannot hold on a line comment in the fewest words that carry it",
267
+ },
268
+ ];
269
+
270
+ function spans(from: number, lineCount: number, within: ReadonlySet<number> | undefined): boolean {
271
+ if (within === undefined) return true;
272
+ for (let line = from; line < from + lineCount; line += 1) if (within.has(line)) return true;
273
+ return false;
274
+ }
275
+
276
+ export function refusalsIn(path: string, source: string, syntax: Syntax, within?: ReadonlySet<number>): string[] {
277
+ const out: string[] = [];
278
+ const found = commentsIn(source, syntax);
279
+
280
+ const opening = openingBlock(found, source);
281
+ const openingText = opening.map((comment) => comment.text).join("\n");
282
+ const openingLines = opening.reduce((sum, comment) => sum + comment.text.split("\n").length, 0);
283
+ if (
284
+ opening.length > 0 &&
285
+ openingLines > A_WRAPPED_SENTENCE_FITS_WITHIN &&
286
+ !LICENCE.test(openingText) &&
287
+ spans(1, openingLines, within)
288
+ ) {
289
+ out.push(
290
+ `${path}:1 opens with a ${openingLines}-line rationale block. A record this long belongs in docs/adr or the repo's decision log, and the code does not point at it`,
291
+ );
292
+ }
293
+
294
+ for (const comment of found) {
295
+ if (LICENCE.test(comment.text)) continue;
296
+ if (!spans(comment.line, comment.text.split("\n").length, within)) continue;
297
+ for (const check of CHECKS) {
298
+ const [match] = check.find.exec(comment.text) ?? [];
299
+ if (match !== undefined) out.push(`${path}:${comment.line} ${check.refusal(match)}`);
300
+ }
301
+ }
302
+ return out;
303
+ }
304
+
305
+ export function refused(path: string, source: string, within?: ReadonlySet<number>): string[] {
306
+ const syntax = syntaxOf(path);
307
+ if (syntax === undefined) throw new Error(unreadable(path));
308
+ return refusalsIn(path, source, syntax, within);
309
+ }
@@ -1,309 +1,21 @@
1
1
  import { Effect, Schema } from "effect";
2
+ import { commentsIn, refusalsIn, syntaxOf, unreadable, type Syntax } from "./comment-matchers.ts";
2
3
 
3
- export type Comment = { line: number; text: string };
4
-
5
- type Marker = { readonly token: string; readonly afterAWordBreak: boolean };
6
- type Quote = { readonly token: string; readonly backslashEscapes: boolean; readonly opensMidWord: boolean };
7
-
8
- type Syntax = {
9
- readonly line: readonly Marker[];
10
- readonly block: readonly (readonly [open: string, close: string])[];
11
- readonly quotes: readonly Quote[];
12
- readonly regexLiterals: boolean;
13
- };
14
-
15
- const anywhere = (token: string): Marker => ({ token, afterAWordBreak: false });
16
- const startingAWord = (token: string): Marker => ({ token, afterAWordBreak: true });
17
- const escaping = (token: string): Quote => ({ token, backslashEscapes: true, opensMidWord: true });
18
- const literal = (token: string): Quote => ({ token, backslashEscapes: false, opensMidWord: true });
19
- const outsideAWord = (quote: Quote): Quote => ({ ...quote, opensMidWord: false });
20
-
21
- const SLASHES: readonly (readonly [string, string])[] = [["/*", "*/"]];
22
- const SLASH_SLASH = [startingAWord("//")];
23
- const DASHES = [anywhere("--")];
24
- const STRINGS = [escaping('"'), escaping("'")];
25
-
26
- const JS: Syntax = {
27
- line: SLASH_SLASH,
28
- block: SLASHES,
29
- quotes: [escaping('"'), outsideAWord(escaping("'")), escaping("`")],
30
- regexLiterals: true,
31
- };
32
- const CURLY: Syntax = { ...JS, quotes: [...STRINGS, escaping("`")], regexLiterals: false };
33
- const PHP: Syntax = { line: [...SLASH_SLASH, anywhere("#")], block: SLASHES, quotes: STRINGS, regexLiterals: false };
34
- const HASH: Syntax = {
35
- line: [startingAWord("#")],
36
- block: [],
37
- quotes: [escaping('"'), literal("'")],
38
- regexLiterals: false,
39
- };
40
- const YAML: Syntax = { ...HASH, quotes: [escaping('"'), outsideAWord(literal("'"))] };
41
- const SQL: Syntax = { line: DASHES, block: SLASHES, quotes: STRINGS, regexLiterals: false };
42
- const LUA: Syntax = { line: DASHES, block: [["--[[", "]]"]], quotes: STRINGS, regexLiterals: false };
43
- const HASKELL: Syntax = { line: DASHES, block: [["{-", "-}"]], quotes: [escaping('"')], regexLiterals: false };
44
- const ML: Syntax = { line: [], block: [["(*", "*)"]], quotes: [escaping('"')], regexLiterals: false };
45
-
46
- export const SYNTAXES: Readonly<Record<string, Syntax>> = {
47
- ts: JS,
48
- tsx: JS,
49
- mts: JS,
50
- cts: JS,
51
- js: JS,
52
- jsx: JS,
53
- mjs: JS,
54
- cjs: JS,
55
- go: CURLY,
56
- rs: CURLY,
57
- java: CURLY,
58
- kt: CURLY,
59
- swift: CURLY,
60
- scala: CURLY,
61
- c: CURLY,
62
- h: CURLY,
63
- cc: CURLY,
64
- cpp: CURLY,
65
- hpp: CURLY,
66
- cs: CURLY,
67
- php: PHP,
68
- py: HASH,
69
- rb: HASH,
70
- ex: HASH,
71
- exs: HASH,
72
- sh: HASH,
73
- bash: HASH,
74
- zsh: HASH,
75
- nix: HASH,
76
- sql: SQL,
77
- lua: LUA,
78
- hs: HASKELL,
79
- yaml: YAML,
80
- yml: YAML,
81
- toml: HASH,
82
- ml: ML,
83
- };
4
+ export { SYNTAXES, syntaxOf, type Comment } from "./comment-matchers.ts";
84
5
 
85
6
  export class UnreadableCode extends Schema.TaggedError<UnreadableCode>()("UnreadableCode", {
86
7
  message: Schema.String,
87
8
  }) {}
88
9
 
89
10
  export function syntaxFor(path: string): Effect.Effect<Syntax, UnreadableCode> {
90
- const extension = path.slice(path.lastIndexOf(".") + 1);
91
- const syntax = SYNTAXES[extension];
92
- return syntax
93
- ? Effect.succeed(syntax)
94
- : Effect.fail(
95
- new UnreadableCode({
96
- message: `${path} is code the comment checks cannot read: add a comment syntax for .${extension} to scripts/comments.ts`,
97
- }),
98
- );
99
- }
100
-
101
- const WORD = /[\w$]/;
102
-
103
- const KEYWORDS = new Set([
104
- "return",
105
- "typeof",
106
- "instanceof",
107
- "in",
108
- "of",
109
- "new",
110
- "delete",
111
- "void",
112
- "throw",
113
- "case",
114
- "do",
115
- "else",
116
- "yield",
117
- "await",
118
- ]);
119
-
120
- const AFTER_A_VALUE = /[)\]}"'`]/;
121
-
122
- function opensRegex(previous: string): boolean {
123
- if (previous === "") return true;
124
- if (WORD.test(previous[0]!)) return KEYWORDS.has(previous);
125
- return !AFTER_A_VALUE.test(previous);
126
- }
127
-
128
- const WORD_BREAK = /[\s;|&(]/;
129
-
130
- function endOfQuoted(source: string, start: number, quote: Quote): number {
131
- let i = start + quote.token.length;
132
- while (i < source.length) {
133
- if (quote.backslashEscapes && source[i] === "\\") i += 2;
134
- else if (source.startsWith(quote.token, i)) return i + quote.token.length;
135
- else i += 1;
136
- }
137
- return start + quote.token.length;
138
- }
139
-
140
- function endOfRegex(source: string, start: number): number {
141
- let i = start + 1;
142
- let inClass = false;
143
- while (i < source.length) {
144
- const char = source[i]!;
145
- if (char === "\\") i += 2;
146
- else if (char === "\n") return i;
147
- else {
148
- if (char === "[") inClass = true;
149
- else if (char === "]") inClass = false;
150
- else if (char === "/" && !inClass) return i + 1;
151
- i += 1;
152
- }
153
- }
154
- return source.length;
11
+ const syntax = syntaxOf(path);
12
+ return syntax ? Effect.succeed(syntax) : Effect.fail(new UnreadableCode({ message: unreadable(path) }));
155
13
  }
156
14
 
157
15
  export const comments = Effect.fn("comments")(function* (path: string, source: string) {
158
- const syntax = yield* syntaxFor(path);
159
- const found: Comment[] = [];
160
- let i = 0;
161
- let line = 1;
162
- let previous = "";
163
-
164
- const advance = (to: number): void => {
165
- for (let k = i; k < to; k++) if (source[k] === "\n") line += 1;
166
- i = to;
167
- };
168
-
169
- while (i < source.length) {
170
- const char = source[i]!;
171
-
172
- const block = syntax.block.find(([open]) => source.startsWith(open, i));
173
- if (block) {
174
- const [open, close] = block;
175
- const closed = source.indexOf(close, i + open.length);
176
- const stop = closed < 0 ? source.length : closed + close.length;
177
- found.push({ line, text: source.slice(i, stop) });
178
- advance(stop);
179
- previous = "";
180
- continue;
181
- }
182
-
183
- const marker = syntax.line.find(
184
- ({ token, afterAWordBreak }) =>
185
- source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source[i - 1]!)),
186
- );
187
- if (marker) {
188
- const newline = source.indexOf("\n", i);
189
- const stop = newline < 0 ? source.length : newline;
190
- found.push({ line, text: source.slice(i, stop) });
191
- advance(stop);
192
- previous = "";
193
- continue;
194
- }
195
-
196
- const quote = syntax.quotes.find(
197
- ({ token, opensMidWord }) =>
198
- source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source[i - 1]!)),
199
- );
200
- if (quote) {
201
- advance(endOfQuoted(source, i, quote));
202
- previous = quote.token;
203
- continue;
204
- }
205
-
206
- if (syntax.regexLiterals && char === "/" && opensRegex(previous)) {
207
- advance(endOfRegex(source, i));
208
- previous = "/";
209
- continue;
210
- }
211
-
212
- if (WORD.test(char)) {
213
- let end = i;
214
- while (end < source.length && WORD.test(source[end]!)) end += 1;
215
- previous = source.slice(i, end);
216
- advance(end);
217
- continue;
218
- }
219
-
220
- if (!/\s/.test(char)) previous = char;
221
- advance(i + 1);
222
- }
223
-
224
- return found;
16
+ return commentsIn(source, yield* syntaxFor(path));
225
17
  });
226
18
 
227
- const LICENCE = /copyright|spdx-license-identifier|all rights reserved|licen[cs]ed under|permission is hereby granted/i;
228
-
229
- // A single sentence wraps to at most a few lines at a normal width, so a longer run at the
230
- // file's own opening is the shape of a paragraph rather than of one fact.
231
- const A_WRAPPED_SENTENCE_FITS_WITHIN = 3;
232
-
233
- function beginsItsLine(comment: Comment, lines: readonly string[]): boolean {
234
- return lines[comment.line - 1]!.trimStart().startsWith(comment.text.split("\n")[0]!);
235
- }
236
-
237
- function openingBlock(found: readonly Comment[], source: string): Comment[] {
238
- const lines = source.split("\n");
239
- if (found.length === 0 || found[0]!.line !== 1 || !beginsItsLine(found[0]!, lines)) return [];
240
- const block = [found[0]!];
241
- let next = found[0]!.line + found[0]!.text.split("\n").length;
242
- for (let i = 1; i < found.length && found[i]!.line === next && beginsItsLine(found[i]!, lines); i++) {
243
- block.push(found[i]!);
244
- next = found[i]!.line + found[i]!.text.split("\n").length;
245
- }
246
- return block;
247
- }
248
-
249
- type Check = {
250
- readonly find: RegExp;
251
- readonly refusal: (match: string) => string;
252
- };
253
-
254
- const CHECKS: readonly Check[] = [
255
- {
256
- find: /@ts-expect-error|@ts-ignore|\bprettier-ignore\b|\beslint-disable[\w-]*|\bbiome-ignore\b/,
257
- refusal: (match) =>
258
- `carries the machine-read directive \`${match}\`. Fix what the tool is reporting, or stop running the tool on this file`,
259
- },
260
- {
261
- find: /\bADR[\s-]?\d+|\bdocs\/adr\b|\bRFC[\s-]?\d+|(?:^|[\s(["'`])#\d+\b|[\w.-]+\/[\w.-]+#\d+|\/(?:issues|pull)\/\d+/i,
262
- refusal: (match) =>
263
- `points at a record or a ticket (${JSON.stringify(match.trim())}). Drop the pointer: a record is reached by searching docs/adr, and the story of the change goes in the commit message`,
264
- },
265
- {
266
- find: /^\/\*\*/,
267
- refusal: () =>
268
- "opens a doc block. Make the code say it, or put what the types cannot hold on a line comment in the fewest words that carry it",
269
- },
270
- ];
271
-
272
- function spans(from: number, lineCount: number, within: ReadonlySet<number> | undefined): boolean {
273
- if (within === undefined) return true;
274
- for (let line = from; line < from + lineCount; line += 1) if (within.has(line)) return true;
275
- return false;
276
- }
277
-
278
- export const refused = Effect.fn("refused")(function* (
279
- path: string,
280
- source: string,
281
- within?: ReadonlySet<number>,
282
- ) {
283
- const out: string[] = [];
284
- const found = yield* comments(path, source);
285
-
286
- const opening = openingBlock(found, source);
287
- const openingText = opening.map((comment) => comment.text).join("\n");
288
- const openingLines = opening.reduce((sum, comment) => sum + comment.text.split("\n").length, 0);
289
- if (
290
- opening.length > 0 &&
291
- openingLines > A_WRAPPED_SENTENCE_FITS_WITHIN &&
292
- !LICENCE.test(openingText) &&
293
- spans(1, openingLines, within)
294
- ) {
295
- out.push(
296
- `${path}:1 opens with a ${openingLines}-line rationale block. A record this long belongs in docs/adr or the repo's decision log, and the code does not point at it`,
297
- );
298
- }
299
-
300
- for (const comment of found) {
301
- if (LICENCE.test(comment.text)) continue;
302
- if (!spans(comment.line, comment.text.split("\n").length, within)) continue;
303
- for (const check of CHECKS) {
304
- const [match] = check.find.exec(comment.text) ?? [];
305
- if (match !== undefined) out.push(`${path}:${comment.line} ${check.refusal(match)}`);
306
- }
307
- }
308
- return out;
19
+ export const refused = Effect.fn("refused")(function* (path: string, source: string, within?: ReadonlySet<number>) {
20
+ return refusalsIn(path, source, yield* syntaxFor(path), within);
309
21
  });
package/scripts/gates.ts CHANGED
@@ -1,21 +1,59 @@
1
+ import { Schema } from "effect";
2
+
1
3
  export type Program = {
2
4
  readonly bin: string;
3
5
  readonly script: string;
4
6
  };
5
7
 
8
+ type TrackedContent = {
9
+ readonly pathspecs: readonly string[];
10
+ readonly content: string;
11
+ };
12
+
13
+ export const EVERY_REPOSITORY = "every repository";
14
+
6
15
  export type KitGate = Program & {
7
16
  readonly reads: "tree" | "range";
17
+ readonly appliesTo: typeof EVERY_REPOSITORY | TrackedContent;
8
18
  };
9
19
 
10
20
  export const ENTRY_POINT: Program = { bin: "checks-lint", script: "lint.ts" };
11
21
 
12
22
  export const DEFAULT_BRANCH = "main";
13
23
 
14
- export const KIT_GATES: readonly KitGate[] = [
15
- { bin: "checks-lint-coverage", script: "lint-coverage.sh", reads: "tree" },
16
- { bin: "checks-test-layout", script: "test-layout.ts", reads: "tree" },
17
- { bin: "checks-commit-identity", script: "commit-identity.ts", reads: "range" },
18
- { bin: "checks-comment-gate", script: "comment-gate.ts", reads: "range" },
19
- { bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range" },
20
- { bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree" },
21
- ];
24
+ const TYPESCRIPT_SOURCE: TrackedContent = { pathspecs: ["*.ts", "*.tsx"], content: "TypeScript source" };
25
+
26
+ export const KIT_GATES = [
27
+ { bin: "checks-lint-coverage", script: "lint-coverage.sh", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
28
+ { bin: "checks-test-layout", script: "test-layout.ts", reads: "tree", appliesTo: TYPESCRIPT_SOURCE },
29
+ { bin: "checks-commit-identity", script: "commit-identity.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
30
+ { bin: "checks-comment-gate", script: "comment-gate.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
31
+ { bin: "checks-suppressions-ratchet", script: "suppressions-ratchet.ts", reads: "range", appliesTo: EVERY_REPOSITORY },
32
+ { bin: "checks-ci-wiring", script: "ci-wiring.ts", reads: "tree", appliesTo: EVERY_REPOSITORY },
33
+ ] as const satisfies readonly KitGate[];
34
+
35
+ const UNCONDITIONAL = KIT_GATES.filter((gate) => gate.appliesTo === EVERY_REPOSITORY).map((gate) => gate.bin);
36
+
37
+ // A selection without ci-wiring would go unchecked under checks-lint, so a gate every repository runs
38
+ // is refused where checks-lint decodes the selection, not by ci-wiring.
39
+ export const LintGates = Schema.Array(Schema.Literals(KIT_GATES.map((gate) => gate.bin))).check(
40
+ Schema.makeFilter((selected) => {
41
+ const missing = UNCONDITIONAL.filter((bin) => !selected.includes(bin));
42
+ if (missing.length === 0) return true;
43
+ const verb = missing.length === 1 ? "applies" : "apply";
44
+ return `checks-lint must run ${missing.join(", ")}, which ${verb} to ${EVERY_REPOSITORY}`;
45
+ }),
46
+ );
47
+
48
+ export const LintWiring = Schema.Struct({
49
+ ciWiring: Schema.optionalKey(
50
+ Schema.Struct({
51
+ defaultBranch: Schema.optionalKey(Schema.NonEmptyString),
52
+ lintGates: Schema.optionalKey(LintGates),
53
+ }),
54
+ ),
55
+ });
56
+
57
+ export function selectedGates(lintGates: typeof LintGates.Type | undefined): readonly KitGate[] {
58
+ return lintGates === undefined ? KIT_GATES : KIT_GATES.filter((gate) => lintGates.includes(gate.bin));
59
+ }
package/scripts/git.ts CHANGED
@@ -25,3 +25,17 @@ export const git = Effect.fn("git")(
25
25
  },
26
26
  Effect.scoped,
27
27
  );
28
+
29
+ function namesAParent(commitObject: string): boolean {
30
+ const [headers = ""] = commitObject.split("\n\n", 1);
31
+ return headers.split("\n").some((header) => header.startsWith("parent "));
32
+ }
33
+
34
+ // A shallow clone's boundary commit reads as parentless to rev-parse and log, and judged against
35
+ // the empty tree it would carry the whole repository; only the commit object still names its parents.
36
+ export const parentOrEmptyTree = Effect.fn("parentOrEmptyTree")(function* (rev: string, cwd?: string) {
37
+ if (!namesAParent(yield* git(["cat-file", "commit", rev], cwd))) {
38
+ return (yield* git(["hash-object", "-t", "tree", "/dev/null"], cwd)).trim();
39
+ }
40
+ return (yield* git(["rev-parse", "--verify", `${rev}^`], cwd)).trim();
41
+ });
@@ -12,15 +12,21 @@ if ! [ -s "$tmp/expected" ]; then
12
12
  fi
13
13
 
14
14
  # Explicit paths bypass ignore files, so only an unscoped walk proves coverage.
15
- oxlint --debug=files 2>/dev/null | grep -E '\.tsx?$' | LC_ALL=C sort > "$tmp/walked" || true
15
+ if ! oxlint --debug=files > "$tmp/walk" 2> "$tmp/walk-error"; then
16
+ echo "lint-coverage: oxlint could not walk the tree:"
17
+ cat "$tmp/walk" "$tmp/walk-error"
18
+ exit 2
19
+ fi
20
+ grep -E '\.tsx?$' "$tmp/walk" | LC_ALL=C sort > "$tmp/walked" || true
16
21
 
17
22
  expected_count="$(wc -l < "$tmp/expected" | tr -d ' ')"
18
23
  walked_count="$(grep -c . "$tmp/walked" || true)"
19
24
 
20
- missing="$(comm -23 "$tmp/expected" "$tmp/walked")"
21
- if [ -n "$missing" ]; then
22
- echo "lint-coverage: oxlint skips ${walked_count}/${expected_count} tracked .ts/.tsx files; missing:"
23
- printf '%s\n' "$missing"
25
+ comm -23 "$tmp/expected" "$tmp/walked" > "$tmp/missing"
26
+ if [ -s "$tmp/missing" ]; then
27
+ missing_count="$(wc -l < "$tmp/missing" | tr -d ' ')"
28
+ echo "lint-coverage: oxlint skips ${missing_count}/${expected_count} tracked .ts/.tsx files; missing:"
29
+ cat "$tmp/missing"
24
30
  exit 1
25
31
  fi
26
32
 
package/scripts/lint.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
3
3
  import { ChildProcess, ChildProcessSpawner } from "effect/unstable/process";
4
- import { DEFAULT_BRANCH, KIT_GATES, type KitGate } from "./gates.ts";
4
+ import { DEFAULT_BRANCH, LintWiring, selectedGates, type KitGate } from "./gates.ts";
5
5
  import { git } from "./git.ts";
6
6
  import { runMain, Usage } from "./main.ts";
7
7
 
@@ -20,6 +20,10 @@ class GatesUndecided extends Schema.TaggedError<GatesUndecided>()("GatesUndecide
20
20
  message: Schema.String,
21
21
  }) {}
22
22
 
23
+ class WiringUnreadable extends Schema.TaggedError<WiringUnreadable>()("WiringUnreadable", {
24
+ message: Schema.String,
25
+ }) {}
26
+
23
27
  const NAME = "checks-lint";
24
28
  const USAGE = "usage: lint.ts [<base-ref> <head-ref>]";
25
29
  const PULL_REQUEST_EVENT = "pull_request";
@@ -52,30 +56,38 @@ const pullRequestEnds = Effect.fn("pullRequestEnds")(function* (eventPath: strin
52
56
  };
53
57
  });
54
58
 
55
- const decodeManifest = Schema.decodeUnknownEffect(
56
- Schema.fromJsonString(
57
- Schema.Struct({
58
- ciWiring: Schema.optionalKey(Schema.Struct({ defaultBranch: Schema.optionalKey(Schema.NonEmptyString) })),
59
- }),
60
- ),
61
- );
59
+ const decodeWiring = Schema.decodeUnknownEffect(Schema.fromJsonString(LintWiring));
62
60
 
63
- const declaredDefaultBranch = Effect.gen(function* () {
61
+ const readWiring = Effect.gen(function* () {
64
62
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
65
63
  const manifest = (yield* Path.Path).join(root, "package.json");
66
- const { ciWiring } = yield* (yield* FileSystem.FileSystem).readFileString(manifest).pipe(Effect.flatMap(decodeManifest));
67
- return ciWiring?.defaultBranch ?? DEFAULT_BRANCH;
68
- }).pipe(
69
- Effect.mapError((cause) => new RangeUnresolved({ message: `cannot read ciWiring.defaultBranch: ${cause.message}` })),
70
- );
64
+ const { ciWiring } = yield* (yield* FileSystem.FileSystem).readFileString(manifest).pipe(
65
+ Effect.flatMap(decodeWiring),
66
+ Effect.mapError((cause) => new WiringUnreadable({ message: `cannot read ciWiring from ${manifest}: ${cause.message}` })),
67
+ );
68
+ return { defaultBranch: ciWiring?.defaultBranch ?? DEFAULT_BRANCH, lintGates: ciWiring?.lintGates };
69
+ });
71
70
 
72
- const localEnds = git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]).pipe(
73
- Effect.map((ref) => ref.trim()),
74
- Effect.catchTag("GitFailure", () => declaredDefaultBranch.pipe(Effect.map((branch) => `origin/${branch}`))),
75
- Effect.map((base) => ({ base, head: "HEAD", source: `HEAD against ${base}` })),
76
- );
71
+ const originEnds = (defaultBranch: string) =>
72
+ git(["symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD"]).pipe(
73
+ Effect.map((ref) => ref.trim()),
74
+ Effect.catchTag("GitFailure", () => Effect.succeed(`origin/${defaultBranch}`)),
75
+ Effect.map((base) => ({ base, head: "HEAD", source: `HEAD against ${base}` })),
76
+ );
77
77
 
78
- const endsOf = Effect.fn("endsOf")(function* (args: readonly string[]) {
78
+ // A clone holding any remote-tracking ref but not the default branch is a shallow CI checkout,
79
+ // where judging HEAD alone would pass every commit before it unchecked.
80
+ const localEnds = Effect.fn("localEnds")(function* (defaultBranch: string) {
81
+ const remoteTracking = yield* git(["for-each-ref", "--count=1", "refs/remotes/"]).pipe(
82
+ Effect.mapError((cause) => new RangeUnresolved({ message: cause.message })),
83
+ );
84
+ if (remoteTracking.trim() === "") {
85
+ return { base: "HEAD", head: "HEAD", source: "HEAD alone, as the clone has no remote-tracking refs" };
86
+ }
87
+ return yield* originEnds(defaultBranch);
88
+ });
89
+
90
+ const endsOf = Effect.fn("endsOf")(function* (args: readonly string[], defaultBranch: string) {
79
91
  const [base, head, ...extra] = args;
80
92
  if (base !== undefined && head !== undefined && extra.length === 0) {
81
93
  return { base, head, source: `${head} against ${base}` };
@@ -92,7 +104,7 @@ const endsOf = Effect.fn("endsOf")(function* (args: readonly string[]) {
92
104
  }
93
105
  return yield* pullRequestEnds(event.path.value);
94
106
  }
95
- return yield* localEnds;
107
+ return yield* localEnds(defaultBranch);
96
108
  });
97
109
 
98
110
  const commitOf = (ref: string) =>
@@ -105,8 +117,8 @@ const commitOf = (ref: string) =>
105
117
 
106
118
  // From the base branch's tip, every range gate would charge the head with the commits the base
107
119
  // branch gained after the head branched off.
108
- const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[]) {
109
- const ends = yield* endsOf(args);
120
+ const resolveRange = Effect.fn("resolveRange")(function* (args: readonly string[], defaultBranch: string) {
121
+ const ends = yield* endsOf(args, defaultBranch);
110
122
  const head = yield* commitOf(ends.head);
111
123
  const base = yield* git(["merge-base", yield* commitOf(ends.base), head]).pipe(
112
124
  Effect.map((sha) => sha.trim()),
@@ -142,16 +154,21 @@ const runGate = Effect.fn("runGate")(function* (gate: KitGate, range: Range) {
142
154
  });
143
155
 
144
156
  const lint = Effect.gen(function* () {
145
- const range = yield* resolveRange(process.argv.slice(2));
157
+ const { defaultBranch, lintGates } = yield* readWiring;
158
+ const range = yield* resolveRange(process.argv.slice(2), defaultBranch);
146
159
  yield* Console.log(`${NAME}: ${describe(range)} from ${range.source}`);
160
+ const gates = selectedGates(lintGates);
161
+ if (lintGates !== undefined) {
162
+ yield* Console.log(`${NAME}: ciWiring.lintGates selects ${gates.map((gate) => gate.bin).join(", ")}`);
163
+ }
147
164
 
148
- const verdicts = yield* Effect.forEach(KIT_GATES, (gate) => runGate(gate, range));
165
+ const verdicts = yield* Effect.forEach(gates, (gate) => runGate(gate, range));
149
166
  const failed = verdicts.filter((verdict) => verdict.outcome !== "passed");
150
167
  if (failed.length === 0) {
151
- yield* Console.log(`${NAME}: ${KIT_GATES.length} gate(s) pass`);
168
+ yield* Console.log(`${NAME}: ${gates.length} gate(s) pass`);
152
169
  return true;
153
170
  }
154
- const summary = `${failed.length} of ${KIT_GATES.length} gate(s) failed: ${failed.map((verdict) => verdict.gate.bin).join(", ")}`;
171
+ const summary = `${failed.length} of ${gates.length} gate(s) failed: ${failed.map((verdict) => verdict.gate.bin).join(", ")}`;
155
172
  if (failed.every((verdict) => verdict.outcome === "undecided")) {
156
173
  return yield* new GatesUndecided({ message: summary });
157
174
  }
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Schema } from "effect";
3
- import { git } from "./git.ts";
3
+ import { git, parentOrEmptyTree } from "./git.ts";
4
4
  import { runMain, Usage } from "./main.ts";
5
5
 
6
6
  export const SUPPRESSIONS = "oxlint-suppressions.json";
@@ -99,10 +99,10 @@ const mergeBase = Effect.fn("mergeBase")(function* (base: string, head: string)
99
99
  return commit.trim();
100
100
  });
101
101
 
102
- const suppressionsAt = Effect.fn("suppressionsAt")(function* (commit: string) {
103
- const blob = (yield* git(["ls-tree", "--object-only", commit, "--", SUPPRESSIONS])).trim();
102
+ const suppressionsAt = Effect.fn("suppressionsAt")(function* (treeish: string) {
103
+ const blob = (yield* git(["ls-tree", "--object-only", treeish, "--", SUPPRESSIONS])).trim();
104
104
  if (blob === "") return new Map() satisfies Suppressions;
105
- return yield* parseSuppressions(yield* git(["cat-file", "blob", blob]), `${SUPPRESSIONS} at ${commit}`);
105
+ return yield* parseSuppressions(yield* git(["cat-file", "blob", blob]), `${SUPPRESSIONS} at ${treeish}`);
106
106
  });
107
107
 
108
108
  export const runRange = Effect.fn("runRange")(function* (base: string, head: string) {
@@ -112,11 +112,16 @@ export const runRange = Effect.fn("runRange")(function* (base: string, head: str
112
112
  return compareSuppressions(yield* suppressionsAt(branchPoint), yield* suppressionsAt(headCommit));
113
113
  });
114
114
 
115
+ export const runTip = Effect.fn("runTip")(function* (tip: string) {
116
+ const tipCommit = yield* commitOf(tip);
117
+ return compareSuppressions(yield* suppressionsAt(yield* parentOrEmptyTree(tipCommit)), yield* suppressionsAt(tipCommit));
118
+ });
119
+
115
120
  const ratchet = Effect.gen(function* () {
116
121
  const [first, second, ...extra] = process.argv.slice(2);
117
122
  if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
118
123
 
119
- const result = second !== undefined ? yield* runRange(first, second) : yield* runRange(`${first}^`, first);
124
+ const result = second !== undefined ? yield* runRange(first, second) : yield* runTip(first);
120
125
 
121
126
  yield* Console.log(report(result));
122
127
  return result.rises.length === 0;