@avi2dg/checks 0.14.0 → 0.16.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.
@@ -0,0 +1,72 @@
1
+ # checks-repetition
2
+
3
+ `checks-repetition` is the gate that fails a change adding repeated lines to production code, and a reader looks it up when a production file repeats more lines than it did where the range starts.
4
+
5
+ ## What it checks
6
+
7
+ It runs jscpd over the files `quality.json` declares as production:
8
+
9
+ ```json
10
+ "sources": { "production": ["src/**/*.ts"] }
11
+ ```
12
+
13
+ jscpd finds each block of at least 50 tokens and 5 lines that appears twice, within one file or across two.
14
+ A repeated line is a line of a file inside such a block.
15
+ The gate counts the repeated lines of each production file at the head and where the range starts, and fails when a file counts more at the head.
16
+ A renamed file is compared with its count under the old path.
17
+ A block counts only when both copies are in production files, so a test that copies production code changes no count.
18
+ Each file whose count rose is listed with the blocks it repeats and where the other copy is.
19
+
20
+ Every other file that repeats lines is listed as advisory and never fails the gate.
21
+ The advisory list names the production files whose count did not rise, and every other tracked `.ts` or `.tsx` file that repeats a block within itself or from another such file.
22
+ `.d.ts` files are not measured.
23
+ Nothing is committed as a baseline, because each run measures where the range starts as well as the head.
24
+
25
+ ## What it reads
26
+
27
+ It reads each file from the commits at the two ends of the range rather than the working tree, so an uncommitted edit neither fails nor passes a range, and a pull request's merge checkout measures what the pull request holds.
28
+ It reads `sources.production` from `quality.json`.
29
+ jscpd must be on `PATH`, as it is under a package script.
30
+
31
+ ## Arguments
32
+
33
+ ```sh
34
+ checks-repetition <base-ref> <head-ref>
35
+ checks-repetition <ref>
36
+ ```
37
+
38
+ With two arguments the range starts where the head branched from the base, at their merge-base.
39
+ With one it is that commit against its parent, or against the empty tree for a repository's first commit.
40
+
41
+ ## Exit codes
42
+
43
+ | Code | When |
44
+ | --- | --- |
45
+ | 0 | no production file repeats more lines than where the range starts |
46
+ | 1 | a production file repeats more lines than where the range starts |
47
+ | 2 | `quality.json` does not decode, a ref does not resolve, or jscpd cannot run |
48
+
49
+ ## Sample output
50
+
51
+ ```
52
+ repetition: 1 production file(s) repeat more lines than where the range starts, at 50 tokens and 5 lines:
53
+ src/billing/refund.ts: 11 repeated line(s), up from 0
54
+ src/billing/refund.ts:1-11 repeats src/billing/legacy.ts:1-11
55
+ repetition: advisory, 4 file(s) repeat lines the hold does not fail:
56
+ src/billing/ledger.ts: 11 repeated line(s)
57
+ src/billing/legacy.ts: 21 repeated line(s)
58
+ tests/one.test.ts: 11 repeated line(s)
59
+ tests/two.test.ts: 11 repeated line(s)
60
+ ```
61
+
62
+ ## Opting out
63
+
64
+ A repository that declares no `sources.production` passes.
65
+ `checks-quality` refuses a `sources.production` glob that matches no file.
66
+ A repository that tracks no `.ts` or `.tsx` file leaves it out of `gates.lint`, as [Gate selection](checks-lint.md#gate-selection) says.
67
+
68
+ ## Related topics
69
+
70
+ - [The quality file](../configs/quality-file.md)
71
+ - [checks-size-budget](checks-size-budget.md)
72
+ - [checks-lint](checks-lint.md)
@@ -1,26 +1,66 @@
1
1
  # checks-size-budget
2
2
 
3
- `checks-size-budget` is the gate that holds production files to the line budget `quality.json` declares, and a reader looks it up when a file or a function runs over it.
3
+ `checks-size-budget` is the gate that holds production and test files to the size budget `quality.json` declares, and a reader looks it up when a file or a function runs over it.
4
4
 
5
5
  ## What it checks
6
6
 
7
- It holds production files to the line budget `quality.json` declares, and lists every other file over it without failing:
7
+ It holds production and test files to the size budget, and lists every other file over it without failing:
8
8
 
9
9
  ```json
10
10
  "sources": { "production": ["src/**/*.ts"] },
11
- "size": { "fileLines": 400, "functionLines": 100, "applies": "changed" }
11
+ "size": { "applies": "ratchet" }
12
12
  ```
13
13
 
14
- It runs oxlint with a configuration of two rules and nothing else, `max-lines` at `fileLines` and `max-lines-per-function` at `functionLines`, both counting blank and comment lines.
15
- With `applies` set to `changed` it holds the files under `sources.production` that the range adds or changes, a rename that edits the file included.
16
- With `all` it holds every file under `sources.production`.
14
+ A production file is one under `sources.production`, and a test file is a tracked `.ts` or `.tsx` file under `tests/`.
15
+ A test file keeps to the tests budget, and every other file keeps to the production budget.
16
+ It runs oxlint with a configuration of five rules and nothing else, one for each limit.
17
+ The kit sets each limit:
18
+
19
+ <!-- generated size-limits: bun run build writes it from SIZE_RULES and SIZE_DEFAULTS in scripts/size-rules.ts and scripts/doc-blocks.ts -->
20
+
21
+ | Key | Limits | oxlint rule | Production | Tests |
22
+ | --- | --- | --- | --- | --- |
23
+ | `fileLines` | The most lines a file may hold, blank and comment lines counted | `max-lines` | 400 | 600 |
24
+ | `functionLines` | The most lines a function may span, blank and comment lines counted | `max-lines-per-function` | 100 | none |
25
+ | `statements` | The most statements a function may hold | `max-statements` | 30 | 50 |
26
+ | `complexity` | The highest cyclomatic complexity a function may reach, a switch counted once | `complexity` | 15 | 15 |
27
+ | `depth` | The deepest a block may nest inside a function | `max-depth` | 4 | 4 |
28
+
29
+ <!-- end generated size-limits -->
30
+
31
+ `size.production` and `size.tests` state only a limit that differs from the kit's:
32
+
33
+ ```json
34
+ "size": {
35
+ "applies": "ratchet",
36
+ "production": { "complexity": 12 },
37
+ "tests": { "fileLines": 800 }
38
+ }
39
+ ```
40
+
41
+ `applies` decides which files the gate holds and what fails them:
42
+
43
+ - `ratchet` holds each production and test file the range adds or changes, a rename that edits the file included.
44
+ For each file and each rule, it sums how far every site runs over its limit, and fails when that sum is higher at the head than at the base of the range.
45
+ A file the range adds starts from zero, so it has to keep within the budget.
46
+ A file already over the budget passes while its overrun does not grow, and an edit that shrinks the overrun passes.
47
+ An edited rename is compared with the file it was renamed from.
48
+ Nothing is committed as a baseline, because the base of the range holds it.
49
+ - `all` holds every production and test file, and fails on any overrun in them.
50
+
51
+ `applies` is `ratchet` when `size` leaves it out.
17
52
  A file the range deletes or only renames is not held.
18
- Every other tracked `.ts` or `.tsx` file over the budget, tests and unchanged production files alike, is listed as advisory and never fails the gate.
53
+ Every other tracked `.ts` or `.tsx` file over the budget, tooling and unchanged files alike, is listed as advisory and never fails the gate.
54
+ Under `ratchet` the advisory list also names each site in a held file whose overrun did not grow.
19
55
  `.d.ts` files are not measured.
20
56
 
57
+ `quality.json` refuses `applies: "changed"`, which `ratchet` replaces.
58
+ It refuses a limit set directly under `size`, since each limit goes in `size.production` or `size.tests`.
59
+
21
60
  ## What it reads
22
61
 
23
62
  It reads each file from the head commit rather than the working tree, so an uncommitted edit neither fails nor passes a range, and a pull request's merge checkout measures what the pull request holds.
63
+ Under `ratchet` it also reads each held file from the base of the range, under the path the file has at the head, so a file keeps the same budget at both ends.
24
64
  It reads `sources.production` and `size` from `quality.json`.
25
65
  oxlint must be on `PATH`, as it is under a package script.
26
66
 
@@ -38,25 +78,28 @@ With one it is that commit against its parent, or against the empty tree for a r
38
78
 
39
79
  | Code | When |
40
80
  | --- | --- |
41
- | 0 | every file it holds keeps within the budget |
42
- | 1 | a file it holds runs over the budget |
81
+ | 0 | every file it holds keeps within the budget, or under `ratchet` no file's overrun grows |
82
+ | 1 | a file it holds runs over the budget, or under `ratchet` a file's overrun grows |
43
83
  | 2 | `quality.json` does not decode, a ref does not resolve, or oxlint cannot run |
44
84
 
45
85
  ## Sample output
46
86
 
47
87
  ```
48
- size-budget: 1 overrun(s) of 400 lines per file and 100 per function in the production files the range adds or changes:
49
- src/billing/ledger.ts:12: The function `settle` has too many lines (131). Maximum allowed is 100.
88
+ size-budget: 2 overrun(s) grew past the base in the production and test files the range adds or changes:
89
+ src/billing/invoice.ts: max-lines over by 31 in total, up from 19
90
+ src/billing/invoice.ts: File has too many lines (431). Maximum allowed is 400.
91
+ src/billing/ledger.ts: complexity over by 3 in total, up from 0
92
+ src/billing/ledger.ts:12: function `settle` has a complexity of 18. Maximum allowed is 15.
50
93
  size-budget: advisory, 1 overrun(s) where the budget does not hold yet:
51
- tests/e2e/billing.test.ts: File has too many lines (512).
94
+ tests/e2e/billing.test.ts: File has too many lines (612). Maximum allowed is 600.
52
95
  ```
53
96
 
54
97
  ## Opting out
55
98
 
56
99
  A repository that declares no `size` passes.
57
- `quality.json` refuses a `size` without `sources.production`, which would hold nothing, and with `size` declared `checks-quality` refuses a `sources.production` glob that matches no file.
100
+ `quality.json` refuses a `size` without `sources.production`, and `checks-quality` refuses a `sources.production` glob that matches no file.
58
101
  A repository that tracks no `.ts` or `.tsx` file leaves it out of `gates.lint`, as [Gate selection](checks-lint.md#gate-selection) says.
59
- Moving `applies` from `changed` to `all` tightens the budget to every production file, once the advisory list names none.
102
+ Moving `applies` from `ratchet` to `all` tightens the budget to every production and test file, once the advisory list names none.
60
103
 
61
104
  ## Related topics
62
105
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@avi2dg/checks",
3
- "version": "0.14.0",
3
+ "version": "0.16.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",
@@ -40,10 +41,14 @@
40
41
  "scripts/quality-file.ts",
41
42
  "scripts/quality.ts",
42
43
  "scripts/size-budget.ts",
44
+ "scripts/size-rules.ts",
45
+ "scripts/repetition.ts",
43
46
  "scripts/feature-owners.ts",
44
47
  "scripts/docs.ts",
45
48
  "scripts/doc-outline.ts",
46
49
  "scripts/doc-rules.ts",
50
+ "scripts/doc-references.ts",
51
+ "scripts/doc-snapshot.ts",
47
52
  "scripts/doc-templates.ts",
48
53
  "templates/",
49
54
  "presets/effect.oxlint.json",
@@ -68,6 +73,7 @@
68
73
  "./scripts/mutation-compare.ts": "./scripts/mutation-compare.ts",
69
74
  "./scripts/ci-wiring.ts": "./scripts/ci-wiring.ts",
70
75
  "./scripts/comment-matchers.ts": "./scripts/comment-matchers.ts",
76
+ "./scripts/prose-matchers.ts": "./scripts/prose-matchers.ts",
71
77
  "./scripts/comments.ts": "./scripts/comments.ts",
72
78
  "./scripts/comment-gate.ts": "./scripts/comment-gate.ts",
73
79
  "./scripts/suppressions-ratchet.ts": "./scripts/suppressions-ratchet.ts",
@@ -75,6 +81,7 @@
75
81
  "./scripts/quality-file.ts": "./scripts/quality-file.ts",
76
82
  "./scripts/quality.ts": "./scripts/quality.ts",
77
83
  "./scripts/size-budget.ts": "./scripts/size-budget.ts",
84
+ "./scripts/repetition.ts": "./scripts/repetition.ts",
78
85
  "./scripts/feature-owners.ts": "./scripts/feature-owners.ts",
79
86
  "./scripts/docs.ts": "./scripts/docs.ts",
80
87
  "./templates/readme.md": "./templates/readme.md",
@@ -109,6 +116,7 @@
109
116
  "checks-backtest": "scripts/backtest.ts",
110
117
  "checks-quality": "scripts/quality.ts",
111
118
  "checks-size-budget": "scripts/size-budget.ts",
119
+ "checks-repetition": "scripts/repetition.ts",
112
120
  "checks-feature-owners": "scripts/feature-owners.ts",
113
121
  "checks-docs": "scripts/docs.ts"
114
122
  },
@@ -123,6 +131,7 @@
123
131
  "dependency-cruiser": "18.4.0",
124
132
  "@effect/tsgo": "0.45.0",
125
133
  "effect": "4.0.0-rc.115",
134
+ "jscpd": "5.3.2",
126
135
  "oxlint": "1.83.0",
127
136
  "oxlint-tsgolint": "7.0.2002"
128
137
  },
