@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 +102 -16
- package/oxlintrc.json +2 -2
- package/package.json +4 -2
- package/scripts/backtest.ts +2 -3
- package/scripts/ci-wiring.ts +68 -13
- package/scripts/comment-gate.ts +4 -7
- package/scripts/comment-matchers.ts +309 -0
- package/scripts/comments.ts +7 -295
- package/scripts/gates.ts +46 -8
- package/scripts/git.ts +14 -0
- package/scripts/lint-coverage.sh +11 -5
- package/scripts/lint.ts +44 -27
- package/scripts/suppressions-ratchet.ts +10 -5
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
|
-
|
|
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
|
|
140
|
-
not resolve or no failing gate could decide.
|
|
141
|
-
repository on `checks-lint` declares `ciWiring.gates`
|
|
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,
|
|
412
|
-
|
|
413
|
-
|
|
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
|
|
419
|
-
|
|
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,
|
|
438
|
-
|
|
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.
|
|
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": "
|
|
18
|
-
"typescript/no-non-null-assertion": "
|
|
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.
|
|
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
|
|
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
|
},
|
package/scripts/backtest.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Option } from "effect";
|
|
3
|
-
import { comments, refused,
|
|
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
|
-
|
|
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);
|
package/scripts/ci-wiring.ts
CHANGED
|
@@ -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 {
|
|
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) =>
|
|
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
|
|
297
|
-
|
|
298
|
-
|
|
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
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
-
|
|
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);
|
package/scripts/comment-gate.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Option } from "effect";
|
|
3
|
-
import { refused,
|
|
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
|
|
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*
|
|
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
|
+
}
|
package/scripts/comments.ts
CHANGED
|
@@ -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
|
|
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
|
|
91
|
-
|
|
92
|
-
return syntax
|
|
93
|
-
? Effect.succeed(syntax)
|
|
94
|
-
: Effect.fail(
|
|
95
|
-
new UnreadableCode({
|
|
96
|
-
message: `${path} is code the comment checks cannot read: add a comment syntax for .${extension} to scripts/comments.ts`,
|
|
97
|
-
}),
|
|
98
|
-
);
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
{ bin: "checks-
|
|
18
|
-
{ bin: "checks-
|
|
19
|
-
{ bin: "checks-
|
|
20
|
-
{ bin: "checks-
|
|
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
|
+
});
|
package/scripts/lint-coverage.sh
CHANGED
|
@@ -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
|
|
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
|
-
|
|
21
|
-
if [ -
|
|
22
|
-
|
|
23
|
-
|
|
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,
|
|
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
|
|
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
|
|
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(
|
|
67
|
-
|
|
68
|
-
})
|
|
69
|
-
|
|
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
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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}: ${
|
|
168
|
+
yield* Console.log(`${NAME}: ${gates.length} gate(s) pass`);
|
|
152
169
|
return true;
|
|
153
170
|
}
|
|
154
|
-
const summary = `${failed.length} of ${
|
|
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* (
|
|
103
|
-
const blob = (yield* git(["ls-tree", "--object-only",
|
|
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 ${
|
|
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*
|
|
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;
|