@avi2dg/checks 0.13.0 → 0.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +132 -0
- package/CONTRIBUTING.md +89 -0
- package/README.md +131 -1216
- package/dist/feature-rules.js +9 -0
- package/docs/configs/commit-messages.md +43 -0
- package/docs/configs/dependency-rules.md +62 -0
- package/docs/configs/effect-rules.md +48 -0
- package/docs/configs/quality-file.md +100 -0
- package/docs/design.md +94 -0
- package/docs/gates/checks-backtest.md +50 -0
- package/docs/gates/checks-ci-wiring.md +122 -0
- package/docs/gates/checks-comment-gate.md +58 -0
- package/docs/gates/checks-commit-identity.md +63 -0
- package/docs/gates/checks-docs.md +175 -0
- package/docs/gates/checks-feature-owners.md +113 -0
- package/docs/gates/checks-flake.md +88 -0
- package/docs/gates/checks-lint-coverage.md +49 -0
- package/docs/gates/checks-lint.md +136 -0
- package/docs/gates/checks-mutation-compare.md +84 -0
- package/docs/gates/checks-quality.md +94 -0
- package/docs/gates/checks-size-budget.md +64 -0
- package/docs/gates/checks-suppressions-ratchet.md +51 -0
- package/docs/gates/checks-test-layout.md +77 -0
- package/docs/gates/checks-test.md +75 -0
- package/package.json +11 -2
- package/quality.schema.json +12 -0
- package/scripts/comment-gate.ts +5 -48
- package/scripts/doc-references.ts +190 -0
- package/scripts/doc-rules.ts +6 -5
- package/scripts/doc-snapshot.ts +70 -0
- package/scripts/doc-templates.ts +10 -5
- package/scripts/docs.ts +100 -24
- package/scripts/git.ts +64 -0
- package/scripts/prose-matchers.ts +380 -0
- package/scripts/quality-file.ts +16 -1
- package/templates/changelog.md +5 -5
- package/templates/claude.md +1 -1
- package/templates/readme.md +3 -1
|
@@ -0,0 +1,380 @@
|
|
|
1
|
+
export type ProseFinding = { readonly line: number; readonly message: string };
|
|
2
|
+
|
|
3
|
+
export type LineKind = "prose" | "heading" | "table" | "html" | "definition" | "break" | "blank" | "code" | "front-matter";
|
|
4
|
+
|
|
5
|
+
export type MarkdownLine = {
|
|
6
|
+
readonly line: number;
|
|
7
|
+
readonly kind: LineKind;
|
|
8
|
+
readonly raw: string;
|
|
9
|
+
readonly prose: string;
|
|
10
|
+
readonly code: readonly string[];
|
|
11
|
+
readonly links: readonly string[];
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
export type Reader = "people" | "agents";
|
|
15
|
+
|
|
16
|
+
export type ProseRule = {
|
|
17
|
+
readonly readers: readonly Reader[];
|
|
18
|
+
readonly refuses: string;
|
|
19
|
+
readonly example: string;
|
|
20
|
+
readonly instead: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
type MatchedRule = ProseRule & { readonly find: RegExp };
|
|
24
|
+
|
|
25
|
+
export const ADR_DIRECTORY = "docs/adr/";
|
|
26
|
+
export const DOCS_DIRECTORY = "docs/";
|
|
27
|
+
export const LIVING_NAMES: readonly string[] = ["README.md", "CONTRIBUTING.md"];
|
|
28
|
+
export const AGENT_NAMES: readonly string[] = ["AGENTS.md", "CLAUDE.md"];
|
|
29
|
+
export const HISTORY_NAMES: readonly string[] = ["CHANGELOG.md"];
|
|
30
|
+
export const DATED_RECORD_EXAMPLES: readonly string[] = ["0001-", "2026-05-08-"];
|
|
31
|
+
const DATED_RECORD = /^\d{4}-/;
|
|
32
|
+
|
|
33
|
+
export function isLivingDoc(repositoryPath: string): boolean {
|
|
34
|
+
const name = repositoryPath.slice(repositoryPath.lastIndexOf("/") + 1);
|
|
35
|
+
if (HISTORY_NAMES.includes(name) || AGENT_NAMES.includes(name) || DATED_RECORD.test(name) || repositoryPath.startsWith(ADR_DIRECTORY)) return false;
|
|
36
|
+
return LIVING_NAMES.includes(name) || (repositoryPath.startsWith(DOCS_DIRECTORY) && name.endsWith(".md"));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function readerOf(repositoryPath: string): Reader | undefined {
|
|
40
|
+
if (isLivingDoc(repositoryPath)) return "people";
|
|
41
|
+
return AGENT_NAMES.includes(repositoryPath.slice(repositoryPath.lastIndexOf("/") + 1)) ? "agents" : undefined;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Masking keeps each line's length, so a column in the masked prose is the same column in the raw line.
|
|
45
|
+
const HIDDEN = "\0";
|
|
46
|
+
const BLANK = " ";
|
|
47
|
+
|
|
48
|
+
type Masked = { readonly prose: string; readonly code: string[]; readonly links: string[]; readonly inComment: boolean };
|
|
49
|
+
|
|
50
|
+
const AUTOLINK = /^<(?:[A-Za-z][A-Za-z0-9+.-]{1,31}:[^\s<>]*|[^\s@<>]+@[^\s<>]+)>/;
|
|
51
|
+
const HTML_TAG = /^<\/?[A-Za-z][A-Za-z0-9-]*(?:\s[^<>]*)?\/?>/;
|
|
52
|
+
const BARE_URL = /^https?:\/\/[^\s<>]*[^\s<>.,:;!?'")\]]/;
|
|
53
|
+
const ENTITY = /^&(?:#\d+|#x[0-9a-f]+|[a-z][a-z0-9]*);/i;
|
|
54
|
+
const WORD = /\w/;
|
|
55
|
+
|
|
56
|
+
function hiddenAt(raw: string, at: number, rest: string): { readonly length: number; readonly fill: string } | undefined {
|
|
57
|
+
const char = raw.charAt(at);
|
|
58
|
+
const url = char === "h" && !WORD.test(raw.charAt(at - 1)) ? BARE_URL.exec(rest) : null;
|
|
59
|
+
const visible = url ?? (char === "&" ? ENTITY.exec(rest) : null) ?? (char === "<" ? AUTOLINK.exec(rest) : null);
|
|
60
|
+
if (visible !== null) return { length: visible[0].length, fill: HIDDEN };
|
|
61
|
+
const tag = char === "<" ? HTML_TAG.exec(rest) : null;
|
|
62
|
+
return tag === null ? undefined : { length: tag[0].length, fill: BLANK };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function runOf(raw: string, at: number, char: string): number {
|
|
66
|
+
let end = at;
|
|
67
|
+
while (raw[end] === char) end += 1;
|
|
68
|
+
return end - at;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function closingRun(raw: string, from: number, length: number): number {
|
|
72
|
+
for (let at = raw.indexOf("`", from); at >= 0; at = raw.indexOf("`", at + 1)) {
|
|
73
|
+
const run = runOf(raw, at, "`");
|
|
74
|
+
if (run === length) return at;
|
|
75
|
+
at += run - 1;
|
|
76
|
+
}
|
|
77
|
+
return -1;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function destinationEnd(raw: string, open: number): number {
|
|
81
|
+
let depth = 0;
|
|
82
|
+
for (let at = open + 1; at < raw.length; at += 1) {
|
|
83
|
+
const char = raw[at];
|
|
84
|
+
if (char === "\\") at += 1;
|
|
85
|
+
else if (char === "(") depth += 1;
|
|
86
|
+
else if (char === ")") {
|
|
87
|
+
if (depth === 0) return at;
|
|
88
|
+
depth -= 1;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return -1;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function targetOf(destination: string): string {
|
|
95
|
+
const trimmed = destination.trim();
|
|
96
|
+
if (trimmed.startsWith("<")) return trimmed.slice(1, Math.max(trimmed.indexOf(">"), 1));
|
|
97
|
+
return trimmed.split(/\s/, 1)[0] ?? "";
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function mask(raw: string, startsInComment: boolean): Masked {
|
|
101
|
+
const out = raw.split("");
|
|
102
|
+
const code: string[] = [];
|
|
103
|
+
const links: string[] = [];
|
|
104
|
+
const hide = (from: number, to: number, fill: string): void => {
|
|
105
|
+
for (let at = from; at < to; at += 1) out[at] = fill;
|
|
106
|
+
};
|
|
107
|
+
let inComment = startsInComment;
|
|
108
|
+
let at = 0;
|
|
109
|
+
while (at < raw.length) {
|
|
110
|
+
if (inComment) {
|
|
111
|
+
const close = raw.indexOf("-->", at);
|
|
112
|
+
const end = close < 0 ? raw.length : close + 3;
|
|
113
|
+
hide(at, end, BLANK);
|
|
114
|
+
inComment = close < 0;
|
|
115
|
+
at = end;
|
|
116
|
+
continue;
|
|
117
|
+
}
|
|
118
|
+
const rest = raw.slice(at);
|
|
119
|
+
if (rest.startsWith("<!--")) {
|
|
120
|
+
hide(at, at + 4, BLANK);
|
|
121
|
+
inComment = true;
|
|
122
|
+
at += 4;
|
|
123
|
+
continue;
|
|
124
|
+
}
|
|
125
|
+
if (rest.startsWith("`")) {
|
|
126
|
+
const run = runOf(raw, at, "`");
|
|
127
|
+
const close = closingRun(raw, at + run, run);
|
|
128
|
+
if (close >= 0) {
|
|
129
|
+
code.push(raw.slice(at + run, close).trim());
|
|
130
|
+
hide(at, close + run, HIDDEN);
|
|
131
|
+
at = close + run;
|
|
132
|
+
} else at += run;
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
if (rest.startsWith("](")) {
|
|
136
|
+
const end = destinationEnd(raw, at + 1);
|
|
137
|
+
if (end >= 0) {
|
|
138
|
+
links.push(targetOf(raw.slice(at + 2, end)));
|
|
139
|
+
hide(at + 1, end + 1, HIDDEN);
|
|
140
|
+
at = end + 1;
|
|
141
|
+
continue;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
const hidden = hiddenAt(raw, at, rest);
|
|
145
|
+
if (hidden !== undefined) {
|
|
146
|
+
hide(at, at + hidden.length, hidden.fill);
|
|
147
|
+
at += hidden.length;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
at += 1;
|
|
151
|
+
}
|
|
152
|
+
return { prose: out.join(""), code, links, inComment };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
const FENCE = /^\s*(`{3,}|~{3,})(.*)$/;
|
|
156
|
+
const DEFINITION = /^\s{0,3}\[[^\]]+\]:\s*(\S+)/;
|
|
157
|
+
const HEADING = /^\s{0,3}#{1,6}(?:\s|$)/;
|
|
158
|
+
const BREAK = /^\s{0,3}(?:[-*_](?:\s*[-*_]){2,}|=+|-+)\s*$/;
|
|
159
|
+
const UNDERLINE = /^\s{0,3}(?:=+|-+)\s*$/;
|
|
160
|
+
const HTML_BLOCK = /^\s{0,3}<[A-Za-z/!?]/;
|
|
161
|
+
|
|
162
|
+
function fenceOpener(raw: string): string | undefined {
|
|
163
|
+
const match = FENCE.exec(raw);
|
|
164
|
+
if (match === null) return undefined;
|
|
165
|
+
const [, fence = "", info = ""] = match;
|
|
166
|
+
return fence.startsWith("`") && info.includes("`") ? undefined : fence;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function fenceCloses(raw: string, opener: string): boolean {
|
|
170
|
+
const closer = FENCE.exec(raw);
|
|
171
|
+
return closer !== null && closer[1]?.[0] === opener[0] && (closer[1]?.length ?? 0) >= opener.length && closer[2]?.trim() === "";
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function kindOf(raw: string, prose: string): LineKind {
|
|
175
|
+
if (raw.trim() === "") return "blank";
|
|
176
|
+
if (HEADING.test(raw)) return "heading";
|
|
177
|
+
if (BREAK.test(raw)) return "break";
|
|
178
|
+
if (raw.trimStart().startsWith("|")) return "table";
|
|
179
|
+
if (HTML_BLOCK.test(raw) && !AUTOLINK.test(raw.trimStart())) return "html";
|
|
180
|
+
return prose.trim() === "" ? "blank" : "prose";
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function unread(line: number, raw: string, kind: LineKind): MarkdownLine {
|
|
184
|
+
return { line, kind, raw, prose: BLANK.repeat(raw.length), code: [], links: [] };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function settext(lines: MarkdownLine[]): MarkdownLine[] {
|
|
188
|
+
return lines.map((line, index) => {
|
|
189
|
+
const next = lines[index + 1];
|
|
190
|
+
return line.kind === "prose" && next?.kind === "break" && UNDERLINE.test(next.raw) ? { ...line, kind: "heading" } : line;
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
export function scanMarkdown(text: string): readonly MarkdownLine[] {
|
|
195
|
+
const raws = text.split("\n").map((raw) => raw.replace(/\r$/, ""));
|
|
196
|
+
const lines: MarkdownLine[] = [];
|
|
197
|
+
let fence: string | undefined;
|
|
198
|
+
let inComment = false;
|
|
199
|
+
let inHtmlBlock = false;
|
|
200
|
+
let frontMatter = raws[0] === "---";
|
|
201
|
+
for (const [index, raw] of raws.entries()) {
|
|
202
|
+
const line = index + 1;
|
|
203
|
+
if (frontMatter) {
|
|
204
|
+
frontMatter = index === 0 || (raw !== "---" && raw !== "...");
|
|
205
|
+
lines.push(unread(line, raw, "front-matter"));
|
|
206
|
+
continue;
|
|
207
|
+
}
|
|
208
|
+
if (fence !== undefined) {
|
|
209
|
+
if (fenceCloses(raw, fence)) fence = undefined;
|
|
210
|
+
lines.push(unread(line, raw, "code"));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const opener = inComment ? undefined : fenceOpener(raw);
|
|
214
|
+
if (opener !== undefined) {
|
|
215
|
+
fence = opener;
|
|
216
|
+
lines.push(unread(line, raw, "code"));
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
219
|
+
const definition = inComment ? null : DEFINITION.exec(raw);
|
|
220
|
+
if (definition !== null) {
|
|
221
|
+
lines.push({ ...unread(line, raw, "definition"), links: [targetOf(definition[1] ?? "")] });
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
const masked = mask(raw, inComment);
|
|
225
|
+
inComment = masked.inComment;
|
|
226
|
+
const kind = kindOf(raw, masked.prose);
|
|
227
|
+
// An HTML block runs to the next blank line, whatever its later lines open with.
|
|
228
|
+
inHtmlBlock = kind === "html" || (inHtmlBlock && kind !== "blank");
|
|
229
|
+
lines.push({ line, kind: inHtmlBlock ? "html" : kind, raw, prose: masked.prose, code: masked.code, links: masked.links });
|
|
230
|
+
}
|
|
231
|
+
return settext(lines);
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const LEADING_MARKERS = /^(?:\s*>)*\s*(?:#{1,6}\s+|(?:[-*+]|\d{1,9}[.)])\s+(?:\[[ xX]\]\s+)?)?/;
|
|
235
|
+
|
|
236
|
+
function bodyOf({ prose }: MarkdownLine): string {
|
|
237
|
+
const markers = LEADING_MARKERS.exec(prose)?.[0] ?? "";
|
|
238
|
+
return BLANK.repeat(markers.length) + prose.slice(markers.length);
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
const SEPARATOR_INSTEAD = "End the sentence, or use a comma";
|
|
242
|
+
|
|
243
|
+
const PROMISES: readonly (readonly [shows: string, pattern: string])[] = [
|
|
244
|
+
["until #11", String.raw`until \[?(?:[\w.-]+\/[\w.-]+)?#\d+`],
|
|
245
|
+
["is planned", "(?:is|are) planned"],
|
|
246
|
+
["will soon", "will soon"],
|
|
247
|
+
["coming soon", "coming soon"],
|
|
248
|
+
["in a future release", "in a future (?:release|version)"],
|
|
249
|
+
];
|
|
250
|
+
|
|
251
|
+
const code = (text: string): string => `\`${text}\``;
|
|
252
|
+
const EVERY_READER: readonly Reader[] = ["people", "agents"];
|
|
253
|
+
const PEOPLE: readonly Reader[] = ["people"];
|
|
254
|
+
|
|
255
|
+
const RULES: readonly MatchedRule[] = [
|
|
256
|
+
{ readers: EVERY_READER, refuses: "an em dash", example: code("—"), instead: SEPARATOR_INSTEAD, find: /—/g },
|
|
257
|
+
{ readers: EVERY_READER, refuses: "an en dash", example: code("–"), instead: SEPARATOR_INSTEAD, find: /–/g },
|
|
258
|
+
{
|
|
259
|
+
readers: EVERY_READER,
|
|
260
|
+
refuses: "a parenthesis other than the plural `(s)`",
|
|
261
|
+
example: code("("),
|
|
262
|
+
instead: "Make the aside its own sentence, or set it off with commas",
|
|
263
|
+
find: /\((?!s\))/g,
|
|
264
|
+
},
|
|
265
|
+
{
|
|
266
|
+
readers: EVERY_READER,
|
|
267
|
+
refuses: "a hyphen used as a dash",
|
|
268
|
+
example: `${code("a - b")} or ${code("a -- b")}`,
|
|
269
|
+
instead: SEPARATOR_INSTEAD,
|
|
270
|
+
find: /(?<=[^\s|]) -{1,3} (?=[^\s|])/g,
|
|
271
|
+
},
|
|
272
|
+
{ readers: EVERY_READER, refuses: "a semicolon", example: code(";"), instead: "Use two sentences", find: /;/g },
|
|
273
|
+
{
|
|
274
|
+
readers: PEOPLE,
|
|
275
|
+
refuses: "a promise about the future",
|
|
276
|
+
example: PROMISES.map(([shows]) => code(shows)).join(", "),
|
|
277
|
+
instead: "Say what is true now",
|
|
278
|
+
find: new RegExp(PROMISES.map(([, pattern]) => String.raw`\b${pattern}\b`).join("|"), "gi"),
|
|
279
|
+
},
|
|
280
|
+
{
|
|
281
|
+
readers: PEOPLE,
|
|
282
|
+
refuses: "a sentence that opens by talking about the page",
|
|
283
|
+
example: code("This page explains"),
|
|
284
|
+
instead: "Talk directly about the subject",
|
|
285
|
+
find: /(?<=^\s*|[.!?:]\s+)(?:this|the following|in this) (?:page|topic|section|document|doc|guide|tutorial|article|readme|file|chapter)\b(?: \w+)?/gi,
|
|
286
|
+
},
|
|
287
|
+
];
|
|
288
|
+
|
|
289
|
+
const SECOND_SENTENCE: ProseRule = {
|
|
290
|
+
readers: PEOPLE,
|
|
291
|
+
refuses: "a second sentence on one line",
|
|
292
|
+
example: code("It builds. It ships."),
|
|
293
|
+
instead: "Start it on its own line",
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
const RUN_ON: ProseRule = {
|
|
297
|
+
readers: PEOPLE,
|
|
298
|
+
refuses: "a sentence that runs across lines",
|
|
299
|
+
example: `${code("It builds")} with ${code("and ships.")} on the next line`,
|
|
300
|
+
instead: "Join the sentence onto one line",
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
export const PROSE_RULES: readonly ProseRule[] = [
|
|
304
|
+
...RULES.map(({ readers, refuses, example, instead }) => ({ readers, refuses, example, instead })),
|
|
305
|
+
SECOND_SENTENCE,
|
|
306
|
+
RUN_ON,
|
|
307
|
+
];
|
|
308
|
+
|
|
309
|
+
const JUDGED_KINDS: ReadonlySet<LineKind> = new Set(["prose", "heading", "table", "html"]);
|
|
310
|
+
|
|
311
|
+
function ruleFindings(line: MarkdownLine, reader: Reader): ProseFinding[] {
|
|
312
|
+
const body = bodyOf(line);
|
|
313
|
+
return RULES.filter(({ readers }) => readers.includes(reader)).flatMap(({ refuses, instead, find }) =>
|
|
314
|
+
[...body.matchAll(find)].map(([match]) => ({ line: line.line, message: `carries \`${match.trim()}\`, ${refuses}. ${instead}` })),
|
|
315
|
+
);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
const SENTENCE_BREAK = /(?<=[^\s.!?])[.!?]["'”’)\]*_]*\s+(?=[A-Z"“*_[\0])/g;
|
|
319
|
+
const ABBREVIATION = /\b(?:e\.g|i\.e|etc|vs|cf|approx|Mr|Mrs|Ms|Dr|St|No|Fig)$/i;
|
|
320
|
+
const SENTENCE_END = /[.!?:]["'”’)\]*_]*\s*$/;
|
|
321
|
+
const LIST_ITEM = /^(?:\s*>)*\s*(?:[-*+]|\d{1,9}[.)])(?:\s|$)/;
|
|
322
|
+
const QUOTE_DEPTH = /^(?:\s*>)*/;
|
|
323
|
+
|
|
324
|
+
// A bold label that opens a line, as in **Status.**, heads the sentence after it rather than being one.
|
|
325
|
+
const RUN_IN_LABEL = /^\s*(\*\*|__)(?:(?!\1).)+?[.!?:]\1\s/;
|
|
326
|
+
|
|
327
|
+
function secondSentence(line: MarkdownLine): ProseFinding | undefined {
|
|
328
|
+
const body = bodyOf(line);
|
|
329
|
+
const label = RUN_IN_LABEL.exec(body)?.[0].length ?? 0;
|
|
330
|
+
for (const match of body.matchAll(SENTENCE_BREAK)) {
|
|
331
|
+
if (match.index < label || ABBREVIATION.test(body.slice(0, match.index))) continue;
|
|
332
|
+
const opening = line.raw.slice(match.index + match[0].length).split(/\s+/, 3).join(" ").replace(/[.!?,:]+$/, "");
|
|
333
|
+
return { line: line.line, message: `carries ${SECOND_SENTENCE.refuses}, which opens with \`${opening}\`. ${SECOND_SENTENCE.instead}` };
|
|
334
|
+
}
|
|
335
|
+
return undefined;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const OPENS_LOWERCASE = /^\s*[a-z]/;
|
|
339
|
+
|
|
340
|
+
// A sentence may end inside the code or link that closes its line, so only a lowercase next line proves it runs on.
|
|
341
|
+
function endsMidSentence(line: MarkdownLine, next: MarkdownLine): boolean {
|
|
342
|
+
const body = bodyOf(line).trimEnd();
|
|
343
|
+
if (SENTENCE_END.test(body) || line.raw.endsWith(" ") || line.raw.endsWith("\\")) return false;
|
|
344
|
+
return !body.endsWith(HIDDEN) || OPENS_LOWERCASE.test(bodyOf(next));
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function continues(earlier: MarkdownLine | undefined, later: MarkdownLine | undefined): boolean {
|
|
348
|
+
if (earlier?.kind !== "prose" || later?.kind !== "prose" || LIST_ITEM.test(later.raw)) return false;
|
|
349
|
+
return QUOTE_DEPTH.exec(earlier.raw)?.[0].split(">").length === QUOTE_DEPTH.exec(later.raw)?.[0].split(">").length;
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
function spansLines(lines: readonly MarkdownLine[], index: number): ProseFinding | undefined {
|
|
353
|
+
const line = lines[index];
|
|
354
|
+
if (line === undefined) return undefined;
|
|
355
|
+
const next = lines[index + 1];
|
|
356
|
+
const previous = lines[index - 1];
|
|
357
|
+
const forward = next !== undefined && continues(line, next) && endsMidSentence(line, next);
|
|
358
|
+
const back = previous !== undefined && continues(previous, line) && endsMidSentence(previous, line);
|
|
359
|
+
if (!forward && !back) return undefined;
|
|
360
|
+
return { line: line.line, message: `carries ${RUN_ON.refuses}. ${RUN_ON.instead}` };
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
export function proseFindings(text: string, reader: Reader, within?: ReadonlySet<number>): readonly ProseFinding[] {
|
|
364
|
+
const lines = scanMarkdown(text);
|
|
365
|
+
return lines.flatMap((line, index) => {
|
|
366
|
+
if ((within !== undefined && !within.has(line.line)) || !JUDGED_KINDS.has(line.kind)) return [];
|
|
367
|
+
const prose = line.kind === "prose";
|
|
368
|
+
const sentences = [
|
|
369
|
+
prose && SECOND_SENTENCE.readers.includes(reader) ? secondSentence(line) : undefined,
|
|
370
|
+
prose && RUN_ON.readers.includes(reader) ? spansLines(lines, index) : undefined,
|
|
371
|
+
];
|
|
372
|
+
return [...ruleFindings(line, reader), ...sentences.filter((finding) => finding !== undefined)];
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
export function proseRefused(repositoryPath: string, text: string, within?: ReadonlySet<number>): string[] {
|
|
377
|
+
const reader = readerOf(repositoryPath);
|
|
378
|
+
if (reader === undefined) return [];
|
|
379
|
+
return proseFindings(text, reader, within).map(({ line, message }) => `${repositoryPath}:${line} ${message}`);
|
|
380
|
+
}
|
package/scripts/quality-file.ts
CHANGED
|
@@ -20,6 +20,16 @@ const PathGlob = Schema.String.check(
|
|
|
20
20
|
"A glob from the repository root that oxlint, the Effect language service and git read alike: a directory first, * within a segment, ** as a whole one, a file name with an extension last, and no braces, ?, [ or leading ./",
|
|
21
21
|
});
|
|
22
22
|
|
|
23
|
+
// Only checks-docs reads a doc glob, matching it from the root, so a file at the root names itself.
|
|
24
|
+
const DocGlob = Schema.String.check(
|
|
25
|
+
Schema.isPattern(new RegExp(`^(?:${SEGMENT}/)*${FILE}$`), {
|
|
26
|
+
expected: "a glob from the repository root such as README.md or docs/**/*.md: * within a segment, ** as a whole one, a file name with an extension last",
|
|
27
|
+
}),
|
|
28
|
+
).annotate({
|
|
29
|
+
identifier: "DocGlob",
|
|
30
|
+
description: "A glob from the repository root that checks-docs reads: * within a segment, ** as a whole one, a file name with an extension last",
|
|
31
|
+
});
|
|
32
|
+
|
|
23
33
|
const LITERAL_SEGMENT = String.raw`(?!\.\.?(?:/|$))[\w.@+-]+`;
|
|
24
34
|
|
|
25
35
|
const DirectoryPath = Schema.String.check(
|
|
@@ -166,6 +176,11 @@ const Docs = Schema.Struct({
|
|
|
166
176
|
description: "The Diátaxis mode of each page, whose template checks-docs holds the page to; a page under docs/ needs one",
|
|
167
177
|
}),
|
|
168
178
|
),
|
|
179
|
+
forConsumers: Schema.optionalKey(
|
|
180
|
+
Schema.Array(DocGlob).annotate({
|
|
181
|
+
description: "The living docs that speak to a repository installing this one, whose bun run commands checks-docs does not hold to this package.json",
|
|
182
|
+
}),
|
|
183
|
+
),
|
|
169
184
|
});
|
|
170
185
|
export type Docs = typeof Docs.Type;
|
|
171
186
|
|
|
@@ -221,7 +236,7 @@ export const Quality = Schema.Struct({
|
|
|
221
236
|
);
|
|
222
237
|
export type Quality = typeof Quality.Type;
|
|
223
238
|
|
|
224
|
-
const LegacyManifest = Schema.Struct({
|
|
239
|
+
export const LegacyManifest = Schema.Struct({
|
|
225
240
|
ciWiring: Schema.optionalKey(
|
|
226
241
|
Schema.Struct({
|
|
227
242
|
gates: Schema.optionalKey(Schema.NonEmptyArray(Command)),
|
package/templates/changelog.md
CHANGED
|
@@ -10,28 +10,28 @@ Released <YYYY-MM-DD>.
|
|
|
10
10
|
|
|
11
11
|
<Leave this section out when the release holds no such commit.>
|
|
12
12
|
|
|
13
|
-
-
|
|
13
|
+
- **<the commit's scope, when it has one>:** <its description>
|
|
14
14
|
|
|
15
15
|
### Features
|
|
16
16
|
|
|
17
17
|
<Leave this section out when the release holds no such commit.>
|
|
18
18
|
|
|
19
|
-
-
|
|
19
|
+
- **<the commit's scope, when it has one>:** <its description>
|
|
20
20
|
|
|
21
21
|
### Fixes
|
|
22
22
|
|
|
23
23
|
<Leave this section out when the release holds no such commit.>
|
|
24
24
|
|
|
25
|
-
-
|
|
25
|
+
- **<the commit's scope, when it has one>:** <its description>
|
|
26
26
|
|
|
27
27
|
### Performance
|
|
28
28
|
|
|
29
29
|
<Leave this section out when the release holds no such commit.>
|
|
30
30
|
|
|
31
|
-
-
|
|
31
|
+
- **<the commit's scope, when it has one>:** <its description>
|
|
32
32
|
|
|
33
33
|
### Reverts
|
|
34
34
|
|
|
35
35
|
<Leave this section out when the release holds no such commit.>
|
|
36
36
|
|
|
37
|
-
-
|
|
37
|
+
- **<the commit's scope, when it has one>:** <its description>
|
package/templates/claude.md
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
<!-- Points Claude at AGENTS.md via import
|
|
1
|
+
<!-- Points Claude at AGENTS.md via import; edit AGENTS.md, not this file. -->
|
|
2
2
|
@AGENTS.md
|
package/templates/readme.md
CHANGED
|
@@ -15,13 +15,15 @@ To install <name>:
|
|
|
15
15
|
|
|
16
16
|
<What you see when it worked.>
|
|
17
17
|
|
|
18
|
-
## <Everyday task, verb first>
|
|
18
|
+
## <Everyday task, verb first, or what the reader looks up>
|
|
19
19
|
|
|
20
20
|
To <do the task>:
|
|
21
21
|
|
|
22
22
|
1. <step>
|
|
23
23
|
1. <step>
|
|
24
24
|
|
|
25
|
+
<Or, for what the reader looks up, a table or a list with no steps.>
|
|
26
|
+
|
|
25
27
|
## Where things are
|
|
26
28
|
|
|
27
29
|
| Path | What it holds |
|