@avi2dg/checks 0.32.0 → 0.34.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 +23 -0
- package/README.md +4 -3
- package/dist/readability/index.js +18 -9
- package/docs/configs/typescript-rules.md +1 -1
- package/docs/design.md +6 -2
- package/docs/gates/checks-advisories.md +3 -2
- package/docs/gates/checks-ci-wiring.md +20 -1
- package/docs/gates/checks-docs.md +9 -3
- package/docs/gates/checks-flake.md +2 -0
- package/docs/gates/checks-mutation-compare.md +3 -2
- package/docs/gates/checks-mutation.md +6 -4
- package/docs/gates/checks-release-notes.md +1 -0
- package/docs/gates/checks-release-pr.md +2 -2
- package/docs/gates/checks-vendor.md +3 -3
- package/package.json +11 -5
- package/src/core/git.ts +1 -1
- package/src/core/lint.ts +1 -1
- package/src/delivery/ci-wiring.ts +107 -5
- package/src/delivery/github.ts +1 -1
- package/src/delivery/release-pr.ts +3 -2
- package/src/delivery/release.ts +1 -1
- package/src/delivery/shell-command.ts +143 -0
- package/src/dependencies/pinned-binary.ts +5 -4
- package/src/docs/doc-agents.ts +22 -20
- package/src/docs/doc-names.ts +1 -1
- package/src/docs/doc-references.ts +14 -7
- package/src/docs/docs.ts +15 -2
- package/src/docs/prose-matchers.ts +1 -1
- package/src/docs/sentence-length.ts +106 -0
- package/src/testing/flake.ts +1 -1
- package/src/testing/mutation.ts +1 -1
- package/src/testing/test.ts +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.34.0
|
|
6
|
+
|
|
7
|
+
Released 2026-10-03.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **delivery:** pin mutation jobs to winbox and default other jobs to hosted runners [#123](https://github.com/avi2d/checks/pull/123)
|
|
12
|
+
|
|
13
|
+
## 0.33.0
|
|
14
|
+
|
|
15
|
+
Released 2026-10-03.
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
|
|
19
|
+
- **docs:** report sentences over trial length caps as advisory [#120](https://github.com/avi2d/checks/pull/120)
|
|
20
|
+
|
|
21
|
+
### Fixes
|
|
22
|
+
|
|
23
|
+
- move effect to stable 4.0.0 [#122](https://github.com/avi2d/checks/pull/122)
|
|
24
|
+
- **complexity:** accept signed numbers and earlier props names in thin-astro defaults [#115](https://github.com/avi2d/checks/pull/115)
|
|
25
|
+
- **docs:** judge agent file entries and headings from a CommonMark parse [#116](https://github.com/avi2d/checks/pull/116)
|
|
26
|
+
- **docs:** fail link anchors the checks-docs reference rule could not check [#114](https://github.com/avi2d/checks/pull/114)
|
|
27
|
+
|
|
5
28
|
## 0.32.0
|
|
6
29
|
|
|
7
30
|
Released 2026-09-29.
|
package/README.md
CHANGED
|
@@ -9,12 +9,12 @@ Each repository owns its workflows and native tool configs, as [Native settings]
|
|
|
9
9
|
<!-- generated prerequisites: bun run build writes it from package.json, .bun-version and scripts/doc-blocks.ts -->
|
|
10
10
|
|
|
11
11
|
- A git repository, whose history the range gates read.
|
|
12
|
-
- Bun 1.
|
|
12
|
+
- Bun 1.4.2, which runs every bin.
|
|
13
13
|
- The peer dependencies, at the exact versions the kit pins:
|
|
14
14
|
- `@effect/tsgo` 0.45.0
|
|
15
15
|
- `@swc/core` 1.16.2
|
|
16
16
|
- `dependency-cruiser` 18.4.0
|
|
17
|
-
- `effect` 4.0.0
|
|
17
|
+
- `effect` 4.0.0
|
|
18
18
|
- `jscpd` 5.3.2
|
|
19
19
|
- `oxlint` 1.83.0
|
|
20
20
|
- `oxlint-tsgolint` 7.0.2002
|
|
@@ -31,7 +31,7 @@ To consume the kit from a repository:
|
|
|
31
31
|
<!-- generated install: bun run build writes it from package.json and scripts/doc-blocks.ts -->
|
|
32
32
|
|
|
33
33
|
```sh
|
|
34
|
-
bun add -d @avi2dg/checks @effect/tsgo@0.45.0 @swc/core@1.16.2 dependency-cruiser@18.4.0 effect@4.0.0
|
|
34
|
+
bun add -d @avi2dg/checks @effect/tsgo@0.45.0 @swc/core@1.16.2 dependency-cruiser@18.4.0 effect@4.0.0 jscpd@5.3.2 oxlint@1.83.0 oxlint-tsgolint@7.0.2002 typescript@7.0.2
|
|
35
35
|
```
|
|
36
36
|
|
|
37
37
|
<!-- end generated install -->
|
|
@@ -104,6 +104,7 @@ To consume the kit from a repository:
|
|
|
104
104
|
```
|
|
105
105
|
|
|
106
106
|
Add a pull request title lint step in another workflow using `./node_modules/.bin/commitlint`.
|
|
107
|
+
A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on each job, as [checks-ci-wiring](docs/gates/checks-ci-wiring.md#runners) requires.
|
|
107
108
|
`bun run lint` then ends with `checks-lint: <count> gate(s) pass`.
|
|
108
109
|
|
|
109
110
|
## What runs
|
|
@@ -400,11 +400,11 @@ var rule = {
|
|
|
400
400
|
create(context) {
|
|
401
401
|
const max = maxOf(context.options);
|
|
402
402
|
const check = (node) => {
|
|
403
|
-
const
|
|
404
|
-
if (
|
|
403
|
+
const score = node.type === "StaticBlock" ? cognitiveComplexity(node, NO_NAMES) : cognitiveComplexity(node, selfNames(node));
|
|
404
|
+
if (score <= max)
|
|
405
405
|
return;
|
|
406
406
|
const name = node.type === "StaticBlock" ? "static block" : `function \`${displayName(node)}\``;
|
|
407
|
-
context.report({ node, message: `${name} has a cognitive complexity of ${
|
|
407
|
+
context.report({ node, message: `${name} has a cognitive complexity of ${score}. Maximum allowed is ${max}.` });
|
|
408
408
|
};
|
|
409
409
|
return {
|
|
410
410
|
FunctionDeclaration: check,
|
|
@@ -430,6 +430,9 @@ function isAstroProps(value) {
|
|
|
430
430
|
const { object, property } = value;
|
|
431
431
|
return object.type === "Identifier" && object.name === "Astro" && property.type === "Identifier" && property.name === "props";
|
|
432
432
|
}
|
|
433
|
+
function isSignedNumber(value) {
|
|
434
|
+
return value.type === "UnaryExpression" && (value.operator === "-" || value.operator === "+") && value.argument.type === "Literal" && typeof value.argument.value === "number";
|
|
435
|
+
}
|
|
433
436
|
function isPlainValue(value, bound) {
|
|
434
437
|
return value.type === "Literal" || value.type === "Identifier" && bound.has(value.name);
|
|
435
438
|
}
|
|
@@ -442,17 +445,23 @@ function readsProps(expression, bound) {
|
|
|
442
445
|
return isAstroProps(value) || readsProps(value.object, bound);
|
|
443
446
|
}
|
|
444
447
|
function isPlainPattern(pattern, bound) {
|
|
448
|
+
return isPlainInOrder(pattern, bound, new Set(bound));
|
|
449
|
+
}
|
|
450
|
+
function isPlainInOrder(pattern, bound, seen) {
|
|
445
451
|
if (pattern === null)
|
|
446
452
|
return true;
|
|
447
453
|
if (pattern.type === "RestElement")
|
|
448
|
-
return
|
|
449
|
-
if (pattern.type === "AssignmentPattern")
|
|
450
|
-
return isPlainValue(pattern.right,
|
|
454
|
+
return isPlainInOrder(pattern.argument, bound, seen);
|
|
455
|
+
if (pattern.type === "AssignmentPattern") {
|
|
456
|
+
return (isPlainValue(pattern.right, seen) || isSignedNumber(pattern.right)) && isPlainInOrder(pattern.left, bound, seen);
|
|
457
|
+
}
|
|
451
458
|
if (pattern.type === "ArrayPattern")
|
|
452
|
-
return pattern.elements.every((element) =>
|
|
453
|
-
if (pattern.type === "Identifier")
|
|
459
|
+
return pattern.elements.every((element) => isPlainInOrder(element, bound, seen));
|
|
460
|
+
if (pattern.type === "Identifier") {
|
|
461
|
+
seen.add(pattern.name);
|
|
454
462
|
return true;
|
|
455
|
-
|
|
463
|
+
}
|
|
464
|
+
return pattern.properties.every((property) => property.type === "RestElement" ? isPlainInOrder(property.argument, bound, seen) : (!property.computed || isPlainValue(property.key, bound)) && isPlainInOrder(property.value, bound, seen));
|
|
456
465
|
}
|
|
457
466
|
function isPropsRead(declarator, bound) {
|
|
458
467
|
return declarator.init !== null && readsProps(declarator.init, bound) && isPlainPattern(declarator.id, bound);
|
|
@@ -48,7 +48,7 @@ The base loads the kit's `data-shape` plugin from `dist/` with one rule for ever
|
|
|
48
48
|
An override in `oxlintrc.json` turns on one rule of the kit's `readability` plugin in each `.astro` file:
|
|
49
49
|
|
|
50
50
|
- `readability/thin-astro` refuses a statement in the frontmatter or a script block that is neither an import, a re-export from another module, a type or interface declaration, nor a variable read from `Astro.props`.
|
|
51
|
-
- A default inside an `Astro.props` destructuring passes only when it is a literal or a name read from `Astro.props` earlier, so `const { title = "Home" } = Astro.props;` passes and a call or `await` in a default is refused.
|
|
51
|
+
- A default inside an `Astro.props` destructuring passes only when it is a literal, a `-` or `+` on a numeric literal, or a name read from `Astro.props` earlier, in the same pattern or a statement before it, so `const { title = "Home", heading = title } = Astro.props;` passes and a call or `await` in a default is refused.
|
|
52
52
|
- Move a refused statement into a `.ts` file and import it, so the `.astro` file holds only imports, props and markup.
|
|
53
53
|
- A script block loads client code with a side-effect import, as in `<script>import "../client.ts";</script>`, and the override turns off `import/no-unassigned-import` so that import passes.
|
|
54
54
|
- A dynamic route re-exports `getStaticPaths` from a `.ts` file, as in `export { getStaticPaths } from "../lib/paths.ts";`.
|
package/docs/design.md
CHANGED
|
@@ -67,7 +67,7 @@ So the fragment lists the rules in `files`, and in `include` beside every file u
|
|
|
67
67
|
|
|
68
68
|
The source sits under `src/<vector>/`, one directory for each thing the kit judges a repository on: complexity, quality, testing, docs, delivery and dependencies.
|
|
69
69
|
`src/core/` holds what every vector runs on.
|
|
70
|
-
`scripts/` holds only the kit's own build, and nothing in it ships.
|
|
70
|
+
`scripts/` holds only the kit's own build and CI tooling, and nothing in it ships.
|
|
71
71
|
Sorting files by what loads them would put both oxlint plugins at the root and every bin in one flat directory.
|
|
72
72
|
Nothing would then say which gate a helper serves.
|
|
73
73
|
A mutation runner's default scope covers `src/`, so the kit's own Stryker run mutates its source with no `mutate` list.
|
|
@@ -100,7 +100,7 @@ The `.ts` bins keep a `bun` shebang and need no build step, unlike the oxlint pl
|
|
|
100
100
|
The bins are written in Effect.
|
|
101
101
|
So `effect` is a peer dependency, and `@effect/platform-bun`, which only the bins use, is a dependency.
|
|
102
102
|
`@effect/platform-node-shared` is a direct dependency only to pin its version.
|
|
103
|
-
`@effect/platform-bun` asks for it with a `^` range,
|
|
103
|
+
`@effect/platform-bun` asks for it with a `^` range, so a newer minor of it could resolve and peer on a newer `effect` than the exact version consumers install.
|
|
104
104
|
So the three packages move together at one exact version.
|
|
105
105
|
|
|
106
106
|
## checks-lint runs each gate as its own bin
|
|
@@ -211,6 +211,10 @@ A score cannot fail a change without failing correct prose, and a suggestion tha
|
|
|
211
211
|
`src/docs/prose-matchers.ts` imports nothing, so the gate and a write-time hook run one matcher and refuse in the same words.
|
|
212
212
|
A hook bundle ships without `node_modules`, so a matcher that needed Vale or another package could not refuse at write time.
|
|
213
213
|
|
|
214
|
+
The entry rule and the rule against a `## Maintaining this file` section take their list items and headings from `commonmark`, the CommonMark reference parser.
|
|
215
|
+
A reader sees the blocks a renderer builds, and a line rule that guesses at blockquotes, HTML blocks and indented code misjudges each corner its guess misses.
|
|
216
|
+
Only the gate runs these rules, so the parser costs no hook anything, and the line scan reads each entry's code spans and links from that entry's line alone.
|
|
217
|
+
|
|
214
218
|
A path, link or command on a line the range leaves alone still fails when the range broke it, for example by deleting the file it names.
|
|
215
219
|
A reference goes stale far more often because the code it names moves than because its own line is edited.
|
|
216
220
|
So a gate on edited lines alone would miss the usual break.
|
|
@@ -110,7 +110,7 @@ on:
|
|
|
110
110
|
workflow_dispatch:
|
|
111
111
|
jobs:
|
|
112
112
|
advisories:
|
|
113
|
-
runs-on:
|
|
113
|
+
runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}
|
|
114
114
|
timeout-minutes: 10
|
|
115
115
|
steps:
|
|
116
116
|
- uses: actions/checkout@v5
|
|
@@ -119,8 +119,9 @@ jobs:
|
|
|
119
119
|
- run: ./node_modules/.bin/checks-advisories --all
|
|
120
120
|
```
|
|
121
121
|
|
|
122
|
+
The job runs on a hosted runner unless `CI_RUNS_ON` names another, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires.
|
|
123
|
+
A hosted runner starts each run with an empty cache, so each run downloads both the scanner and the database.
|
|
122
124
|
A self-hosted runner keeps `~/.cache/avi2dg-checks/` between runs, so it downloads the scanner once per pinned version and the database about once a day.
|
|
123
|
-
A hosted runner starts each run with an empty cache, so each run downloads both.
|
|
124
125
|
|
|
125
126
|
## Related topics
|
|
126
127
|
|
|
@@ -21,10 +21,22 @@ It refuses a step or job with `if: false` or `continue-on-error: true`.
|
|
|
21
21
|
It also refuses a workflow that does not trigger on both `opened` and `synchronize` pull requests to the target branch.
|
|
22
22
|
A path filter cannot cover every pull request and therefore cannot satisfy the check.
|
|
23
23
|
|
|
24
|
+
### Runners
|
|
25
|
+
|
|
26
|
+
It also judges the runner of every job in every workflow.
|
|
27
|
+
A mutation job is one with a run step that calls `stryker run`, `checks-mutation`, `checks-mutation-compare` or a `package.json` script that does, directly or through `bun`, `bun run` or `bunx`.
|
|
28
|
+
The call counts as a command of its own or inside a command substitution, never as an argument to another command.
|
|
29
|
+
A mutation job never names `CI_RUNS_ON` directly in `runs-on`, as `vars.CI_RUNS_ON` or `vars['CI_RUNS_ON']`, so an override for an outage cannot send its full sweeps to hosted runners.
|
|
30
|
+
Any other job that names `CI_RUNS_ON` in `runs-on` carries exactly the hosted-default expression `${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}`.
|
|
31
|
+
In a private repository each mutation job carries exactly the labels `[self-hosted, Linux, X64, winbox]` and every other job exactly the hosted-default expression.
|
|
32
|
+
A public repository may keep `runs-on: ubuntu-latest` on every job, because a pull request from a fork runs its own code on the runner.
|
|
33
|
+
The check knows a repository is private only from GitHub's event, so a run outside CI judges only the rules that hold in either.
|
|
34
|
+
|
|
24
35
|
## What it reads
|
|
25
36
|
|
|
26
37
|
The bin reads `.github/workflows/*.yml`, `*.yaml` and the `scripts` in `package.json` from the working tree.
|
|
27
38
|
It reads the target branch from `GITHUB_BASE_REF`, then from `refs/remotes/origin/HEAD` and then from the event file `GITHUB_EVENT_PATH` names.
|
|
39
|
+
It reads whether the repository is private from `repository.private` in that event file.
|
|
28
40
|
It parses the workflows with `Bun.YAML` without executing them.
|
|
29
41
|
|
|
30
42
|
## Arguments
|
|
@@ -36,7 +48,7 @@ It takes no arguments.
|
|
|
36
48
|
| Code | Result |
|
|
37
49
|
| --- | --- |
|
|
38
50
|
| 0 | Every required command has a reachable step. |
|
|
39
|
-
| 1 | A required command is missing or blocked. |
|
|
51
|
+
| 1 | A required command is missing or blocked, or a job runs on the wrong runner. |
|
|
40
52
|
| 2 | A workflow or `package.json` cannot be decoded. |
|
|
41
53
|
|
|
42
54
|
## Sample output
|
|
@@ -49,6 +61,13 @@ ci-wiring: 1 of 6 gate(s) do not run on pull requests to main:
|
|
|
49
61
|
no run step invokes it
|
|
50
62
|
```
|
|
51
63
|
|
|
64
|
+
A mutation job that names `CI_RUNS_ON` in `runs-on` produces a report like this:
|
|
65
|
+
|
|
66
|
+
```
|
|
67
|
+
ci-wiring: 1 job(s) run on the wrong runner:
|
|
68
|
+
.github/workflows/mutation-compare.yml job mutation-compare: a mutation job reads CI_RUNS_ON, so an override moves its full sweeps off winbox; set runs-on: [self-hosted, Linux, X64, winbox]
|
|
69
|
+
```
|
|
70
|
+
|
|
52
71
|
## When it runs
|
|
53
72
|
|
|
54
73
|
`checks-lint` runs it in every repository.
|
|
@@ -110,6 +110,8 @@ A line holds one sentence, so a changed line is a changed sentence.
|
|
|
110
110
|
A bold label that opens a line, as in `**Status.**`, heads the sentence after it and is not a sentence of its own.
|
|
111
111
|
No rule reads fenced code, inline code, link destinations, URLs, HTML comments or front matter.
|
|
112
112
|
Readability scores and word choice, such as easy, are not checked.
|
|
113
|
+
A sentence over the trial length cap is listed as advisory and never fails the run.
|
|
114
|
+
The cap is 20 words in an ordered list item and 25 words elsewhere.
|
|
113
115
|
|
|
114
116
|
`src/docs/prose-matchers.ts` holds the rules and a synchronous `proseRefused()`, and imports nothing.
|
|
115
117
|
The package exports it as `@avi2dg/checks/scripts/prose-matchers.ts`.
|
|
@@ -121,7 +123,8 @@ Each reference a living doc or an agent file names has to resolve at the head co
|
|
|
121
123
|
|
|
122
124
|
- A path in inline code that ends in a file extension, such as `src/core/lint.ts`, names a file from the root or from the doc's directory.
|
|
123
125
|
- A relative Markdown link names a file or a directory.
|
|
124
|
-
Its anchor names a heading in that file, as GitHub derives the anchor, or an explicit `id`.
|
|
126
|
+
Its anchor names a heading in that Markdown or MDX file, as GitHub derives the anchor, or an explicit `id`.
|
|
127
|
+
An anchor into a directory or any other file fails, because it names no heading the gate can check.
|
|
125
128
|
- A `bun run` command in code names a script in the nearest `package.json`, a bin in `node_modules/.bin`, or a file that exists.
|
|
126
129
|
- A code span fails when some tracked file outside the docs held that exact text at the base, and none holds it at the head.
|
|
127
130
|
A span the path check already fails on is not reported again.
|
|
@@ -150,7 +153,8 @@ audience: consumers
|
|
|
150
153
|
An agent file holds the router its template sketches, and the rules below hold its shape whatever the range touches.
|
|
151
154
|
A file over 3,000 characters fails.
|
|
152
155
|
Move each part's notes into the people doc that covers that part, and delete what a check or the code already holds.
|
|
153
|
-
A file that holds a `## Maintaining this file` section fails, because this gate holds the shape the section asked for.
|
|
156
|
+
A file that holds a `## Maintaining this file` section a reader sees fails, because this gate holds the shape the section asked for.
|
|
157
|
+
A heading inside an HTML comment, an HTML block or a code block is not such a section.
|
|
154
158
|
Each entry names at least one of these, or it fails:
|
|
155
159
|
|
|
156
160
|
- A path in inline code that git tracks at the head commit, a file or a directory, such as `package.json`, `LICENSE` or `.gitignore`.
|
|
@@ -159,7 +163,7 @@ Each entry names at least one of these, or it fails:
|
|
|
159
163
|
- A `bun run` command.
|
|
160
164
|
|
|
161
165
|
Whether the link or the command resolves is the reference rule's call, as [Paths, links and commands](#paths-links-and-commands) says, and it fails when the range adds or breaks one.
|
|
162
|
-
An entry is any list item a reader sees, the items above the first section included.
|
|
166
|
+
An entry is any list item a reader sees as CommonMark renders the file, the items above the first section included.
|
|
163
167
|
A list item inside an HTML comment, an HTML block or an indented code block is not an entry.
|
|
164
168
|
A fresh file passes the ceiling, and its entries pass once each names the file that holds its detail.
|
|
165
169
|
|
|
@@ -205,6 +209,8 @@ docs: 6 violation(s):
|
|
|
205
209
|
docs/parts.md:9: names `gates.lint`, which the range removed from every file outside the docs. Say what holds now, or drop the line
|
|
206
210
|
docs: advisory, 1 doc file(s) the range leaves alone do not hold to their templates yet:
|
|
207
211
|
docs/adr/0001-quality-gates.md: 5 violation(s)
|
|
212
|
+
docs: advisory, 1 sentence(s) over the trial length caps:
|
|
213
|
+
README.md:16: carries a 27-word descriptive sentence, over the 25-word cap (procedural means an ordered list item, capped at 20 words)
|
|
208
214
|
docs: advisory, 1 path(s), link(s) or command(s) the living docs or agent files name were broken before the range:
|
|
209
215
|
docs/parts.md:9: links to `suppliers.md#prices`, and `docs/suppliers.md` has no heading with that anchor
|
|
210
216
|
```
|
|
@@ -107,8 +107,9 @@ jobs:
|
|
|
107
107
|
Both Stryker runs are full sweeps, and GitHub sets `CI=true` on every runner, so the preset lets them through.
|
|
108
108
|
The base worktree's path carries the run's id and attempt, and the last step removes it even when a run fails, so a runner kept between jobs starts each job clean.
|
|
109
109
|
A public repository keeps `runs-on: ubuntu-latest`, because a pull request from a fork runs its own code on the runner.
|
|
110
|
-
A private repository sets `runs-on:
|
|
111
|
-
|
|
110
|
+
A private repository sets `runs-on: [self-hosted, Linux, X64, winbox]` instead, which is the fleet's self-hosted Linux runner where it sends its full sweeps.
|
|
111
|
+
The job never reads `CI_RUNS_ON`, so an override that moves a repository's other jobs during a runner outage leaves the comparison waiting for `winbox`.
|
|
112
|
+
[checks-ci-wiring](checks-ci-wiring.md#runners) refuses a mutation job that names it in `runs-on`.
|
|
112
113
|
|
|
113
114
|
## Related topics
|
|
114
115
|
|
|
@@ -59,8 +59,9 @@ An `--incremental` run with no report stops before instrumenting with the missin
|
|
|
59
59
|
## When it runs
|
|
60
60
|
|
|
61
61
|
A repository runs full baselines from the mutation workflow on `workflow_dispatch`.
|
|
62
|
+
A repository that picks a pull request scope from a baseline also runs that workflow on a nightly schedule.
|
|
62
63
|
Run scoped checks locally during development.
|
|
63
|
-
A scheduled run
|
|
64
|
+
A scheduled run costs a full Stryker run.
|
|
64
65
|
|
|
65
66
|
## Running it in CI
|
|
66
67
|
|
|
@@ -72,7 +73,7 @@ on:
|
|
|
72
73
|
workflow_dispatch:
|
|
73
74
|
jobs:
|
|
74
75
|
mutation:
|
|
75
|
-
runs-on:
|
|
76
|
+
runs-on: [self-hosted, Linux, X64, winbox]
|
|
76
77
|
steps:
|
|
77
78
|
- uses: actions/checkout@v5
|
|
78
79
|
- uses: oven-sh/setup-bun@v2
|
|
@@ -87,8 +88,9 @@ jobs:
|
|
|
87
88
|
path: reports/mutation/mutation.json
|
|
88
89
|
```
|
|
89
90
|
|
|
90
|
-
|
|
91
|
-
|
|
91
|
+
The job runs on the fleet's self-hosted Linux runner labelled `winbox`, which is where a repository sends its full sweeps.
|
|
92
|
+
It never reads `CI_RUNS_ON`, so an override that moves a repository's other jobs during a runner outage leaves the sweep waiting for `winbox`.
|
|
93
|
+
[checks-ci-wiring](checks-ci-wiring.md#runners) refuses a mutation job that names it in `runs-on`.
|
|
92
94
|
The `name: mutation` line is what `gh workflow run mutation` looks up.
|
|
93
95
|
GitHub sets `CI=true` on every runner, so the preset lets the full run through there.
|
|
94
96
|
|
|
@@ -133,6 +133,7 @@ jobs:
|
|
|
133
133
|
run: gh release create "$GITHUB_REF_NAME" --title "$GITHUB_REF_NAME" --notes-file "$RUNNER_TEMP/release-notes.md"
|
|
134
134
|
```
|
|
135
135
|
|
|
136
|
+
A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on each job in either workflow, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires.
|
|
136
137
|
A release is a pull request that holds only the version bump and the built changelog, which [checks-release-pr](checks-release-pr.md) opens once a day.
|
|
137
138
|
When it lands, [checks-release-tag](checks-release-tag.md) tags the merge commit and dispatches this workflow on the tag.
|
|
138
139
|
A tag the workflow token pushes starts no `push` run, so the workflow triggers on `workflow_dispatch` as well, and refuses a dispatch on a ref that is not a tag.
|
|
@@ -138,11 +138,11 @@ jobs:
|
|
|
138
138
|
```
|
|
139
139
|
|
|
140
140
|
A public repository keeps `runs-on: ubuntu-latest`.
|
|
141
|
-
A private repository sets `runs-on: ${{ vars.CI_RUNS_ON ||
|
|
141
|
+
A private repository sets `runs-on: ${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}` on both jobs, as [checks-ci-wiring](checks-ci-wiring.md#runners) requires of every job but a mutation job.
|
|
142
142
|
A repository whose build needs more than Bun adds the steps its `.github/workflows/ci.yml` runs before `bun run build` to both jobs, and nothing after it.
|
|
143
143
|
Neither job runs a test suite or a mutation run.
|
|
144
144
|
On a hosted runner the `pull-request` job takes about 25 seconds, which GitHub bills as one minute, whether it opens, refreshes or leaves the pull request alone.
|
|
145
|
-
A private repository runs it
|
|
145
|
+
A private repository runs it there too unless `CI_RUNS_ON` names its self-hosted runner, which bills no minutes.
|
|
146
146
|
The `tag` job runs only when a release lands, and a job its `if` skips bills nothing.
|
|
147
147
|
The checks it dispatches are the release pull request's own required checks, and they run again only when `main` moves under it.
|
|
148
148
|
The pull request also lists its own `pull_request` runs as waiting for approval.
|
|
@@ -60,19 +60,19 @@ With no arguments it pins nothing.
|
|
|
60
60
|
## Sample output
|
|
61
61
|
|
|
62
62
|
```
|
|
63
|
-
checks-vendor: cloned effect@4.0.0
|
|
63
|
+
checks-vendor: cloned effect@4.0.0 from https://github.com/Effect-TS/effect.git and linked repos/effect
|
|
64
64
|
```
|
|
65
65
|
|
|
66
66
|
A fresh fetch reports the tag it cloned and the link it made.
|
|
67
67
|
|
|
68
68
|
```
|
|
69
|
-
checks-vendor: repos/effect still holds effect@4.0.0
|
|
69
|
+
checks-vendor: repos/effect still holds effect@4.0.0, verified against its recorded commit
|
|
70
70
|
```
|
|
71
71
|
|
|
72
72
|
A later run reports the link it kept.
|
|
73
73
|
|
|
74
74
|
```
|
|
75
|
-
checks-vendor: found 1 path writable by its owner, starting with /home/runner/.cache/avi2dg-checks/repos/github.com/Effect-TS/effect/effect@4.0.0
|
|
75
|
+
checks-vendor: found 1 path writable by its owner, starting with /home/runner/.cache/avi2dg-checks/repos/github.com/Effect-TS/effect/effect@4.0.0, and froze the tree again, so repos/effect still holds effect@4.0.0, verified against its recorded commit
|
|
76
76
|
```
|
|
77
77
|
|
|
78
78
|
A run that found an owner write bit reports how many paths carried one and the first of them.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.34.0",
|
|
4
4
|
"description": "Deterministic checks shared across a set of TypeScript repositories",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -60,6 +60,7 @@
|
|
|
60
60
|
"src/complexity/exports.ts",
|
|
61
61
|
"src/complexity/knip.ts",
|
|
62
62
|
"src/testing/quarantine-clock.ts",
|
|
63
|
+
"src/docs/sentence-length.ts",
|
|
63
64
|
"src/docs/docs.ts",
|
|
64
65
|
"src/dependencies/vendor.ts",
|
|
65
66
|
"src/dependencies/vendor-args.ts",
|
|
@@ -136,7 +137,7 @@
|
|
|
136
137
|
"@swc/core": "1.16.2",
|
|
137
138
|
"dependency-cruiser": "18.4.0",
|
|
138
139
|
"@effect/tsgo": "0.45.0",
|
|
139
|
-
"effect": "4.0.0
|
|
140
|
+
"effect": "4.0.0",
|
|
140
141
|
"jscpd": "5.3.2",
|
|
141
142
|
"oxlint": "1.83.0",
|
|
142
143
|
"oxlint-tsgolint": "7.0.2002",
|
|
@@ -149,8 +150,9 @@
|
|
|
149
150
|
"@stryker-mutator/core": "10.0.0",
|
|
150
151
|
"@swc/core": "1.16.2",
|
|
151
152
|
"@types/bun": "^1.3.0",
|
|
153
|
+
"@types/commonmark": "0.27.10",
|
|
152
154
|
"dependency-cruiser": "18.4.0",
|
|
153
|
-
"effect": "4.0.0
|
|
155
|
+
"effect": "4.0.0",
|
|
154
156
|
"jscpd": "5.3.2",
|
|
155
157
|
"oxlint": "1.83.0",
|
|
156
158
|
"oxlint-tsgolint": "7.0.2002",
|
|
@@ -159,13 +161,17 @@
|
|
|
159
161
|
"dependencies": {
|
|
160
162
|
"@commitlint/cli": "21.2.3",
|
|
161
163
|
"@commitlint/config-conventional": "21.2.3",
|
|
162
|
-
"@effect/platform-bun": "4.0.0
|
|
163
|
-
"@effect/platform-node-shared": "4.0.0
|
|
164
|
+
"@effect/platform-bun": "4.0.0",
|
|
165
|
+
"@effect/platform-node-shared": "4.0.0",
|
|
164
166
|
"@total-typescript/ts-reset": "0.6.1",
|
|
167
|
+
"commonmark": "0.31.2",
|
|
165
168
|
"knip": "6.38.0"
|
|
166
169
|
},
|
|
167
170
|
"overrides": {
|
|
168
171
|
"qs": "6.16.0",
|
|
169
172
|
"smol-toml": "1.9.0"
|
|
173
|
+
},
|
|
174
|
+
"patchedDependencies": {
|
|
175
|
+
"@hughescr/stryker-bun-runner@1.4.0": "patches/@hughescr%2Fstryker-bun-runner@1.4.0.patch"
|
|
170
176
|
}
|
|
171
177
|
}
|
package/src/core/git.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Config, Effect, FileSystem, Path, Schema, Stream } from "effect";
|
|
2
|
-
import { ChildProcess, ChildProcessSpawner } from "effect/
|
|
2
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
3
3
|
import { DEFAULT_BRANCH } from "./gates.ts";
|
|
4
4
|
import { Usage } from "./main.ts";
|
|
5
5
|
|
package/src/core/lint.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Config, Console, Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
3
|
-
import { ChildProcess, ChildProcessSpawner } from "effect/
|
|
3
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
4
|
import { EVERY_REPOSITORY, KIT_GATES, type KitGate } from "./gates.ts";
|
|
5
5
|
import { defaultBranch, git } from "./git.ts";
|
|
6
6
|
import { runMain, Usage } from "./main.ts";
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
3
|
import { defaultBranch, git } from "../core/git.ts";
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
5
|
import { ENTRY_POINT, KIT_GATES, type KitGate } from "../core/gates.ts";
|
|
6
|
-
import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
|
|
6
|
+
import { fromProgram, invokes, mentions, plainCommand, shellCommands, withoutOptions, type Command } from "./shell-command.ts";
|
|
7
7
|
|
|
8
8
|
export type { Command };
|
|
9
9
|
|
|
@@ -34,6 +34,18 @@ export type Gap = {
|
|
|
34
34
|
readonly blocked: readonly BlockedInvocation[];
|
|
35
35
|
};
|
|
36
36
|
|
|
37
|
+
export type Visibility = "private" | "public" | "unknown";
|
|
38
|
+
|
|
39
|
+
export type RunnerPolicy = {
|
|
40
|
+
readonly visibility: Visibility;
|
|
41
|
+
readonly scripts: ReadonlyMap<string, string>;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export type RunnerFault = {
|
|
45
|
+
readonly location: string;
|
|
46
|
+
readonly fault: string;
|
|
47
|
+
};
|
|
48
|
+
|
|
37
49
|
type RunStep = {
|
|
38
50
|
readonly location: string;
|
|
39
51
|
readonly blocker: string | undefined;
|
|
@@ -58,6 +70,17 @@ const CONSTANTS = new Map([
|
|
|
58
70
|
["false", false],
|
|
59
71
|
]);
|
|
60
72
|
const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
|
|
73
|
+
// GitHub expressions match context and property names without regard to case.
|
|
74
|
+
const OVERRIDE_ACCESS = String.raw`vars\s*(?:\.\s*CI_RUNS_ON\b|\[\s*'CI_RUNS_ON'\s*\])`;
|
|
75
|
+
const RUNNER_OVERRIDE = new RegExp(String.raw`\b${OVERRIDE_ACCESS}`, "i");
|
|
76
|
+
const HOSTED_OVERRIDE = new RegExp(String.raw`^\$\{\{\s*${OVERRIDE_ACCESS}\s*\|\|\s*'ubuntu-latest'\s*\}\}$`, "i");
|
|
77
|
+
const MUTATION_RUNNER = ["self-hosted", "Linux", "X64", "winbox"];
|
|
78
|
+
const HOSTED_DEFAULT = "${{ vars.CI_RUNS_ON || 'ubuntu-latest' }}";
|
|
79
|
+
const MUTATION_BINS = ["checks-mutation", "checks-mutation-compare"];
|
|
80
|
+
const BUN_OPERANDS = new Set(["--cwd", "-c", "--config", "--env-file", "-F", "--filter", "-r", "--preload", "--require", "--import", "-e", "--eval", "-p", "--print", "--elide-lines", "--tsconfig-override"]);
|
|
81
|
+
// bun's own commands take precedence over a package.json script of the same name unless bun run names it.
|
|
82
|
+
const BUN_COMMANDS = new Set(["test", "repl", "exec", "install", "i", "add", "a", "remove", "rm", "update", "outdated", "link", "unlink", "pm", "build", "init", "create", "c", "upgrade", "publish", "patch", "patch-commit", "audit", "info", "why"]);
|
|
83
|
+
const EventRepository = Schema.fromJsonString(Schema.Struct({ repository: Schema.Struct({ private: Schema.Boolean }) }));
|
|
61
84
|
|
|
62
85
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
63
86
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -243,13 +266,75 @@ export function declarationFor(scripts: readonly string[], branch: string): Decl
|
|
|
243
266
|
};
|
|
244
267
|
}
|
|
245
268
|
|
|
269
|
+
function runsMutation(script: string, scripts: ReadonlyMap<string, string>, walked: readonly string[] = []): boolean {
|
|
270
|
+
return shellCommands(script).some((command) => mutationCommand(command, scripts, walked));
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function mutationCommand(command: Command, scripts: ReadonlyMap<string, string>, walked: readonly string[]): boolean {
|
|
274
|
+
const [first = "", ...rest] = fromProgram(command);
|
|
275
|
+
return first === "bun" ? bunRunsMutation(rest, scripts, walked) : mutationBin([first, ...rest]);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
function bunRunsMutation(args: Command, scripts: ReadonlyMap<string, string>, walked: readonly string[]): boolean {
|
|
279
|
+
const [command = "", ...rest] = withoutOptions(args, BUN_OPERANDS);
|
|
280
|
+
if (command === "x") return mutationCommand(["bunx", ...rest], scripts, walked);
|
|
281
|
+
if (BUN_COMMANDS.has(command)) return false;
|
|
282
|
+
const [target = "", ...targetArgs] = command === "run" ? withoutOptions(rest, BUN_OPERANDS) : [command, ...rest];
|
|
283
|
+
const body = walked.includes(target) ? undefined : scripts.get(target);
|
|
284
|
+
return body === undefined ? mutationBin([target, ...targetArgs]) : runsMutation(body, scripts, [...walked, target]);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function mutationBin([program = "", subcommand]: Command): boolean {
|
|
288
|
+
const bin = program.slice(program.lastIndexOf("/") + 1);
|
|
289
|
+
return MUTATION_BINS.includes(bin) || (bin === "stryker" && subcommand === "run");
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function sameLabels(runsOn: unknown, labels: readonly string[]): boolean {
|
|
293
|
+
const named = Array.isArray(runsOn) ? names(runsOn) : undefined;
|
|
294
|
+
return named?.length === labels.length && labels.every((label) => named.includes(label));
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
function readsOverride(runsOn: unknown): boolean {
|
|
298
|
+
if (typeof runsOn === "string") return RUNNER_OVERRIDE.test(runsOn);
|
|
299
|
+
const values = isRecord(runsOn) ? Object.values(runsOn) : Array.isArray(runsOn) ? runsOn : [];
|
|
300
|
+
return values.some(readsOverride);
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function runnerFault(runsOn: unknown, mutation: boolean, visibility: Visibility): string | undefined {
|
|
304
|
+
const overridden = readsOverride(runsOn);
|
|
305
|
+
const pin = `set runs-on: [${MUTATION_RUNNER.join(", ")}]`;
|
|
306
|
+
if (mutation && overridden) return `a mutation job reads CI_RUNS_ON, so an override moves its full sweeps off winbox; ${pin}`;
|
|
307
|
+
if (mutation) return visibility === "private" && !sameLabels(runsOn, MUTATION_RUNNER) ? `a mutation job in a private repository runs off winbox; ${pin}` : undefined;
|
|
308
|
+
const hosted = typeof runsOn === "string" && HOSTED_OVERRIDE.test(runsOn.trim());
|
|
309
|
+
if (hosted || (visibility !== "private" && !overridden)) return undefined;
|
|
310
|
+
return `the job is not on a hosted runner CI_RUNS_ON can override; set runs-on: ${HOSTED_DEFAULT}`;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
export function findRunnerFaults(policy: RunnerPolicy, workflows: readonly Workflow[]): readonly RunnerFault[] {
|
|
314
|
+
return workflows.flatMap((workflow) => {
|
|
315
|
+
const jobs = isRecord(workflow.document) ? workflow.document["jobs"] : undefined;
|
|
316
|
+
if (!isRecord(jobs)) return [];
|
|
317
|
+
return Object.entries(jobs).flatMap(([id, job]) => {
|
|
318
|
+
if (!isRecord(job) || job["runs-on"] === undefined) return [];
|
|
319
|
+
const steps = Array.isArray(job["steps"]) ? job["steps"] : [];
|
|
320
|
+
const mutation = steps.some((step: unknown) => isRecord(step) && typeof step["run"] === "string" && runsMutation(step["run"], policy.scripts));
|
|
321
|
+
const fault = runnerFault(job["runs-on"], mutation, policy.visibility);
|
|
322
|
+
return fault === undefined ? [] : [{ location: `${workflow.path} job ${id}`, fault }];
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
export function formatRunnerReport(faults: readonly RunnerFault[]): string {
|
|
328
|
+
return [`ci-wiring: ${faults.length} job(s) run on the wrong runner:`, ...faults.map(({ location, fault }) => ` ${location}: ${fault}`)].join("\n");
|
|
329
|
+
}
|
|
330
|
+
|
|
246
331
|
export const parseWorkflow = (path: string, text: string): Effect.Effect<Workflow, WiringError> =>
|
|
247
332
|
Effect.try({
|
|
248
333
|
try: () => ({ path, document: Bun.YAML.parse(text) }),
|
|
249
334
|
catch: (error) => new WiringError({ message: `cannot parse ${path}: ${String(error)}` }),
|
|
250
335
|
});
|
|
251
336
|
|
|
252
|
-
|
|
337
|
+
const readScripts = Effect.fn("readScripts")(function* (root: string) {
|
|
253
338
|
const fs = yield* FileSystem.FileSystem;
|
|
254
339
|
const file = (yield* Path.Path).join(root, "package.json");
|
|
255
340
|
const { scripts = {} } = (yield* fs.exists(file))
|
|
@@ -258,7 +343,22 @@ export const readDeclaration = Effect.fn("readDeclaration")(function* (root: str
|
|
|
258
343
|
Effect.mapError((cause) => new WiringError({ message: `cannot read ${file}: ${cause.message}` })),
|
|
259
344
|
)
|
|
260
345
|
: Manifest.make({});
|
|
261
|
-
return
|
|
346
|
+
return new Map(Object.entries(scripts));
|
|
347
|
+
});
|
|
348
|
+
|
|
349
|
+
export const readDeclaration = Effect.fn("readDeclaration")(function* (root: string) {
|
|
350
|
+
return declarationFor([...(yield* readScripts(root)).keys()], yield* defaultBranch(root));
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
// Only GitHub's event says whether the repository is private, so a run outside CI judges what holds in either.
|
|
354
|
+
const readVisibility = Effect.gen(function* () {
|
|
355
|
+
const eventPath = yield* Config.String("GITHUB_EVENT_PATH").pipe(Config.withDefault(""));
|
|
356
|
+
if (eventPath === "") return "unknown" satisfies Visibility;
|
|
357
|
+
return yield* (yield* FileSystem.FileSystem).readFileString(eventPath).pipe(
|
|
358
|
+
Effect.flatMap(Schema.decodeUnknownEffect(EventRepository)),
|
|
359
|
+
Effect.map(({ repository }): Visibility => (repository.private ? "private" : "public")),
|
|
360
|
+
Effect.orElseSucceed((): Visibility => "unknown"),
|
|
361
|
+
);
|
|
262
362
|
});
|
|
263
363
|
|
|
264
364
|
export const readWorkflows = Effect.fn("readWorkflows")(function* (root: string) {
|
|
@@ -283,7 +383,9 @@ const wiring = Effect.gen(function* () {
|
|
|
283
383
|
const gaps = findGaps(declaration, workflows);
|
|
284
384
|
const gapReport = formatReport(declaration, gaps);
|
|
285
385
|
yield* gaps.length > 0 ? Console.error(gapReport) : Console.log(gapReport);
|
|
286
|
-
|
|
386
|
+
const faults = findRunnerFaults({ visibility: yield* readVisibility, scripts: yield* readScripts(root) }, workflows);
|
|
387
|
+
if (faults.length > 0) yield* Console.error(formatRunnerReport(faults));
|
|
388
|
+
return gaps.length === 0 && faults.length === 0;
|
|
287
389
|
});
|
|
288
390
|
|
|
289
391
|
if (import.meta.main) runMain("ci-wiring", wiring);
|
package/src/delivery/github.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, Schema } from "effect";
|
|
2
|
-
import { ChildProcessSpawner } from "effect/
|
|
2
|
+
import { ChildProcessSpawner } from "effect/process";
|
|
3
3
|
import { collect } from "../core/git.ts";
|
|
4
4
|
|
|
5
5
|
export class GitHubFailure extends Schema.TaggedError<GitHubFailure>()("GitHubFailure", {
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
import { Console, Effect,
|
|
2
|
+
import { Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
+
import * as Base64 from "effect/encoding/Base64";
|
|
3
4
|
import { dispatch, gitHubJson, REPOSITORY } from "./github.ts";
|
|
4
5
|
import { nextVersion, type Pending, readPending, releaseTitle, runBuild, withVersion } from "./release.ts";
|
|
5
6
|
import { git } from "../core/git.ts";
|
|
@@ -105,7 +106,7 @@ const readStaged = Effect.fn("readStaged")(function* (root: string, staged: Stag
|
|
|
105
106
|
if (staged.kind === "deleted") return staged;
|
|
106
107
|
if (!REGULAR_FILE_MODES.has(staged.mode)) return yield* refused(`the build wrote ${staged.path} with mode ${staged.mode}, which is no regular file`);
|
|
107
108
|
const bytes = yield* (yield* FileSystem.FileSystem).readFile((yield* Path.Path).join(root, staged.path));
|
|
108
|
-
return { ...staged, content:
|
|
109
|
+
return { ...staged, content: Base64.encode(bytes) };
|
|
109
110
|
});
|
|
110
111
|
|
|
111
112
|
// The build writes into the working tree, so the tree it starts from has to hold nothing a release would sweep in.
|
package/src/delivery/release.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, Schema } from "effect";
|
|
2
|
-
import { ChildProcess, ChildProcessSpawner } from "effect/
|
|
2
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
3
3
|
import { groupOf } from "./changelog.ts";
|
|
4
4
|
import { git } from "../core/git.ts";
|
|
5
5
|
|
|
@@ -64,6 +64,149 @@ export function plainCommand(script: string): Command | undefined {
|
|
|
64
64
|
return words.length === 0 ? undefined : words;
|
|
65
65
|
}
|
|
66
66
|
|
|
67
|
+
type ShellPart = Span & { readonly substitutions: readonly string[] };
|
|
68
|
+
|
|
69
|
+
const COMMAND_BREAKS = new Set(["\n", ";", "&", "|", "(", ")", "`"]);
|
|
70
|
+
const WORD_BREAKS = new Set([" ", "\t"]);
|
|
71
|
+
const REDIRECTS = new Set(["<", ">"]);
|
|
72
|
+
const REDIRECT_OPERATOR = /^[<>][<>&|]*/;
|
|
73
|
+
const FILE_DESCRIPTOR = /^\d+$/;
|
|
74
|
+
const DOUBLE_QUOTE_ESCAPES = new Set(["$", "`", '"', "\\"]);
|
|
75
|
+
const ASSIGNMENT = /^[A-Za-z_][A-Za-z0-9_]*=/;
|
|
76
|
+
const RESERVED_WORDS = new Set(["!", "{", "if", "then", "elif", "else", "while", "until", "do"]);
|
|
77
|
+
const LAUNCHER_OPERANDS = new Map<string, ReadonlySet<string>>([
|
|
78
|
+
["time", new Set(["-o", "-f"])],
|
|
79
|
+
["env", new Set(["-u", "-C", "--unset", "--chdir"])],
|
|
80
|
+
["exec", new Set(["-a"])],
|
|
81
|
+
["nohup", new Set()],
|
|
82
|
+
["sudo", new Set(["-u", "-g", "-C", "-D", "-h", "-p", "-r", "-t", "-U", "--user", "--group", "--chdir", "--host", "--prompt"])],
|
|
83
|
+
["bunx", new Set(["-p", "--package"])],
|
|
84
|
+
["npx", new Set(["-p", "--package", "-c", "--call", "-w", "--workspace"])],
|
|
85
|
+
]);
|
|
86
|
+
|
|
87
|
+
function substitutionEnd(script: string, start: number): number {
|
|
88
|
+
if (script.charAt(start) === "`") {
|
|
89
|
+
const close = script.indexOf("`", start + 1);
|
|
90
|
+
return close === -1 ? script.length : close;
|
|
91
|
+
}
|
|
92
|
+
let depth = 0;
|
|
93
|
+
for (let index = start + 1; index < script.length; index += 1) {
|
|
94
|
+
if (script.charAt(index) === "(") depth += 1;
|
|
95
|
+
if (script.charAt(index) === ")") depth -= 1;
|
|
96
|
+
if (depth === 0) return index;
|
|
97
|
+
}
|
|
98
|
+
return script.length;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function doubleQuotedPart(script: string, index: number): ShellPart {
|
|
102
|
+
const char = script.charAt(index);
|
|
103
|
+
const next = script.charAt(index + 1);
|
|
104
|
+
if (char === "\\" && next === "\n") return { text: "", end: index + 2, substitutions: [] };
|
|
105
|
+
if (char === "\\" && DOUBLE_QUOTE_ESCAPES.has(next)) return { text: next, end: index + 2, substitutions: [] };
|
|
106
|
+
if (char !== "`" && !(char === "$" && next === "(")) return { text: char, end: index + 1, substitutions: [] };
|
|
107
|
+
const end = substitutionEnd(script, index);
|
|
108
|
+
return { text: "", end: end + 1, substitutions: [script.slice(index + (char === "`" ? 1 : 2), end)] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
function shellDoubleQuoted(script: string, open: number): ShellPart {
|
|
112
|
+
let text = "";
|
|
113
|
+
const substitutions: string[] = [];
|
|
114
|
+
let index = open + 1;
|
|
115
|
+
while (index < script.length && script.charAt(index) !== '"') {
|
|
116
|
+
const part = doubleQuotedPart(script, index);
|
|
117
|
+
text += part.text;
|
|
118
|
+
substitutions.push(...part.substitutions);
|
|
119
|
+
index = part.end;
|
|
120
|
+
}
|
|
121
|
+
return { text, end: index + 1, substitutions };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function shellPart(script: string, index: number): ShellPart {
|
|
125
|
+
const char = script.charAt(index);
|
|
126
|
+
if (char === "'") {
|
|
127
|
+
const close = script.indexOf("'", index + 1);
|
|
128
|
+
const end = close === -1 ? script.length : close;
|
|
129
|
+
return { text: script.slice(index + 1, end), end: end + 1, substitutions: [] };
|
|
130
|
+
}
|
|
131
|
+
if (char === '"') return shellDoubleQuoted(script, index);
|
|
132
|
+
if (char === "\\") return { text: script.charAt(index + 1), end: index + 2, substitutions: [] };
|
|
133
|
+
return { text: char, end: index + 1, substitutions: [] };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function commentEnd(script: string, from: number): number {
|
|
137
|
+
const newline = script.indexOf("\n", from);
|
|
138
|
+
return newline === -1 ? script.length : newline;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
type ShellParse = {
|
|
142
|
+
readonly commands: string[][];
|
|
143
|
+
readonly substitutions: string[];
|
|
144
|
+
words: string[];
|
|
145
|
+
word: string | undefined;
|
|
146
|
+
redirecting: boolean;
|
|
147
|
+
};
|
|
148
|
+
|
|
149
|
+
function endWord(parse: ShellParse): void {
|
|
150
|
+
if (parse.word === undefined) return;
|
|
151
|
+
if (!parse.redirecting) parse.words.push(parse.word);
|
|
152
|
+
parse.redirecting = false;
|
|
153
|
+
parse.word = undefined;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function endCommand(parse: ShellParse): void {
|
|
157
|
+
endWord(parse);
|
|
158
|
+
parse.commands.push(parse.words);
|
|
159
|
+
parse.words = [];
|
|
160
|
+
parse.redirecting = false;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function startRedirect(parse: ShellParse, script: string, index: number): number {
|
|
164
|
+
if (parse.word !== undefined && FILE_DESCRIPTOR.test(parse.word)) parse.word = undefined;
|
|
165
|
+
endWord(parse);
|
|
166
|
+
parse.redirecting = true;
|
|
167
|
+
return index + (REDIRECT_OPERATOR.exec(script.slice(index))?.[0].length ?? 1);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function shellCommands(script: string): readonly Command[] {
|
|
171
|
+
const parse: ShellParse = { commands: [], substitutions: [], words: [], word: undefined, redirecting: false };
|
|
172
|
+
let index = 0;
|
|
173
|
+
while (index < script.length) {
|
|
174
|
+
const char = script.charAt(index);
|
|
175
|
+
if (char === "\\" && script.charAt(index + 1) === "\n") {
|
|
176
|
+
index += 2;
|
|
177
|
+
} else if (char === "#" && parse.word === undefined) {
|
|
178
|
+
index = commentEnd(script, index);
|
|
179
|
+
} else if (REDIRECTS.has(char)) {
|
|
180
|
+
index = startRedirect(parse, script, index);
|
|
181
|
+
} else if (COMMAND_BREAKS.has(char) || WORD_BREAKS.has(char)) {
|
|
182
|
+
if (COMMAND_BREAKS.has(char)) endCommand(parse);
|
|
183
|
+
else endWord(parse);
|
|
184
|
+
index += 1;
|
|
185
|
+
} else {
|
|
186
|
+
const part = shellPart(script, index);
|
|
187
|
+
parse.word = (parse.word ?? "") + part.text;
|
|
188
|
+
parse.substitutions.push(...part.substitutions);
|
|
189
|
+
index = part.end;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
endCommand(parse);
|
|
193
|
+
return [...parse.commands, ...parse.substitutions.flatMap(shellCommands)].filter((command) => command.length > 0);
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function withoutOptions(words: Command, operands: ReadonlySet<string>): Command {
|
|
197
|
+
const [first = "", ...rest] = words;
|
|
198
|
+
if (first === "--") return rest;
|
|
199
|
+
if (!first.startsWith("-")) return words;
|
|
200
|
+
return withoutOptions(operands.has(first) ? rest.slice(1) : rest, operands);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export function fromProgram(words: Command): Command {
|
|
204
|
+
const [first = "", ...rest] = words;
|
|
205
|
+
if (ASSIGNMENT.test(first) || RESERVED_WORDS.has(first)) return fromProgram(rest);
|
|
206
|
+
const operands = LAUNCHER_OPERANDS.get(first);
|
|
207
|
+
return operands === undefined ? words : fromProgram(withoutOptions(rest, operands));
|
|
208
|
+
}
|
|
209
|
+
|
|
67
210
|
// bun run resolves any other word, even one holding a slash, to a package.json script of that name first.
|
|
68
211
|
const FILE_PATH = /^\.{0,2}\//;
|
|
69
212
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { Crypto, Effect,
|
|
2
|
-
import
|
|
3
|
-
import
|
|
1
|
+
import { Crypto, Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
import * as Hex from "effect/encoding/Hex";
|
|
3
|
+
import { HttpClient, HttpClientResponse } from "effect/http";
|
|
4
|
+
import * as FetchHttpClient from "effect/http/FetchHttpClient";
|
|
4
5
|
import { collect } from "../core/git.ts";
|
|
5
6
|
|
|
6
7
|
const EXECUTABLE_MODE = 0o755;
|
|
@@ -19,7 +20,7 @@ function installedSha256(asset: Asset): string {
|
|
|
19
20
|
|
|
20
21
|
const sha256Of = Effect.fn("sha256Of")(function* (bytes: Uint8Array) {
|
|
21
22
|
const crypto = yield* Crypto.Crypto;
|
|
22
|
-
return
|
|
23
|
+
return Hex.encode(yield* crypto.digest("SHA-256", bytes));
|
|
23
24
|
});
|
|
24
25
|
|
|
25
26
|
const verified = Effect.fn("verified")(function* (binary: string, sha256: string) {
|
package/src/docs/doc-agents.ts
CHANGED
|
@@ -1,11 +1,9 @@
|
|
|
1
|
+
import { Parser, type NodeType } from "commonmark";
|
|
1
2
|
import { commandNames, type Snapshot } from "./doc-references.ts";
|
|
2
3
|
import { AGENT_NAMES, scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
|
|
3
4
|
|
|
4
5
|
export const AGENT_CEILING = 3000;
|
|
5
6
|
|
|
6
|
-
const LIST_ITEM = /^(?:\s*>)*\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
|
|
7
|
-
const INDENT = /^[ \t]*/;
|
|
8
|
-
const CODE_INDENT = 4;
|
|
9
7
|
const INLINE_LINK =
|
|
10
8
|
/(?<![!\\])\[(?:[^[\]\\]|\\.|\[[^\]]*\])*\]\(\s*(?:<[^<>\n]+>|[^\s()<>]+(?:\([^\s()]*\)[^\s()<>]*)*)(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g;
|
|
11
9
|
const MAINTAINING = /^\s{0,3}#{1,6}\s+Maintaining this file(?:\s+#+)?\s*$/;
|
|
@@ -27,8 +25,24 @@ export function ceilingFinding(text: string): AgentFinding | undefined {
|
|
|
27
25
|
};
|
|
28
26
|
}
|
|
29
27
|
|
|
28
|
+
function blockStarts(lines: readonly MarkdownLine[], type: NodeType): ReadonlySet<number> {
|
|
29
|
+
const read = lines.map(({ kind, raw }) => (kind === "front-matter" ? "" : raw)).join("\n");
|
|
30
|
+
const walker = new Parser().parse(read).walker();
|
|
31
|
+
const starts = new Set<number>();
|
|
32
|
+
for (let step = walker.next(); step !== null; step = walker.next()) {
|
|
33
|
+
if (step.entering && step.node.type === type) starts.add(step.node.sourcepos[0][0]);
|
|
34
|
+
}
|
|
35
|
+
return starts;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function linesOpening(text: string, type: NodeType): readonly MarkdownLine[] {
|
|
39
|
+
const lines = scanMarkdown(text);
|
|
40
|
+
const starts = blockStarts(lines, type);
|
|
41
|
+
return lines.filter(({ line }) => starts.has(line)).flatMap(({ line, raw }) => scanMarkdown(raw).map((alone) => ({ ...alone, line })));
|
|
42
|
+
}
|
|
43
|
+
|
|
30
44
|
export function maintainingFinding(text: string): AgentFinding | undefined {
|
|
31
|
-
const heading =
|
|
45
|
+
const heading = linesOpening(text, "heading").find(({ raw }) => MAINTAINING.test(raw));
|
|
32
46
|
if (heading === undefined) return undefined;
|
|
33
47
|
return {
|
|
34
48
|
line: heading.line,
|
|
@@ -36,27 +50,14 @@ export function maintainingFinding(text: string): AgentFinding | undefined {
|
|
|
36
50
|
};
|
|
37
51
|
}
|
|
38
52
|
|
|
39
|
-
function indentOf(raw: string): number {
|
|
40
|
-
return (INDENT.exec(raw)?.[0] ?? "").replaceAll("\t", " ").length;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
53
|
export function entries(text: string): readonly MarkdownLine[] {
|
|
44
|
-
|
|
45
|
-
let afterBlank = true;
|
|
46
|
-
return scanMarkdown(text).filter((line) => {
|
|
47
|
-
const blank = line.raw.trim() === "";
|
|
48
|
-
const indented = !blank && indentOf(line.raw) >= CODE_INDENT && !line.raw.trimStart().startsWith(">");
|
|
49
|
-
const listItem = line.kind === "prose" && LIST_ITEM.test(line.prose) && (inList || !indented);
|
|
50
|
-
if (listItem) inList = true;
|
|
51
|
-
else if (!blank && !indented && (afterBlank || line.kind !== "prose")) inList = false;
|
|
52
|
-
afterBlank = blank;
|
|
53
|
-
return listItem;
|
|
54
|
-
});
|
|
54
|
+
return linesOpening(text, "item");
|
|
55
55
|
}
|
|
56
56
|
|
|
57
57
|
type Tracked = Pick<Snapshot, "files" | "directories">;
|
|
58
58
|
|
|
59
59
|
const STAYS = new Set(["", "."]);
|
|
60
|
+
const NAMES_A_SEGMENT = /[^/]/;
|
|
60
61
|
|
|
61
62
|
function joined(directory: string, segment: string): string {
|
|
62
63
|
return directory === "" ? segment : `${directory}/${segment}`;
|
|
@@ -76,10 +77,11 @@ function resolvesFrom(directory: string, span: string, tracked: Tracked): boolea
|
|
|
76
77
|
if (walked === undefined) return false;
|
|
77
78
|
if (!STAYS.has(last) && last !== ".." && tracked.files.has(joined(walked, last))) return true;
|
|
78
79
|
const end = enter(walked, last, tracked);
|
|
79
|
-
return end !== undefined && end !== ""
|
|
80
|
+
return end !== undefined && end !== "";
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
function namesTrackedPath(agentFile: string, span: string, tracked: Tracked): boolean {
|
|
84
|
+
if (!NAMES_A_SEGMENT.test(span)) return false;
|
|
83
85
|
const directory = agentFile.includes("/") ? agentFile.slice(0, agentFile.lastIndexOf("/")) : "";
|
|
84
86
|
const fromFile = resolvesFrom(directory, span, tracked);
|
|
85
87
|
return fromFile || (!span.startsWith("./") && !span.startsWith("../") && resolvesFrom("", span, tracked));
|
package/src/docs/doc-names.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Effect, FileSystem, Option, Path, Schema } from "effect";
|
|
2
|
-
import { ChildProcessSpawner } from "effect/
|
|
2
|
+
import { ChildProcessSpawner } from "effect/process";
|
|
3
3
|
import { collect, git, pathsAt } from "../core/git.ts";
|
|
4
4
|
import type { Unresolved } from "./doc-references.ts";
|
|
5
5
|
import { scanMarkdown } from "./prose-matchers.ts";
|
|
@@ -78,20 +78,28 @@ function unresolvedPath(doc: string, line: number, span: string, snapshot: Snaps
|
|
|
78
78
|
const SCHEME = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
79
79
|
const ASCII_ESCAPE = /%([0-7][0-9a-f])/gi;
|
|
80
80
|
|
|
81
|
+
function linkPath(doc: string, target: string, hash: number): string | undefined {
|
|
82
|
+
const written = (hash < 0 ? target : target.slice(0, hash)).split("?", 1)[0] ?? "";
|
|
83
|
+
const decoded = written.replace(ASCII_ESCAPE, (_, code: string) => String.fromCharCode(Number.parseInt(code, 16)));
|
|
84
|
+
return decoded === "" ? doc : decoded.startsWith("/") ? normalize(decoded) : within(directoryOf(doc), decoded);
|
|
85
|
+
}
|
|
86
|
+
|
|
81
87
|
function unresolvedLink(doc: string, line: number, target: string, snapshot: Snapshot): Unresolved | undefined {
|
|
82
88
|
if (target === "" || SCHEME.test(target)) return undefined;
|
|
83
89
|
const hash = target.indexOf("#");
|
|
84
|
-
const
|
|
85
|
-
const decoded = written.replace(ASCII_ESCAPE, (_, code: string) => String.fromCharCode(Number.parseInt(code, 16)));
|
|
86
|
-
const path = decoded === "" ? doc : decoded.startsWith("/") ? normalize(decoded) : within(directoryOf(doc), decoded);
|
|
90
|
+
const path = linkPath(doc, target, hash);
|
|
87
91
|
if (path === undefined) return undefined;
|
|
88
92
|
const named = target;
|
|
89
93
|
if (!exists(snapshot, path)) {
|
|
90
94
|
return { kind: "link", line, named, message: `links to \`${named}\`, which is not in the repository`, missing: { type: "file", path } };
|
|
91
95
|
}
|
|
92
96
|
const anchor = hash < 0 ? "" : target.slice(hash + 1);
|
|
97
|
+
if (anchor === "") return undefined;
|
|
93
98
|
const anchors = snapshot.anchors.get(path);
|
|
94
|
-
if (
|
|
99
|
+
if (anchors === undefined) {
|
|
100
|
+
return { kind: "link", line, named, message: `links to \`${named}\`, and \`${path}\` has no headings to check`, missing: { type: "anchor" } };
|
|
101
|
+
}
|
|
102
|
+
if (anchors.has(anchor) || anchors.has(anchor.toLowerCase())) return undefined;
|
|
95
103
|
return { kind: "link", line, named, message: `links to \`${named}\`, and \`${path}\` has no heading with that anchor`, missing: { type: "anchor" } };
|
|
96
104
|
}
|
|
97
105
|
|
|
@@ -152,9 +160,8 @@ export function anchoredTargets(doc: string, text: string): readonly string[] {
|
|
|
152
160
|
links.flatMap((target) => {
|
|
153
161
|
const hash = target.indexOf("#");
|
|
154
162
|
if (hash < 0 || SCHEME.test(target)) return [];
|
|
155
|
-
const
|
|
156
|
-
|
|
157
|
-
return path?.endsWith(".md") === true ? [path] : [];
|
|
163
|
+
const path = linkPath(doc, target, hash);
|
|
164
|
+
return path !== undefined && (path.endsWith(".md") || path.endsWith(".mdx")) ? [path] : [];
|
|
158
165
|
}),
|
|
159
166
|
);
|
|
160
167
|
}
|
package/src/docs/docs.ts
CHANGED
|
@@ -8,6 +8,7 @@ import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
|
|
|
8
8
|
import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "../core/git.ts";
|
|
9
9
|
import { runMain } from "../core/main.ts";
|
|
10
10
|
import { proseFindings, readerOf } from "./prose-matchers.ts";
|
|
11
|
+
import { longSentences } from "./sentence-length.ts";
|
|
11
12
|
|
|
12
13
|
type Finding = {
|
|
13
14
|
readonly path: string;
|
|
@@ -22,6 +23,7 @@ type Judged = {
|
|
|
22
23
|
readonly agents: number;
|
|
23
24
|
readonly findings: readonly Finding[];
|
|
24
25
|
readonly advisory: ReadonlyMap<string, number>;
|
|
26
|
+
readonly sentenceAdvisory: readonly Finding[];
|
|
25
27
|
readonly brokenBefore: readonly Finding[];
|
|
26
28
|
};
|
|
27
29
|
|
|
@@ -105,6 +107,9 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
105
107
|
const prose = edited.flatMap(({ path, reader }) =>
|
|
106
108
|
proseFindings(text(path), reader, changed.get(path)).map(({ line, message }) => ({ path, line, message })),
|
|
107
109
|
);
|
|
110
|
+
const sentenceAdvisory = edited
|
|
111
|
+
.flatMap(({ path }) => longSentences(text(path), changed.get(path)).map(({ line, message }) => ({ path, line, message })))
|
|
112
|
+
.toSorted(inPathOrder);
|
|
108
113
|
const judging = (path: string): Judging => ({ commands: !speaksToConsumers(text(path)) });
|
|
109
114
|
// A directory the range deletes still belongs to this repository, so a path under it is stale rather than another repository's.
|
|
110
115
|
const roots = rootsOf(yield* pathsAt(base, [], root));
|
|
@@ -133,6 +138,7 @@ const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head
|
|
|
133
138
|
...entries,
|
|
134
139
|
].toSorted(inPathOrder),
|
|
135
140
|
advisory,
|
|
141
|
+
sentenceAdvisory,
|
|
136
142
|
brokenBefore: references.brokenBefore.toSorted(inPathOrder),
|
|
137
143
|
} satisfies Judged;
|
|
138
144
|
});
|
|
@@ -141,7 +147,7 @@ function describe({ path, line, message }: Finding): string {
|
|
|
141
147
|
return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
142
148
|
}
|
|
143
149
|
|
|
144
|
-
export function report({ held, edited, named, agents, findings, advisory, brokenBefore }: Judged): string {
|
|
150
|
+
export function report({ held, edited, named, agents, findings, advisory, sentenceAdvisory, brokenBefore }: Judged): string {
|
|
145
151
|
const verdict =
|
|
146
152
|
findings.length === 0
|
|
147
153
|
? [
|
|
@@ -158,6 +164,13 @@ export function report({ held, edited, named, agents, findings, advisory, broken
|
|
|
158
164
|
`${NAME}: advisory, ${advisory.size} doc file(s) the range leaves alone do not hold to their templates yet:`,
|
|
159
165
|
...[...advisory].map(([path, count]) => ` ${path}: ${count} violation(s)`),
|
|
160
166
|
];
|
|
167
|
+
const sentences =
|
|
168
|
+
sentenceAdvisory.length === 0
|
|
169
|
+
? []
|
|
170
|
+
: [
|
|
171
|
+
`${NAME}: advisory, ${sentenceAdvisory.length} sentence(s) over the trial length caps:`,
|
|
172
|
+
...sentenceAdvisory.map(describe),
|
|
173
|
+
];
|
|
161
174
|
const broken =
|
|
162
175
|
brokenBefore.length === 0
|
|
163
176
|
? []
|
|
@@ -165,7 +178,7 @@ export function report({ held, edited, named, agents, findings, advisory, broken
|
|
|
165
178
|
`${NAME}: advisory, ${brokenBefore.length} path(s), link(s) or command(s) the living docs or agent files name were broken before the range:`,
|
|
166
179
|
...brokenBefore.map(describe),
|
|
167
180
|
];
|
|
168
|
-
return [...verdict, ...unconformed, ...broken].join("\n");
|
|
181
|
+
return [...verdict, ...unconformed, ...sentences, ...broken].join("\n");
|
|
169
182
|
}
|
|
170
183
|
|
|
171
184
|
const docs = Effect.gen(function* () {
|
|
@@ -243,7 +243,7 @@ export function scanMarkdown(text: string): readonly MarkdownLine[] {
|
|
|
243
243
|
|
|
244
244
|
const LEADING_MARKERS = /^(?:\s*>)*\s*(?:#{1,6}\s+|(?:[-*+]|\d{1,9}[.)])\s+(?:\[[ xX]\]\s+)?)?/;
|
|
245
245
|
|
|
246
|
-
function bodyOf({ prose }: MarkdownLine): string {
|
|
246
|
+
export function bodyOf({ prose }: MarkdownLine): string {
|
|
247
247
|
const markers = LEADING_MARKERS.exec(prose)?.[0] ?? "";
|
|
248
248
|
return BLANK.repeat(markers.length) + prose.slice(markers.length);
|
|
249
249
|
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { bodyOf, scanMarkdown } from "./prose-matchers.ts";
|
|
2
|
+
|
|
3
|
+
export const PROCEDURAL_WORD_CAP = 20;
|
|
4
|
+
export const DESCRIPTIVE_WORD_CAP = 25;
|
|
5
|
+
|
|
6
|
+
export type SentenceKind = "procedural" | "descriptive";
|
|
7
|
+
|
|
8
|
+
export type SentenceLength = {
|
|
9
|
+
readonly line: number;
|
|
10
|
+
readonly words: number;
|
|
11
|
+
readonly kind: SentenceKind;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type LongSentence = SentenceLength & {
|
|
15
|
+
readonly message: string;
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
function graphemesOf(body: string): readonly string[] {
|
|
19
|
+
return [...new Intl.Segmenter().segment(body)].map(({ segment }) => segment);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function dropPrefix(raw: string): string {
|
|
23
|
+
const chars = graphemesOf(raw);
|
|
24
|
+
for (const [at, char] of chars.entries()) {
|
|
25
|
+
if (char !== " " && char !== "\t" && char !== ">") return chars.slice(at).join("");
|
|
26
|
+
}
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function orderedAt(raw: string): boolean {
|
|
31
|
+
const rest = dropPrefix(raw);
|
|
32
|
+
const digits = /^\d+/.exec(rest)?.[0] ?? "";
|
|
33
|
+
if (digits === "") return false;
|
|
34
|
+
const marker = rest.charAt(digits.length);
|
|
35
|
+
if (marker !== "." && marker !== ")") return false;
|
|
36
|
+
const after = rest.charAt(digits.length + 1);
|
|
37
|
+
return after === " " || after === "\t" || after === "";
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isWordChar(char: string): boolean {
|
|
41
|
+
return (char >= "0" && char <= "9") || (char >= "A" && char <= "Z") || (char >= "a" && char <= "z");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function wordsIn(sentence: string): number {
|
|
45
|
+
let words = 0;
|
|
46
|
+
let inToken = false;
|
|
47
|
+
let holdsWordChar = false;
|
|
48
|
+
for (const char of sentence) {
|
|
49
|
+
if (char === " " || char === "\t") {
|
|
50
|
+
if (inToken && holdsWordChar) words += 1;
|
|
51
|
+
inToken = false;
|
|
52
|
+
holdsWordChar = false;
|
|
53
|
+
} else {
|
|
54
|
+
inToken = true;
|
|
55
|
+
if (isWordChar(char)) holdsWordChar = true;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
return inToken && holdsWordChar ? words + 1 : words;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function isCloser(char: string): boolean {
|
|
62
|
+
return char === '"' || char === "'" || char === ")" || char === "]" || char === "*" || char === "_" || char === "”" || char === "’";
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function sentencesIn(body: string): readonly string[] {
|
|
66
|
+
const chars = graphemesOf(body);
|
|
67
|
+
const sentences: string[] = [];
|
|
68
|
+
let start = 0;
|
|
69
|
+
let cut = -1;
|
|
70
|
+
for (const [at, char] of chars.entries()) {
|
|
71
|
+
if (char === "." || char === "!" || char === "?") cut = at + 1;
|
|
72
|
+
else if (cut === at && isCloser(char)) cut = at + 1;
|
|
73
|
+
else if (cut > start && (char === " " || char === "\t")) {
|
|
74
|
+
sentences.push(chars.slice(start, cut).join(""));
|
|
75
|
+
start = cut;
|
|
76
|
+
cut = -1;
|
|
77
|
+
} else if (cut > start) cut = -1;
|
|
78
|
+
}
|
|
79
|
+
if (cut === chars.length && cut > start) {
|
|
80
|
+
sentences.push(chars.slice(start, cut).join(""));
|
|
81
|
+
start = cut;
|
|
82
|
+
}
|
|
83
|
+
const tail = chars.slice(start).join("");
|
|
84
|
+
if (tail.trim() !== "") sentences.push(tail);
|
|
85
|
+
return sentences;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
export function sentenceLengths(text: string, within?: ReadonlySet<number>): readonly SentenceLength[] {
|
|
89
|
+
return scanMarkdown(text).flatMap((line) => {
|
|
90
|
+
if (line.kind !== "prose" || (within !== undefined && !within.has(line.line))) return [];
|
|
91
|
+
const kind: SentenceKind = orderedAt(line.raw) ? "procedural" : "descriptive";
|
|
92
|
+
return sentencesIn(bodyOf(line)).map((sentence) => ({ line: line.line, words: wordsIn(sentence), kind }));
|
|
93
|
+
});
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function longSentences(text: string, within?: ReadonlySet<number>): readonly LongSentence[] {
|
|
97
|
+
return sentenceLengths(text, within).flatMap(({ line, words, kind }) => {
|
|
98
|
+
const cap = kind === "procedural" ? PROCEDURAL_WORD_CAP : DESCRIPTIVE_WORD_CAP;
|
|
99
|
+
if (words <= cap) return [];
|
|
100
|
+
const rule =
|
|
101
|
+
kind === "procedural"
|
|
102
|
+
? `procedural means an ordered list item; descriptive caps at ${DESCRIPTIVE_WORD_CAP} words`
|
|
103
|
+
: `procedural means an ordered list item, capped at ${PROCEDURAL_WORD_CAP} words`;
|
|
104
|
+
return [{ line, words, kind, message: `carries a ${words}-word ${kind} sentence, over the ${cap}-word cap (${rule})` }];
|
|
105
|
+
});
|
|
106
|
+
}
|
package/src/testing/flake.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Config, Console, Effect, FileSystem, Option, Path, Random, Schema } from "effect";
|
|
3
|
-
import { ChildProcess, ChildProcessSpawner } from "effect/
|
|
3
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
4
|
import { runMain, Usage } from "../core/main.ts";
|
|
5
5
|
import { NAME_SEPARATOR, parseReport, ReportError, reporterArgs, type TestResult } from "./test-report.ts";
|
|
6
6
|
|
package/src/testing/mutation.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Config, Effect, Schema } from "effect";
|
|
3
|
-
import { ChildProcess, ChildProcessSpawner } from "effect/
|
|
3
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
4
|
import { runMain } from "../core/main.ts";
|
|
5
5
|
import { fullRunRefusal } from "./mutation-scope.js";
|
|
6
6
|
|
package/src/testing/test.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Config, Console, Effect, FileSystem, Path, Schema } from "effect";
|
|
3
|
-
import { ChildProcess, ChildProcessSpawner } from "effect/
|
|
3
|
+
import { ChildProcess, ChildProcessSpawner } from "effect/process";
|
|
4
4
|
import { TEST_ENTRY_POINT } from "../core/gates.ts";
|
|
5
5
|
import { runMain, Usage } from "../core/main.ts";
|
|
6
6
|
import { readSkipDeclarations, type Environment, type SkipDeclaration, type TestTier } from "./test-skips.ts";
|