@@ -134,6 +143,7 @@
134
143
  "ajv": "8.20.0",
135
144
  "dependency-cruiser": "18.4.0",
136
145
  "effect": "4.0.0-rc.115",
146
+ "jscpd": "5.3.2",
137
147
  "oxlint": "1.83.0",
138
148
  "oxlint-tsgolint": "7.0.2002",
139
149
  "typescript": "7.0.2"
@@ -47,6 +47,7 @@
47
47
  "checks-docs",
48
48
  "checks-quality",
49
49
  "checks-size-budget",
50
+ "checks-repetition",
50
51
  "checks-feature-owners"
51
52
  ]
52
53
  },
@@ -148,32 +149,76 @@
148
149
  "size": {
149
150
  "type": "object",
150
151
  "properties": {
151
- "fileLines": {
152
- "type": "integer",
153
- "exclusiveMinimum": 0,
154
- "description": "The most lines a file may hold, blank and comment lines counted"
155
- },
156
- "functionLines": {
157
- "type": "integer",
158
- "exclusiveMinimum": 0,
159
- "description": "The most lines a function may span, blank and comment lines counted"
160
- },
161
152
  "applies": {
162
153
  "type": "string",
163
154
  "enum": [
164
- "changed",
155
+ "ratchet",
165
156
  "all"
166
157
  ],
167
- "description": "Which production files the budget holds: changed, the ones a range adds or changes; all, every one. The rest are reported as advisory"
158
+ "description": "Which production and test files the budget holds: ratchet, the ones a range adds or changes, to no more overrun per rule than at the range's base; all, every one. ratchet when absent. The rest are listed as advisory"
159
+ },
160
+ "production": {
161
+ "type": "object",
162
+ "properties": {
163
+ "fileLines": {
164
+ "type": "integer",
165
+ "exclusiveMinimum": 0,
166
+ "description": "The most lines a file may hold, blank and comment lines counted; 400 when absent"
167
+ },
168
+ "functionLines": {
169
+ "type": "integer",
170
+ "exclusiveMinimum": 0,
171
+ "description": "The most lines a function may span, blank and comment lines counted; 100 when absent"
172
+ },
173
+ "statements": {
174
+ "type": "integer",
175
+ "exclusiveMinimum": 0,
176
+ "description": "The most statements a function may hold; 30 when absent"
177
+ },
178
+ "complexity": {
179
+ "type": "integer",
180
+ "exclusiveMinimum": 0,
181
+ "description": "The highest cyclomatic complexity a function may reach, a switch counted once; 15 when absent"
182
+ },
183
+ "depth": {
184
+ "type": "integer",
185
+ "exclusiveMinimum": 0,
186
+ "description": "The deepest a block may nest inside a function; 4 when absent"
187
+ }
188
+ },
189
+ "additionalProperties": false,
190
+ "description": "The budget of the files under sources.production, and of the files listed as advisory outside tests/"
191
+ },
192
+ "tests": {
193
+ "type": "object",
194
+ "properties": {
195
+ "fileLines": {
196
+ "type": "integer",
197
+ "exclusiveMinimum": 0,
198
+ "description": "The most lines a file may hold, blank and comment lines counted; 600 when absent"
199
+ },
200
+ "statements": {
201
+ "type": "integer",
202
+ "exclusiveMinimum": 0,
203
+ "description": "The most statements a function may hold; 50 when absent"
204
+ },
205
+ "complexity": {
206
+ "type": "integer",
207
+ "exclusiveMinimum": 0,
208
+ "description": "The highest cyclomatic complexity a function may reach, a switch counted once; 15 when absent"
209
+ },
210
+ "depth": {
211
+ "type": "integer",
212
+ "exclusiveMinimum": 0,
213
+ "description": "The deepest a block may nest inside a function; 4 when absent"
214
+ }
215
+ },
216
+ "additionalProperties": false,
217
+ "description": "The budget of the files under tests/, which sets no limit on a function's lines"
168
218
  }
169
219
  },
170
- "required": [
171
- "fileLines",
172
- "functionLines",
173
- "applies"
174
- ],
175
220
  "additionalProperties": false,
176
- "description": "The line budget oxlint holds production files to, read by checks-size-budget"
221
+ "description": "The size budget oxlint holds production and test files to, read by checks-size-budget"
177
222
  },
