@avi2dg/checks 0.17.0 → 0.19.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.
Files changed (40) hide show
  1. package/CHANGELOG.md +22 -0
  2. package/README.md +3 -1
  3. package/bunfig.toml +1 -1
  4. package/dependency-cruiser.config.js +2 -0
  5. package/dist/feature-rules.js +22 -5
  6. package/docs/configs/commit-messages.md +2 -18
  7. package/docs/configs/quality-file.md +3 -2
  8. package/docs/gates/checks-lint.md +2 -1
  9. package/docs/gates/checks-mutation-compare.md +20 -11
  10. package/docs/gates/checks-quality.md +23 -8
  11. package/docs/gates/checks-quarantine-clock.md +54 -0
  12. package/docs/gates/checks-test-layout.md +9 -3
  13. package/docs/gates/checks-vendor.md +97 -0
  14. package/package.json +11 -2
  15. package/quality.schema.json +56 -4
  16. package/scripts/backtest.ts +57 -41
  17. package/scripts/ci-wiring.ts +2 -63
  18. package/scripts/comment-gate.ts +3 -4
  19. package/scripts/comment-matchers.ts +54 -57
  20. package/scripts/commit-identity.ts +3 -4
  21. package/scripts/doc-outline.ts +34 -16
  22. package/scripts/doc-rules.ts +34 -21
  23. package/scripts/docs.ts +3 -4
  24. package/scripts/feature-owners.ts +5 -8
  25. package/scripts/gates.ts +1 -0
  26. package/scripts/git.ts +41 -26
  27. package/scripts/lint.ts +75 -35
  28. package/scripts/mutation-compare.ts +179 -70
  29. package/scripts/prose-matchers.ts +93 -83
  30. package/scripts/quality-file.ts +30 -3
  31. package/scripts/quality.ts +157 -10
  32. package/scripts/quarantine-clock.ts +153 -0
  33. package/scripts/range-gate.ts +9 -0
  34. package/scripts/repetition.ts +40 -19
  35. package/scripts/shell-command.ts +74 -0
  36. package/scripts/size-budget.ts +51 -28
  37. package/scripts/suppressions-ratchet.ts +22 -28
  38. package/scripts/test-layout.ts +65 -52
  39. package/scripts/test-report.ts +18 -13
  40. package/scripts/vendor.ts +337 -0
@@ -48,7 +48,8 @@
48
48
  "checks-quality",
49
49
  "checks-size-budget",
50
50
  "checks-repetition",
51
- "checks-feature-owners"
51
+ "checks-feature-owners",
52
+ "checks-quarantine-clock"
52
53
  ]
53
54
  },
54
55
  "allOf": [
@@ -76,6 +77,11 @@
76
77
  "contains": {
77
78
  "const": "checks-docs"
78
79
  }
80
+ },
81
+ {
82
+ "contains": {
83
+ "const": "checks-quarantine-clock"
84
+ }
79
85
  }
80
86
  ]
81
87
  }
@@ -114,6 +120,12 @@
114
120
  },
115
121
  "description": "The source the repository ships, as against tests and tooling"
116
122
  },
123
+ "libraries": {
124
+ "type": "array",
125
+ "items": {
126
+ "$ref": "#/$defs/Library"
127
+ }
128
+ },
117
129
  "effect": {
118
130
  "type": "object",
119
131
  "properties": {
@@ -237,12 +249,12 @@
237
249
  "type": "array",
238
250
  "prefixItems": [
239
251
  {
240
- "$ref": "#/$defs/FilePath"
252
+ "$ref": "#/$defs/FilePath_1"
241
253
  }
242
254
  ],
243
255
  "minItems": 1,
244
256
  "items": {
245
- "$ref": "#/$defs/FilePath"
257
+ "$ref": "#/$defs/FilePath_1"
246
258
  },
247
259
  "description": "The files under root that code outside it imports the feature through"
248
260
  },
@@ -417,12 +429,52 @@
417
429
  "pattern": "^(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+)(?:\\/(?!\\.\\.?(?:\\/|$))(?:\\*\\*|(?:[\\w.@+-]|\\*(?!\\*))+))*\\/(?:[\\w.@+-]|\\*(?!\\*))*\\.\\w+$",
418
430
  "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 ./"
419
431
  },
