@avi2dg/checks 0.16.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +22 -0
- package/CONTRIBUTING.md +1 -1
- package/README.md +2 -1
- package/dependency-cruiser.config.js +8 -0
- package/dist/feature-rules.js +5 -4
- package/dist/index.js +426 -7
- package/docs/configs/commit-messages.md +2 -18
- package/docs/configs/dependency-rules.md +1 -0
- package/docs/configs/effect-rules.md +1 -0
- package/docs/configs/quality-file.md +2 -2
- package/docs/configs/typescript-rules.md +36 -0
- package/docs/design.md +3 -1
- package/docs/gates/checks-comment-gate.md +2 -0
- package/docs/gates/checks-quality.md +23 -8
- package/docs/gates/checks-size-budget.md +4 -4
- package/oxlintrc.json +1 -0
- package/package.json +3 -1
- package/quality.schema.json +2 -2
- package/scripts/backtest.ts +57 -41
- package/scripts/ci-wiring.ts +2 -63
- package/scripts/comment-gate.ts +3 -4
- package/scripts/comment-matchers.ts +73 -58
- package/scripts/commit-identity.ts +3 -4
- package/scripts/doc-outline.ts +34 -16
- package/scripts/doc-rules.ts +34 -21
- package/scripts/docs.ts +3 -4
- package/scripts/feature-owners.ts +5 -8
- package/scripts/git.ts +31 -26
- package/scripts/prose-matchers.ts +93 -83
- package/scripts/quality.ts +157 -10
- package/scripts/range-gate.ts +9 -0
- package/scripts/shell-command.ts +74 -0
- package/scripts/size-budget.ts +18 -17
- package/scripts/size-rules.ts +13 -4
- package/scripts/suppressions-ratchet.ts +22 -24
- package/scripts/test-layout.ts +37 -35
- package/scripts/test-report.ts +18 -13
|
@@ -18,6 +18,8 @@ It passes over a file with any other extension.
|
|
|
18
18
|
`scripts/comment-matchers.ts` holds the scanner, the comment syntaxes and a synchronous `refused()`, and imports nothing.
|
|
19
19
|
A host such as a hook bundle can therefore copy it alone into a directory with no `node_modules` and import it as `@avi2dg/checks/scripts/comment-matchers.ts`.
|
|
20
20
|
The kit's own dependency cruise fails when that file gains an import.
|
|
21
|
+
`REFUSED_DIRECTIVES` in that file owns the refused directive names, and the checker builds its directive pattern from that list, so the two cannot disagree.
|
|
22
|
+
A consumer reads the same contract by importing `REFUSED_DIRECTIVES` from `@avi2dg/checks/scripts/comment-matchers.ts`.
|
|
21
23
|
`scripts/comments.ts` wraps the same matchers in Effect for the gate and for [checks-backtest](checks-backtest.md).
|
|
22
24
|
|
|
23
25
|
## Arguments
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# checks-quality
|
|
2
2
|
|
|
3
|
-
`checks-quality` is the bin that writes what `quality.json` declares
|
|
3
|
+
`checks-quality` is the bin that writes what `quality.json` declares into generated fragments and workflows and checks them, and a reader looks it up when generated text is stale.
|
|
4
4
|
|
|
5
5
|
## What it checks
|
|
6
6
|
|
|
@@ -29,11 +29,20 @@ A rule only this repository needs stays in its own `.oxlintrc.json`, whose overr
|
|
|
29
29
|
}
|
|
30
30
|
```
|
|
31
31
|
|
|
32
|
+
GitHub Actions reads its own YAML and nothing else, so `checks-quality generate` also writes the kit recipe workflows whole.
|
|
33
|
+
`.github/workflows/ci.yml` runs every `gates.ci` command but the title lint as its own step after a frozen install.
|
|
34
|
+
`.github/workflows/commitlint.yml` lints the pull request title with the installed kit config.
|
|
35
|
+
A step one repository alone needs lives in another workflow file, never in the recipe.
|
|
36
|
+
All generated workflows are committed.
|
|
37
|
+
|
|
32
38
|
`checks-quality --check` fails when any of these holds:
|
|
33
39
|
|
|
34
40
|
- A fragment is missing, or differs from what `generate` would write from `quality.json` and the installed kit's presets.
|
|
35
41
|
- A fragment is left over once `quality.json` stops declaring `sources.effect`.
|
|
42
|
+
- A generated workflow is missing, or differs from what `generate` would write from `quality.json` and the kit recipe.
|
|
36
43
|
- `.oxlintrc.json` or `tsconfig.json` does not list its fragment in `extends`, so the tool never reads it.
|
|
44
|
+
- `.oxlintrc.json` does not extend `./node_modules/@avi2dg/checks/oxlintrc.json`, so the kit's oxlint rules are not loaded.
|
|
45
|
+
- `tsconfig.json` does not extend `@avi2dg/checks/tsconfig.effect.json`, the one accepted spelling of the kit's Effect config.
|
|
37
46
|
- A `sources.effect.paths` or `sources.production` glob matches no tracked or untracked file, so it holds nothing.
|
|
38
47
|
|
|
39
48
|
Two details of the fragments are easy to get wrong, so the kit's tests pin both:
|
|
@@ -47,7 +56,10 @@ Two details of the fragments are easy to get wrong, so the kit's tests pin both:
|
|
|
47
56
|
|
|
48
57
|
## What it reads
|
|
49
58
|
|
|
50
|
-
It reads the working tree: `quality.json`, the presets of the installed kit, `.oxlintrc.json`, `tsconfig.json
|
|
59
|
+
It reads the working tree: `quality.json`, the presets of the installed kit, `.oxlintrc.json`, `tsconfig.json`, the two fragments and the generated workflows.
|
|
60
|
+
It reads the root `package.json` name, since only the kit's own tree lints titles with its root `commitlint.config.js`.
|
|
61
|
+
The name also decides which kit configs `extends` must list, since the kit's own tree extends its root `oxlintrc.json` and `tsconfig.effect.json`.
|
|
62
|
+
It looks for `.bun-version`, and the suite pins its bun to that file when the file exists.
|
|
51
63
|
It lists the tracked and untracked files to see what each declared glob matches.
|
|
52
64
|
|
|
53
65
|
## Arguments
|
|
@@ -57,15 +69,15 @@ checks-quality generate
|
|
|
57
69
|
checks-quality --check
|
|
58
70
|
```
|
|
59
71
|
|
|
60
|
-
`generate` writes the fragments, removes a left-over one, then runs the same check as `--check`.
|
|
72
|
+
`generate` writes the fragments and the workflows, removes a left-over one, then runs the same check as `--check`.
|
|
61
73
|
`--check` writes nothing.
|
|
62
74
|
|
|
63
75
|
## Exit codes
|
|
64
76
|
|
|
65
77
|
| Code | When |
|
|
66
78
|
| --- | --- |
|
|
67
|
-
| 0 | the
|
|
68
|
-
| 1 | a
|
|
79
|
+
| 0 | the generated files hold what `quality.json` declares |
|
|
80
|
+
| 1 | a generated file is stale, missing or left over, a fragment is not extended, the kit's config is not extended, or a declared glob matches no file |
|
|
69
81
|
| 2 | `quality.json` does not decode, or the arguments are neither `generate` nor `--check` |
|
|
70
82
|
|
|
71
83
|
## Sample output
|
|
@@ -76,16 +88,19 @@ checks-quality: 2 problem(s) with what quality.json declares:
|
|
|
76
88
|
tsconfig.json does not extend ./tsconfig.quality.json, so the language service never reads it
|
|
77
89
|
```
|
|
78
90
|
|
|
79
|
-
A passing run says what the
|
|
91
|
+
A passing run says what the generated files hold:
|
|
80
92
|
|
|
81
93
|
```
|
|
82
|
-
checks-quality: oxlintrc.quality.json and tsconfig.quality.json hold what quality.json declares
|
|
94
|
+
checks-quality: oxlintrc.quality.json and tsconfig.quality.json and .github/workflows/ci.yml and .github/workflows/commitlint.yml hold what quality.json declares
|
|
83
95
|
```
|
|
84
96
|
|
|
97
|
+
A stale workflow is reported the same way as a stale fragment.
|
|
98
|
+
|
|
85
99
|
## Opting out
|
|
86
100
|
|
|
87
101
|
It runs only in a repository that tracks `quality.json`, and a selection in `quality.json` always keeps it, since the file it sits in is what makes it apply.
|
|
88
|
-
A repository that declares no `sources.effect` gets no fragment, and the check then
|
|
102
|
+
A repository that declares no `sources.effect` gets no fragment, and the check then refuses a left-over one.
|
|
103
|
+
Every repository gets the title lint workflow, and one with `gates.ci` gets the suite with it.
|
|
89
104
|
|
|
90
105
|
## Related topics
|
|
91
106
|
|
|
@@ -13,7 +13,7 @@ It holds production and test files to the size budget, and lists every other fil
|
|
|
13
13
|
|
|
14
14
|
A production file is one under `sources.production`, and a test file is a tracked `.ts` or `.tsx` file under `tests/`.
|
|
15
15
|
A test file keeps to the tests budget, and every other file keeps to the production budget.
|
|
16
|
-
It runs oxlint with a configuration of five rules and
|
|
16
|
+
It runs oxlint with a configuration of five rules, one for each limit, and the kit's own plugin bundle, which holds the complexity rule.
|
|
17
17
|
The kit sets each limit:
|
|
18
18
|
|
|
19
19
|
<!-- generated size-limits: bun run build writes it from SIZE_RULES and SIZE_DEFAULTS in scripts/size-rules.ts and scripts/doc-blocks.ts -->
|
|
@@ -23,7 +23,7 @@ The kit sets each limit:
|
|
|
23
23
|
| `fileLines` | The most lines a file may hold, blank and comment lines counted | `max-lines` | 400 | 600 |
|
|
24
24
|
| `functionLines` | The most lines a function may span, blank and comment lines counted | `max-lines-per-function` | 100 | none |
|
|
25
25
|
| `statements` | The most statements a function may hold | `max-statements` | 30 | 50 |
|
|
26
|
-
| `complexity` | The highest
|
|
26
|
+
| `complexity` | The highest cognitive complexity a function may reach, a switch counted once | `effect-channel/cognitive-complexity` | 15 | 15 |
|
|
27
27
|
| `depth` | The deepest a block may nest inside a function | `max-depth` | 4 | 4 |
|
|
28
28
|
|
|
29
29
|
<!-- end generated size-limits -->
|
|
@@ -88,8 +88,8 @@ With one it is that commit against its parent, or against the empty tree for a r
|
|
|
88
88
|
size-budget: 2 overrun(s) grew past the base in the production and test files the range adds or changes:
|
|
89
89
|
src/billing/invoice.ts: max-lines over by 31 in total, up from 19
|
|
90
90
|
src/billing/invoice.ts: File has too many lines (431). Maximum allowed is 400.
|
|
91
|
-
src/billing/ledger.ts: complexity over by 3 in total, up from 0
|
|
92
|
-
src/billing/ledger.ts:12: function `settle` has a complexity of 18. Maximum allowed is 15.
|
|
91
|
+
src/billing/ledger.ts: cognitive-complexity over by 3 in total, up from 0
|
|
92
|
+
src/billing/ledger.ts:12: function `settle` has a cognitive complexity of 18. Maximum allowed is 15.
|
|
93
93
|
size-budget: advisory, 1 overrun(s) where the budget does not hold yet:
|
|
94
94
|
tests/e2e/billing.test.ts: File has too many lines (612). Maximum allowed is 600.
|
|
95
95
|
```
|
package/oxlintrc.json
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
"eslint/no-unused-vars": ["error", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_", "ignoreRestSiblings": true }],
|
|
17
17
|
"typescript/no-unsafe-type-assertion": "error",
|
|
18
18
|
"typescript/no-non-null-assertion": "error",
|
|
19
|
+
"typescript/no-deprecated": "error",
|
|
19
20
|
"typescript/consistent-return": "off"
|
|
20
21
|
}
|
|
21
22
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.18.0",
|
|
4
4
|
"description": "Deterministic checks shared across the captain's TypeScript repos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -32,6 +32,7 @@
|
|
|
32
32
|
"scripts/commit-identity.ts",
|
|
33
33
|
"scripts/mutation-compare.ts",
|
|
34
34
|
"scripts/ci-wiring.ts",
|
|
35
|
+
"scripts/shell-command.ts",
|
|
35
36
|
"scripts/comment-matchers.ts",
|
|
36
37
|
"scripts/prose-matchers.ts",
|
|
37
38
|
"scripts/comments.ts",
|
|
@@ -41,6 +42,7 @@
|
|
|
41
42
|
"scripts/quality-file.ts",
|
|
42
43
|
"scripts/quality.ts",
|
|
43
44
|
"scripts/size-budget.ts",
|
|
45
|
+
"scripts/range-gate.ts",
|
|
44
46
|
"scripts/size-rules.ts",
|
|
45
47
|
"scripts/repetition.ts",
|
|
46
48
|
"scripts/feature-owners.ts",
|
package/quality.schema.json
CHANGED
|
@@ -178,7 +178,7 @@
|
|
|
178
178
|
"complexity": {
|
|
179
179
|
"type": "integer",
|
|
180
180
|
"exclusiveMinimum": 0,
|
|
181
|
-
"description": "The highest
|
|
181
|
+
"description": "The highest cognitive complexity a function may reach, a switch counted once; 15 when absent"
|
|
182
182
|
},
|
|
183
183
|
"depth": {
|
|
184
184
|
"type": "integer",
|
|
@@ -205,7 +205,7 @@
|
|
|
205
205
|
"complexity": {
|
|
206
206
|
"type": "integer",
|
|
207
207
|
"exclusiveMinimum": 0,
|
|
208
|
-
"description": "The highest
|
|
208
|
+
"description": "The highest cognitive complexity a function may reach, a switch counted once; 15 when absent"
|
|
209
209
|
},
|
|
210
210
|
"depth": {
|
|
211
211
|
"type": "integer",
|
package/scripts/backtest.ts
CHANGED
|
@@ -27,65 +27,72 @@ function withoutLine(refusal: string): string {
|
|
|
27
27
|
return refusal.replace(/^(\S+?):\d+ /, "$1 ");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
type Commit = {
|
|
31
|
+
readonly sha: string;
|
|
32
|
+
readonly parent: string;
|
|
33
|
+
readonly subject: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const firstParentCommits = Effect.fn("firstParentCommits")(function* (count: string) {
|
|
37
|
+
return (yield* git(["log", "--first-parent", "--format=%H%x09%P%x09%s", "-n", count]))
|
|
33
38
|
.trim()
|
|
34
39
|
.split("\n")
|
|
35
|
-
.map((row) => {
|
|
40
|
+
.map((row): Commit => {
|
|
36
41
|
const [sha = "", parents = "", subject = ""] = row.split("\t");
|
|
37
42
|
return { sha, parent: parents.split(" ")[0] ?? "", subject };
|
|
38
43
|
})
|
|
39
44
|
.filter((commit) => commit.sha !== "" && commit.parent !== "");
|
|
45
|
+
});
|
|
40
46
|
|
|
41
|
-
|
|
47
|
+
const addedLines = Effect.fn("addedLines")(function* ({ sha, parent }: Commit, changed: readonly string[]) {
|
|
48
|
+
const numstat = (yield* git(["diff", "--numstat", parent, sha, "--", ...changed])).trim();
|
|
49
|
+
return numstat === "" ? 0 : numstat.split("\n").reduce((sum, row) => sum + Number(row.split("\t")[0] ?? 0), 0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const pathLanded = Effect.fn("pathLanded")(function* ({ sha, parent }: Commit, path: string) {
|
|
53
|
+
const after = yield* at(sha, path);
|
|
54
|
+
if (Option.isNone(after)) return { comments: 0, refusals: [] };
|
|
55
|
+
const before = Option.getOrElse(yield* at(parent, path), () => "");
|
|
56
|
+
|
|
57
|
+
const wasRefused = new Set((yield* refused(path, before)).map(withoutLine));
|
|
58
|
+
const refusals = (yield* refused(path, after.value)).filter((refusal) => !wasRefused.has(withoutLine(refusal)));
|
|
42
59
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const before = Option.getOrElse(yield* at(parent, path), () => "");
|
|
63
|
-
|
|
64
|
-
const wasRefused = new Set((yield* refused(path, before)).map(withoutLine));
|
|
65
|
-
for (const refusal of yield* refused(path, after.value)) {
|
|
66
|
-
if (!wasRefused.has(withoutLine(refusal))) refusals.push(refusal);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const wasComment = new Set((yield* comments(path, before)).map((one) => one.text));
|
|
70
|
-
for (const one of yield* comments(path, after.value)) {
|
|
71
|
-
if (!wasComment.has(one.text)) commentLines += one.text.split("\n").length;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
landed.push({ sha: sha.slice(0, 7), subject, added, comments: commentLines, refusals });
|
|
60
|
+
const wasComment = new Set((yield* comments(path, before)).map((one) => one.text));
|
|
61
|
+
const added = (yield* comments(path, after.value)).filter((one) => !wasComment.has(one.text));
|
|
62
|
+
return { comments: added.reduce((sum, one) => sum + one.text.split("\n").length, 0), refusals };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const commitLanded = Effect.fn("commitLanded")(function* (commit: Commit) {
|
|
66
|
+
const changed = (yield* git(["diff", "--name-only", "--diff-filter=d", commit.parent, commit.sha]))
|
|
67
|
+
.trim()
|
|
68
|
+
.split("\n")
|
|
69
|
+
.filter((path) => path !== "" && readable(path));
|
|
70
|
+
if (changed.length === 0) return Option.none<Landed>();
|
|
71
|
+
|
|
72
|
+
const added = yield* addedLines(commit, changed);
|
|
73
|
+
let commentLines = 0;
|
|
74
|
+
const refusals: string[] = [];
|
|
75
|
+
for (const path of changed) {
|
|
76
|
+
const one = yield* pathLanded(commit, path);
|
|
77
|
+
commentLines += one.comments;
|
|
78
|
+
refusals.push(...one.refusals);
|
|
76
79
|
}
|
|
80
|
+
return Option.some<Landed>({ sha: commit.sha.slice(0, 7), subject: commit.subject, added, comments: commentLines, refusals });
|
|
81
|
+
});
|
|
77
82
|
|
|
83
|
+
function summary(landed: readonly Landed[]): string {
|
|
84
|
+
const lines: string[] = [];
|
|
78
85
|
const total = (pick: (one: Landed) => number): number => landed.reduce((sum, one) => sum + pick(one), 0);
|
|
79
|
-
const
|
|
86
|
+
const added = total((one) => one.added);
|
|
80
87
|
const commentLines = total((one) => one.comments);
|
|
81
88
|
const refusalCount = total((one) => one.refusals.length);
|
|
82
89
|
|
|
83
90
|
for (const one of landed) {
|
|
84
91
|
lines.push(`${one.sha}\t${one.added}\t${one.comments}\t${one.refusals.length}\t${one.subject.slice(0, 58)}`);
|
|
85
92
|
}
|
|
86
|
-
lines.push(`${landed.length} commits touching code, ${
|
|
93
|
+
lines.push(`${landed.length} commits touching code, ${added} added lines`);
|
|
87
94
|
lines.push(
|
|
88
|
-
`comment lines added: ${commentLines} (${((commentLines / Math.max(
|
|
95
|
+
`comment lines added: ${commentLines} (${((commentLines / Math.max(added, 1)) * 100).toFixed(1)}% of added lines)`,
|
|
89
96
|
);
|
|
90
97
|
lines.push(`refusals introduced: ${refusalCount}`);
|
|
91
98
|
for (const one of landed) {
|
|
@@ -94,6 +101,15 @@ export const backtest = Effect.fn("backtest")(function* (count: string) {
|
|
|
94
101
|
for (const refusal of one.refusals) lines.push(` ${refusal}`);
|
|
95
102
|
}
|
|
96
103
|
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const backtest = Effect.fn("backtest")(function* (count: string) {
|
|
107
|
+
const landed: Landed[] = [];
|
|
108
|
+
for (const commit of yield* firstParentCommits(count)) {
|
|
109
|
+
const one = yield* commitLanded(commit);
|
|
110
|
+
if (Option.isSome(one)) landed.push(one.value);
|
|
111
|
+
}
|
|
112
|
+
return summary(landed);
|
|
97
113
|
});
|
|
98
114
|
|
|
99
115
|
const main = Effect.gen(function* () {
|
package/scripts/ci-wiring.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { git } from "./git.ts";
|
|
|
4
4
|
import { runMain } from "./main.ts";
|
|
5
5
|
import { DEFAULT_BRANCH, ENTRY_POINT, EVERY_REPOSITORY, KIT_GATES, QUALITY_FILE, selectedGates, type KitGate } from "./gates.ts";
|
|
6
6
|
import { readQuality, type Quality } from "./quality-file.ts";
|
|
7
|
+
import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
|
|
7
8
|
|
|
8
|
-
export type Command
|
|
9
|
+
export type { Command };
|
|
9
10
|
|
|
10
11
|
export type Gate = {
|
|
11
12
|
readonly command: string;
|
|
@@ -61,8 +62,6 @@ const CONSTANTS = new Map([
|
|
|
61
62
|
["false", false],
|
|
62
63
|
]);
|
|
63
64
|
const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
|
|
64
|
-
const VARIABLE = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/;
|
|
65
|
-
const UNPLAIN = new Set(["|", "&", ";", "<", ">", "(", ")", "`", "\\", "#", "\n"]);
|
|
66
65
|
|
|
67
66
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
68
67
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -74,66 +73,6 @@ function names(value: unknown): readonly string[] | undefined {
|
|
|
74
73
|
return value.filter((entry): entry is string => typeof entry === "string");
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
function expansion(text: string, from: number): number | undefined {
|
|
78
|
-
const match = VARIABLE.exec(text.slice(from));
|
|
79
|
-
return match === null ? undefined : from + match[0].length;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// A script counts only when it is one line of plain words: any shell control, redirection or
|
|
83
|
-
// substitution can run the gate without its failure failing the step.
|
|
84
|
-
function plainCommand(script: string): Command | undefined {
|
|
85
|
-
const line = script.trim();
|
|
86
|
-
const words: string[] = [];
|
|
87
|
-
let word: string | undefined;
|
|
88
|
-
for (let index = 0; index < line.length; index += 1) {
|
|
89
|
-
const char = line.charAt(index);
|
|
90
|
-
if (char === " " || char === "\t") {
|
|
91
|
-
if (word !== undefined) words.push(word);
|
|
92
|
-
word = undefined;
|
|
93
|
-
} else if (char === "'") {
|
|
94
|
-
const close = line.indexOf("'", index + 1);
|
|
95
|
-
if (close === -1) return undefined;
|
|
96
|
-
word = (word ?? "") + line.slice(index + 1, close);
|
|
97
|
-
index = close;
|
|
98
|
-
} else if (char === '"') {
|
|
99
|
-
let quoted = "";
|
|
100
|
-
for (index += 1; line.charAt(index) !== '"'; index += 1) {
|
|
101
|
-
const inner = line.charAt(index);
|
|
102
|
-
if (inner === "" || inner === "\\" || inner === "`") return undefined;
|
|
103
|
-
if (inner === "$") {
|
|
104
|
-
const end = expansion(line, index);
|
|
105
|
-
if (end === undefined) return undefined;
|
|
106
|
-
quoted += line.slice(index, end);
|
|
107
|
-
index = end - 1;
|
|
108
|
-
} else {
|
|
109
|
-
quoted += inner;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
word = (word ?? "") + quoted;
|
|
113
|
-
} else if (char === "$") {
|
|
114
|
-
const end = expansion(line, index);
|
|
115
|
-
if (end === undefined) return undefined;
|
|
116
|
-
word = (word ?? "") + line.slice(index, end);
|
|
117
|
-
index = end - 1;
|
|
118
|
-
} else if (UNPLAIN.has(char)) {
|
|
119
|
-
return undefined;
|
|
120
|
-
} else {
|
|
121
|
-
word = (word ?? "") + char;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (word !== undefined) words.push(word);
|
|
125
|
-
return words.length === 0 ? undefined : words;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function invokes(command: Command | undefined, gate: Command): boolean {
|
|
129
|
-
return command !== undefined && gate.length <= command.length && gate.every((word, index) => command[index] === word);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function mentions(script: string, gate: Command): boolean {
|
|
133
|
-
const tokens = script.split(/[\s|&;<>()`]+/);
|
|
134
|
-
return tokens.some((_, start) => invokes(tokens.slice(start), gate));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
76
|
function constant(value: unknown): boolean | undefined {
|
|
138
77
|
if (typeof value === "boolean") return value;
|
|
139
78
|
if (typeof value !== "string") return undefined;
|
package/scripts/comment-gate.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Option } from "effect";
|
|
3
3
|
import { refused, syntaxOf } from "./comments.ts";
|
|
4
|
-
import { changedLines, git, parentOrEmptyTree } from "./git.ts";
|
|
5
|
-
import { runMain
|
|
4
|
+
import { changedLines, git, parentOrEmptyTree, refArgs } from "./git.ts";
|
|
5
|
+
import { runMain } from "./main.ts";
|
|
6
6
|
|
|
7
7
|
export type GateResult = {
|
|
8
8
|
readonly files: number;
|
|
@@ -41,8 +41,7 @@ export function report({ files, addedLines, violations }: GateResult): string {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const gate = Effect.gen(function* () {
|
|
44
|
-
const
|
|
45
|
-
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
44
|
+
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
46
45
|
|
|
47
46
|
const root = (yield* git(["rev-parse", "--show-toplevel"], process.cwd())).trim();
|
|
48
47
|
const base = second === undefined ? yield* parentOrEmptyTree(first, root) : first;
|
|
@@ -147,70 +147,67 @@ function endOfRegex(source: string, start: number): number {
|
|
|
147
147
|
return source.length;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
type Token = { readonly stop: number; readonly previous: string };
|
|
151
|
+
|
|
152
|
+
function endOfBlockComment(source: string, i: number, syntax: Syntax): number | undefined {
|
|
153
|
+
const block = syntax.block.find(([open]) => source.startsWith(open, i));
|
|
154
|
+
if (block === undefined) return undefined;
|
|
155
|
+
const [open, close] = block;
|
|
156
|
+
const closed = source.indexOf(close, i + open.length);
|
|
157
|
+
return closed < 0 ? source.length : closed + close.length;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function endOfLineComment(source: string, i: number, syntax: Syntax): number | undefined {
|
|
161
|
+
const marker = syntax.line.find(
|
|
162
|
+
({ token, afterAWordBreak }) =>
|
|
163
|
+
source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source.charAt(i - 1))),
|
|
164
|
+
);
|
|
165
|
+
if (marker === undefined) return undefined;
|
|
166
|
+
const newline = source.indexOf("\n", i);
|
|
167
|
+
return newline < 0 ? source.length : newline;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function endOfWord(source: string, i: number): number {
|
|
171
|
+
let end = i;
|
|
172
|
+
while (end < source.length && WORD.test(source.charAt(end))) end += 1;
|
|
173
|
+
return end;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function codeToken(source: string, i: number, syntax: Syntax, previous: string): Token {
|
|
177
|
+
const quote = syntax.quotes.find(
|
|
178
|
+
({ token, opensMidWord }) =>
|
|
179
|
+
source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source.charAt(i - 1))),
|
|
180
|
+
);
|
|
181
|
+
if (quote) return { stop: endOfQuoted(source, i, quote), previous: quote.token };
|
|
182
|
+
|
|
183
|
+
const char = source.charAt(i);
|
|
184
|
+
if (syntax.regexLiterals && char === "/" && opensRegex(previous)) return { stop: endOfRegex(source, i), previous: "/" };
|
|
185
|
+
if (WORD.test(char)) {
|
|
186
|
+
const end = endOfWord(source, i);
|
|
187
|
+
return { stop: end, previous: source.slice(i, end) };
|
|
188
|
+
}
|
|
189
|
+
return { stop: i + 1, previous: /\s/.test(char) ? previous : char };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function newlinesIn(source: string, from: number, to: number): number {
|
|
193
|
+
let count = 0;
|
|
194
|
+
for (let k = from; k < to; k++) if (source[k] === "\n") count += 1;
|
|
195
|
+
return count;
|
|
196
|
+
}
|
|
197
|
+
|
|
150
198
|
export function commentsIn(source: string, syntax: Syntax): Comment[] {
|
|
151
199
|
const found: Comment[] = [];
|
|
152
200
|
let i = 0;
|
|
153
201
|
let line = 1;
|
|
154
202
|
let previous = "";
|
|
155
203
|
|
|
156
|
-
const advance = (to: number): void => {
|
|
157
|
-
for (let k = i; k < to; k++) if (source[k] === "\n") line += 1;
|
|
158
|
-
i = to;
|
|
159
|
-
};
|
|
160
|
-
|
|
161
204
|
while (i < source.length) {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const stop = closed < 0 ? source.length : closed + close.length;
|
|
169
|
-
found.push({ line, text: source.slice(i, stop) });
|
|
170
|
-
advance(stop);
|
|
171
|
-
previous = "";
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const marker = syntax.line.find(
|
|
176
|
-
({ token, afterAWordBreak }) =>
|
|
177
|
-
source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source.charAt(i - 1))),
|
|
178
|
-
);
|
|
179
|
-
if (marker) {
|
|
180
|
-
const newline = source.indexOf("\n", i);
|
|
181
|
-
const stop = newline < 0 ? source.length : newline;
|
|
182
|
-
found.push({ line, text: source.slice(i, stop) });
|
|
183
|
-
advance(stop);
|
|
184
|
-
previous = "";
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const quote = syntax.quotes.find(
|
|
189
|
-
({ token, opensMidWord }) =>
|
|
190
|
-
source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source.charAt(i - 1))),
|
|
191
|
-
);
|
|
192
|
-
if (quote) {
|
|
193
|
-
advance(endOfQuoted(source, i, quote));
|
|
194
|
-
previous = quote.token;
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (syntax.regexLiterals && char === "/" && opensRegex(previous)) {
|
|
199
|
-
advance(endOfRegex(source, i));
|
|
200
|
-
previous = "/";
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (WORD.test(char)) {
|
|
205
|
-
let end = i;
|
|
206
|
-
while (end < source.length && WORD.test(source.charAt(end))) end += 1;
|
|
207
|
-
previous = source.slice(i, end);
|
|
208
|
-
advance(end);
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (!/\s/.test(char)) previous = char;
|
|
213
|
-
advance(i + 1);
|
|
205
|
+
const commentStop = endOfBlockComment(source, i, syntax) ?? endOfLineComment(source, i, syntax);
|
|
206
|
+
if (commentStop !== undefined) found.push({ line, text: source.slice(i, commentStop) });
|
|
207
|
+
const token = commentStop === undefined ? codeToken(source, i, syntax, previous) : { stop: commentStop, previous: "" };
|
|
208
|
+
line += newlinesIn(source, i, token.stop);
|
|
209
|
+
i = token.stop;
|
|
210
|
+
previous = token.previous;
|
|
214
211
|
}
|
|
215
212
|
|
|
216
213
|
return found;
|
|
@@ -244,6 +241,24 @@ function openingBlock(found: readonly Comment[], source: string): Comment[] {
|
|
|
244
241
|
return block;
|
|
245
242
|
}
|
|
246
243
|
|
|
244
|
+
export const REFUSED_DIRECTIVES = [
|
|
245
|
+
"@ts-expect-error",
|
|
246
|
+
"@ts-ignore",
|
|
247
|
+
"biome-ignore",
|
|
248
|
+
"eslint-disable",
|
|
249
|
+
"prettier-ignore",
|
|
250
|
+
] as const satisfies readonly string[];
|
|
251
|
+
|
|
252
|
+
const escapeRegExp = (text: string): string => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
253
|
+
|
|
254
|
+
const directiveSource = (name: string): string => {
|
|
255
|
+
const escaped = escapeRegExp(name);
|
|
256
|
+
if (name === "eslint-disable") return `\\b${escaped}[\\w-]*`;
|
|
257
|
+
return /^[\w]/.test(name) ? `\\b${escaped}\\b` : escaped;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
const DIRECTIVE = new RegExp(REFUSED_DIRECTIVES.map(directiveSource).join("|"));
|
|
261
|
+
|
|
247
262
|
type Check = {
|
|
248
263
|
readonly find: RegExp;
|
|
249
264
|
readonly refusal: (match: string) => string;
|
|
@@ -251,7 +266,7 @@ type Check = {
|
|
|
251
266
|
|
|
252
267
|
const CHECKS: readonly Check[] = [
|
|
253
268
|
{
|
|
254
|
-
find:
|
|
269
|
+
find: DIRECTIVE,
|
|
255
270
|
refusal: (match) =>
|
|
256
271
|
`carries the machine-read directive \`${match}\`. Fix what the tool is reporting, or stop running the tool on this file`,
|
|
257
272
|
},
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Schema } from "effect";
|
|
3
|
-
import { git } from "./git.ts";
|
|
4
|
-
import { runMain
|
|
3
|
+
import { git, refArgs } from "./git.ts";
|
|
4
|
+
import { runMain } from "./main.ts";
|
|
5
5
|
import { readQuality, type Identity } from "./quality-file.ts";
|
|
6
6
|
|
|
7
7
|
type Commit = {
|
|
@@ -98,8 +98,7 @@ function inspect(commit: Commit, allowed: readonly Identity[]): Offence | undefi
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
const check = Effect.gen(function* () {
|
|
101
|
-
const
|
|
102
|
-
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
101
|
+
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
103
102
|
const range = second === undefined ? first : `${first}..${second}`;
|
|
104
103
|
const revisions = second === undefined ? ["-1", range] : [range];
|
|
105
104
|
|
package/scripts/doc-outline.ts
CHANGED
|
@@ -156,32 +156,50 @@ function missing(slot: Slot, level: number): string {
|
|
|
156
156
|
return slot.type === "fixed" ? `lacks ${marked(level, slot.text)}` : `lacks a ${marked(level, slot.placeholder)} section`;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
type Placement =
|
|
160
|
+
| { readonly kind: "behind"; readonly index: number }
|
|
161
|
+
| { readonly kind: "unplaced" }
|
|
162
|
+
| { readonly kind: "placed"; readonly index: number; readonly slot: Slot; readonly at: number };
|
|
163
|
+
|
|
164
|
+
function placementOf(slots: readonly Slot[], title: string, at: number): Placement {
|
|
165
|
+
const fixed = fixedIndexOf(slots, title);
|
|
166
|
+
if (fixed !== -1 && fixed <= at) return { kind: "behind", index: fixed };
|
|
167
|
+
const open = openIndexFrom(slots, at);
|
|
168
|
+
const index = fixed !== -1 ? fixed : open.index;
|
|
169
|
+
const slot = slots[index];
|
|
170
|
+
if (slot === undefined) return { kind: "unplaced" };
|
|
171
|
+
return { kind: "placed", index, slot, at: fixed !== -1 || open.advances ? index : at };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function placedProblems(slot: Slot, { heading, subsections }: Section, level: number): Violation[] {
|
|
175
|
+
const problem = slot.type === "open" ? ruleProblem(slot.rule, heading.title) : undefined;
|
|
176
|
+
return [
|
|
177
|
+
...(problem === undefined ? [] : [{ line: heading.line, message: `${marked(level, heading.title)} ${problem}` }]),
|
|
178
|
+
...(slot.subsections === undefined ? [] : matchSections(subsections, slot.subsections, level + 1, heading.line)),
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
159
182
|
export function matchSections(sections: readonly Section[], slots: readonly Slot[], level: number, parentLine: number): Violation[] {
|
|
160
183
|
const order = `the template's order is ${slots.map(slotLabel).join(", ")}`;
|
|
161
184
|
const violations: Violation[] = [];
|
|
162
185
|
const found = slots.map(() => false);
|
|
163
186
|
let at = -1;
|
|
164
|
-
for (const
|
|
187
|
+
for (const section of sections) {
|
|
188
|
+
const { heading } = section;
|
|
165
189
|
const named = marked(level, heading.title);
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
const problem = fixed === at ? "appears twice" : `is out of order, as ${order}`;
|
|
170
|
-
violations.push({ line: heading.line, message: `${named} ${problem}` });
|
|
190
|
+
const placement = placementOf(slots, heading.title, at);
|
|
191
|
+
if (placement.kind === "unplaced") {
|
|
192
|
+
violations.push({ line: heading.line, message: `${named} is not a section the template has there, as ${order}` });
|
|
171
193
|
continue;
|
|
172
194
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
violations.push({ line: heading.line, message: `${named} is not a section the template has there, as ${order}` });
|
|
195
|
+
found[placement.index] = true;
|
|
196
|
+
if (placement.kind === "behind") {
|
|
197
|
+
const problem = placement.index === at ? "appears twice" : `is out of order, as ${order}`;
|
|
198
|
+
violations.push({ line: heading.line, message: `${named} ${problem}` });
|
|
178
199
|
continue;
|
|
179
200
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const problem = slot.type === "open" ? ruleProblem(slot.rule, heading.title) : undefined;
|
|
183
|
-
if (problem !== undefined) violations.push({ line: heading.line, message: `${named} ${problem}` });
|
|
184
|
-
if (slot.subsections !== undefined) violations.push(...matchSections(subsections, slot.subsections, level + 1, heading.line));
|
|
201
|
+
at = placement.at;
|
|
202
|
+
violations.push(...placedProblems(placement.slot, section, level));
|
|
185
203
|
}
|
|
186
204
|
slots.forEach((slot, index) => {
|
|
187
205
|
if (slot.presence.required && found[index] !== true) violations.push({ line: parentLine, message: missing(slot, level) });
|