@avi2dg/checks 0.17.0 → 0.18.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +12 -0
- package/docs/configs/commit-messages.md +2 -18
- package/docs/configs/quality-file.md +2 -2
- package/docs/gates/checks-quality.md +23 -8
- package/package.json +3 -1
- 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 +54 -57
- 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 +5 -8
- package/scripts/suppressions-ratchet.ts +22 -24
- package/scripts/test-layout.ts +37 -35
- package/scripts/test-report.ts +18 -13
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,18 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.18.0
|
|
6
|
+
|
|
7
|
+
Released 2026-09-26.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **scripts:** generate commitlint and CI workflows with checks-quality (#54)
|
|
12
|
+
|
|
13
|
+
### Fixes
|
|
14
|
+
|
|
15
|
+
- **scripts:** make checks-quality refuse a config that drops the kit's extends (#55)
|
|
16
|
+
|
|
5
17
|
## 0.17.0
|
|
6
18
|
|
|
7
19
|
Released 2026-09-25.
|
|
@@ -10,24 +10,8 @@ It arrives with the kit, since `@commitlint/cli` and `@commitlint/config-convent
|
|
|
10
10
|
## Workflow
|
|
11
11
|
|
|
12
12
|
The lint runs in CI on pull requests, because `jj` never fires a git hook.
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
```yaml
|
|
16
|
-
on:
|
|
17
|
-
pull_request:
|
|
18
|
-
types: [opened, edited, synchronize, reopened]
|
|
19
|
-
jobs:
|
|
20
|
-
commitlint:
|
|
21
|
-
runs-on: ubuntu-latest
|
|
22
|
-
steps:
|
|
23
|
-
- uses: actions/checkout@v5
|
|
24
|
-
- uses: oven-sh/setup-bun@v2
|
|
25
|
-
- run: bun install --frozen-lockfile
|
|
26
|
-
- run: printf '%s' "$PR_TITLE (#0000)" > "$RUNNER_TEMP/pr-title"
|
|
27
|
-
env:
|
|
28
|
-
PR_TITLE: ${{ github.event.pull_request.title }}
|
|
29
|
-
- run: ./node_modules/.bin/commitlint --config ./node_modules/@avi2dg/checks/commitlint.config.js --edit "$RUNNER_TEMP/pr-title"
|
|
30
|
-
```
|
|
13
|
+
`checks-quality generate` writes the workflow whole into `.github/workflows/commitlint.yml`, as [checks-quality](../gates/checks-quality.md) says.
|
|
14
|
+
The workflow lints with the installed kit's `commitlint.config.js`, so every repository holds titles to the same rules.
|
|
31
15
|
|
|
32
16
|
## What it lints
|
|
33
17
|
|
|
@@ -39,8 +39,8 @@ The kit's bins find the file at the git root and read it there:
|
|
|
39
39
|
|
|
40
40
|
| Key | Read by | Holds |
|
|
41
41
|
| --- | --- | --- |
|
|
42
|
-
| `defaultBranch` | `checks-lint`, `checks-ci-wiring` | the branch pull requests merge into, `main` when absent |
|
|
43
|
-
| `gates.ci` | `checks-ci-wiring` | the commands CI runs on every pull request, as [checks-ci-wiring](../gates/checks-ci-wiring.md) says |
|
|
42
|
+
| `defaultBranch` | `checks-lint`, `checks-ci-wiring`, `checks-quality` | the branch pull requests merge into, `main` when absent |
|
|
43
|
+
| `gates.ci` | `checks-ci-wiring`, `checks-quality` | the commands CI runs on every pull request, as [checks-ci-wiring](../gates/checks-ci-wiring.md) says |
|
|
44
44
|
| `gates.scheduled` | `checks-ci-wiring` | the commands a schedule runs |
|
|
45
45
|
| `gates.lint` | `checks-lint`, `checks-ci-wiring` | the gates `checks-lint` runs when not all apply, as [Gate selection](../gates/checks-lint.md#gate-selection) says |
|
|
46
46
|
| `commitIdentity.authors` | `checks-commit-identity` | the identities allowed to author and commit, as [checks-commit-identity](../gates/checks-commit-identity.md) says |
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# checks-quality
|
|
2
2
|
|
|
3
|
-
`checks-quality` is the bin that writes what `quality.json` declares
|
|
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
|
|
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/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;
|
|
@@ -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) });
|