432
+ "FilePath": {
433
+ "type": "string",
434
+ "pattern": "^(?:(?!\\.\\.?(?:\\/|$))[\\w.@+-]+\\/)*[\\w.@+-]*\\.\\w+$",
435
+ "description": "The manifest inside the clone holding the version; package.json when absent"
436
+ },
437
+ "Library": {
438
+ "type": "object",
439
+ "properties": {
440
+ "name": {
441
+ "type": "string",
442
+ "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$",
443
+ "description": "The link checks-vendor manages under repos/"
444
+ },
445
+ "package": {
446
+ "type": "string",
447
+ "minLength": 1,
448
+ "description": "The npm package whose installed version picks the tag"
449
+ },
450
+ "repository": {
451
+ "type": "string",
452
+ "minLength": 1,
453
+ "description": "The git remote checks-vendor clones the tag from"
454
+ },
455
+ "tag": {
456
+ "type": "string",
457
+ "pattern": "\\{version\\}",
458
+ "description": "The tag template, with {version} for the installed version"
459
+ },
460
+ "path": {
461
+ "$ref": "#/$defs/FilePath"
462
+ }
463
+ },
464
+ "required": [
465
+ "name",
466
+ "package",
467
+ "repository",
468
+ "tag"
469
+ ],
470
+ "additionalProperties": false
471
+ },
420
472
  "DirectoryPath": {
421
473
  "type": "string",
422
474
  "pattern": "^(?!\\.\\.?(?:\\/|$))[\\w.@+-]+(?:\\/(?!\\.\\.?(?:\\/|$))[\\w.@+-]+)*$",
423
475
  "description": "The directory the feature owns"
424
476
  },
