@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.
- package/CHANGELOG.md +22 -0
- package/README.md +3 -1
- package/bunfig.toml +1 -1
- package/dependency-cruiser.config.js +2 -0
- package/dist/feature-rules.js +22 -5
- package/docs/configs/commit-messages.md +2 -18
- package/docs/configs/quality-file.md +3 -2
- package/docs/gates/checks-lint.md +2 -1
- package/docs/gates/checks-mutation-compare.md +20 -11
- package/docs/gates/checks-quality.md +23 -8
- package/docs/gates/checks-quarantine-clock.md +54 -0
- package/docs/gates/checks-test-layout.md +9 -3
- package/docs/gates/checks-vendor.md +97 -0
- package/package.json +11 -2
- package/quality.schema.json +56 -4
- package/scripts/backtest.ts +57 -41
- package/scripts/ci-wiring.ts +2 -63
- package/scripts/comment-gate.ts +3 -4
- package/scripts/comment-matchers.ts +54 -57
- package/scripts/commit-identity.ts +3 -4
- package/scripts/doc-outline.ts +34 -16
- package/scripts/doc-rules.ts +34 -21
- package/scripts/docs.ts +3 -4
- package/scripts/feature-owners.ts +5 -8
- package/scripts/gates.ts +1 -0
- package/scripts/git.ts +41 -26
- package/scripts/lint.ts +75 -35
- package/scripts/mutation-compare.ts +179 -70
- package/scripts/prose-matchers.ts +93 -83
- package/scripts/quality-file.ts +30 -3
- package/scripts/quality.ts +157 -10
- package/scripts/quarantine-clock.ts +153 -0
- package/scripts/range-gate.ts +9 -0
- package/scripts/repetition.ts +40 -19
- package/scripts/shell-command.ts +74 -0
- package/scripts/size-budget.ts +51 -28
- package/scripts/suppressions-ratchet.ts +22 -28
- package/scripts/test-layout.ts +65 -52
- package/scripts/test-report.ts +18 -13
- package/scripts/vendor.ts +337 -0
package/quality.schema.json
CHANGED
|
@@ -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/
|
|
252
|
+
"$ref": "#/$defs/FilePath_1"
|
|
241
253
|
}
|
|
242
254
|
],
|
|
243
255
|
"minItems": 1,
|
|
244
256
|
"items": {
|
|
245
|
-
"$ref": "#/$defs/
|
|
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
|
-
"
|
|
477
|
+
"FilePath_1": {
|
|
426
478
|
"type": "string",
|
|
427
479
|
"pattern": "^(?:(?!\\.\\.?(?:\\/|$))[\\w.@+-]+\\/)*[\\w.@+-]*\\.\\w+$"
|
|
428
480
|
},
|
package/scripts/backtest.ts
CHANGED
|
@@ -27,65 +27,72 @@ function withoutLine(refusal: string): string {
|
|
|
27
27
|
return refusal.replace(/^(\S+?):\d+ /, "$1 ");
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
30
|
+
type Commit = {
|
|
31
|
+
readonly sha: string;
|
|
32
|
+
readonly parent: string;
|
|
33
|
+
readonly subject: string;
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const firstParentCommits = Effect.fn("firstParentCommits")(function* (count: string) {
|
|
37
|
+
return (yield* git(["log", "--first-parent", "--format=%H%x09%P%x09%s", "-n", count]))
|
|
33
38
|
.trim()
|
|
34
39
|
.split("\n")
|
|
35
|
-
.map((row) => {
|
|
40
|
+
.map((row): Commit => {
|
|
36
41
|
const [sha = "", parents = "", subject = ""] = row.split("\t");
|
|
37
42
|
return { sha, parent: parents.split(" ")[0] ?? "", subject };
|
|
38
43
|
})
|
|
39
44
|
.filter((commit) => commit.sha !== "" && commit.parent !== "");
|
|
45
|
+
});
|
|
40
46
|
|
|
41
|
-
|
|
47
|
+
const addedLines = Effect.fn("addedLines")(function* ({ sha, parent }: Commit, changed: readonly string[]) {
|
|
48
|
+
const numstat = (yield* git(["diff", "--numstat", parent, sha, "--", ...changed])).trim();
|
|
49
|
+
return numstat === "" ? 0 : numstat.split("\n").reduce((sum, row) => sum + Number(row.split("\t")[0] ?? 0), 0);
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
const pathLanded = Effect.fn("pathLanded")(function* ({ sha, parent }: Commit, path: string) {
|
|
53
|
+
const after = yield* at(sha, path);
|
|
54
|
+
if (Option.isNone(after)) return { comments: 0, refusals: [] };
|
|
55
|
+
const before = Option.getOrElse(yield* at(parent, path), () => "");
|
|
56
|
+
|
|
57
|
+
const wasRefused = new Set((yield* refused(path, before)).map(withoutLine));
|
|
58
|
+
const refusals = (yield* refused(path, after.value)).filter((refusal) => !wasRefused.has(withoutLine(refusal)));
|
|
42
59
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
const before = Option.getOrElse(yield* at(parent, path), () => "");
|
|
63
|
-
|
|
64
|
-
const wasRefused = new Set((yield* refused(path, before)).map(withoutLine));
|
|
65
|
-
for (const refusal of yield* refused(path, after.value)) {
|
|
66
|
-
if (!wasRefused.has(withoutLine(refusal))) refusals.push(refusal);
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
const wasComment = new Set((yield* comments(path, before)).map((one) => one.text));
|
|
70
|
-
for (const one of yield* comments(path, after.value)) {
|
|
71
|
-
if (!wasComment.has(one.text)) commentLines += one.text.split("\n").length;
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
landed.push({ sha: sha.slice(0, 7), subject, added, comments: commentLines, refusals });
|
|
60
|
+
const wasComment = new Set((yield* comments(path, before)).map((one) => one.text));
|
|
61
|
+
const added = (yield* comments(path, after.value)).filter((one) => !wasComment.has(one.text));
|
|
62
|
+
return { comments: added.reduce((sum, one) => sum + one.text.split("\n").length, 0), refusals };
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const commitLanded = Effect.fn("commitLanded")(function* (commit: Commit) {
|
|
66
|
+
const changed = (yield* git(["diff", "--name-only", "--diff-filter=d", commit.parent, commit.sha]))
|
|
67
|
+
.trim()
|
|
68
|
+
.split("\n")
|
|
69
|
+
.filter((path) => path !== "" && readable(path));
|
|
70
|
+
if (changed.length === 0) return Option.none<Landed>();
|
|
71
|
+
|
|
72
|
+
const added = yield* addedLines(commit, changed);
|
|
73
|
+
let commentLines = 0;
|
|
74
|
+
const refusals: string[] = [];
|
|
75
|
+
for (const path of changed) {
|
|
76
|
+
const one = yield* pathLanded(commit, path);
|
|
77
|
+
commentLines += one.comments;
|
|
78
|
+
refusals.push(...one.refusals);
|
|
76
79
|
}
|
|
80
|
+
return Option.some<Landed>({ sha: commit.sha.slice(0, 7), subject: commit.subject, added, comments: commentLines, refusals });
|
|
81
|
+
});
|
|
77
82
|
|
|
83
|
+
function summary(landed: readonly Landed[]): string {
|
|
84
|
+
const lines: string[] = [];
|
|
78
85
|
const total = (pick: (one: Landed) => number): number => landed.reduce((sum, one) => sum + pick(one), 0);
|
|
79
|
-
const
|
|
86
|
+
const added = total((one) => one.added);
|
|
80
87
|
const commentLines = total((one) => one.comments);
|
|
81
88
|
const refusalCount = total((one) => one.refusals.length);
|
|
82
89
|
|
|
83
90
|
for (const one of landed) {
|
|
84
91
|
lines.push(`${one.sha}\t${one.added}\t${one.comments}\t${one.refusals.length}\t${one.subject.slice(0, 58)}`);
|
|
85
92
|
}
|
|
86
|
-
lines.push(`${landed.length} commits touching code, ${
|
|
93
|
+
lines.push(`${landed.length} commits touching code, ${added} added lines`);
|
|
87
94
|
lines.push(
|
|
88
|
-
`comment lines added: ${commentLines} (${((commentLines / Math.max(
|
|
95
|
+
`comment lines added: ${commentLines} (${((commentLines / Math.max(added, 1)) * 100).toFixed(1)}% of added lines)`,
|
|
89
96
|
);
|
|
90
97
|
lines.push(`refusals introduced: ${refusalCount}`);
|
|
91
98
|
for (const one of landed) {
|
|
@@ -94,6 +101,15 @@ export const backtest = Effect.fn("backtest")(function* (count: string) {
|
|
|
94
101
|
for (const refusal of one.refusals) lines.push(` ${refusal}`);
|
|
95
102
|
}
|
|
96
103
|
return lines.join("\n");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export const backtest = Effect.fn("backtest")(function* (count: string) {
|
|
107
|
+
const landed: Landed[] = [];
|
|
108
|
+
for (const commit of yield* firstParentCommits(count)) {
|
|
109
|
+
const one = yield* commitLanded(commit);
|
|
110
|
+
if (Option.isSome(one)) landed.push(one.value);
|
|
111
|
+
}
|
|
112
|
+
return summary(landed);
|
|
97
113
|
});
|
|
98
114
|
|
|
99
115
|
const main = Effect.gen(function* () {
|
package/scripts/ci-wiring.ts
CHANGED
|
@@ -4,8 +4,9 @@ import { git } from "./git.ts";
|
|
|
4
4
|
import { runMain } from "./main.ts";
|
|
5
5
|
import { DEFAULT_BRANCH, ENTRY_POINT, EVERY_REPOSITORY, KIT_GATES, QUALITY_FILE, selectedGates, type KitGate } from "./gates.ts";
|
|
6
6
|
import { readQuality, type Quality } from "./quality-file.ts";
|
|
7
|
+
import { invokes, mentions, plainCommand, type Command } from "./shell-command.ts";
|
|
7
8
|
|
|
8
|
-
export type Command
|
|
9
|
+
export type { Command };
|
|
9
10
|
|
|
10
11
|
export type Gate = {
|
|
11
12
|
readonly command: string;
|
|
@@ -61,8 +62,6 @@ const CONSTANTS = new Map([
|
|
|
61
62
|
["false", false],
|
|
62
63
|
]);
|
|
63
64
|
const STATUS_OVERRIDE = /\b(?:always|failure|cancelled)\s*\(/;
|
|
64
|
-
const VARIABLE = /^\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[A-Za-z_][A-Za-z0-9_]*\})/;
|
|
65
|
-
const UNPLAIN = new Set(["|", "&", ";", "<", ">", "(", ")", "`", "\\", "#", "\n"]);
|
|
66
65
|
|
|
67
66
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
68
67
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
@@ -74,66 +73,6 @@ function names(value: unknown): readonly string[] | undefined {
|
|
|
74
73
|
return value.filter((entry): entry is string => typeof entry === "string");
|
|
75
74
|
}
|
|
76
75
|
|
|
77
|
-
function expansion(text: string, from: number): number | undefined {
|
|
78
|
-
const match = VARIABLE.exec(text.slice(from));
|
|
79
|
-
return match === null ? undefined : from + match[0].length;
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
// A script counts only when it is one line of plain words: any shell control, redirection or
|
|
83
|
-
// substitution can run the gate without its failure failing the step.
|
|
84
|
-
function plainCommand(script: string): Command | undefined {
|
|
85
|
-
const line = script.trim();
|
|
86
|
-
const words: string[] = [];
|
|
87
|
-
let word: string | undefined;
|
|
88
|
-
for (let index = 0; index < line.length; index += 1) {
|
|
89
|
-
const char = line.charAt(index);
|
|
90
|
-
if (char === " " || char === "\t") {
|
|
91
|
-
if (word !== undefined) words.push(word);
|
|
92
|
-
word = undefined;
|
|
93
|
-
} else if (char === "'") {
|
|
94
|
-
const close = line.indexOf("'", index + 1);
|
|
95
|
-
if (close === -1) return undefined;
|
|
96
|
-
word = (word ?? "") + line.slice(index + 1, close);
|
|
97
|
-
index = close;
|
|
98
|
-
} else if (char === '"') {
|
|
99
|
-
let quoted = "";
|
|
100
|
-
for (index += 1; line.charAt(index) !== '"'; index += 1) {
|
|
101
|
-
const inner = line.charAt(index);
|
|
102
|
-
if (inner === "" || inner === "\\" || inner === "`") return undefined;
|
|
103
|
-
if (inner === "$") {
|
|
104
|
-
const end = expansion(line, index);
|
|
105
|
-
if (end === undefined) return undefined;
|
|
106
|
-
quoted += line.slice(index, end);
|
|
107
|
-
index = end - 1;
|
|
108
|
-
} else {
|
|
109
|
-
quoted += inner;
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
word = (word ?? "") + quoted;
|
|
113
|
-
} else if (char === "$") {
|
|
114
|
-
const end = expansion(line, index);
|
|
115
|
-
if (end === undefined) return undefined;
|
|
116
|
-
word = (word ?? "") + line.slice(index, end);
|
|
117
|
-
index = end - 1;
|
|
118
|
-
} else if (UNPLAIN.has(char)) {
|
|
119
|
-
return undefined;
|
|
120
|
-
} else {
|
|
121
|
-
word = (word ?? "") + char;
|
|
122
|
-
}
|
|
123
|
-
}
|
|
124
|
-
if (word !== undefined) words.push(word);
|
|
125
|
-
return words.length === 0 ? undefined : words;
|
|
126
|
-
}
|
|
127
|
-
|
|
128
|
-
function invokes(command: Command | undefined, gate: Command): boolean {
|
|
129
|
-
return command !== undefined && gate.length <= command.length && gate.every((word, index) => command[index] === word);
|
|
130
|
-
}
|
|
131
|
-
|
|
132
|
-
function mentions(script: string, gate: Command): boolean {
|
|
133
|
-
const tokens = script.split(/[\s|&;<>()`]+/);
|
|
134
|
-
return tokens.some((_, start) => invokes(tokens.slice(start), gate));
|
|
135
|
-
}
|
|
136
|
-
|
|
137
76
|
function constant(value: unknown): boolean | undefined {
|
|
138
77
|
if (typeof value === "boolean") return value;
|
|
139
78
|
if (typeof value !== "string") return undefined;
|
package/scripts/comment-gate.ts
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Option } from "effect";
|
|
3
3
|
import { refused, syntaxOf } from "./comments.ts";
|
|
4
|
-
import { changedLines, git, parentOrEmptyTree } from "./git.ts";
|
|
5
|
-
import { runMain
|
|
4
|
+
import { changedLines, git, parentOrEmptyTree, refArgs } from "./git.ts";
|
|
5
|
+
import { runMain } from "./main.ts";
|
|
6
6
|
|
|
7
7
|
export type GateResult = {
|
|
8
8
|
readonly files: number;
|
|
@@ -41,8 +41,7 @@ export function report({ files, addedLines, violations }: GateResult): string {
|
|
|
41
41
|
}
|
|
42
42
|
|
|
43
43
|
const gate = Effect.gen(function* () {
|
|
44
|
-
const
|
|
45
|
-
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
44
|
+
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
46
45
|
|
|
47
46
|
const root = (yield* git(["rev-parse", "--show-toplevel"], process.cwd())).trim();
|
|
48
47
|
const base = second === undefined ? yield* parentOrEmptyTree(first, root) : first;
|
|
@@ -147,70 +147,67 @@ function endOfRegex(source: string, start: number): number {
|
|
|
147
147
|
return source.length;
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
type Token = { readonly stop: number; readonly previous: string };
|
|
151
|
+
|
|
152
|
+
function endOfBlockComment(source: string, i: number, syntax: Syntax): number | undefined {
|
|
153
|
+
const block = syntax.block.find(([open]) => source.startsWith(open, i));
|
|
154
|
+
if (block === undefined) return undefined;
|
|
155
|
+
const [open, close] = block;
|
|
156
|
+
const closed = source.indexOf(close, i + open.length);
|
|
157
|
+
return closed < 0 ? source.length : closed + close.length;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function endOfLineComment(source: string, i: number, syntax: Syntax): number | undefined {
|
|
161
|
+
const marker = syntax.line.find(
|
|
162
|
+
({ token, afterAWordBreak }) =>
|
|
163
|
+
source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source.charAt(i - 1))),
|
|
164
|
+
);
|
|
165
|
+
if (marker === undefined) return undefined;
|
|
166
|
+
const newline = source.indexOf("\n", i);
|
|
167
|
+
return newline < 0 ? source.length : newline;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function endOfWord(source: string, i: number): number {
|
|
171
|
+
let end = i;
|
|
172
|
+
while (end < source.length && WORD.test(source.charAt(end))) end += 1;
|
|
173
|
+
return end;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function codeToken(source: string, i: number, syntax: Syntax, previous: string): Token {
|
|
177
|
+
const quote = syntax.quotes.find(
|
|
178
|
+
({ token, opensMidWord }) =>
|
|
179
|
+
source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source.charAt(i - 1))),
|
|
180
|
+
);
|
|
181
|
+
if (quote) return { stop: endOfQuoted(source, i, quote), previous: quote.token };
|
|
182
|
+
|
|
183
|
+
const char = source.charAt(i);
|
|
184
|
+
if (syntax.regexLiterals && char === "/" && opensRegex(previous)) return { stop: endOfRegex(source, i), previous: "/" };
|
|
185
|
+
if (WORD.test(char)) {
|
|
186
|
+
const end = endOfWord(source, i);
|
|
187
|
+
return { stop: end, previous: source.slice(i, end) };
|
|
188
|
+
}
|
|
189
|
+
return { stop: i + 1, previous: /\s/.test(char) ? previous : char };
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function newlinesIn(source: string, from: number, to: number): number {
|
|
193
|
+
let count = 0;
|
|
194
|
+
for (let k = from; k < to; k++) if (source[k] === "\n") count += 1;
|
|
195
|
+
return count;
|
|
196
|
+
}
|
|
197
|
+
|
|
150
198
|
export function commentsIn(source: string, syntax: Syntax): Comment[] {
|
|
151
199
|
const found: Comment[] = [];
|
|
152
200
|
let i = 0;
|
|
153
201
|
let line = 1;
|
|
154
202
|
let previous = "";
|
|
155
203
|
|
|
156
|
-
const advance = (to: number): void => {
|
|
157
|
-
for (let k = i; k < to; k++) if (source[k] === "\n") line += 1;
|
|
158
|
-
i = to;
|
|
159
|
-
};
|
|
160
|
-
|
|
161
204
|
while (i < source.length) {
|
|
162
|
-
const
|
|
163
|
-
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
const stop = closed < 0 ? source.length : closed + close.length;
|
|
169
|
-
found.push({ line, text: source.slice(i, stop) });
|
|
170
|
-
advance(stop);
|
|
171
|
-
previous = "";
|
|
172
|
-
continue;
|
|
173
|
-
}
|
|
174
|
-
|
|
175
|
-
const marker = syntax.line.find(
|
|
176
|
-
({ token, afterAWordBreak }) =>
|
|
177
|
-
source.startsWith(token, i) && (!afterAWordBreak || i === 0 || WORD_BREAK.test(source.charAt(i - 1))),
|
|
178
|
-
);
|
|
179
|
-
if (marker) {
|
|
180
|
-
const newline = source.indexOf("\n", i);
|
|
181
|
-
const stop = newline < 0 ? source.length : newline;
|
|
182
|
-
found.push({ line, text: source.slice(i, stop) });
|
|
183
|
-
advance(stop);
|
|
184
|
-
previous = "";
|
|
185
|
-
continue;
|
|
186
|
-
}
|
|
187
|
-
|
|
188
|
-
const quote = syntax.quotes.find(
|
|
189
|
-
({ token, opensMidWord }) =>
|
|
190
|
-
source.startsWith(token, i) && (opensMidWord || i === 0 || !WORD.test(source.charAt(i - 1))),
|
|
191
|
-
);
|
|
192
|
-
if (quote) {
|
|
193
|
-
advance(endOfQuoted(source, i, quote));
|
|
194
|
-
previous = quote.token;
|
|
195
|
-
continue;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
if (syntax.regexLiterals && char === "/" && opensRegex(previous)) {
|
|
199
|
-
advance(endOfRegex(source, i));
|
|
200
|
-
previous = "/";
|
|
201
|
-
continue;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
if (WORD.test(char)) {
|
|
205
|
-
let end = i;
|
|
206
|
-
while (end < source.length && WORD.test(source.charAt(end))) end += 1;
|
|
207
|
-
previous = source.slice(i, end);
|
|
208
|
-
advance(end);
|
|
209
|
-
continue;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
if (!/\s/.test(char)) previous = char;
|
|
213
|
-
advance(i + 1);
|
|
205
|
+
const commentStop = endOfBlockComment(source, i, syntax) ?? endOfLineComment(source, i, syntax);
|
|
206
|
+
if (commentStop !== undefined) found.push({ line, text: source.slice(i, commentStop) });
|
|
207
|
+
const token = commentStop === undefined ? codeToken(source, i, syntax, previous) : { stop: commentStop, previous: "" };
|
|
208
|
+
line += newlinesIn(source, i, token.stop);
|
|
209
|
+
i = token.stop;
|
|
210
|
+
previous = token.previous;
|
|
214
211
|
}
|
|
215
212
|
|
|
216
213
|
return found;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect, Schema } from "effect";
|
|
3
|
-
import { git } from "./git.ts";
|
|
4
|
-
import { runMain
|
|
3
|
+
import { git, refArgs } from "./git.ts";
|
|
4
|
+
import { runMain } from "./main.ts";
|
|
5
5
|
import { readQuality, type Identity } from "./quality-file.ts";
|
|
6
6
|
|
|
7
7
|
type Commit = {
|
|
@@ -98,8 +98,7 @@ function inspect(commit: Commit, allowed: readonly Identity[]): Offence | undefi
|
|
|
98
98
|
}
|
|
99
99
|
|
|
100
100
|
const check = Effect.gen(function* () {
|
|
101
|
-
const
|
|
102
|
-
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
101
|
+
const { first, second } = yield* refArgs(process.argv.slice(2), USAGE);
|
|
103
102
|
const range = second === undefined ? first : `${first}..${second}`;
|
|
104
103
|
const revisions = second === undefined ? ["-1", range] : [range];
|
|
105
104
|
|
package/scripts/doc-outline.ts
CHANGED
|
@@ -156,32 +156,50 @@ function missing(slot: Slot, level: number): string {
|
|
|
156
156
|
return slot.type === "fixed" ? `lacks ${marked(level, slot.text)}` : `lacks a ${marked(level, slot.placeholder)} section`;
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
type Placement =
|
|
160
|
+
| { readonly kind: "behind"; readonly index: number }
|
|
161
|
+
| { readonly kind: "unplaced" }
|
|
162
|
+
| { readonly kind: "placed"; readonly index: number; readonly slot: Slot; readonly at: number };
|
|
163
|
+
|
|
164
|
+
function placementOf(slots: readonly Slot[], title: string, at: number): Placement {
|
|
165
|
+
const fixed = fixedIndexOf(slots, title);
|
|
166
|
+
if (fixed !== -1 && fixed <= at) return { kind: "behind", index: fixed };
|
|
167
|
+
const open = openIndexFrom(slots, at);
|
|
168
|
+
const index = fixed !== -1 ? fixed : open.index;
|
|
169
|
+
const slot = slots[index];
|
|
170
|
+
if (slot === undefined) return { kind: "unplaced" };
|
|
171
|
+
return { kind: "placed", index, slot, at: fixed !== -1 || open.advances ? index : at };
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function placedProblems(slot: Slot, { heading, subsections }: Section, level: number): Violation[] {
|
|
175
|
+
const problem = slot.type === "open" ? ruleProblem(slot.rule, heading.title) : undefined;
|
|
176
|
+
return [
|
|
177
|
+
...(problem === undefined ? [] : [{ line: heading.line, message: `${marked(level, heading.title)} ${problem}` }]),
|
|
178
|
+
...(slot.subsections === undefined ? [] : matchSections(subsections, slot.subsections, level + 1, heading.line)),
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
159
182
|
export function matchSections(sections: readonly Section[], slots: readonly Slot[], level: number, parentLine: number): Violation[] {
|
|
160
183
|
const order = `the template's order is ${slots.map(slotLabel).join(", ")}`;
|
|
161
184
|
const violations: Violation[] = [];
|
|
162
185
|
const found = slots.map(() => false);
|
|
163
186
|
let at = -1;
|
|
164
|
-
for (const
|
|
187
|
+
for (const section of sections) {
|
|
188
|
+
const { heading } = section;
|
|
165
189
|
const named = marked(level, heading.title);
|
|
166
|
-
const
|
|
167
|
-
if (
|
|
168
|
-
|
|
169
|
-
const problem = fixed === at ? "appears twice" : `is out of order, as ${order}`;
|
|
170
|
-
violations.push({ line: heading.line, message: `${named} ${problem}` });
|
|
190
|
+
const placement = placementOf(slots, heading.title, at);
|
|
191
|
+
if (placement.kind === "unplaced") {
|
|
192
|
+
violations.push({ line: heading.line, message: `${named} is not a section the template has there, as ${order}` });
|
|
171
193
|
continue;
|
|
172
194
|
}
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
violations.push({ line: heading.line, message: `${named} is not a section the template has there, as ${order}` });
|
|
195
|
+
found[placement.index] = true;
|
|
196
|
+
if (placement.kind === "behind") {
|
|
197
|
+
const problem = placement.index === at ? "appears twice" : `is out of order, as ${order}`;
|
|
198
|
+
violations.push({ line: heading.line, message: `${named} ${problem}` });
|
|
178
199
|
continue;
|
|
179
200
|
}
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
const problem = slot.type === "open" ? ruleProblem(slot.rule, heading.title) : undefined;
|
|
183
|
-
if (problem !== undefined) violations.push({ line: heading.line, message: `${named} ${problem}` });
|
|
184
|
-
if (slot.subsections !== undefined) violations.push(...matchSections(subsections, slot.subsections, level + 1, heading.line));
|
|
201
|
+
at = placement.at;
|
|
202
|
+
violations.push(...placedProblems(placement.slot, section, level));
|
|
185
203
|
}
|
|
186
204
|
slots.forEach((slot, index) => {
|
|
187
205
|
if (slot.presence.required && found[index] !== true) violations.push({ line: parentLine, message: missing(slot, level) });
|
package/scripts/doc-rules.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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
|
|
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
|
|
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);
|