@avi2dg/checks 0.14.0 → 0.15.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 +8 -0
- package/CONTRIBUTING.md +1 -1
- package/dist/feature-rules.js +9 -0
- package/docs/configs/quality-file.md +1 -0
- package/docs/design.md +18 -1
- package/docs/gates/checks-docs.md +83 -6
- package/package.json +5 -1
- package/quality.schema.json +12 -0
- package/scripts/comment-gate.ts +5 -48
- package/scripts/doc-references.ts +190 -0
- package/scripts/doc-rules.ts +2 -2
- package/scripts/doc-snapshot.ts +70 -0
- package/scripts/docs.ts +100 -24
- package/scripts/git.ts +64 -0
- package/scripts/prose-matchers.ts +380 -0
- package/scripts/quality-file.ts +15 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,14 @@
|
|
|
2
2
|
|
|
3
3
|
Every release of `@avi2dg/checks`, newest first, written by the release from its conventional commits.
|
|
4
4
|
|
|
5
|
+
## 0.15.0
|
|
6
|
+
|
|
7
|
+
Released 2026-09-25.
|
|
8
|
+
|
|
9
|
+
### Features
|
|
10
|
+
|
|
11
|
+
- **scripts:** hold living docs to prose rules and resolvable references in checks-docs (#42)
|
|
12
|
+
|
|
5
13
|
## 0.14.0
|
|
6
14
|
|
|
7
15
|
Released 2026-09-25.
|
package/CONTRIBUTING.md
CHANGED
|
@@ -81,7 +81,7 @@ To place a change:
|
|
|
81
81
|
|
|
82
82
|
This repository holds itself to the kit, with two exceptions of its own.
|
|
83
83
|
Its `.dependency-cruiser.cjs` redeclares `no-orphans` with the plugin entry added to its `pathNot`.
|
|
84
|
-
Its `.oxlintrc.json` lifts `effect-channel/no-throw` from `scripts/comment-matchers.ts`,
|
|
84
|
+
Its `.oxlintrc.json` lifts `effect-channel/no-throw` from `scripts/comment-matchers.ts`, whose synchronous `refused()` a host loads without `node_modules`.
|
|
85
85
|
|
|
86
86
|
## Related topics
|
|
87
87
|
|
package/dist/feature-rules.js
CHANGED
|
@@ -40,6 +40,12 @@ var PathGlob = Schema2.String.check(Schema2.isPattern(new RegExp(`^${SEGMENT}(?:
|
|
|
40
40
|
identifier: "PathGlob",
|
|
41
41
|
description: "A glob from the repository root that oxlint, the Effect language service and git read alike: a directory first, * within a segment, ** as a whole one, a file name with an extension last, and no braces, ?, [ or leading ./"
|
|
42
42
|
});
|
|
43
|
+
var DocGlob = Schema2.String.check(Schema2.isPattern(new RegExp(`^(?:${SEGMENT}/)*${FILE}$`), {
|
|
44
|
+
expected: "a glob from the repository root such as README.md or docs/**/*.md: * within a segment, ** as a whole one, a file name with an extension last"
|
|
45
|
+
})).annotate({
|
|
46
|
+
identifier: "DocGlob",
|
|
47
|
+
description: "A glob from the repository root that checks-docs reads: * within a segment, ** as a whole one, a file name with an extension last"
|
|
48
|
+
});
|
|
43
49
|
var LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
|
|
44
50
|
var DirectoryPath = Schema2.String.check(Schema2.isPattern(new RegExp(`^${LITERAL_SEGMENT}(?:/${LITERAL_SEGMENT})*$`), {
|
|
45
51
|
expected: "a directory from the repository root such as src/billing, with no glob and no trailing slash"
|
|
@@ -133,6 +139,9 @@ var Docs = Schema2.Struct({
|
|
|
133
139
|
explanation: pagesIn("an explanation, which says why")
|
|
134
140
|
}).annotate({
|
|
135
141
|
description: "The Diátaxis mode of each page, whose template checks-docs holds the page to; a page under docs/ needs one"
|
|
142
|
+
})),
|
|
143
|
+
forConsumers: Schema2.optionalKey(Schema2.Array(DocGlob).annotate({
|
|
144
|
+
description: "The living docs that speak to a repository installing this one, whose bun run commands checks-docs does not hold to this package.json"
|
|
136
145
|
}))
|
|
137
146
|
});
|
|
138
147
|
var Quality = Schema2.Struct({
|
|
@@ -52,6 +52,7 @@ The kit's bins find the file at the git root and read it there:
|
|
|
52
52
|
| `agentRules.on` | agent Rule selection, not the kit | catalogued Rules switched on for this repository |
|
|
53
53
|
| `agentRules.off` | agent Rule selection, not the kit | catalogued Rules switched off for this repository |
|
|
54
54
|
| `docs.pages` | `checks-docs` | the Diátaxis mode of each page, by glob, as [checks-docs](../gates/checks-docs.md) says |
|
|
55
|
+
| `docs.forConsumers` | `checks-docs` | the living docs that speak to a repository installing this one, by glob, whose `bun run` commands name that repository's scripts |
|
|
55
56
|
|
|
56
57
|
<!-- end generated quality-keys -->
|
|
57
58
|
|
package/docs/design.md
CHANGED
|
@@ -25,7 +25,8 @@ Each entry below is a choice in the kit's shape and the constraint that forced i
|
|
|
25
25
|
A bump not newer than the release before it is a revert: it cancels every release above the version it returns to, and a tag only keeps a reverted release that was already published.
|
|
26
26
|
The committed changelog is the record of what was released: a version older than the newest it lists and absent from it was never published, and its commits go into the next release.
|
|
27
27
|
A section keeps the date it was written with, since the squash merge that lands the release commit may fall on another day.
|
|
28
|
-
Entries come from commit subjects, the squash-merged pull request titles commitlint holds to the conventional format
|
|
28
|
+
Entries come from commit subjects, the squash-merged pull request titles commitlint holds to the conventional format.
|
|
29
|
+
The bodies are the branch's own messages, which nothing lints.
|
|
29
30
|
The release path needs no `contents: write`: the changelog arrives in the release commit's pull request, not from a workflow that pushes.
|
|
30
31
|
- `quality.json` is JSON, not TOML or a TypeScript module: a bun bin, a hook running without `node_modules`, a `.cjs` or `.mjs` config and `jq` all parse it with nothing installed, and nobody runs a repository's own code to learn its policy.
|
|
31
32
|
It holds declarations only.
|
|
@@ -71,6 +72,22 @@ Each entry below is a choice in the kit's shape and the constraint that forced i
|
|
|
71
72
|
A repository adopts the templates as its files change, and an untouched file is listed as advisory rather than failing a change that never read it.
|
|
72
73
|
- A task heading is verb first, and review holds it there rather than the check.
|
|
73
74
|
No word list tells `Test layout` from `Test the layout`, and a check that passes the noun is worse than none.
|
|
75
|
+
- The prose rules judge only the lines a change adds or edits, the way `checks-comment-gate` judges comments.
|
|
76
|
+
Text nobody touched never turns a change red, a record keeps the words it was written in, and a repository needs no cleanup pass before the gate runs.
|
|
77
|
+
- A living doc takes one sentence per line, so a changed line is a changed sentence.
|
|
78
|
+
Under a hard wrap a one-word edit reflows a paragraph, and the gate would then demand fixes to sentences the edit never touched.
|
|
79
|
+
- An agent file such as `AGENTS.md` takes the separator rules and no other prose rule.
|
|
80
|
+
One sentence per line serves the people who review a doc's diffs, and an agent file keeps each entry to one line however many sentences it holds.
|
|
81
|
+
- `scripts/prose-matchers.ts` imports nothing, so the gate and a write-time hook run one matcher and refuse in the same words.
|
|
82
|
+
A hook bundle ships without `node_modules`, so a matcher that needed Vale or a package could not refuse at write time.
|
|
83
|
+
- Readability grades and words such as easy stay out of the prose rules.
|
|
84
|
+
A score cannot fail a change without failing correct prose, and a suggestion nobody runs an editor for is never seen.
|
|
85
|
+
- A path, link or command on a line the range leaves alone fails when the range broke it, as by deleting the file it names.
|
|
86
|
+
A reference goes stale when the code it names moves far more often than when its own line is edited, so a gate on edited lines alone would miss the usual break.
|
|
87
|
+
- A path under a top directory the repository lacks names a file in another repository, such as a consumer's, and no program tells that from a typo.
|
|
88
|
+
A directory the range deletes still counts as this repository's, so a path under it reads as stale rather than foreign.
|
|
89
|
+
- The command check passes over the docs a repository declares under `docs.forConsumers`.
|
|
90
|
+
This kit's README and reference pages speak to a consuming repository, whose scripts are not this one's, and no program tells an example for a consumer from an instruction for a contributor.
|
|
74
91
|
|
|
75
92
|
## Related topics
|
|
76
93
|
|
|
@@ -1,10 +1,12 @@
|
|
|
1
1
|
# checks-docs
|
|
2
2
|
|
|
3
|
-
`checks-docs` is the gate that holds each doc file a change touches to the template for its kind, and a reader looks it up to learn
|
|
3
|
+
`checks-docs` is the gate that holds each doc file a change touches to the template for its kind, each line a change adds to a living doc or an agent file to the prose rules, and each path, link and command a living doc names to what the repository holds, and a reader looks it up to learn what a doc file answers to.
|
|
4
4
|
|
|
5
5
|
## What it checks
|
|
6
6
|
|
|
7
7
|
It holds each doc file a change touches to the template for its kind, and lists every other doc file that does not conform yet without failing.
|
|
8
|
+
It holds each line a change adds or edits in a living doc or an agent file to the prose rules, as [The prose rules](#the-prose-rules) says.
|
|
9
|
+
It fails when a living doc names a path, link or command that does not resolve, and the range added it or broke it, as [Paths, links and commands](#paths-links-and-commands) says.
|
|
8
10
|
The package ships one template per kind under `templates/`, and a repository starts a new doc file by copying one:
|
|
9
11
|
|
|
10
12
|
```sh
|
|
@@ -51,12 +53,81 @@ A template decides a file's structure, and the template file itself is the refer
|
|
|
51
53
|
- A changelog lists its releases newest first, each opening with a `Released YYYY-MM-DD.` line.
|
|
52
54
|
- A how-to or tutorial page numbers its steps.
|
|
53
55
|
- `CLAUDE.md` is its template word for word.
|
|
54
|
-
It is a fixed agent pointer rather than a
|
|
56
|
+
It is a fixed agent pointer rather than a living doc, so it takes only the prose rules for agent files.
|
|
57
|
+
|
|
58
|
+
## The prose rules
|
|
59
|
+
|
|
60
|
+
A line a change adds or edits in a living doc or an agent file is held to the prose rules, and a line the change leaves alone is not, so a repository needs no cleanup pass before it runs them.
|
|
61
|
+
|
|
62
|
+
<!-- generated living-docs: bun run build writes it from scripts/prose-matchers.ts and scripts/doc-blocks.ts -->
|
|
63
|
+
|
|
64
|
+
A living doc is one of these:
|
|
65
|
+
|
|
66
|
+
- a `README.md` or `CONTRIBUTING.md` in any directory
|
|
67
|
+
- a Markdown page under `docs/`
|
|
68
|
+
|
|
69
|
+
An agent file is a `AGENTS.md` or `CLAUDE.md` in any directory, and takes only the rules the table below marks for agent files.
|
|
70
|
+
|
|
71
|
+
These are records, and take no prose rule:
|
|
72
|
+
|
|
73
|
+
- a file in `docs/adr/`
|
|
74
|
+
- a file whose name opens with four digits, as in `0001-` or `2026-05-08-`
|
|
75
|
+
- a `CHANGELOG.md`
|
|
76
|
+
|
|
77
|
+
<!-- end generated living-docs -->
|
|
78
|
+
|
|
79
|
+
<!-- generated prose-rules: bun run build writes it from PROSE_RULES in scripts/prose-matchers.ts and scripts/doc-blocks.ts -->
|
|
80
|
+
|
|
81
|
+
| Refused | For example | Write instead | In agent files |
|
|
82
|
+
| --- | --- | --- | --- |
|
|
83
|
+
| an em dash | `—` | End the sentence, or use a comma | yes |
|
|
84
|
+
| an en dash | `–` | End the sentence, or use a comma | yes |
|
|
85
|
+
| a parenthesis other than the plural `(s)` | `(` | Make the aside its own sentence, or set it off with commas | yes |
|
|
86
|
+
| a hyphen used as a dash | `a - b` or `a -- b` | End the sentence, or use a comma | yes |
|
|
87
|
+
| a semicolon | `;` | Use two sentences | yes |
|
|
88
|
+
| a promise about the future | `until #11`, `is planned`, `will soon`, `coming soon`, `in a future release` | Say what is true now | no |
|
|
89
|
+
| a sentence that opens by talking about the page | `This page explains` | Talk directly about the subject | no |
|
|
90
|
+
| a second sentence on one line | `It builds. It ships.` | Start it on its own line | no |
|
|
91
|
+
| a sentence that runs across lines | `It builds` with `and ships.` on the next line | Join the sentence onto one line | no |
|
|
92
|
+
|
|
93
|
+
<!-- end generated prose-rules -->
|
|
94
|
+
|
|
95
|
+
A line holds one sentence, so a changed line is a changed sentence.
|
|
96
|
+
A bold label that opens a line, as in `**Status.**`, heads the sentence after it rather than counting as one.
|
|
97
|
+
Fenced code, inline code, link destinations, URLs, HTML comments and front matter are not prose, so no rule reads them.
|
|
98
|
+
Readability scores and word choice, such as easy, are not checked.
|
|
99
|
+
|
|
100
|
+
`scripts/prose-matchers.ts` holds the rules and a synchronous `proseRefused()`, and imports nothing.
|
|
101
|
+
A host such as a hook bundle can therefore copy it alone into a directory with no `node_modules` and import it as `@avi2dg/checks/scripts/prose-matchers.ts`, to refuse the same lines at write time.
|
|
102
|
+
|
|
103
|
+
## Paths, links and commands
|
|
104
|
+
|
|
105
|
+
Each reference a living doc names has to resolve at the head commit:
|
|
106
|
+
|
|
107
|
+
- A path in inline code that ends in a file extension, such as `scripts/lint.ts`, names a file from the root or from the doc's directory.
|
|
108
|
+
- A relative Markdown link names a file or a directory, and its anchor names a heading in the file it links, as GitHub derives the anchor, or an explicit `id`.
|
|
109
|
+
- 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.
|
|
110
|
+
|
|
111
|
+
A reference that does not resolve fails when it sits on a line the range adds or edits.
|
|
112
|
+
It fails on any other line when the range broke it, as by deleting the file it names or renaming the heading it links, and is listed as advisory when it was broken before the range.
|
|
113
|
+
A path under a top directory the repository lacks at both ends of the range names another repository's file, such as a consumer's, and is passed over.
|
|
114
|
+
So is a path git ignores, since a clean checkout lacks a generated file by design.
|
|
115
|
+
|
|
116
|
+
A doc that speaks to a repository installing this one is declared under `docs.forConsumers` in `quality.json`, and its commands are not held to this repository's `package.json`:
|
|
117
|
+
|
|
118
|
+
```json
|
|
119
|
+
"docs": {
|
|
120
|
+
"forConsumers": ["README.md", "docs/gates/*.md"]
|
|
121
|
+
}
|
|
122
|
+
```
|
|
55
123
|
|
|
56
124
|
## What it reads
|
|
57
125
|
|
|
58
126
|
It reads each Markdown file from the head commit, and `docs.pages` from `quality.json`.
|
|
59
127
|
A file the range adds, changes or renames is held to its template, and a file it deletes is not.
|
|
128
|
+
It reads the lines the range adds or edits from the diff, with renames detected, so a renamed doc is judged only on the lines the rename changed.
|
|
129
|
+
It reads the files tracked at both ends of the range, the `scripts` of each `package.json` a living doc sits under, and `docs.forConsumers` from `quality.json`.
|
|
130
|
+
From the working tree it reads the ignore files git reads, and `node_modules/.bin`.
|
|
60
131
|
|
|
61
132
|
## Arguments
|
|
62
133
|
|
|
@@ -72,24 +143,30 @@ With one it is that commit against its parent, or against the empty tree for a r
|
|
|
72
143
|
|
|
73
144
|
| Code | When |
|
|
74
145
|
| --- | --- |
|
|
75
|
-
| 0 | every doc file the range touches holds to its template |
|
|
76
|
-
| 1 | a doc file the range touches does not |
|
|
77
|
-
| 2 | `quality.json` does not decode, or a ref does not resolve |
|
|
146
|
+
| 0 | every doc file the range touches holds to its template, every line it adds to a living doc or an agent file holds to the prose rules, and it adds or breaks no reference that does not resolve |
|
|
147
|
+
| 1 | a doc file the range touches does not hold to its template, a line the range adds to a living doc or an agent file breaks a prose rule, or the range adds or breaks a reference that does not resolve |
|
|
148
|
+
| 2 | `quality.json` or a `package.json` does not decode, or a ref does not resolve |
|
|
78
149
|
|
|
79
150
|
## Sample output
|
|
80
151
|
|
|
81
152
|
```
|
|
82
|
-
docs:
|
|
153
|
+
docs: 4 violation(s):
|
|
83
154
|
README.md:1: lacks `## Where things are`
|
|
155
|
+
README.md:12: carries `;`, a semicolon. Use two sentences
|
|
156
|
+
README.md:20: names `scripts/bild.ts`, which is not in the repository
|
|
84
157
|
docs/parts.md: is a page under docs/ with no mode; declare it under docs.pages in quality.json as tutorial, how-to, reference, explanation
|
|
85
158
|
docs: advisory, 1 doc file(s) the range leaves alone do not hold to their templates yet:
|
|
86
159
|
docs/adr/0001-quality-gates.md: 5 violation(s)
|
|
160
|
+
docs: advisory, 1 path(s), link(s) or command(s) the living docs name were broken before the range:
|
|
161
|
+
docs/parts.md:9: links to `suppliers.md#prices`, and `docs/suppliers.md` has no heading with that anchor
|
|
87
162
|
```
|
|
88
163
|
|
|
89
164
|
## Opting out
|
|
90
165
|
|
|
91
166
|
It applies to every repository, so no selection leaves it out.
|
|
92
167
|
A file the range leaves alone is only listed as advisory, so a repository adopts the templates as its files change.
|
|
168
|
+
A line the range leaves alone takes no prose rule, so a repository adopts the prose rules as its lines change.
|
|
169
|
+
A doc listed under `docs.forConsumers` holds no `bun run` command to this repository's `package.json`.
|
|
93
170
|
`checks-lint` runs it over each pull request's range, as [checks-lint](checks-lint.md) says.
|
|
94
171
|
|
|
95
172
|
## Related topics
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@avi2dg/checks",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.15.0",
|
|
4
4
|
"description": "Deterministic checks shared across the captain's TypeScript repos",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
"scripts/mutation-compare.ts",
|
|
34
34
|
"scripts/ci-wiring.ts",
|
|
35
35
|
"scripts/comment-matchers.ts",
|
|
36
|
+
"scripts/prose-matchers.ts",
|
|
36
37
|
"scripts/comments.ts",
|
|
37
38
|
"scripts/comment-gate.ts",
|
|
38
39
|
"scripts/suppressions-ratchet.ts",
|
|
@@ -44,6 +45,8 @@
|
|
|
44
45
|
"scripts/docs.ts",
|
|
45
46
|
"scripts/doc-outline.ts",
|
|
46
47
|
"scripts/doc-rules.ts",
|
|
48
|
+
"scripts/doc-references.ts",
|
|
49
|
+
"scripts/doc-snapshot.ts",
|
|
47
50
|
"scripts/doc-templates.ts",
|
|
48
51
|
"templates/",
|
|
49
52
|
"presets/effect.oxlint.json",
|
|
@@ -68,6 +71,7 @@
|
|
|
68
71
|
"./scripts/mutation-compare.ts": "./scripts/mutation-compare.ts",
|
|
69
72
|
"./scripts/ci-wiring.ts": "./scripts/ci-wiring.ts",
|
|
70
73
|
"./scripts/comment-matchers.ts": "./scripts/comment-matchers.ts",
|
|
74
|
+
"./scripts/prose-matchers.ts": "./scripts/prose-matchers.ts",
|
|
71
75
|
"./scripts/comments.ts": "./scripts/comments.ts",
|
|
72
76
|
"./scripts/comment-gate.ts": "./scripts/comment-gate.ts",
|
|
73
77
|
"./scripts/suppressions-ratchet.ts": "./scripts/suppressions-ratchet.ts",
|
package/quality.schema.json
CHANGED
|
@@ -285,6 +285,13 @@
|
|
|
285
285
|
},
|
|
286
286
|
"additionalProperties": false,
|
|
287
287
|
"description": "The Diátaxis mode of each page, whose template checks-docs holds the page to; a page under docs/ needs one"
|
|
288
|
+
},
|
|
289
|
+
"forConsumers": {
|
|
290
|
+
"type": "array",
|
|
291
|
+
"items": {
|
|
292
|
+
"$ref": "#/$defs/DocGlob"
|
|
293
|
+
},
|
|
294
|
+
"description": "The living docs that speak to a repository installing this one, whose bun run commands checks-docs does not hold to this package.json"
|
|
288
295
|
}
|
|
289
296
|
},
|
|
290
297
|
"additionalProperties": false,
|
|
@@ -382,6 +389,11 @@
|
|
|
382
389
|
"RuleName": {
|
|
383
390
|
"type": "string",
|
|
384
391
|
"pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
|
|
392
|
+
},
|
|
393
|
+
"DocGlob": {
|
|
394
|
+
"type": "string",
|
|
395
|
+
"pattern": "^(?:(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+)\\/)*(?:[\\w.@+-]|\\*(?!\\*))*\\.\\w+$",
|
|
396
|
+
"description": "A glob from the repository root that checks-docs reads: * within a segment, ** as a whole one, a file name with an extension last"
|
|
385
397
|
}
|
|
386
398
|
}
|
|
387
399
|
}
|
package/scripts/comment-gate.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Option } from "effect";
|
|
3
3
|
import { refused, syntaxOf } from "./comments.ts";
|
|
4
|
-
import { git, parentOrEmptyTree } from "./git.ts";
|
|
4
|
+
import { changedLines, git, parentOrEmptyTree } from "./git.ts";
|
|
5
5
|
import { runMain, Usage } from "./main.ts";
|
|
6
6
|
|
|
7
7
|
export type GateResult = {
|
|
@@ -16,64 +16,21 @@ function readable(path: string): boolean {
|
|
|
16
16
|
return syntaxOf(path) !== undefined;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
-
function newPathOf(line: string): string | undefined {
|
|
20
|
-
const path = line.startsWith("+++ b/") ? line.slice("+++ b/".length) : line.slice("+++ ".length);
|
|
21
|
-
return path === "/dev/null" ? undefined : path;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
const HUNK = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/;
|
|
25
|
-
|
|
26
|
-
export function parseAddedLines(diff: string): Map<string, Set<number>> {
|
|
27
|
-
const added = new Map<string, Set<number>>();
|
|
28
|
-
let path: string | undefined;
|
|
29
|
-
let line = 0;
|
|
30
|
-
let inHunk = false;
|
|
31
|
-
for (const row of diff.split("\n")) {
|
|
32
|
-
if (row.startsWith("+++ ")) {
|
|
33
|
-
path = newPathOf(row);
|
|
34
|
-
inHunk = false;
|
|
35
|
-
continue;
|
|
36
|
-
}
|
|
37
|
-
const hunk = HUNK.exec(row);
|
|
38
|
-
if (hunk !== null) {
|
|
39
|
-
line = Number(hunk[1]);
|
|
40
|
-
inHunk = true;
|
|
41
|
-
continue;
|
|
42
|
-
}
|
|
43
|
-
if (!inHunk || path === undefined) continue;
|
|
44
|
-
if (row.startsWith("+")) {
|
|
45
|
-
let lines = added.get(path);
|
|
46
|
-
if (lines === undefined) {
|
|
47
|
-
lines = new Set<number>();
|
|
48
|
-
added.set(path, lines);
|
|
49
|
-
}
|
|
50
|
-
lines.add(line);
|
|
51
|
-
line += 1;
|
|
52
|
-
} else if (row.startsWith("-")) {
|
|
53
|
-
continue;
|
|
54
|
-
} else {
|
|
55
|
-
line += 1;
|
|
56
|
-
}
|
|
57
|
-
}
|
|
58
|
-
return added;
|
|
59
|
-
}
|
|
60
|
-
|
|
61
19
|
const show = (rev: string, path: string, root: string) =>
|
|
62
20
|
git(["show", `${rev}:${path}`], root).pipe(Effect.option);
|
|
63
21
|
|
|
64
22
|
export const runRange = Effect.fn("runRange")(function* (root: string, base: string, head: string) {
|
|
65
|
-
const
|
|
66
|
-
const added = parseAddedLines(diff);
|
|
23
|
+
const added = yield* changedLines(base, head, [], root);
|
|
67
24
|
const violations: string[] = [];
|
|
68
|
-
let
|
|
25
|
+
let lineCount = 0;
|
|
69
26
|
for (const [path, lines] of added) {
|
|
70
|
-
|
|
27
|
+
lineCount += lines.size;
|
|
71
28
|
if (!readable(path)) continue;
|
|
72
29
|
const source = yield* show(head, path, root);
|
|
73
30
|
if (Option.isNone(source)) continue;
|
|
74
31
|
violations.push(...(yield* refused(path, source.value, lines)));
|
|
75
32
|
}
|
|
76
|
-
return { files: added.size, addedLines, violations };
|
|
33
|
+
return { files: added.size, addedLines: lineCount, violations };
|
|
77
34
|
});
|
|
78
35
|
|
|
79
36
|
export function report({ files, addedLines, violations }: GateResult): string {
|
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
import { scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
|
|
2
|
+
|
|
3
|
+
export type ReferenceKind = "path" | "link" | "command";
|
|
4
|
+
|
|
5
|
+
export type Unresolved = {
|
|
6
|
+
readonly kind: ReferenceKind;
|
|
7
|
+
readonly line: number;
|
|
8
|
+
readonly named: string;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
readonly missing:
|
|
11
|
+
| { readonly type: "file"; readonly path: string }
|
|
12
|
+
| { readonly type: "script"; readonly name: string; readonly packageDirectory: string }
|
|
13
|
+
| { readonly type: "anchor" };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type Snapshot = {
|
|
17
|
+
readonly files: ReadonlySet<string>;
|
|
18
|
+
readonly directories: ReadonlySet<string>;
|
|
19
|
+
readonly roots: ReadonlySet<string>;
|
|
20
|
+
readonly anchors: ReadonlyMap<string, ReadonlySet<string>>;
|
|
21
|
+
readonly scripts: ReadonlyMap<string, ReadonlySet<string>>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const PACKAGE_MANIFEST = "package.json";
|
|
25
|
+
|
|
26
|
+
export function rootsOf(files: readonly string[]): readonly string[] {
|
|
27
|
+
return files.flatMap((file) => (file.includes("/") ? [file.slice(0, file.indexOf("/"))] : []));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function snapshotOf(
|
|
31
|
+
files: readonly string[],
|
|
32
|
+
anchors: ReadonlyMap<string, ReadonlySet<string>>,
|
|
33
|
+
scripts: ReadonlyMap<string, ReadonlySet<string>>,
|
|
34
|
+
alsoRoots: readonly string[] = [],
|
|
35
|
+
): Snapshot {
|
|
36
|
+
const directories = new Set<string>([""]);
|
|
37
|
+
for (const file of files) for (let at = file.indexOf("/"); at >= 0; at = file.indexOf("/", at + 1)) directories.add(file.slice(0, at));
|
|
38
|
+
return { files: new Set(files), directories, roots: new Set([...rootsOf(files), ...alsoRoots]), anchors, scripts };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function directoryOf(path: string): string {
|
|
42
|
+
return path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalize(path: string): string | undefined {
|
|
46
|
+
const parts: string[] = [];
|
|
47
|
+
for (const part of path.split("/")) {
|
|
48
|
+
if (part === "" || part === ".") continue;
|
|
49
|
+
if (part !== "..") parts.push(part);
|
|
50
|
+
else if (parts.pop() === undefined) return undefined;
|
|
51
|
+
}
|
|
52
|
+
return parts.join("/");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function within(directory: string, path: string): string | undefined {
|
|
56
|
+
return normalize(directory === "" ? path : `${directory}/${path}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function exists(snapshot: Snapshot, path: string): boolean {
|
|
60
|
+
return snapshot.files.has(path) || snapshot.directories.has(path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const PARENT = /^\.\.\//;
|
|
64
|
+
const PATH = /^((?:\.{1,2}\/)*(?:[\w.@-]+\/)+[\w.@-]+\.(?:ts|tsx|mts|cts|js|jsx|cjs|mjs|md|mdx|json|jsonc|sh|bash|zsh|toml|nix|lua|ya?ml|txt|py|rs|go|css|html))(?::\d+(?::\d+)?)?$/;
|
|
65
|
+
|
|
66
|
+
function unresolvedPath(doc: string, line: number, span: string, snapshot: Snapshot): Unresolved | undefined {
|
|
67
|
+
const named = PATH.exec(span)?.[1];
|
|
68
|
+
if (named === undefined) return undefined;
|
|
69
|
+
const fromDoc = within(directoryOf(doc), named);
|
|
70
|
+
const fromRoot = PARENT.test(named) ? undefined : normalize(named);
|
|
71
|
+
if ([fromRoot, fromDoc].some((path) => path !== undefined && exists(snapshot, path))) return undefined;
|
|
72
|
+
const checked = fromRoot ?? fromDoc;
|
|
73
|
+
// A path whose top directory this repository lacks names a file in another one, such as a consumer's.
|
|
74
|
+
if (checked === undefined || (fromRoot !== undefined && !snapshot.roots.has(fromRoot.split("/")[0] ?? ""))) return undefined;
|
|
75
|
+
return { kind: "path", line, named, message: `names \`${named}\`, which is not in the repository`, missing: { type: "file", path: checked } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const SCHEME = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
79
|
+
const ASCII_ESCAPE = /%([0-7][0-9a-f])/gi;
|
|
80
|
+
|
|
81
|
+
function unresolvedLink(doc: string, line: number, target: string, snapshot: Snapshot): Unresolved | undefined {
|
|
82
|
+
if (target === "" || SCHEME.test(target)) return undefined;
|
|
83
|
+
const hash = target.indexOf("#");
|
|
84
|
+
const written = (hash < 0 ? target : target.slice(0, hash)).split("?", 1)[0] ?? "";
|
|
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);
|
|
87
|
+
if (path === undefined) return undefined;
|
|
88
|
+
const named = target;
|
|
89
|
+
if (!exists(snapshot, path)) {
|
|
90
|
+
return { kind: "link", line, named, message: `links to \`${named}\`, which is not in the repository`, missing: { type: "file", path } };
|
|
91
|
+
}
|
|
92
|
+
const anchor = hash < 0 ? "" : target.slice(hash + 1);
|
|
93
|
+
const anchors = snapshot.anchors.get(path);
|
|
94
|
+
if (anchor === "" || anchors === undefined || anchors.has(anchor) || anchors.has(anchor.toLowerCase())) return undefined;
|
|
95
|
+
return { kind: "link", line, named, message: `links to \`${named}\`, and \`${path}\` has no heading with that anchor`, missing: { type: "anchor" } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const RUN = /\bbun run\s+([^\s`'"]+)/g;
|
|
99
|
+
const RUNS_A_FILE = /\/|\.[cm]?[jt]sx?$/;
|
|
100
|
+
const NOT_A_NAME = /^[-$<{]/;
|
|
101
|
+
|
|
102
|
+
function packageOf(doc: string, snapshot: Snapshot): string | undefined {
|
|
103
|
+
for (let directory: string | undefined = directoryOf(doc); directory !== undefined; directory = directory === "" ? undefined : directoryOf(directory)) {
|
|
104
|
+
if (snapshot.files.has(directory === "" ? PACKAGE_MANIFEST : `${directory}/${PACKAGE_MANIFEST}`)) return directory;
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unresolvedCommand(doc: string, line: number, name: string, snapshot: Snapshot): Unresolved | undefined {
|
|
110
|
+
const packageDirectory = packageOf(doc, snapshot);
|
|
111
|
+
if (packageDirectory === undefined || NOT_A_NAME.test(name)) return undefined;
|
|
112
|
+
const named = `bun run ${name}`;
|
|
113
|
+
if (RUNS_A_FILE.test(name)) {
|
|
114
|
+
const path = within(packageDirectory, name);
|
|
115
|
+
if (path === undefined || exists(snapshot, path)) return undefined;
|
|
116
|
+
return { kind: "command", line, named, message: `runs \`${named}\`, and \`${path}\` is not in the repository`, missing: { type: "file", path } };
|
|
117
|
+
}
|
|
118
|
+
if (snapshot.scripts.get(packageDirectory)?.has(name) === true) return undefined;
|
|
119
|
+
const manifest = packageDirectory === "" ? PACKAGE_MANIFEST : `${packageDirectory}/${PACKAGE_MANIFEST}`;
|
|
120
|
+
return {
|
|
121
|
+
kind: "command",
|
|
122
|
+
line,
|
|
123
|
+
named,
|
|
124
|
+
message: `runs \`${named}\`, and \`${name}\` is not a script in \`${manifest}\``,
|
|
125
|
+
missing: { type: "script", name, packageDirectory },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function commandsOn({ kind, raw, code }: MarkdownLine): readonly string[] {
|
|
130
|
+
const texts = kind === "code" ? [raw] : code;
|
|
131
|
+
return texts.flatMap((text) => [...text.matchAll(RUN)].map(([, name = ""]) => name.replace(/[),.;:]+$/, "")));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type Judging = { readonly commands: boolean };
|
|
135
|
+
|
|
136
|
+
export function unresolvedIn(doc: string, text: string, snapshot: Snapshot, { commands }: Judging): readonly Unresolved[] {
|
|
137
|
+
return scanMarkdown(text).flatMap((markdown) => {
|
|
138
|
+
const { line } = markdown;
|
|
139
|
+
const prose = markdown.kind === "code" || markdown.kind === "front-matter" ? [] : markdown.code.map((span) => unresolvedPath(doc, line, span, snapshot));
|
|
140
|
+
const links = markdown.links.map((target) => unresolvedLink(doc, line, target, snapshot));
|
|
141
|
+
const runs = commands && markdown.kind !== "front-matter" ? commandsOn(markdown).map((name) => unresolvedCommand(doc, line, name, snapshot)) : [];
|
|
142
|
+
return [...prose, ...links, ...runs].filter((found) => found !== undefined);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function anchoredTargets(doc: string, text: string): readonly string[] {
|
|
147
|
+
return scanMarkdown(text).flatMap(({ links }) =>
|
|
148
|
+
links.flatMap((target) => {
|
|
149
|
+
const hash = target.indexOf("#");
|
|
150
|
+
if (hash < 0 || SCHEME.test(target)) return [];
|
|
151
|
+
const written = target.slice(0, hash);
|
|
152
|
+
const path = written === "" ? doc : written.startsWith("/") ? normalize(written) : within(directoryOf(doc), written);
|
|
153
|
+
return path?.endsWith(".md") === true ? [path] : [];
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function packagesOf(docs: readonly string[], snapshot: Snapshot): readonly string[] {
|
|
159
|
+
return [...new Set(docs.flatMap((doc) => packageOf(doc, snapshot) ?? []))];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const EXPLICIT_ANCHOR = /<a\s[^>]*\b(?:id|name)="([^"]+)"/g;
|
|
163
|
+
const HEADING_MARKERS = /^\s{0,3}#{1,6}\s*|\s+#+\s*$/g;
|
|
164
|
+
|
|
165
|
+
function headingText(raw: string): string {
|
|
166
|
+
return raw
|
|
167
|
+
.replace(HEADING_MARKERS, "")
|
|
168
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
169
|
+
.replace(/<[^>]+>/g, "")
|
|
170
|
+
.replace(/[`*]/g, "")
|
|
171
|
+
.trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function anchorsOf(text: string): ReadonlySet<string> {
|
|
175
|
+
const anchors = new Set<string>();
|
|
176
|
+
const seen = new Map<string, number>();
|
|
177
|
+
for (const { kind, raw } of scanMarkdown(text)) {
|
|
178
|
+
if (kind === "code" || kind === "front-matter") continue;
|
|
179
|
+
for (const [, id = ""] of raw.matchAll(EXPLICIT_ANCHOR)) anchors.add(id);
|
|
180
|
+
if (kind !== "heading") continue;
|
|
181
|
+
const slug = headingText(raw)
|
|
182
|
+
.toLowerCase()
|
|
183
|
+
.replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "")
|
|
184
|
+
.replaceAll(" ", "-");
|
|
185
|
+
const count = seen.get(slug) ?? 0;
|
|
186
|
+
anchors.add(count === 0 ? slug : `${slug}-${count}`);
|
|
187
|
+
seen.set(slug, count + 1);
|
|
188
|
+
}
|
|
189
|
+
return anchors;
|
|
190
|
+
}
|
package/scripts/doc-rules.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
VERSION,
|
|
12
12
|
} from "./doc-outline.ts";
|
|
13
13
|
import { ADR_STATUSES, TEMPLATES, templateFile, type Kind, type Title } from "./doc-templates.ts";
|
|
14
|
+
import { ADR_DIRECTORY, DOCS_DIRECTORY } from "./prose-matchers.ts";
|
|
14
15
|
import { MODES, type Docs, type Mode } from "./quality-file.ts";
|
|
15
16
|
|
|
16
17
|
export type Placement =
|
|
@@ -24,10 +25,9 @@ export type Doc = {
|
|
|
24
25
|
readonly text: string;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
|
-
export
|
|
28
|
+
export { ADR_DIRECTORY };
|
|
28
29
|
// A generated index takes its shape from its generator.
|
|
29
30
|
export const ADR_INDEX = `${ADR_DIRECTORY}README.md`;
|
|
30
|
-
const DOCS_DIRECTORY = "docs/";
|
|
31
31
|
|
|
32
32
|
export const ROOT_FILES: ReadonlyMap<string, Kind> = new Map<string, Kind>([
|
|
33
33
|
["README.md", "readme"],
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
import { anchoredTargets, anchorsOf, PACKAGE_MANIFEST, packagesOf, snapshotOf, type Unresolved } from "./doc-references.ts";
|
|
3
|
+
import { git, pathsAt } from "./git.ts";
|
|
4
|
+
|
|
5
|
+
export class ManifestUnreadable extends Schema.TaggedError<ManifestUnreadable>()("ManifestUnreadable", {
|
|
6
|
+
message: Schema.String,
|
|
7
|
+
}) {}
|
|
8
|
+
|
|
9
|
+
const Scripts = Schema.fromJsonString(Schema.Struct({ scripts: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)) }));
|
|
10
|
+
const decodeScripts = Schema.decodeUnknownEffect(Scripts);
|
|
11
|
+
|
|
12
|
+
export const readTexts = Effect.fn("readTexts")(function* (root: string, rev: string, paths: readonly string[]) {
|
|
13
|
+
const texts = yield* Effect.forEach(paths, (path) => git(["show", `${rev}:${path}`], root), { concurrency: 8 });
|
|
14
|
+
return new Map(paths.map((path, index) => [path, texts[index] ?? ""]));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function manifestIn(directory: string): string {
|
|
18
|
+
return directory === "" ? PACKAGE_MANIFEST : `${directory}/${PACKAGE_MANIFEST}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const scriptsAt = Effect.fn("scriptsAt")(function* (root: string, rev: string, directory: string) {
|
|
22
|
+
const manifest = manifestIn(directory);
|
|
23
|
+
const { scripts = {} } = yield* git(["show", `${rev}:${manifest}`], root).pipe(
|
|
24
|
+
Effect.flatMap(decodeScripts),
|
|
25
|
+
Effect.mapError((cause) => new ManifestUnreadable({ message: `${manifest} at ${rev}: ${cause.message}` })),
|
|
26
|
+
);
|
|
27
|
+
return new Set(Object.keys(scripts));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const snapshotAt = Effect.fn("snapshotAt")(function* (
|
|
31
|
+
root: string,
|
|
32
|
+
rev: string,
|
|
33
|
+
docs: ReadonlyMap<string, string>,
|
|
34
|
+
alsoRoots: readonly string[],
|
|
35
|
+
) {
|
|
36
|
+
const files = yield* pathsAt(rev, [], root);
|
|
37
|
+
const tracked = snapshotOf(files, new Map(), new Map());
|
|
38
|
+
const targets = [...new Set([...docs].flatMap(([doc, text]) => anchoredTargets(doc, text)))].filter((path) => tracked.files.has(path));
|
|
39
|
+
const read = yield* readTexts(root, rev, targets.filter((path) => !docs.has(path)));
|
|
40
|
+
const anchors = new Map(targets.map((path) => [path, anchorsOf(docs.get(path) ?? read.get(path) ?? "")]));
|
|
41
|
+
const packages = packagesOf([...docs.keys()], tracked);
|
|
42
|
+
const scripts = yield* Effect.forEach(packages, (directory) => scriptsAt(root, rev, directory), { concurrency: 8 });
|
|
43
|
+
return snapshotOf(files, anchors, new Map(packages.map((directory, index) => [directory, scripts[index] ?? new Set<string>()])), alsoRoots);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const ignored = (root: string, path: string) =>
|
|
47
|
+
git(["check-ignore", "-q", "--no-index", "--", path], root).pipe(
|
|
48
|
+
Effect.as(true),
|
|
49
|
+
Effect.catchTag("GitFailure", () => Effect.succeed(false)),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// A script bun runs from an installed dependency's bin names no entry in package.json.
|
|
53
|
+
const installedBin = Effect.fn("installedBin")(function* (root: string, directory: string, name: string) {
|
|
54
|
+
const fs = yield* FileSystem.FileSystem;
|
|
55
|
+
const path = yield* Path.Path;
|
|
56
|
+
const bins = [...new Set([directory, ""])].map((from) => path.join(root, from, "node_modules", ".bin", name));
|
|
57
|
+
const found = yield* Effect.forEach(bins, (bin) => fs.exists(bin).pipe(Effect.orElseSucceed(() => false)));
|
|
58
|
+
return found.some(Boolean);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// A path git ignores is generated or fetched, so a clean checkout lacks it by design.
|
|
62
|
+
const stillBroken = Effect.fn("stillBroken")(function* (root: string, { missing }: Unresolved) {
|
|
63
|
+
if (missing.type === "file") return !(yield* ignored(root, missing.path));
|
|
64
|
+
if (missing.type === "script") return !(yield* installedBin(root, missing.packageDirectory, missing.name));
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const stillMissing = Effect.fn("stillMissing")(function* (root: string, unresolved: readonly Unresolved[]) {
|
|
69
|
+
return yield* Effect.forEach(unresolved, (one) => stillBroken(root, one), { concurrency: 8 });
|
|
70
|
+
});
|
package/scripts/docs.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect } from "effect";
|
|
3
|
+
import { rootsOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
|
|
3
4
|
import { ADR_DIRECTORY, judge, placementOf, placementProblem, type Placement } from "./doc-rules.ts";
|
|
4
|
-
import {
|
|
5
|
+
import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
|
|
6
|
+
import { changedLines, changedPaths, git, pathsAt, rangeEnds } from "./git.ts";
|
|
5
7
|
import { runMain, Usage } from "./main.ts";
|
|
8
|
+
import { isLivingDoc, proseFindings, readerOf } from "./prose-matchers.ts";
|
|
6
9
|
import { readQuality } from "./quality-file.ts";
|
|
7
10
|
|
|
8
11
|
type Finding = {
|
|
@@ -13,46 +16,111 @@ type Finding = {
|
|
|
13
16
|
|
|
14
17
|
type Judged = {
|
|
15
18
|
readonly held: readonly string[];
|
|
19
|
+
readonly edited: { readonly docs: number; readonly lines: number };
|
|
20
|
+
readonly living: number;
|
|
16
21
|
readonly findings: readonly Finding[];
|
|
17
22
|
readonly advisory: ReadonlyMap<string, number>;
|
|
23
|
+
readonly brokenBefore: readonly Finding[];
|
|
18
24
|
};
|
|
19
25
|
|
|
26
|
+
type Range = {
|
|
27
|
+
readonly root: string;
|
|
28
|
+
readonly base: string;
|
|
29
|
+
readonly head: string;
|
|
30
|
+
readonly roots: readonly string[];
|
|
31
|
+
readonly changed: ReadonlyMap<string, ReadonlySet<number>>;
|
|
32
|
+
readonly renamedFrom: ReadonlyMap<string, string>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Located = Unresolved & { readonly path: string };
|
|
36
|
+
|
|
20
37
|
const NAME = "docs";
|
|
21
38
|
const USAGE = "usage: docs.ts <ref> | <base-ref> <head-ref>";
|
|
22
39
|
const MARKDOWN = [":(glob)**/*.md"];
|
|
23
40
|
|
|
24
|
-
|
|
25
|
-
root: string,
|
|
26
|
-
head: string,
|
|
27
|
-
path: string,
|
|
28
|
-
placement: Placement,
|
|
29
|
-
records: readonly string[],
|
|
30
|
-
) {
|
|
41
|
+
function templateFindings(path: string, text: string, placement: Placement, records: readonly string[]): readonly Finding[] {
|
|
31
42
|
const misplaced = placementProblem(placement);
|
|
32
43
|
if (misplaced !== undefined) return [{ path, line: undefined, message: misplaced }];
|
|
33
44
|
if (placement.type !== "judged") return [];
|
|
34
|
-
const text = yield* git(["show", `${head}:${path}`], root);
|
|
35
45
|
return judge(placement.kind, { path, text }, records).map(({ line, message }) => ({ path, line, message }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function inPathOrder(a: Finding, b: Finding): number {
|
|
49
|
+
return a.path === b.path ? (a.line ?? 0) - (b.line ?? 0) : a.path < b.path ? -1 : 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function locate(path: string, text: string, snapshot: Parameters<typeof unresolvedIn>[2], judging: Judging): readonly Located[] {
|
|
53
|
+
return unresolvedIn(path, text, snapshot, judging).map((unresolved) => ({ ...unresolved, path }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const keyOf = ({ path, kind, named }: Located): string => `${path}\0${kind}\0${named}`;
|
|
57
|
+
|
|
58
|
+
// A reference on a line the range leaves alone fails only when the range broke it, as by deleting the file it names.
|
|
59
|
+
const brokenBeforeRange = Effect.fn("brokenBeforeRange")(function* (range: Range, found: readonly Located[], judging: (path: string) => Judging) {
|
|
60
|
+
const earlier = new Set(yield* pathsAt(range.base, MARKDOWN, range.root));
|
|
61
|
+
const docs = [...new Set(found.map(({ path }) => path))]
|
|
62
|
+
.map((path) => ({ path, from: range.renamedFrom.get(path) ?? path }))
|
|
63
|
+
.filter(({ from }) => earlier.has(from));
|
|
64
|
+
const texts = yield* readTexts(range.root, range.base, docs.map(({ from }) => from));
|
|
65
|
+
const snapshot = yield* snapshotAt(range.root, range.base, texts, range.roots);
|
|
66
|
+
return new Set(docs.flatMap(({ path, from }) => locate(from, texts.get(from) ?? "", snapshot, judging(path)).map((one) => keyOf({ ...one, path }))));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const referenceFindings = Effect.fn("referenceFindings")(function* (range: Range, texts: ReadonlyMap<string, string>, judging: (path: string) => Judging) {
|
|
70
|
+
const snapshot = yield* snapshotAt(range.root, range.head, texts, range.roots);
|
|
71
|
+
const unresolved = [...texts].flatMap(([path, text]) => locate(path, text, snapshot, judging(path)));
|
|
72
|
+
const missing = yield* stillMissing(range.root, unresolved);
|
|
73
|
+
const found = unresolved.filter((_, index) => missing[index] === true);
|
|
74
|
+
const onChangedLines = (one: Located): boolean => range.changed.get(one.path)?.has(one.line) === true;
|
|
75
|
+
const elsewhere = found.filter((one) => !onChangedLines(one));
|
|
76
|
+
const before = elsewhere.length === 0 ? new Set<string>() : yield* brokenBeforeRange(range, elsewhere, judging);
|
|
77
|
+
const finding = ({ path, line, message }: Located): Finding => ({ path, line, message });
|
|
78
|
+
return {
|
|
79
|
+
failing: found.filter((one) => onChangedLines(one) || !before.has(keyOf(one))).map(finding),
|
|
80
|
+
brokenBefore: elsewhere.filter((one) => before.has(keyOf(one))).map(finding),
|
|
81
|
+
};
|
|
36
82
|
});
|
|
37
83
|
|
|
38
84
|
const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head: string) {
|
|
39
85
|
const { quality } = yield* readQuality(root);
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
);
|
|
86
|
+
const changes = yield* changedPaths(base, head, MARKDOWN, root);
|
|
87
|
+
const touched = new Set(changes.flatMap((change) => (change.kind === "deleted" ? [] : [change.path])));
|
|
88
|
+
const renamedFrom = new Map(changes.flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from] as const] : [])));
|
|
89
|
+
const changed = yield* changedLines(base, head, MARKDOWN, root);
|
|
43
90
|
const present = yield* pathsAt(head, MARKDOWN, root);
|
|
44
91
|
const records = present.filter((path) => path.startsWith(ADR_DIRECTORY));
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
92
|
+
const judged = present.map((path) => ({ path, placement: placementOf(path, quality.docs) })).filter(({ placement }) => placement.type !== "unjudged");
|
|
93
|
+
const living = present.filter(isLivingDoc);
|
|
94
|
+
const proseDocs = present.flatMap((path) => {
|
|
95
|
+
const reader = readerOf(path);
|
|
96
|
+
return reader === undefined ? [] : [{ path, reader }];
|
|
97
|
+
});
|
|
98
|
+
const texts = yield* readTexts(root, head, [...new Set([...judged.map(({ path }) => path), ...proseDocs.map(({ path }) => path)])]);
|
|
99
|
+
const text = (path: string): string => texts.get(path) ?? "";
|
|
100
|
+
|
|
101
|
+
const templated = judged.flatMap(({ path, placement }) => templateFindings(path, text(path), placement, records));
|
|
102
|
+
const edited = proseDocs.filter(({ path }) => changed.has(path));
|
|
103
|
+
const prose = edited.flatMap(({ path, reader }) =>
|
|
104
|
+
proseFindings(text(path), reader, changed.get(path)).map(({ line, message }) => ({ path, line, message })),
|
|
105
|
+
);
|
|
106
|
+
const forConsumers = (quality.docs?.forConsumers ?? []).map((glob) => new Bun.Glob(glob));
|
|
107
|
+
const judging = (path: string): Judging => ({ commands: !forConsumers.some((glob) => glob.match(path)) });
|
|
108
|
+
// A directory the range deletes still belongs to this repository, so a path under it is stale rather than another repository's.
|
|
109
|
+
const roots = rootsOf(yield* pathsAt(base, [], root));
|
|
110
|
+
const references = yield* referenceFindings(
|
|
111
|
+
{ root, base, head, roots, changed, renamedFrom },
|
|
112
|
+
new Map(living.map((path) => [path, text(path)])),
|
|
113
|
+
judging,
|
|
114
|
+
);
|
|
50
115
|
const advisory = new Map<string, number>();
|
|
51
|
-
for (const { path } of
|
|
116
|
+
for (const { path } of templated.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
|
|
52
117
|
return {
|
|
53
118
|
held: judged.map(({ path }) => path).filter((path) => touched.has(path)),
|
|
54
|
-
|
|
119
|
+
edited: { docs: edited.length, lines: edited.reduce((sum, { path }) => sum + (changed.get(path)?.size ?? 0), 0) },
|
|
120
|
+
living: living.length,
|
|
121
|
+
findings: [...templated.filter((finding) => touched.has(finding.path)), ...prose, ...references.failing].toSorted(inPathOrder),
|
|
55
122
|
advisory,
|
|
123
|
+
brokenBefore: references.brokenBefore.toSorted(inPathOrder),
|
|
56
124
|
} satisfies Judged;
|
|
57
125
|
});
|
|
58
126
|
|
|
@@ -60,19 +128,27 @@ function describe({ path, line, message }: Finding): string {
|
|
|
60
128
|
return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
61
129
|
}
|
|
62
130
|
|
|
63
|
-
export function report({ held, findings, advisory }: Judged): string {
|
|
131
|
+
export function report({ held, edited, living, findings, advisory, brokenBefore }: Judged): string {
|
|
64
132
|
const verdict =
|
|
65
133
|
findings.length === 0
|
|
66
|
-
? [
|
|
67
|
-
|
|
68
|
-
|
|
134
|
+
? [
|
|
135
|
+
`${NAME}: ${held.length} doc file(s) the range touches hold to their templates`,
|
|
136
|
+
`${NAME}: ${edited.lines} line(s) the range adds or edits in ${edited.docs} living doc(s) or agent file(s) hold to the prose rules`,
|
|
137
|
+
`${NAME}: the range breaks no path, link or command the ${living} living doc(s) name`,
|
|
138
|
+
]
|
|
139
|
+
: [`${NAME}: ${findings.length} violation(s):`, ...findings.map(describe)];
|
|
140
|
+
const unconformed =
|
|
69
141
|
advisory.size === 0
|
|
70
142
|
? []
|
|
71
143
|
: [
|
|
72
144
|
`${NAME}: advisory, ${advisory.size} doc file(s) the range leaves alone do not hold to their templates yet:`,
|
|
73
145
|
...[...advisory].map(([path, count]) => ` ${path}: ${count} violation(s)`),
|
|
74
146
|
];
|
|
75
|
-
|
|
147
|
+
const broken =
|
|
148
|
+
brokenBefore.length === 0
|
|
149
|
+
? []
|
|
150
|
+
: [`${NAME}: advisory, ${brokenBefore.length} path(s), link(s) or command(s) the living docs name were broken before the range:`, ...brokenBefore.map(describe)];
|
|
151
|
+
return [...verdict, ...unconformed, ...broken].join("\n");
|
|
76
152
|
}
|
|
77
153
|
|
|
78
154
|
const docs = Effect.gen(function* () {
|
package/scripts/git.ts
CHANGED
|
@@ -71,6 +71,70 @@ export const changedPaths = Effect.fn("changedPaths")(function* (
|
|
|
71
71
|
return parseNameStatus(yield* git(["diff", "--name-status", "-z", "-M", base, head, "--", ...pathspecs], cwd));
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
const ESCAPES: Readonly<Record<string, string>> = { a: "\x07", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" };
|
|
75
|
+
|
|
76
|
+
// Git quotes a path holding a quote, a backslash or a control character, and ends one holding a space with a tab.
|
|
77
|
+
function newPathOf(line: string): string | undefined {
|
|
78
|
+
const named = line.slice("+++ ".length).replace(/\t$/, "");
|
|
79
|
+
const path = named.startsWith('"')
|
|
80
|
+
? named.slice(1, -1).replace(/\\(?:([0-7]{3})|(.))/g, (_, octal: string | undefined, char: string) =>
|
|
81
|
+
octal === undefined ? (ESCAPES[char] ?? char) : String.fromCharCode(Number.parseInt(octal, 8)),
|
|
82
|
+
)
|
|
83
|
+
: named;
|
|
84
|
+
return path === "/dev/null" ? undefined : path;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const HUNK = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
88
|
+
|
|
89
|
+
// A hunk's header counts its lines, so an added line reading `++ x` is not taken for the next file's `+++` header.
|
|
90
|
+
export function parseAddedLines(diff: string): Map<string, Set<number>> {
|
|
91
|
+
const added = new Map<string, Set<number>>();
|
|
92
|
+
let path: string | undefined;
|
|
93
|
+
let line = 0;
|
|
94
|
+
let oldLeft = 0;
|
|
95
|
+
let newLeft = 0;
|
|
96
|
+
for (const row of diff.split("\n")) {
|
|
97
|
+
if (oldLeft > 0 || newLeft > 0) {
|
|
98
|
+
if (row.startsWith("+")) {
|
|
99
|
+
if (path !== undefined) added.set(path, (added.get(path) ?? new Set<number>()).add(line));
|
|
100
|
+
line += 1;
|
|
101
|
+
newLeft -= 1;
|
|
102
|
+
} else if (row.startsWith("-")) {
|
|
103
|
+
oldLeft -= 1;
|
|
104
|
+
} else if (row.startsWith(" ")) {
|
|
105
|
+
line += 1;
|
|
106
|
+
oldLeft -= 1;
|
|
107
|
+
newLeft -= 1;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (row.startsWith("+++ ")) {
|
|
112
|
+
path = newPathOf(row);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const hunk = HUNK.exec(row);
|
|
116
|
+
if (hunk !== null) {
|
|
117
|
+
oldLeft = Number(hunk[1] ?? 1);
|
|
118
|
+
line = Number(hunk[2]);
|
|
119
|
+
newLeft = Number(hunk[3] ?? 1);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return added;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const changedLines = Effect.fn("changedLines")(function* (
|
|
126
|
+
base: string,
|
|
127
|
+
head: string,
|
|
128
|
+
pathspecs: readonly string[],
|
|
129
|
+
cwd?: string,
|
|
130
|
+
) {
|
|
131
|
+
const diff = yield* git(
|
|
132
|
+
["-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--no-prefix", "-M", base, head, "--", ...pathspecs],
|
|
133
|
+
cwd,
|
|
134
|
+
);
|
|
135
|
+
return parseAddedLines(diff);
|
|
136
|
+
});
|
|
137
|
+
|
|
74
138
|
const emptyTree = (cwd?: string) => git(["hash-object", "-t", "tree", "/dev/null"], cwd).pipe(Effect.map((sha) => sha.trim()));
|
|
75
139
|
|
|
76
140
|
export const pathsAt = Effect.fn("pathsAt")(function* (rev: string, pathspecs: readonly string[], cwd?: string) {
|
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
export type ProseFinding = { readonly line: number; readonly message: string };
|
|
2
|
+
|
|
3
|
+
export type LineKind = "prose" | "heading" | "table" | "html" | "definition" | "break" | "blank" | "code" | "front-matter";
|
|
4
|
+
|
|
5
|
+
export type MarkdownLine = {
|
|
6
|
+
readonly line: number;
|
|
7
|
+
readonly kind: LineKind;
|
|
8
|
+
readonly raw: string;
|
|
9
|
+
readonly prose: string;
|
|
10
|
+
readonly code: readonly string[];
|
|
11
|
+
readonly links: readonly string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type Reader = "people" | "agents";
|
|
15
|
+
|
|
16
|
+
export type ProseRule = {
|
|
17
|
+
readonly readers: readonly Reader[];
|
|
18
|
+
readonly refuses: string;
|
|
19
|
+
readonly example: string;
|
|
20
|
+
readonly instead: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type MatchedRule = ProseRule & { readonly find: RegExp };
|
|
24
|
+
|
|
25
|
+
export const ADR_DIRECTORY = "docs/adr/";
|
|
26
|
+
export const DOCS_DIRECTORY = "docs/";
|
|
27
|
+
export const LIVING_NAMES: readonly string[] = ["README.md", "CONTRIBUTING.md"];
|
|
28
|
+
export const AGENT_NAMES: readonly string[] = ["AGENTS.md", "CLAUDE.md"];
|
|
29
|
+
export const HISTORY_NAMES: readonly string[] = ["CHANGELOG.md"];
|
|
30
|
+
export const DATED_RECORD_EXAMPLES: readonly string[] = ["0001-", "2026-05-08-"];
|
|
31
|
+
const DATED_RECORD = /^\d{4}-/;
|
|
32
|
+
|
|
33
|
+
export function isLivingDoc(repositoryPath: string): boolean {
|
|
34
|
+
const name = repositoryPath.slice(repositoryPath.lastIndexOf("/") + 1);
|
|
35
|
+
if (HISTORY_NAMES.includes(name) || AGENT_NAMES.includes(name) || DATED_RECORD.test(name) || repositoryPath.startsWith(ADR_DIRECTORY)) return false;
|
|
36
|
+
return LIVING_NAMES.includes(name) || (repositoryPath.startsWith(DOCS_DIRECTORY) && name.endsWith(".md"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readerOf(repositoryPath: string): Reader | undefined {
|
|
40
|
+
if (isLivingDoc(repositoryPath)) return "people";
|
|
41
|
+
return AGENT_NAMES.includes(repositoryPath.slice(repositoryPath.lastIndexOf("/") + 1)) ? "agents" : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Masking keeps each line's length, so a column in the masked prose is the same column in the raw line.
|
|
45
|
+
const HIDDEN = "\0";
|
|
46
|
+
const BLANK = " ";
|
|
47
|
+
|
|
48
|
+
type Masked = { readonly prose: string; readonly code: string[]; readonly links: string[]; readonly inComment: boolean };
|
|
49
|
+
|
|
50
|
+
const AUTOLINK = /^<(?:[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>]*|[^\s@<>]+@[^\s<>]+)>/;
|
|
51
|
+
const HTML_TAG = /^<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*)?\/?>/;
|
|
52
|
+
const BARE_URL = /^https?:\/\/[^\s<>]*[^\s<>.,:;!?'")\]]/;
|
|
53
|
+
const ENTITY = /^&(?:#\d+|#x[0-9a-f]+|[a-z][a-z0-9]*);/i;
|
|
54
|
+
const WORD = /\w/;
|
|
55
|
+
|
|
56
|
+
function hiddenAt(raw: string, at: number, rest: string): { readonly length: number; readonly fill: string } | undefined {
|
|
57
|
+
const char = raw.charAt(at);
|
|
58
|
+
const url = char === "h" && !WORD.test(raw.charAt(at - 1)) ? BARE_URL.exec(rest) : null;
|
|
59
|
+
const visible = url ?? (char === "&" ? ENTITY.exec(rest) : null) ?? (char === "<" ? AUTOLINK.exec(rest) : null);
|
|
60
|
+
if (visible !== null) return { length: visible[0].length, fill: HIDDEN };
|
|
61
|
+
const tag = char === "<" ? HTML_TAG.exec(rest) : null;
|
|
62
|
+
return tag === null ? undefined : { length: tag[0].length, fill: BLANK };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function runOf(raw: string, at: number, char: string): number {
|
|
66
|
+
let end = at;
|
|
67
|
+
while (raw[end] === char) end += 1;
|
|
68
|
+
return end - at;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function closingRun(raw: string, from: number, length: number): number {
|
|
72
|
+
for (let at = raw.indexOf("`", from); at >= 0; at = raw.indexOf("`", at + 1)) {
|
|
73
|
+
const run = runOf(raw, at, "`");
|
|
74
|
+
if (run === length) return at;
|
|
75
|
+
at += run - 1;
|
|
76
|
+
}
|
|
77
|
+
return -1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function destinationEnd(raw: string, open: number): number {
|
|
81
|
+
let depth = 0;
|
|
82
|
+
for (let at = open + 1; at < raw.length; at += 1) {
|
|
83
|
+
const char = raw[at];
|
|
84
|
+
if (char === "\\") at += 1;
|
|
85
|
+
else if (char === "(") depth += 1;
|
|
86
|
+
else if (char === ")") {
|
|
87
|
+
if (depth === 0) return at;
|
|
88
|
+
depth -= 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return -1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function targetOf(destination: string): string {
|
|
95
|
+
const trimmed = destination.trim();
|
|
96
|
+
if (trimmed.startsWith("<")) return trimmed.slice(1, Math.max(trimmed.indexOf(">"), 1));
|
|
97
|
+
return trimmed.split(/\s/, 1)[0] ?? "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mask(raw: string, startsInComment: boolean): Masked {
|
|
101
|
+
const out = raw.split("");
|
|
102
|
+
const code: string[] = [];
|
|
103
|
+
const links: string[] = [];
|
|
104
|
+
const hide = (from: number, to: number, fill: string): void => {
|
|
105
|
+
for (let at = from; at < to; at += 1) out[at] = fill;
|
|
106
|
+
};
|
|
107
|
+
let inComment = startsInComment;
|
|
108
|
+
let at = 0;
|
|
109
|
+
while (at < raw.length) {
|
|
110
|
+
if (inComment) {
|
|
111
|
+
const close = raw.indexOf("-->", at);
|
|
112
|
+
const end = close < 0 ? raw.length : close + 3;
|
|
113
|
+
hide(at, end, BLANK);
|
|
114
|
+
inComment = close < 0;
|
|
115
|
+
at = end;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const rest = raw.slice(at);
|
|
119
|
+
if (rest.startsWith("<!--")) {
|
|
120
|
+
hide(at, at + 4, BLANK);
|
|
121
|
+
inComment = true;
|
|
122
|
+
at += 4;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (rest.startsWith("`")) {
|
|
126
|
+
const run = runOf(raw, at, "`");
|
|
127
|
+
const close = closingRun(raw, at + run, run);
|
|
128
|
+
if (close >= 0) {
|
|
129
|
+
code.push(raw.slice(at + run, close).trim());
|
|
130
|
+
hide(at, close + run, HIDDEN);
|
|
131
|
+
at = close + run;
|
|
132
|
+
} else at += run;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (rest.startsWith("](")) {
|
|
136
|
+
const end = destinationEnd(raw, at + 1);
|
|
137
|
+
if (end >= 0) {
|
|
138
|
+
links.push(targetOf(raw.slice(at + 2, end)));
|
|
139
|
+
hide(at + 1, end + 1, HIDDEN);
|
|
140
|
+
at = end + 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const hidden = hiddenAt(raw, at, rest);
|
|
145
|
+
if (hidden !== undefined) {
|
|
146
|
+
hide(at, at + hidden.length, hidden.fill);
|
|
147
|
+
at += hidden.length;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
at += 1;
|
|
151
|
+
}
|
|
152
|
+
return { prose: out.join(""), code, links, inComment };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const FENCE = /^\s*(`{3,}|~{3,})(.*)$/;
|
|
156
|
+
const DEFINITION = /^\s{0,3}\[[^\]]+\]:\s*(\S+)/;
|
|
157
|
+
const HEADING = /^\s{0,3}#{1,6}(?:\s|$)/;
|
|
158
|
+
const BREAK = /^\s{0,3}(?:[-*_](?:\s*[-*_]){2,}|=+|-+)\s*$/;
|
|
159
|
+
const UNDERLINE = /^\s{0,3}(?:=+|-+)\s*$/;
|
|
160
|
+
const HTML_BLOCK = /^\s{0,3}<[A-Za-z/!?]/;
|
|
161
|
+
|
|
162
|
+
function fenceOpener(raw: string): string | undefined {
|
|
163
|
+
const match = FENCE.exec(raw);
|
|
164
|
+
if (match === null) return undefined;
|
|
165
|
+
const [, fence = "", info = ""] = match;
|
|
166
|
+
return fence.startsWith("`") && info.includes("`") ? undefined : fence;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function fenceCloses(raw: string, opener: string): boolean {
|
|
170
|
+
const closer = FENCE.exec(raw);
|
|
171
|
+
return closer !== null && closer[1]?.[0] === opener[0] && (closer[1]?.length ?? 0) >= opener.length && closer[2]?.trim() === "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function kindOf(raw: string, prose: string): LineKind {
|
|
175
|
+
if (raw.trim() === "") return "blank";
|
|
176
|
+
if (HEADING.test(raw)) return "heading";
|
|
177
|
+
if (BREAK.test(raw)) return "break";
|
|
178
|
+
if (raw.trimStart().startsWith("|")) return "table";
|
|
179
|
+
if (HTML_BLOCK.test(raw) && !AUTOLINK.test(raw.trimStart())) return "html";
|
|
180
|
+
return prose.trim() === "" ? "blank" : "prose";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function unread(line: number, raw: string, kind: LineKind): MarkdownLine {
|
|
184
|
+
return { line, kind, raw, prose: BLANK.repeat(raw.length), code: [], links: [] };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function settext(lines: MarkdownLine[]): MarkdownLine[] {
|
|
188
|
+
return lines.map((line, index) => {
|
|
189
|
+
const next = lines[index + 1];
|
|
190
|
+
return line.kind === "prose" && next?.kind === "break" && UNDERLINE.test(next.raw) ? { ...line, kind: "heading" } : line;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function scanMarkdown(text: string): readonly MarkdownLine[] {
|
|
195
|
+
const raws = text.split("\n").map((raw) => raw.replace(/\r$/, ""));
|
|
196
|
+
const lines: MarkdownLine[] = [];
|
|
197
|
+
let fence: string | undefined;
|
|
198
|
+
let inComment = false;
|
|
199
|
+
let inHtmlBlock = false;
|
|
200
|
+
let frontMatter = raws[0] === "---";
|
|
201
|
+
for (const [index, raw] of raws.entries()) {
|
|
202
|
+
const line = index + 1;
|
|
203
|
+
if (frontMatter) {
|
|
204
|
+
frontMatter = index === 0 || (raw !== "---" && raw !== "...");
|
|
205
|
+
lines.push(unread(line, raw, "front-matter"));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (fence !== undefined) {
|
|
209
|
+
if (fenceCloses(raw, fence)) fence = undefined;
|
|
210
|
+
lines.push(unread(line, raw, "code"));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const opener = inComment ? undefined : fenceOpener(raw);
|
|
214
|
+
if (opener !== undefined) {
|
|
215
|
+
fence = opener;
|
|
216
|
+
lines.push(unread(line, raw, "code"));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const definition = inComment ? null : DEFINITION.exec(raw);
|
|
220
|
+
if (definition !== null) {
|
|
221
|
+
lines.push({ ...unread(line, raw, "definition"), links: [targetOf(definition[1] ?? "")] });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const masked = mask(raw, inComment);
|
|
225
|
+
inComment = masked.inComment;
|
|
226
|
+
const kind = kindOf(raw, masked.prose);
|
|
227
|
+
// An HTML block runs to the next blank line, whatever its later lines open with.
|
|
228
|
+
inHtmlBlock = kind === "html" || (inHtmlBlock && kind !== "blank");
|
|
229
|
+
lines.push({ line, kind: inHtmlBlock ? "html" : kind, raw, prose: masked.prose, code: masked.code, links: masked.links });
|
|
230
|
+
}
|
|
231
|
+
return settext(lines);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const LEADING_MARKERS = /^(?:\s*>)*\s*(?:#{1,6}\s+|(?:[-*+]|\d{1,9}[.)])\s+(?:\[[ xX]\]\s+)?)?/;
|
|
235
|
+
|
|
236
|
+
function bodyOf({ prose }: MarkdownLine): string {
|
|
237
|
+
const markers = LEADING_MARKERS.exec(prose)?.[0] ?? "";
|
|
238
|
+
return BLANK.repeat(markers.length) + prose.slice(markers.length);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const SEPARATOR_INSTEAD = "End the sentence, or use a comma";
|
|
242
|
+
|
|
243
|
+
const PROMISES: readonly (readonly [shows: string, pattern: string])[] = [
|
|
244
|
+
["until #11", String.raw`until \[?(?:[\w.-]+\/[\w.-]+)?#\d+`],
|
|
245
|
+
["is planned", "(?:is|are) planned"],
|
|
246
|
+
["will soon", "will soon"],
|
|
247
|
+
["coming soon", "coming soon"],
|
|
248
|
+
["in a future release", "in a future (?:release|version)"],
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const code = (text: string): string => `\`${text}\``;
|
|
252
|
+
const EVERY_READER: readonly Reader[] = ["people", "agents"];
|
|
253
|
+
const PEOPLE: readonly Reader[] = ["people"];
|
|
254
|
+
|
|
255
|
+
const RULES: readonly MatchedRule[] = [
|
|
256
|
+
{ readers: EVERY_READER, refuses: "an em dash", example: code("—"), instead: SEPARATOR_INSTEAD, find: /—/g },
|
|
257
|
+
{ readers: EVERY_READER, refuses: "an en dash", example: code("–"), instead: SEPARATOR_INSTEAD, find: /–/g },
|
|
258
|
+
{
|
|
259
|
+
readers: EVERY_READER,
|
|
260
|
+
refuses: "a parenthesis other than the plural `(s)`",
|
|
261
|
+
example: code("("),
|
|
262
|
+
instead: "Make the aside its own sentence, or set it off with commas",
|
|
263
|
+
find: /\((?!s\))/g,
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
readers: EVERY_READER,
|
|
267
|
+
refuses: "a hyphen used as a dash",
|
|
268
|
+
example: `${code("a - b")} or ${code("a -- b")}`,
|
|
269
|
+
instead: SEPARATOR_INSTEAD,
|
|
270
|
+
find: /(?<=[^\s|]) -{1,3} (?=[^\s|])/g,
|
|
271
|
+
},
|
|
272
|
+
{ readers: EVERY_READER, refuses: "a semicolon", example: code(";"), instead: "Use two sentences", find: /;/g },
|
|
273
|
+
{
|
|
274
|
+
readers: PEOPLE,
|
|
275
|
+
refuses: "a promise about the future",
|
|
276
|
+
example: PROMISES.map(([shows]) => code(shows)).join(", "),
|
|
277
|
+
instead: "Say what is true now",
|
|
278
|
+
find: new RegExp(PROMISES.map(([, pattern]) => String.raw`\b${pattern}\b`).join("|"), "gi"),
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
readers: PEOPLE,
|
|
282
|
+
refuses: "a sentence that opens by talking about the page",
|
|
283
|
+
example: code("This page explains"),
|
|
284
|
+
instead: "Talk directly about the subject",
|
|
285
|
+
find: /(?<=^\s*|[.!?:]\s+)(?:this|the following|in this) (?:page|topic|section|document|doc|guide|tutorial|article|readme|file|chapter)\b(?: \w+)?/gi,
|
|
286
|
+
},
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
const SECOND_SENTENCE: ProseRule = {
|
|
290
|
+
readers: PEOPLE,
|
|
291
|
+
refuses: "a second sentence on one line",
|
|
292
|
+
example: code("It builds. It ships."),
|
|
293
|
+
instead: "Start it on its own line",
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const RUN_ON: ProseRule = {
|
|
297
|
+
readers: PEOPLE,
|
|
298
|
+
refuses: "a sentence that runs across lines",
|
|
299
|
+
example: `${code("It builds")} with ${code("and ships.")} on the next line`,
|
|
300
|
+
instead: "Join the sentence onto one line",
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
export const PROSE_RULES: readonly ProseRule[] = [
|
|
304
|
+
...RULES.map(({ readers, refuses, example, instead }) => ({ readers, refuses, example, instead })),
|
|
305
|
+
SECOND_SENTENCE,
|
|
306
|
+
RUN_ON,
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
const JUDGED_KINDS: ReadonlySet<LineKind> = new Set(["prose", "heading", "table", "html"]);
|
|
310
|
+
|
|
311
|
+
function ruleFindings(line: MarkdownLine, reader: Reader): ProseFinding[] {
|
|
312
|
+
const body = bodyOf(line);
|
|
313
|
+
return RULES.filter(({ readers }) => readers.includes(reader)).flatMap(({ refuses, instead, find }) =>
|
|
314
|
+
[...body.matchAll(find)].map(([match]) => ({ line: line.line, message: `carries \`${match.trim()}\`, ${refuses}. ${instead}` })),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const SENTENCE_BREAK = /(?<=[^\s.!?])[.!?]["'”’)\]*_]*\s+(?=[A-Z"“*_[\0])/g;
|
|
319
|
+
const ABBREVIATION = /\b(?:e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|St|No|Fig)$/i;
|
|
320
|
+
const SENTENCE_END = /[.!?:]["'”’)\]*_]*\s*$/;
|
|
321
|
+
const LIST_ITEM = /^(?:\s*>)*\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
|
|
322
|
+
const QUOTE_DEPTH = /^(?:\s*>)*/;
|
|
323
|
+
|
|
324
|
+
// A bold label that opens a line, as in **Status.**, heads the sentence after it rather than being one.
|
|
325
|
+
const RUN_IN_LABEL = /^\s*(\*\*|__)(?:(?!\1).)+?[.!?:]\1\s/;
|
|
326
|
+
|
|
327
|
+
function secondSentence(line: MarkdownLine): ProseFinding | undefined {
|
|
328
|
+
const body = bodyOf(line);
|
|
329
|
+
const label = RUN_IN_LABEL.exec(body)?.[0].length ?? 0;
|
|
330
|
+
for (const match of body.matchAll(SENTENCE_BREAK)) {
|
|
331
|
+
if (match.index < label || ABBREVIATION.test(body.slice(0, match.index))) continue;
|
|
332
|
+
const opening = line.raw.slice(match.index + match[0].length).split(/\s+/, 3).join(" ").replace(/[.!?,:]+$/, "");
|
|
333
|
+
return { line: line.line, message: `carries ${SECOND_SENTENCE.refuses}, which opens with \`${opening}\`. ${SECOND_SENTENCE.instead}` };
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const OPENS_LOWERCASE = /^\s*[a-z]/;
|
|
339
|
+
|
|
340
|
+
// A sentence may end inside the code or link that closes its line, so only a lowercase next line proves it runs on.
|
|
341
|
+
function endsMidSentence(line: MarkdownLine, next: MarkdownLine): boolean {
|
|
342
|
+
const body = bodyOf(line).trimEnd();
|
|
343
|
+
if (SENTENCE_END.test(body) || line.raw.endsWith(" ") || line.raw.endsWith("\\")) return false;
|
|
344
|
+
return !body.endsWith(HIDDEN) || OPENS_LOWERCASE.test(bodyOf(next));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function continues(earlier: MarkdownLine | undefined, later: MarkdownLine | undefined): boolean {
|
|
348
|
+
if (earlier?.kind !== "prose" || later?.kind !== "prose" || LIST_ITEM.test(later.raw)) return false;
|
|
349
|
+
return QUOTE_DEPTH.exec(earlier.raw)?.[0].split(">").length === QUOTE_DEPTH.exec(later.raw)?.[0].split(">").length;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function spansLines(lines: readonly MarkdownLine[], index: number): ProseFinding | undefined {
|
|
353
|
+
const line = lines[index];
|
|
354
|
+
if (line === undefined) return undefined;
|
|
355
|
+
const next = lines[index + 1];
|
|
356
|
+
const previous = lines[index - 1];
|
|
357
|
+
const forward = next !== undefined && continues(line, next) && endsMidSentence(line, next);
|
|
358
|
+
const back = previous !== undefined && continues(previous, line) && endsMidSentence(previous, line);
|
|
359
|
+
if (!forward && !back) return undefined;
|
|
360
|
+
return { line: line.line, message: `carries ${RUN_ON.refuses}. ${RUN_ON.instead}` };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function proseFindings(text: string, reader: Reader, within?: ReadonlySet<number>): readonly ProseFinding[] {
|
|
364
|
+
const lines = scanMarkdown(text);
|
|
365
|
+
return lines.flatMap((line, index) => {
|
|
366
|
+
if ((within !== undefined && !within.has(line.line)) || !JUDGED_KINDS.has(line.kind)) return [];
|
|
367
|
+
const prose = line.kind === "prose";
|
|
368
|
+
const sentences = [
|
|
369
|
+
prose && SECOND_SENTENCE.readers.includes(reader) ? secondSentence(line) : undefined,
|
|
370
|
+
prose && RUN_ON.readers.includes(reader) ? spansLines(lines, index) : undefined,
|
|
371
|
+
];
|
|
372
|
+
return [...ruleFindings(line, reader), ...sentences.filter((finding) => finding !== undefined)];
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function proseRefused(repositoryPath: string, text: string, within?: ReadonlySet<number>): string[] {
|
|
377
|
+
const reader = readerOf(repositoryPath);
|
|
378
|
+
if (reader === undefined) return [];
|
|
379
|
+
return proseFindings(text, reader, within).map(({ line, message }) => `${repositoryPath}:${line} ${message}`);
|
|
380
|
+
}
|
package/scripts/quality-file.ts
CHANGED
|
@@ -20,6 +20,16 @@ const PathGlob = Schema.String.check(
|
|
|
20
20
|
"A glob from the repository root that oxlint, the Effect language service and git read alike: a directory first, * within a segment, ** as a whole one, a file name with an extension last, and no braces, ?, [ or leading ./",
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
// Only checks-docs reads a doc glob, matching it from the root, so a file at the root names itself.
|
|
24
|
+
const DocGlob = Schema.String.check(
|
|
25
|
+
Schema.isPattern(new RegExp(`^(?:${SEGMENT}/)*${FILE}$`), {
|
|
26
|
+
expected: "a glob from the repository root such as README.md or docs/**/*.md: * within a segment, ** as a whole one, a file name with an extension last",
|
|
27
|
+
}),
|
|
28
|
+
).annotate({
|
|
29
|
+
identifier: "DocGlob",
|
|
30
|
+
description: "A glob from the repository root that checks-docs reads: * within a segment, ** as a whole one, a file name with an extension last",
|
|
31
|
+
});
|
|
32
|
+
|
|
23
33
|
const LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
|
|
24
34
|
|
|
25
35
|
const DirectoryPath = Schema.String.check(
|
|
@@ -166,6 +176,11 @@ const Docs = Schema.Struct({
|
|
|
166
176
|
description: "The Diátaxis mode of each page, whose template checks-docs holds the page to; a page under docs/ needs one",
|
|
167
177
|
}),
|
|
168
178
|
),
|
|
179
|
+
forConsumers: Schema.optionalKey(
|
|
180
|
+
Schema.Array(DocGlob).annotate({
|
|
181
|
+
description: "The living docs that speak to a repository installing this one, whose bun run commands checks-docs does not hold to this package.json",
|
|
182
|
+
}),
|
|
183
|
+
),
|
|
169
184
|
});
|
|
170
185
|
export type Docs = typeof Docs.Type;
|
|
171
186
|
|