178
223
  "features": {
179
224
  "type": "array",
@@ -285,6 +330,13 @@
285
330
  },
286
331
  "additionalProperties": false,
287
332
  "description": "The Diátaxis mode of each page, whose template checks-docs holds the page to; a page under docs/ needs one"
333
+ },
334
+ "forConsumers": {
335
+ "type": "array",
336
+ "items": {
337
+ "$ref": "#/$defs/DocGlob"
338
+ },
339
+ "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
340
  }
289
341
  },
290
342
  "additionalProperties": false,
@@ -382,6 +434,11 @@
382
434
  "RuleName": {
383
435
  "type": "string",
384
436
  "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"
437
+ },
438
+ "DocGlob": {
439
+ "type": "string",
440
+ "pattern": "^(?:(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+)\\/)*(?:[\\w.@+-]|\\*(?!\\*))*\\.\\w+$",
441
+ "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
442
  }
386
443
  }
387
444
  }
@@ -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 diff = yield* git(["-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--no-prefix", base, head], root);
66
- const added = parseAddedLines(diff);
23
+ const added = yield* changedLines(base, head, [], root);
67
24
  const violations: string[] = [];
68
- let addedLines = 0;
25
+ let lineCount = 0;
69
26
  for (const [path, lines] of added) {
70
- addedLines += lines.size;
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
+ }
@@ -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 const ADR_DIRECTORY = "docs/adr/";
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"],