425
- "FilePath": {
477
+ "FilePath_1": {
426
478
  "type": "string",
427
479
  "pattern": "^(?:(?!\\.\\.?(?:\\/|$))[\\w.@+-]+\\/)*[\\w.@+-]*\\.\\w+$"
428
480
  },
@@ -27,65 +27,72 @@ function withoutLine(refusal: string): string {
27
27
  return refusal.replace(/^(\S+?):\d+ /, "$1 ");
28
28
  }
29
29
 
30
- export const backtest = Effect.fn("backtest")(function* (count: string) {
31
- const lines: string[] = [];
32
- const commits = (yield* git(["log", "--first-parent", "--format=%H%x09%P%x09%s", "-n", count]))
30
+ type Commit = {
31
+ readonly sha: string;
32
+ readonly parent: string;
33
+ readonly subject: string;
34
+ };
35
+
36
+ const firstParentCommits = Effect.fn("firstParentCommits")(function* (count: string) {
37
+ return (yield* git(["log", "--first-parent", "--format=%H%x09%P%x09%s", "-n", count]))
33
38
  .trim()
34
39
  .split("\n")
35
- .map((row) => {
40
+ .map((row): Commit => {
36
41
  const [sha = "", parents = "", subject = ""] = row.split("\t");
37
42
  return { sha, parent: parents.split(" ")[0] ?? "", subject };
38
43
  })
39
44
  .filter((commit) => commit.sha !== "" && commit.parent !== "");
45
+ });
40
46
 
41
- const landed: Landed[] = [];
47
+ const addedLines = Effect.fn("addedLines")(function* ({ sha, parent }: Commit, changed: readonly string[]) {
48
+ const numstat = (yield* git(["diff", "--numstat", parent, sha, "--", ...changed])).trim();
49
+ return numstat === "" ? 0 : numstat.split("\n").reduce((sum, row) => sum + Number(row.split("\t")[0] ?? 0), 0);
50
+ });
51
+
52
+ const pathLanded = Effect.fn("pathLanded")(function* ({ sha, parent }: Commit, path: string) {
53
+ const after = yield* at(sha, path);
54
+ if (Option.isNone(after)) return { comments: 0, refusals: [] };
55
+ const before = Option.getOrElse(yield* at(parent, path), () => "");
56
+
57
+ const wasRefused = new Set((yield* refused(path, before)).map(withoutLine));
58
+ const refusals = (yield* refused(path, after.value)).filter((refusal) => !wasRefused.has(withoutLine(refusal)));
42
59
 
43
- for (const { sha, parent, subject } of commits) {
44
- const changed = (yield* git(["diff", "--name-only", "--diff-filter=d", parent, sha]))
45
- .trim()
46
- .split("\n")
47
- .filter((path) => path !== "" && readable(path));
48
- if (changed.length === 0) continue;
49
-
50
- const numstat = (yield* git(["diff", "--numstat", parent, sha, "--", ...changed])).trim();
51
- const added =
52
- numstat === ""
53
- ? 0
54
- : numstat.split("\n").reduce((sum, row) => sum + Number(row.split("\t")[0] ?? 0), 0);
55
-
56
- let commentLines = 0;
57
- const refusals: string[] = [];
58
-
59
- for (const path of changed) {
60
- const after = yield* at(sha, path);
61
- if (Option.isNone(after)) continue;
62
- const before = Option.getOrElse(yield* at(parent, path), () => "");
63
-
64
- const wasRefused = new Set((yield* refused(path, before)).map(withoutLine));
65
- for (const refusal of yield* refused(path, after.value)) {
66
- if (!wasRefused.has(withoutLine(refusal))) refusals.push(refusal);
67
- }
68
-
69
- const wasComment = new Set((yield* comments(path, before)).map((one) => one.text));
70
- for (const one of yield* comments(path, after.value)) {
71
- if (!wasComment.has(one.text)) commentLines += one.text.split("\n").length;
72
- }
73
- }
74
-
75
- landed.push({ sha: sha.slice(0, 7), subject, added, comments: commentLines, refusals });
60
+ const wasComment = new Set((yield* comments(path, before)).map((one) => one.text));
61
+ const added = (yield* comments(path, after.value)).filter((one) => !wasComment.has(one.text));
62
+ return { comments: added.reduce((sum, one) => sum + one.text.split("\n").length, 0), refusals };
63
+ });
64
+
65
+ const commitLanded = Effect.fn("commitLanded")(function* (commit: Commit) {
66
+ const changed = (yield* git(["diff", "--name-only", "--diff-filter=d", commit.parent, commit.sha]))
67
+ .trim()
68
+ .split("\n")
69
+ .filter((path) => path !== "" && readable(path));
70
+ if (changed.length === 0) return Option.none<Landed>();
71
+
72
+ const added = yield* addedLines(commit, changed);
73
+ let commentLines = 0;
74
+ const refusals: string[] = [];
75
+ for (const path of changed) {
76
+ const one = yield* pathLanded(commit, path);
77
+ commentLines += one.comments;
78
+ refusals.push(...one.refusals);
76
79
  }
80
+ return Option.some<Landed>({ sha: commit.sha.slice(0, 7), subject: commit.subject, added, comments: commentLines, refusals });
81
+ });
77
82
 
83
+ function summary(landed: readonly Landed[]): string {
84
+ const lines: string[] = [];
78
85
  const total = (pick: (one: Landed) => number): number => landed.reduce((sum, one) => sum + pick(one), 0);
79
- const addedLines = total((one) => one.added);
86
+ const added = total((one) => one.added);
80
87
  const commentLines = total((one) => one.comments);
81
88
  const refusalCount = total((one) => one.refusals.length);
82
89
 
83
90
  for (const one of landed) {
84
91
  lines.push(`${one.sha}\t${one.added}\t${one.comments}\t${one.refusals.length}\t${one.subject.slice(0, 58)}`);
85
92
  }
86
- lines.push(`${landed.length} commits touching code, ${addedLines} added lines`);
93
+ lines.push(`${landed.length} commits touching code, ${added} added lines`);
87
94
  lines.push(
88
- `comment lines added: ${commentLines} (${((commentLines / Math.max(addedLines, 1)) * 100).toFixed(1)}% of added lines)`,
95
+ `comment lines added: ${commentLines} (${((commentLines / Math.max(added, 1)) * 100).toFixed(1)}% of added lines)`,
89
96
  );
90
97
  lines.push(`refusals introduced: ${refusalCount}`);
91
98
  for (const one of landed) {
@@ -94,6 +101,15 @@ export const backtest = Effect.fn("backtest")(function* (count: string) {
94
101
  for (const refusal of one.refusals) lines.push(` ${refusal}`);
95
102
  }
96
103
  return lines.join("\n");
104
+ }
105
+
106
+ export const backtest = Effect.fn("backtest")(function* (count: string) {
107
+ const landed: Landed[] = [];
108
+ for (const commit of yield* firstParentCommits(count)) {
109
+ const one = yield* commitLanded(commit);
110
+ if (Option.isSome(one)) landed.push(one.value);
111
+ }
112
+ return summary(landed);
97
113
  });
98
114
 
99
115
  const main = Effect.gen(function* () {
@@ -4,8 +4,9 @@ import { git } from "./git.ts";
4
4
  import { runMain } from "./main.ts";
5
5
  import { DEFAULT_BRANCH, ENTRY_POINT, EVERY_REPOSITORY, KIT_GATES, QUALITY_FILE, selectedGates, type KitGate } from "./gates.ts";
6
6
  import { readQuality, type Quality } from "./quality-file.ts";
7
+ import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
7
8
 
8
- export type Command = readonly string[];
9
+ export type { Command };
9
10
 
10
11
  export type Gate = {
11
12
  readonly command: string;
@@ -61,8 +62,6 @@ const CONSTANTS = new Map([
61
62
  ["false", false],
62
63
  ]);
63
64
  const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
64
- const VARIABLE = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/;
65
- const UNPLAIN = new Set(["|", "&", ";", "<", ">", "(", ")", "`", "\\", "#", "\n"]);
66
65
 
67
66
  function isRecord(value: unknown): value is Record<string, unknown> {
68
67
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -74,66 +73,6 @@ function names(value: unknown): readonly string[] | undefined {
74
73
  return value.filter((entry): entry is string => typeof entry === "string");
75
74
  }
76
75
 
77
- function expansion(text: string, from: number): number | undefined {
78
- const match = VARIABLE.exec(text.slice(from));
79
- return match === null ? undefined : from + match[0].length;
80
- }
81
-
82
- // A script counts only when it is one line of plain words: any shell control, redirection or
83
- // substitution can run the gate without its failure failing the step.
84
- function plainCommand(script: string): Command | undefined {
85
- const line = script.trim();
86
- const words: string[] = [];
87
- let word: string | undefined;
88
- for (let index = 0; index < line.length; index += 1) {
89
- const char = line.charAt(index);
90
- if (char === " " || char === "\t") {
91
- if (word !== undefined) words.push(word);
92
- word = undefined;
93
- } else if (char === "'") {
94
- const close = line.indexOf("'", index + 1);
95
- if (close === -1) return undefined;
96
- word = (word ?? "") + line.slice(index + 1, close);
97
- index = close;
98
- } else if (char === '"') {
99
- let quoted = "";
100
- for (index += 1; line.charAt(index) !== '"'; index += 1) {
101
- const inner = line.charAt(index);
102
- if (inner === "" || inner === "\\" || inner === "`") return undefined;
103
- if (inner === "$") {
104
- const end = expansion(line, index);
105
- if (end === undefined) return undefined;
106
- quoted += line.slice(index, end);
107
- index = end - 1;
108
- } else {
109
- quoted += inner;
110
- }
111
- }
112
- word = (word ?? "") + quoted;
113
- } else if (char === "$") {
114
- const end = expansion(line, index);
115
- if (end === undefined) return undefined;
116
- word = (word ?? "") + line.slice(index, end);
117
- index = end - 1;
118
- } else if (UNPLAIN.has(char)) {
119
- return undefined;
120
- } else {
121
- word = (word ?? "") + char;
122
- }
123
- }
124
- if (word !== undefined) words.push(word);
125
- return words.length === 0 ? undefined : words;
126
- }
127
-
128
- function invokes(command: Command | undefined, gate: Command): boolean {
129
- return command !== undefined && gate.length <= command.length && gate.every((word, index) => command[index] === word);
130
- }
131
-
132
- function mentions(script: string, gate: Command): boolean {
133
- const tokens = script.split(/[\s|&;<>()`]+/);
134
- return tokens.some((_, start) => invokes(tokens.slice(start), gate));
135
- }
136
-
137
76
  function constant(value: unknown): boolean | undefined {
138
77
  if (typeof value === "boolean") return value;
139
78
  if (typeof value !== "string") return undefined;
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Option } from "effect";
3
3
  import { refused, syntaxOf } from "./comments.ts";
4
- import { changedLines, git, parentOrEmptyTree } from "./git.ts";
5
- import { runMain, Usage } from "./main.ts";
4
+ import { changedLines, git, parentOrEmptyTree, refArgs } from "./git.ts";
5
+ import { runMain } from "./main.ts";
6
6
 
7
7
  export type GateResult = {
8
8
  readonly files: number;
@@ -41,8 +41,7 @@ export function report({ files, addedLines, violations }: GateResult): string {
41
41
  }
42
42
 
43
43
  const gate = Effect.gen(function* () {
44
- const [first, second, ...extra] = process.argv.slice(2);
45
- if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
44
+ const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
46
45
 
47
46
  const root = (yield* git(["rev-parse", "--show-toplevel"], process.cwd())).trim();
48
47
  const base = second === undefined ? yield* parentOrEmptyTree(first, root) : first;
@@ -147,70 +147,67 @@ function endOfRegex(source: string, start: number): number {
147
147
  return source.length;
148
148
  }
149
149
 
150
+ type Token = { readonly stop: number; readonly previous: string };
151
+
152
+ function endOfBlockComment(source: string, i: number, syntax: Syntax): number | undefined {
153
+ const block = syntax.block.find(([open]) => source.startsWith(open, i));
154
+ if (block === undefined) return undefined;
155
+ const [open, close] = block;
156
+ const closed = source.indexOf(close, i + open.length);
157
+ return closed < 0 ? source.length : closed + close.length;
158
+ }
159
+
160
+ function endOfLineComment(source: string, i: number, syntax: Syntax): number | undefined {
161
+ const marker = syntax.line.find(
162
+ ({ token, afterAWordBreak }) =>
163
+ source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source.charAt(i - 1))),
164
+ );
165
+ if (marker === undefined) return undefined;
166
+ const newline = source.indexOf("\n", i);
167
+ return newline < 0 ? source.length : newline;
168
+ }
169
+
170
+ function endOfWord(source: string, i: number): number {
171
+ let end = i;
172
+ while (end < source.length && WORD.test(source.charAt(end))) end += 1;
173
+ return end;
174
+ }
175
+
176
+ function codeToken(source: string, i: number, syntax: Syntax, previous: string): Token {
177
+ const quote = syntax.quotes.find(
178
+ ({ token, opensMidWord }) =>
179
+ source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source.charAt(i - 1))),
180
+ );
181
+ if (quote) return { stop: endOfQuoted(source, i, quote), previous: quote.token };
182
+
183
+ const char = source.charAt(i);
184
+ if (syntax.regexLiterals && char === "/" && opensRegex(previous)) return { stop: endOfRegex(source, i), previous: "/" };
185
+ if (WORD.test(char)) {
186
+ const end = endOfWord(source, i);
187
+ return { stop: end, previous: source.slice(i, end) };
188
+ }
189
+ return { stop: i + 1, previous: /\s/.test(char) ? previous : char };
190
+ }
191
+
192
+ function newlinesIn(source: string, from: number, to: number): number {
193
+ let count = 0;
194
+ for (let k = from; k < to; k++) if (source[k] === "\n") count += 1;
195
+ return count;
196
+ }
197
+
150
198
  export function commentsIn(source: string, syntax: Syntax): Comment[] {
151
199
  const found: Comment[] = [];
152
200
  let i = 0;
153
201
  let line = 1;
154
202
  let previous = "";
155
203
 
156
- const advance = (to: number): void => {
157
- for (let k = i; k < to; k++) if (source[k] === "\n") line += 1;
158
- i = to;
159
- };
160
-
161
204
  while (i < source.length) {
162
- const char = source.charAt(i);
163
-
164
- const block = syntax.block.find(([open]) => source.startsWith(open, i));
165
- if (block) {
166
- const [open, close] = block;
167
- const closed = source.indexOf(close, i + open.length);
168
- const stop = closed < 0 ? source.length : closed + close.length;
169
- found.push({ line, text: source.slice(i, stop) });
170
- advance(stop);
171
- previous = "";
172
- continue;
173
- }
174
-
175
- const marker = syntax.line.find(
176
- ({ token, afterAWordBreak }) =>
177
- source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source.charAt(i - 1))),
178
- );
179
- if (marker) {
180
- const newline = source.indexOf("\n", i);
181
- const stop = newline < 0 ? source.length : newline;
182
- found.push({ line, text: source.slice(i, stop) });
183
- advance(stop);
184
- previous = "";
185
- continue;
186
- }
187
-
188
- const quote = syntax.quotes.find(
189
- ({ token, opensMidWord }) =>
190
- source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source.charAt(i - 1))),
191
- );
192
- if (quote) {
193
- advance(endOfQuoted(source, i, quote));
194
- previous = quote.token;
195
- continue;
196
- }
197
-
198
- if (syntax.regexLiterals && char === "/" && opensRegex(previous)) {
199
- advance(endOfRegex(source, i));
200
- previous = "/";
201
- continue;
202
- }
203
-
204
- if (WORD.test(char)) {
205
- let end = i;
206
- while (end < source.length && WORD.test(source.charAt(end))) end += 1;
207
- previous = source.slice(i, end);
208
- advance(end);
209
- continue;
210
- }
211
-
212
- if (!/\s/.test(char)) previous = char;
213
- advance(i + 1);
205
+ const commentStop = endOfBlockComment(source, i, syntax) ?? endOfLineComment(source, i, syntax);
206
+ if (commentStop !== undefined) found.push({ line, text: source.slice(i, commentStop) });
207
+ const token = commentStop === undefined ? codeToken(source, i, syntax, previous) : { stop: commentStop, previous: "" };
208
+ line += newlinesIn(source, i, token.stop);
209
+ i = token.stop;
210
+ previous = token.previous;
214
211
  }
215
212
 
216
213
  return found;
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env bun
2
2
  import { Console, Effect, Schema } from "effect";
3
- import { git } from "./git.ts";
4
- import { runMain, Usage } from "./main.ts";
3
+ import { git, refArgs } from "./git.ts";
4
+ import { runMain } from "./main.ts";
5
5
  import { readQuality, type Identity } from "./quality-file.ts";
6
6
 
7
7
  type Commit = {
@@ -98,8 +98,7 @@ function inspect(commit: Commit, allowed: readonly Identity[]): Offence | undefi
98
98
  }
99
99
 
100
100
  const check = Effect.gen(function* () {
101
- const [first, second, ...extra] = process.argv.slice(2);
102
- if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
101
+ const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
103
102
  const range = second === undefined ? first : `${first}..${second}`;
104
103
  const revisions = second === undefined ? ["-1", range] : [range];
105
104
 
@@ -156,32 +156,50 @@ function missing(slot: Slot, level: number): string {
156
156
  return slot.type === "fixed" ? `lacks ${marked(level, slot.text)}` : `lacks a ${marked(level, slot.placeholder)} section`;
157
157
  }
158
158
 
159
+ type Placement =
160
+ | { readonly kind: "behind"; readonly index: number }
161
+ | { readonly kind: "unplaced" }
162
+ | { readonly kind: "placed"; readonly index: number; readonly slot: Slot; readonly at: number };
163
+
164
+ function placementOf(slots: readonly Slot[], title: string, at: number): Placement {
165
+ const fixed = fixedIndexOf(slots, title);
166
+ if (fixed !== -1 && fixed <= at) return { kind: "behind", index: fixed };
167
+ const open = openIndexFrom(slots, at);
168
+ const index = fixed !== -1 ? fixed : open.index;
169
+ const slot = slots[index];
170
+ if (slot === undefined) return { kind: "unplaced" };
171
+ return { kind: "placed", index, slot, at: fixed !== -1 || open.advances ? index : at };
172
+ }
173
+
174
+ function placedProblems(slot: Slot, { heading, subsections }: Section, level: number): Violation[] {
175
+ const problem = slot.type === "open" ? ruleProblem(slot.rule, heading.title) : undefined;
176
+ return [
177
+ ...(problem === undefined ? [] : [{ line: heading.line, message: `${marked(level, heading.title)} ${problem}` }]),
178
+ ...(slot.subsections === undefined ? [] : matchSections(subsections, slot.subsections, level + 1, heading.line)),
179
+ ];
180
+ }
181
+
159
182
  export function matchSections(sections: readonly Section[], slots: readonly Slot[], level: number, parentLine: number): Violation[] {
160
183
  const order = `the template's order is ${slots.map(slotLabel).join(", ")}`;
161
184
  const violations: Violation[] = [];
162
185
  const found = slots.map(() => false);
163
186
  let at = -1;
164
- for (const { heading, subsections } of sections) {
187
+ for (const section of sections) {
188
+ const { heading } = section;
165
189
  const named = marked(level, heading.title);
166
- const fixed = fixedIndexOf(slots, heading.title);
167
- if (fixed !== -1 && fixed <= at) {
168
- found[fixed] = true;
169
- const problem = fixed === at ? "appears twice" : `is out of order, as ${order}`;
170
- violations.push({ line: heading.line, message: `${named} ${problem}` });
190
+ const placement = placementOf(slots, heading.title, at);
191
+ if (placement.kind === "unplaced") {
192
+ violations.push({ line: heading.line, message: `${named} is not a section the template has there, as ${order}` });
171
193
  continue;
172
194
  }
173
- const open = openIndexFrom(slots, at);
174
- const index = fixed !== -1 ? fixed : open.index;
175
- const slot = slots[index];
176
- if (slot === undefined) {
177
- violations.push({ line: heading.line, message: `${named} is not a section the template has there, as ${order}` });
195
+ found[placement.index] = true;
196
+ if (placement.kind === "behind") {
197
+ const problem = placement.index === at ? "appears twice" : `is out of order, as ${order}`;
198
+ violations.push({ line: heading.line, message: `${named} ${problem}` });
178
199
  continue;
179
200
  }
180
- if (fixed !== -1 || open.advances) at = index;
181
- found[index] = true;
182
- const problem = slot.type === "open" ? ruleProblem(slot.rule, heading.title) : undefined;
183
- if (problem !== undefined) violations.push({ line: heading.line, message: `${named} ${problem}` });
184
- if (slot.subsections !== undefined) violations.push(...matchSections(subsections, slot.subsections, level + 1, heading.line));
201
+ at = placement.at;
202
+ violations.push(...placedProblems(placement.slot, section, level));
185
203
  }
186
204
  slots.forEach((slot, index) => {
187
205
  if (slot.presence.required && found[index] !== true) violations.push({ line: parentLine, message: missing(slot, level) });
@@ -89,29 +89,42 @@ function statusProblem(outline: Outline): Violation | undefined {
89
89
  return { line: opening?.line ?? status.heading.line, message: `\`## Status\` opens with ${found} where one of ${ADR_STATUSES.join(", ")} goes` };
90
90
  }
91
91
 
92
- function adrProblems(path: string, outline: Outline, records: readonly string[]): readonly Violation[] {
93
- const violations: Violation[] = [];
94
- const filed = recordNumber(path);
95
- if (filed === undefined) {
96
- violations.push({ line: 1, message: `is not named as a record, a four-digit number and a kebab-case name directly in ${ADR_DIRECTORY}` });
97
- }
92
+ function recordTitleProblem(outline: Outline, filed: number | undefined): Violation | undefined {
98
93
  const title = titleOf(outline);
99
- const titled = title === undefined ? undefined : RECORD_TITLE.exec(title.title)?.[1];
100
- if (title !== undefined && titled === undefined) {
101
- violations.push({ line: title.line, message: `${marked(1, title.title)} does not open with the record's number, as in \`# 7. The decision\`` });
102
- }
103
- if (title !== undefined && titled !== undefined && filed !== undefined && Number(titled) !== filed) {
104
- violations.push({ line: title.line, message: `${marked(1, title.title)} carries number ${titled}, and the file name ${filed}` });
94
+ if (title === undefined) return undefined;
95
+ const titled = RECORD_TITLE.exec(title.title)?.[1];
96
+ if (titled === undefined) {
97
+ return { line: title.line, message: `${marked(1, title.title)} does not open with the record's number, as in \`# 7. The decision\`` };
105
98
  }
106
- const dated = firstText(outline.lead);
107
- if (!isDate(DATE_LINE.exec(dated?.text.trim() ?? "")?.[1])) {
108
- violations.push({ line: dated?.line ?? 1, message: "does not follow its title with a `Date: YYYY-MM-DD` line" });
109
- }
110
- const status = statusProblem(outline);
111
- if (status !== undefined) violations.push(status);
112
- const sharing = records.filter((other) => other !== path && filed !== undefined && recordNumber(other) === filed);
113
- if (sharing.length > 0) violations.push({ line: 1, message: `shares number ${filed} with ${sharing.join(", ")}` });
114
- return violations;
99
+ if (filed === undefined || Number(titled) === filed) return undefined;
100
+ return { line: title.line, message: `${marked(1, title.title)} carries number ${titled}, and the file name ${filed}` };
101
+ }
102
+
103
+ function recordDateProblem({ lead }: Outline): Violation | undefined {
104
+ const dated = firstText(lead);
105
+ if (isDate(DATE_LINE.exec(dated?.text.trim() ?? "")?.[1])) return undefined;
106
+ return { line: dated?.line ?? 1, message: "does not follow its title with a `Date: YYYY-MM-DD` line" };
107
+ }
108
+
109
+ function sharedNumberProblem(path: string, filed: number | undefined, records: readonly string[]): Violation | undefined {
110
+ if (filed === undefined) return undefined;
111
+ const sharing = records.filter((other) => other !== path && recordNumber(other) === filed);
112
+ return sharing.length === 0 ? undefined : { line: 1, message: `shares number ${filed} with ${sharing.join(", ")}` };
113
+ }
114
+
115
+ function adrProblems(path: string, outline: Outline, records: readonly string[]): readonly Violation[] {
116
+ const filed = recordNumber(path);
117
+ const misnamed: Violation | undefined =
118
+ filed === undefined
119
+ ? { line: 1, message: `is not named as a record, a four-digit number and a kebab-case name directly in ${ADR_DIRECTORY}` }
120
+ : undefined;
121
+ return [
122
+ misnamed,
123
+ recordTitleProblem(outline, filed),
124
+ recordDateProblem(outline),
125
+ statusProblem(outline),
126
+ sharedNumberProblem(path, filed, records),
127
+ ].filter((violation) => violation !== undefined);
115
128
  }
116
129
 
117
130
  export const RELEASED = /^Released (\S+)\.$/;
package/scripts/docs.ts CHANGED
@@ -3,8 +3,8 @@ import { Console, Effect } from "effect";
3
3
  import { rootsOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
4
4
  import { ADR_DIRECTORY, judge, placementOf, placementProblem, type Placement } from "./doc-rules.ts";
5
5
  import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
6
- import { changedLines, changedPaths, git, pathsAt, rangeEnds } from "./git.ts";
7
- import { runMain, Usage } from "./main.ts";
6
+ import { changedLines, changedPaths, git, pathsAt, rangeEnds, refArgs } from "./git.ts";
7
+ import { runMain } from "./main.ts";
8
8
  import { isLivingDoc, proseFindings, readerOf } from "./prose-matchers.ts";
9
9
  import { readQuality } from "./quality-file.ts";
10
10
 
@@ -152,8 +152,7 @@ export function report({ held, edited, living, findings, advisory, brokenBefore
152
152
  }
153
153
 
154
154
  const docs = Effect.gen(function* () {
155
- const [first, second, ...extra] = process.argv.slice(2);
156
- if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
155
+ const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
157
156
 
158
157
  const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
159
158
  const { base, head } = yield* rangeEnds(first, second, root);