@avi2dg/checks 0.11.0 → 0.13.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/README.md +334 -151
- package/dist/feature-rules.js +255 -0
- package/package.json +29 -5
- package/quality.schema.json +191 -1
- package/scripts/doc-outline.ts +215 -0
- package/scripts/doc-rules.ts +176 -0
- package/scripts/doc-templates.ts +203 -0
- package/scripts/docs.ts +90 -0
- package/scripts/feature-owners.ts +142 -0
- package/scripts/gates.ts +3 -0
- package/scripts/git.ts +70 -14
- package/scripts/quality-file.ts +135 -4
- package/scripts/quality.ts +10 -2
- package/scripts/size-budget.ts +158 -0
- package/templates/adr.md +29 -0
- package/templates/agents.md +17 -0
- package/templates/changelog.md +37 -0
- package/templates/claude.md +2 -0
- package/templates/explanation.md +15 -0
- package/templates/how-to.md +33 -0
- package/templates/readme.md +43 -0
- package/templates/reference.md +15 -0
- package/templates/tutorial.md +31 -0
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
export type Line = {
|
|
2
|
+
readonly line: number;
|
|
3
|
+
readonly text: string;
|
|
4
|
+
};
|
|
5
|
+
|
|
6
|
+
export type Heading = {
|
|
7
|
+
readonly level: number;
|
|
8
|
+
readonly title: string;
|
|
9
|
+
readonly line: number;
|
|
10
|
+
};
|
|
11
|
+
|
|
12
|
+
export type Section = {
|
|
13
|
+
readonly heading: Heading;
|
|
14
|
+
readonly body: readonly Line[];
|
|
15
|
+
readonly subsections: readonly Section[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type Outline = {
|
|
19
|
+
readonly lines: readonly Line[];
|
|
20
|
+
readonly prose: readonly Line[];
|
|
21
|
+
readonly headings: readonly Heading[];
|
|
22
|
+
readonly lead: readonly Line[];
|
|
23
|
+
readonly sections: readonly Section[];
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
export type Violation = {
|
|
27
|
+
readonly line: number;
|
|
28
|
+
readonly message: string;
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
export type HeadingRule = "any" | "version";
|
|
32
|
+
|
|
33
|
+
export type Presence = { readonly required: true } | { readonly required: false; readonly omitWhen: string };
|
|
34
|
+
|
|
35
|
+
type SlotShape = {
|
|
36
|
+
readonly presence: Presence;
|
|
37
|
+
readonly body: readonly string[];
|
|
38
|
+
readonly subsections?: readonly Slot[];
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
export type FixedSlot = SlotShape & {
|
|
42
|
+
readonly type: "fixed";
|
|
43
|
+
readonly text: string;
|
|
44
|
+
readonly also?: readonly string[];
|
|
45
|
+
};
|
|
46
|
+
|
|
47
|
+
export type OpenSlot = SlotShape & {
|
|
48
|
+
readonly type: "open";
|
|
49
|
+
readonly placeholder: string;
|
|
50
|
+
readonly rule: HeadingRule;
|
|
51
|
+
readonly interleaved?: true;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export type Slot = FixedSlot | OpenSlot;
|
|
55
|
+
|
|
56
|
+
const FENCE = /^ {0,3}(`{3,}|~{3,})/;
|
|
57
|
+
const HEADING = /^ {0,3}(#{1,6})(?:[ \t]+|$)(.*)$/;
|
|
58
|
+
const CLOSING_HASHES = /(?:^|[ \t]+)#+[ \t]*$/;
|
|
59
|
+
|
|
60
|
+
function fenceCloses(text: string, opener: string): boolean {
|
|
61
|
+
const closer = FENCE.exec(text)?.[1];
|
|
62
|
+
return closer !== undefined && closer[0] === opener[0] && closer.length >= opener.length && text.trim() === closer;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function headingOf({ line, text }: Line): Heading | undefined {
|
|
66
|
+
const match = HEADING.exec(text);
|
|
67
|
+
if (match === null) return undefined;
|
|
68
|
+
const [, hashes = "", rest = ""] = match;
|
|
69
|
+
return { level: hashes.length, title: rest.replace(CLOSING_HASHES, "").trim(), line };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function between(lines: readonly Line[], after: number, before: number): readonly Line[] {
|
|
73
|
+
return lines.filter(({ line }) => line > after && line < before);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function sectionsAt(level: number, headings: readonly Heading[], lines: readonly Line[], end: number): Section[] {
|
|
77
|
+
return headings.flatMap((heading, index) => {
|
|
78
|
+
if (heading.level !== level) return [];
|
|
79
|
+
const next = headings.slice(index + 1);
|
|
80
|
+
const close = next.find((other) => other.level <= level)?.line ?? end;
|
|
81
|
+
const within = next.filter((other) => other.line < close);
|
|
82
|
+
return [
|
|
83
|
+
{
|
|
84
|
+
heading,
|
|
85
|
+
body: between(lines, heading.line, within[0]?.line ?? close),
|
|
86
|
+
subsections: sectionsAt(level + 1, within, lines, close),
|
|
87
|
+
},
|
|
88
|
+
];
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function parseOutline(text: string): Outline {
|
|
93
|
+
const lines = text.split("\n").map((raw, index) => ({ line: index + 1, text: raw.replace(/\r$/, "") }));
|
|
94
|
+
const prose: Line[] = [];
|
|
95
|
+
const headings: Heading[] = [];
|
|
96
|
+
let fence: string | undefined;
|
|
97
|
+
for (const line of lines) {
|
|
98
|
+
if (fence !== undefined) {
|
|
99
|
+
if (fenceCloses(line.text, fence)) fence = undefined;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const opener = FENCE.exec(line.text)?.[1];
|
|
103
|
+
if (opener !== undefined) {
|
|
104
|
+
fence = opener;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
prose.push(line);
|
|
108
|
+
const heading = headingOf(line);
|
|
109
|
+
if (heading !== undefined) headings.push(heading);
|
|
110
|
+
}
|
|
111
|
+
const end = lines.length + 1;
|
|
112
|
+
const titleLine = headings[0]?.level === 1 ? headings[0].line : 0;
|
|
113
|
+
return {
|
|
114
|
+
lines,
|
|
115
|
+
prose,
|
|
116
|
+
headings,
|
|
117
|
+
lead: between(lines, titleLine, headings.find((heading) => heading.line > titleLine)?.line ?? end),
|
|
118
|
+
sections: sectionsAt(2, headings, lines, end),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
export function firstText(lines: readonly Line[]): Line | undefined {
|
|
123
|
+
return lines.find(({ text }) => text.trim() !== "");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function marked(level: number, title: string): string {
|
|
127
|
+
return `\`${"#".repeat(level)} ${title}\``;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
export const VERSION = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
|
|
131
|
+
|
|
132
|
+
export function ruleProblem(rule: HeadingRule, title: string): string | undefined {
|
|
133
|
+
if (rule === "version") return VERSION.test(title) ? undefined : "is not a version such as 1.2.0";
|
|
134
|
+
return undefined;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export function slotLabel(slot: Slot): string {
|
|
138
|
+
return slot.type === "fixed" ? slot.text : slot.placeholder;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function fixedIndexOf(slots: readonly Slot[], title: string): number {
|
|
142
|
+
return slots.findIndex((slot) => slot.type === "fixed" && (slot.text === title || (slot.also ?? []).includes(title)));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function openIndexFrom(slots: readonly Slot[], at: number): { readonly index: number; readonly advances: boolean } {
|
|
146
|
+
const current = slots[at];
|
|
147
|
+
if (current?.type === "open") return { index: at, advances: false };
|
|
148
|
+
const ahead = slots.findIndex((slot, index) => index > at && slot.type === "open");
|
|
149
|
+
if (ahead !== -1) return { index: ahead, advances: true };
|
|
150
|
+
const behind = slots.findLastIndex((slot, index) => index < at && slot.type === "open");
|
|
151
|
+
const reachable = slots[behind];
|
|
152
|
+
return { index: reachable?.type === "open" && reachable.interleaved === true ? behind : -1, advances: false };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function missing(slot: Slot, level: number): string {
|
|
156
|
+
return slot.type === "fixed" ? `lacks ${marked(level, slot.text)}` : `lacks a ${marked(level, slot.placeholder)} section`;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export function matchSections(sections: readonly Section[], slots: readonly Slot[], level: number, parentLine: number): Violation[] {
|
|
160
|
+
const order = `the template's order is ${slots.map(slotLabel).join(", ")}`;
|
|
161
|
+
const violations: Violation[] = [];
|
|
162
|
+
const found = slots.map(() => false);
|
|
163
|
+
let at = -1;
|
|
164
|
+
for (const { heading, subsections } of sections) {
|
|
165
|
+
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}` });
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
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}` });
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
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));
|
|
185
|
+
}
|
|
186
|
+
slots.forEach((slot, index) => {
|
|
187
|
+
if (slot.presence.required && found[index] !== true) violations.push({ line: parentLine, message: missing(slot, level) });
|
|
188
|
+
});
|
|
189
|
+
return violations;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const BANNED_TITLES = new Set(["overview", "introduction", "how it works"]);
|
|
193
|
+
|
|
194
|
+
export function outlineProblems({ headings, lead }: Outline): Violation[] {
|
|
195
|
+
const violations: Violation[] = [];
|
|
196
|
+
const [first] = headings;
|
|
197
|
+
if (first === undefined || first.level !== 1 || first.line !== 1) {
|
|
198
|
+
violations.push({ line: 1, message: "does not open with a `# ` title on its first line" });
|
|
199
|
+
}
|
|
200
|
+
headings.forEach((heading, index) => {
|
|
201
|
+
const named = marked(heading.level, heading.title);
|
|
202
|
+
if (heading.level === 1 && index > 0) violations.push({ line: heading.line, message: `${named} is a second title` });
|
|
203
|
+
const previous = headings[index - 1];
|
|
204
|
+
if (previous !== undefined && heading.level > previous.level + 1) {
|
|
205
|
+
violations.push({ line: heading.line, message: `${named} skips a level under ${marked(previous.level, previous.title)}` });
|
|
206
|
+
}
|
|
207
|
+
if (BANNED_TITLES.has(heading.title.toLowerCase())) {
|
|
208
|
+
violations.push({ line: heading.line, message: `${named} names no topic; title it by what the reader does or looks up` });
|
|
209
|
+
}
|
|
210
|
+
});
|
|
211
|
+
if (first?.level === 1 && firstText(lead) === undefined) {
|
|
212
|
+
violations.push({ line: first.line, message: "has nothing between its title and its first section" });
|
|
213
|
+
}
|
|
214
|
+
return violations;
|
|
215
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import {
|
|
2
|
+
firstText,
|
|
3
|
+
marked,
|
|
4
|
+
matchSections,
|
|
5
|
+
outlineProblems,
|
|
6
|
+
parseOutline,
|
|
7
|
+
ruleProblem,
|
|
8
|
+
type Heading,
|
|
9
|
+
type Outline,
|
|
10
|
+
type Violation,
|
|
11
|
+
VERSION,
|
|
12
|
+
} from "./doc-outline.ts";
|
|
13
|
+
import { ADR_STATUSES, TEMPLATES, templateFile, type Kind, type Title } from "./doc-templates.ts";
|
|
14
|
+
import { MODES, type Docs, type Mode } from "./quality-file.ts";
|
|
15
|
+
|
|
16
|
+
export type Placement =
|
|
17
|
+
| { readonly type: "judged"; readonly kind: Kind }
|
|
18
|
+
| { readonly type: "undeclared" }
|
|
19
|
+
| { readonly type: "ambiguous"; readonly modes: readonly Mode[] }
|
|
20
|
+
| { readonly type: "unjudged" };
|
|
21
|
+
|
|
22
|
+
export type Doc = {
|
|
23
|
+
readonly path: string;
|
|
24
|
+
readonly text: string;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export const ADR_DIRECTORY = "docs/adr/";
|
|
28
|
+
// A generated index takes its shape from its generator.
|
|
29
|
+
const ADR_INDEX = `${ADR_DIRECTORY}README.md`;
|
|
30
|
+
const DOCS_DIRECTORY = "docs/";
|
|
31
|
+
|
|
32
|
+
const ROOT_FILES = new Map<string, Kind>([
|
|
33
|
+
["README.md", "readme"],
|
|
34
|
+
["CHANGELOG.md", "changelog"],
|
|
35
|
+
["AGENTS.md", "agents"],
|
|
36
|
+
["CLAUDE.md", "claude"],
|
|
37
|
+
]);
|
|
38
|
+
|
|
39
|
+
export function placementOf(path: string, docs: Docs | undefined): Placement {
|
|
40
|
+
const root = ROOT_FILES.get(path);
|
|
41
|
+
if (root !== undefined) return { type: "judged", kind: root };
|
|
42
|
+
if (!path.endsWith(".md") || path === ADR_INDEX) return { type: "unjudged" };
|
|
43
|
+
if (path.startsWith(ADR_DIRECTORY)) return { type: "judged", kind: "adr" };
|
|
44
|
+
const modes = MODES.filter((mode) => (docs?.pages?.[mode] ?? []).some((glob) => new Bun.Glob(glob).match(path)));
|
|
45
|
+
const [mode, ...others] = modes;
|
|
46
|
+
if (mode !== undefined) return others.length === 0 ? { type: "judged", kind: mode } : { type: "ambiguous", modes };
|
|
47
|
+
return path.startsWith(DOCS_DIRECTORY) ? { type: "undeclared" } : { type: "unjudged" };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function titleOf({ headings: [first] }: Outline): Heading | undefined {
|
|
51
|
+
return first?.level === 1 && first.line === 1 ? first : undefined;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function titleProblems(title: Title, heading: Heading): readonly Violation[] {
|
|
55
|
+
const named = marked(1, heading.title);
|
|
56
|
+
const at = (message: string) => [{ line: heading.line, message: `${named} ${message}` }];
|
|
57
|
+
if (title.type === "fixed") return heading.title === title.text ? [] : at(`is not the template's title, ${marked(1, title.text)}`);
|
|
58
|
+
const prefix = title.prefix ?? "";
|
|
59
|
+
if (!heading.title.startsWith(prefix)) return at(`does not open with \`${prefix}\``);
|
|
60
|
+
const problem = ruleProblem(title.rule, heading.title.slice(prefix.length));
|
|
61
|
+
return problem === undefined ? [] : at(problem);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/;
|
|
65
|
+
|
|
66
|
+
function isDate(text: string | undefined): boolean {
|
|
67
|
+
if (text === undefined || !ISO_DATE.test(text)) return false;
|
|
68
|
+
const parsed = new Date(`${text}T00:00:00Z`);
|
|
69
|
+
return !Number.isNaN(parsed.getTime()) && parsed.toISOString().startsWith(text);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const RECORD_NAME = /^(\d{4})-[a-z0-9]+(?:-[a-z0-9]+)*\.md$/;
|
|
73
|
+
const RECORD_TITLE = /^(\d+)\. \S/;
|
|
74
|
+
const DATE_LINE = /^Date: (\S+)$/;
|
|
75
|
+
|
|
76
|
+
function recordNumber(path: string): number | undefined {
|
|
77
|
+
const name = RECORD_NAME.exec(path.slice(ADR_DIRECTORY.length))?.[1];
|
|
78
|
+
return name === undefined ? undefined : Number(name);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function statusProblem(outline: Outline): Violation | undefined {
|
|
82
|
+
const status = outline.sections.find(({ heading }) => heading.title === "Status");
|
|
83
|
+
if (status === undefined) return undefined;
|
|
84
|
+
const opening = firstText(status.body);
|
|
85
|
+
const word = opening?.text.trim().split(/\s+/, 1)[0]?.replace(/[.,;:]+$/, "");
|
|
86
|
+
if (ADR_STATUSES.some((known) => known === word)) return undefined;
|
|
87
|
+
const found = word === undefined ? "nothing" : `\`${word}\``;
|
|
88
|
+
return { line: opening?.line ?? status.heading.line, message: `\`## Status\` opens with ${found} where one of ${ADR_STATUSES.join(", ")} goes` };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function adrProblems(path: string, outline: Outline, records: readonly string[]): readonly Violation[] {
|
|
92
|
+
const violations: Violation[] = [];
|
|
93
|
+
const filed = recordNumber(path);
|
|
94
|
+
if (filed === undefined) {
|
|
95
|
+
violations.push({ line: 1, message: `is not named as a record, a four-digit number and a kebab-case name directly in ${ADR_DIRECTORY}` });
|
|
96
|
+
}
|
|
97
|
+
const title = titleOf(outline);
|
|
98
|
+
const titled = title === undefined ? undefined : RECORD_TITLE.exec(title.title)?.[1];
|
|
99
|
+
if (title !== undefined && titled === undefined) {
|
|
100
|
+
violations.push({ line: title.line, message: `${marked(1, title.title)} does not open with the record's number, as in \`# 7. The decision\`` });
|
|
101
|
+
}
|
|
102
|
+
if (title !== undefined && titled !== undefined && filed !== undefined && Number(titled) !== filed) {
|
|
103
|
+
violations.push({ line: title.line, message: `${marked(1, title.title)} carries number ${titled}, and the file name ${filed}` });
|
|
104
|
+
}
|
|
105
|
+
const dated = firstText(outline.lead);
|
|
106
|
+
if (!isDate(DATE_LINE.exec(dated?.text.trim() ?? "")?.[1])) {
|
|
107
|
+
violations.push({ line: dated?.line ?? 1, message: "does not follow its title with a `Date: YYYY-MM-DD` line" });
|
|
108
|
+
}
|
|
109
|
+
const status = statusProblem(outline);
|
|
110
|
+
if (status !== undefined) violations.push(status);
|
|
111
|
+
const sharing = records.filter((other) => other !== path && filed !== undefined && recordNumber(other) === filed);
|
|
112
|
+
if (sharing.length > 0) violations.push({ line: 1, message: `shares number ${filed} with ${sharing.join(", ")}` });
|
|
113
|
+
return violations;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const RELEASED = /^Released (\S+)\.$/;
|
|
117
|
+
|
|
118
|
+
function changelogProblems({ sections }: Outline): readonly Violation[] {
|
|
119
|
+
const releases = sections.filter(({ heading }) => VERSION.test(heading.title));
|
|
120
|
+
return releases.flatMap(({ heading, body }, index) => {
|
|
121
|
+
const named = marked(2, heading.title);
|
|
122
|
+
const dated = firstText(body);
|
|
123
|
+
const violations: Violation[] = [];
|
|
124
|
+
if (!isDate(RELEASED.exec(dated?.text.trim() ?? "")?.[1])) {
|
|
125
|
+
violations.push({ line: dated?.line ?? heading.line, message: `${named} does not open with a \`Released YYYY-MM-DD.\` line` });
|
|
126
|
+
}
|
|
127
|
+
const newer = releases[index - 1]?.heading.title;
|
|
128
|
+
if (newer !== undefined && Bun.semver.order(newer, heading.title) !== 1) {
|
|
129
|
+
violations.push({ line: heading.line, message: `${named} follows ${marked(2, newer)}, and releases run newest first` });
|
|
130
|
+
}
|
|
131
|
+
return violations;
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const STEP = /^ {0,3}\d+[.)][ \t]+\S/;
|
|
136
|
+
|
|
137
|
+
function stepsProblems(kind: Kind, { prose }: Outline): readonly Violation[] {
|
|
138
|
+
if (prose.some(({ text }) => STEP.test(text))) return [];
|
|
139
|
+
return [{ line: 1, message: `numbers no steps, which a ${kind} page lists as \`1.\` items` }];
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
function kindProblems(kind: Kind, doc: Doc, outline: Outline, records: readonly string[]): readonly Violation[] {
|
|
143
|
+
if (kind === "adr") return adrProblems(doc.path, outline, records);
|
|
144
|
+
if (kind === "changelog") return changelogProblems(outline);
|
|
145
|
+
if (kind === "how-to" || kind === "tutorial") return stepsProblems(kind, outline);
|
|
146
|
+
return [];
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function exactProblems(kind: Kind, expected: string, actual: string): readonly Violation[] {
|
|
150
|
+
if (actual === expected) return [];
|
|
151
|
+
const want = expected.split("\n");
|
|
152
|
+
const have = actual.split("\n");
|
|
153
|
+
const line = want.findIndex((text, index) => have[index] !== text) + 1 || want.length + 1;
|
|
154
|
+
return [{ line, message: `differs from ${templateFile(kind)}, which it holds word for word` }];
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function judge(kind: Kind, doc: Doc, records: readonly string[]): readonly Violation[] {
|
|
158
|
+
const template = TEMPLATES[kind];
|
|
159
|
+
if (template.shape === "exact") return exactProblems(kind, template.text, doc.text);
|
|
160
|
+
const outline = parseOutline(doc.text);
|
|
161
|
+
const title = titleOf(outline);
|
|
162
|
+
return [
|
|
163
|
+
...outlineProblems(outline),
|
|
164
|
+
...(title === undefined ? [] : titleProblems(template.title, title)),
|
|
165
|
+
...matchSections(outline.sections, template.sections, 2, title?.line ?? 1),
|
|
166
|
+
...kindProblems(kind, doc, outline, records),
|
|
167
|
+
].toSorted((a, b) => a.line - b.line);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function placementProblem(placement: Placement): string | undefined {
|
|
171
|
+
if (placement.type === "undeclared") {
|
|
172
|
+
return `is a page under ${DOCS_DIRECTORY} with no mode; declare it under docs.pages in quality.json as ${MODES.join(", ")}`;
|
|
173
|
+
}
|
|
174
|
+
if (placement.type === "ambiguous") return `is declared under docs.pages as ${placement.modes.join(" and ")}, and a page has one mode`;
|
|
175
|
+
return undefined;
|
|
176
|
+
}
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
import { slotLabel, type FixedSlot, type HeadingRule, type OpenSlot, type Presence, type Slot } from "./doc-outline.ts";
|
|
2
|
+
import { MODES } from "./quality-file.ts";
|
|
3
|
+
|
|
4
|
+
export const KINDS = ["readme", "changelog", "adr", "agents", "claude", ...MODES] as const;
|
|
5
|
+
export type Kind = (typeof KINDS)[number];
|
|
6
|
+
|
|
7
|
+
export type Title =
|
|
8
|
+
| { readonly type: "fixed"; readonly text: string }
|
|
9
|
+
| { readonly type: "open"; readonly placeholder: string; readonly rule: HeadingRule; readonly prefix?: string };
|
|
10
|
+
|
|
11
|
+
export type Outlined = {
|
|
12
|
+
readonly shape: "outline";
|
|
13
|
+
readonly title: Title;
|
|
14
|
+
readonly lead: readonly string[];
|
|
15
|
+
readonly sections: readonly Slot[];
|
|
16
|
+
};
|
|
17
|
+
|
|
18
|
+
export type Exact = {
|
|
19
|
+
readonly shape: "exact";
|
|
20
|
+
readonly text: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export type Template = Outlined | Exact;
|
|
24
|
+
|
|
25
|
+
export const TEMPLATE_DIRECTORY = "templates";
|
|
26
|
+
|
|
27
|
+
export const ADR_STATUSES = ["Proposed", "Accepted", "Rejected", "Deprecated", "Superseded", "Retired"] as const;
|
|
28
|
+
|
|
29
|
+
const REQUIRED: Presence = { required: true };
|
|
30
|
+
|
|
31
|
+
function optional(omitWhen: string): Presence {
|
|
32
|
+
return { required: false, omitWhen };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function fixed(text: string, presence: Presence, body: readonly string[], more: Partial<FixedSlot> = {}): FixedSlot {
|
|
36
|
+
return { type: "fixed", text, presence, body, ...more };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function open(placeholder: string, rule: HeadingRule, presence: Presence, body: readonly string[], more: Partial<OpenSlot> = {}): OpenSlot {
|
|
40
|
+
return { type: "open", placeholder, rule, presence, body, ...more };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function listed(words: readonly string[]): string {
|
|
44
|
+
return words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} or ${words.at(-1) ?? ""}`;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const TROUBLESHOOTING = fixed("Troubleshooting", optional("no reader has met a failure worth naming yet"), [], {
|
|
48
|
+
subsections: [open("<The symptom, or the error text>", "any", REQUIRED, ["<The cause.>", "<The resolution.>"])],
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const RELATED_TOPICS = fixed("Related topics", optional("there is no other page to send the reader to"), [
|
|
52
|
+
"- [<page title>](<path to the page>)",
|
|
53
|
+
]);
|
|
54
|
+
|
|
55
|
+
const BEFORE_YOU_BEGIN = fixed("Before you begin", REQUIRED, ["- <each prerequisite, with the version it is tested on>"]);
|
|
56
|
+
|
|
57
|
+
const STEPS = ["To <do the task>:", "", "1. <step>", "1. <step>"];
|
|
58
|
+
|
|
59
|
+
const MAINTAINING = [
|
|
60
|
+
"Keep this file for knowledge useful to almost every future agent session in this project.",
|
|
61
|
+
"Do not repeat what the codebase already shows.",
|
|
62
|
+
"Point to the authoritative file or command instead.",
|
|
63
|
+
"Prefer rewriting or pruning existing entries over appending new ones.",
|
|
64
|
+
"When updating this file, preserve this bar for all agents and keep entries concise.",
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
const CHANGE_GROUPS = ["Breaking changes", "Features", "Fixes", "Performance", "Reverts"];
|
|
68
|
+
|
|
69
|
+
export const TEMPLATES: Readonly<Record<Kind, Template>> = {
|
|
70
|
+
readme: {
|
|
71
|
+
shape: "outline",
|
|
72
|
+
title: { type: "open", placeholder: "<name>", rule: "any" },
|
|
73
|
+
lead: ["<The concept in two to four sentences: what this is, who it is for, why you would use it.>"],
|
|
74
|
+
sections: [
|
|
75
|
+
BEFORE_YOU_BEGIN,
|
|
76
|
+
fixed("Install", REQUIRED, ["To install <name>:", "", "1. <step>", "1. <step>", "", "<What you see when it worked.>"]),
|
|
77
|
+
open("<Everyday task, verb first>", "any", REQUIRED, STEPS),
|
|
78
|
+
fixed("Where things are", REQUIRED, ["| Path | What it holds |", "| --- | --- |"]),
|
|
79
|
+
TROUBLESHOOTING,
|
|
80
|
+
RELATED_TOPICS,
|
|
81
|
+
],
|
|
82
|
+
},
|
|
83
|
+
changelog: {
|
|
84
|
+
shape: "outline",
|
|
85
|
+
title: { type: "fixed", text: "Changelog" },
|
|
86
|
+
lead: ["Every release of <package>, newest first, written by the release from its conventional commits."],
|
|
87
|
+
sections: [
|
|
88
|
+
open("<version>", "version", REQUIRED, ["Released <YYYY-MM-DD>."], {
|
|
89
|
+
subsections: CHANGE_GROUPS.map((group) =>
|
|
90
|
+
fixed(group, optional("the release holds no such commit"), ["- <the commit's subject>"]),
|
|
91
|
+
),
|
|
92
|
+
}),
|
|
93
|
+
],
|
|
94
|
+
},
|
|
95
|
+
adr: {
|
|
96
|
+
shape: "outline",
|
|
97
|
+
title: { type: "open", placeholder: "<number>. <The decision, as a sentence>", rule: "any" },
|
|
98
|
+
lead: ["Date: <YYYY-MM-DD, the day the record was written>"],
|
|
99
|
+
sections: [
|
|
100
|
+
fixed("Status", REQUIRED, [`<${listed(ADR_STATUSES)} as its first word, then what it amends or what replaced it.>`]),
|
|
101
|
+
fixed("Context", REQUIRED, ["<What forced a decision, and what was true when it was made.>"]),
|
|
102
|
+
open("<Another part of the record, such as What was considered and rejected>", "any", optional("the record needs no more"), ["<Its text.>"], {
|
|
103
|
+
interleaved: true,
|
|
104
|
+
}),
|
|
105
|
+
fixed("Decision", REQUIRED, ["<What was decided, stated as what now holds.>"], { also: ["Decisions"] }),
|
|
106
|
+
fixed("Consequences", optional("nothing follows from the decision but the decision"), [
|
|
107
|
+
"<What follows from the decision, its cost included.>",
|
|
108
|
+
]),
|
|
109
|
+
],
|
|
110
|
+
},
|
|
111
|
+
agents: {
|
|
112
|
+
shape: "outline",
|
|
113
|
+
title: { type: "fixed", text: "Project agent memory" },
|
|
114
|
+
lead: ["<What this repository is, in one sentence, and that README.md holds what a person reads.>"],
|
|
115
|
+
sections: [
|
|
116
|
+
open("<A topic an agent needs>", "any", optional("the lead holds every constraint"), [
|
|
117
|
+
"- <A constraint an agent cannot infer from the code, and the file that holds its detail.>",
|
|
118
|
+
]),
|
|
119
|
+
fixed("Maintaining this file", REQUIRED, MAINTAINING),
|
|
120
|
+
],
|
|
121
|
+
},
|
|
122
|
+
claude: {
|
|
123
|
+
shape: "exact",
|
|
124
|
+
text: "<!-- Points Claude at AGENTS.md via import. Edit AGENTS.md, not this file. -->\n@AGENTS.md\n",
|
|
125
|
+
},
|
|
126
|
+
tutorial: {
|
|
127
|
+
shape: "outline",
|
|
128
|
+
title: { type: "open", prefix: "Tutorial: ", placeholder: "<Verb and what the reader builds>", rule: "any" },
|
|
129
|
+
lead: ["<What the reader builds, and what they learn on the way.>"],
|
|
130
|
+
sections: [
|
|
131
|
+
BEFORE_YOU_BEGIN,
|
|
132
|
+
open("<Step, verb first>", "any", REQUIRED, [...STEPS, "", "<What the reader sees now.>"]),
|
|
133
|
+
TROUBLESHOOTING,
|
|
134
|
+
RELATED_TOPICS,
|
|
135
|
+
],
|
|
136
|
+
},
|
|
137
|
+
"how-to": {
|
|
138
|
+
shape: "outline",
|
|
139
|
+
title: { type: "open", placeholder: "<Task, verb first>", rule: "any" },
|
|
140
|
+
lead: ["<Who does this, and when, in one or two sentences.>"],
|
|
141
|
+
sections: [
|
|
142
|
+
fixed("Before you begin", optional("the task needs nothing set up first"), BEFORE_YOU_BEGIN.body),
|
|
143
|
+
open("<Part of the task, verb first>", "any", optional("the page is one task, whose steps then follow the lead"), STEPS),
|
|
144
|
+
TROUBLESHOOTING,
|
|
145
|
+
RELATED_TOPICS,
|
|
146
|
+
],
|
|
147
|
+
},
|
|
148
|
+
reference: {
|
|
149
|
+
shape: "outline",
|
|
150
|
+
title: { type: "open", placeholder: "<The thing this page describes, as a noun>", rule: "any" },
|
|
151
|
+
lead: ["<What the thing is, in one sentence, and when a reader looks it up.>"],
|
|
152
|
+
sections: [
|
|
153
|
+
open("<One part of it, as a noun>", "any", optional("the lead and one table describe all of it"), [
|
|
154
|
+
"<A table, a list or a short description, with no steps and no opinion.>",
|
|
155
|
+
]),
|
|
156
|
+
RELATED_TOPICS,
|
|
157
|
+
],
|
|
158
|
+
},
|
|
159
|
+
explanation: {
|
|
160
|
+
shape: "outline",
|
|
161
|
+
title: { type: "open", placeholder: "<The idea this page explains, as a noun>", rule: "any" },
|
|
162
|
+
lead: ["<The question this page answers, and the short answer.>"],
|
|
163
|
+
sections: [
|
|
164
|
+
open("<One strand of the answer>", "any", optional("the lead holds the whole answer"), [
|
|
165
|
+
"<The reasoning, the alternatives, and why they lost.>",
|
|
166
|
+
]),
|
|
167
|
+
RELATED_TOPICS,
|
|
168
|
+
],
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
|
|
172
|
+
function titleText(title: Title): string {
|
|
173
|
+
return title.type === "fixed" ? title.text : `${title.prefix ?? ""}${title.placeholder}`;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function notes(slot: Slot): readonly string[] {
|
|
177
|
+
const omit = slot.presence.required ? [] : [`<Leave this section out when ${slot.presence.omitWhen}.>`];
|
|
178
|
+
const moves = slot.type === "open" && slot.interleaved === true ? ["<A section like this may also follow any section below it.>"] : [];
|
|
179
|
+
return [...omit, ...moves];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function separated(blocks: readonly (readonly string[])[]): readonly string[] {
|
|
183
|
+
return blocks.filter((block) => block.length > 0).flatMap((block, index) => (index === 0 ? block : ["", ...block]));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function renderSlot(slot: Slot, level: number): readonly string[] {
|
|
187
|
+
return separated([
|
|
188
|
+
[`${"#".repeat(level)} ${slotLabel(slot)}`],
|
|
189
|
+
...notes(slot).map((note) => [note]),
|
|
190
|
+
slot.body,
|
|
191
|
+
...(slot.subsections ?? []).map((subsection) => renderSlot(subsection, level + 1)),
|
|
192
|
+
]);
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
export function renderTemplate(template: Template): string {
|
|
196
|
+
if (template.shape === "exact") return template.text;
|
|
197
|
+
const lines = separated([[`# ${titleText(template.title)}`], template.lead, ...template.sections.map((slot) => renderSlot(slot, 2))]);
|
|
198
|
+
return `${lines.join("\n")}\n`;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function templateFile(kind: Kind): string {
|
|
202
|
+
return `${TEMPLATE_DIRECTORY}/${kind}.md`;
|
|
203
|
+
}
|
package/scripts/docs.ts
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
import { Console, Effect } from "effect";
|
|
3
|
+
import { ADR_DIRECTORY, judge, placementOf, placementProblem, type Placement } from "./doc-rules.ts";
|
|
4
|
+
import { changedPaths, git, pathsAt, rangeEnds } from "./git.ts";
|
|
5
|
+
import { runMain, Usage } from "./main.ts";
|
|
6
|
+
import { readQuality } from "./quality-file.ts";
|
|
7
|
+
|
|
8
|
+
type Finding = {
|
|
9
|
+
readonly path: string;
|
|
10
|
+
readonly line: number | undefined;
|
|
11
|
+
readonly message: string;
|
|
12
|
+
};
|
|
13
|
+
|
|
14
|
+
type Judged = {
|
|
15
|
+
readonly held: readonly string[];
|
|
16
|
+
readonly findings: readonly Finding[];
|
|
17
|
+
readonly advisory: ReadonlyMap<string, number>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const NAME = "docs";
|
|
21
|
+
const USAGE = "usage: docs.ts <ref> | <base-ref> <head-ref>";
|
|
22
|
+
const MARKDOWN = [":(glob)**/*.md"];
|
|
23
|
+
|
|
24
|
+
const judgeFile = Effect.fn("judgeFile")(function* (
|
|
25
|
+
root: string,
|
|
26
|
+
head: string,
|
|
27
|
+
path: string,
|
|
28
|
+
placement: Placement,
|
|
29
|
+
records: readonly string[],
|
|
30
|
+
) {
|
|
31
|
+
const misplaced = placementProblem(placement);
|
|
32
|
+
if (misplaced !== undefined) return [{ path, line: undefined, message: misplaced }];
|
|
33
|
+
if (placement.type !== "judged") return [];
|
|
34
|
+
const text = yield* git(["show", `${head}:${path}`], root);
|
|
35
|
+
return judge(placement.kind, { path, text }, records).map(({ line, message }) => ({ path, line, message }));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head: string) {
|
|
39
|
+
const { quality } = yield* readQuality(root);
|
|
40
|
+
const touched = new Set(
|
|
41
|
+
(yield* changedPaths(base, head, MARKDOWN, root)).flatMap((change) => (change.kind === "deleted" ? [] : [change.path])),
|
|
42
|
+
);
|
|
43
|
+
const present = yield* pathsAt(head, MARKDOWN, root);
|
|
44
|
+
const records = present.filter((path) => path.startsWith(ADR_DIRECTORY));
|
|
45
|
+
const placed = present.map((path) => ({ path, placement: placementOf(path, quality.docs) }));
|
|
46
|
+
const judged = placed.filter(({ placement }) => placement.type !== "unjudged");
|
|
47
|
+
const findings = (yield* Effect.forEach(judged, ({ path, placement }) => judgeFile(root, head, path, placement, records), {
|
|
48
|
+
concurrency: 8,
|
|
49
|
+
})).flat();
|
|
50
|
+
const advisory = new Map<string, number>();
|
|
51
|
+
for (const { path } of findings.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
|
|
52
|
+
return {
|
|
53
|
+
held: judged.map(({ path }) => path).filter((path) => touched.has(path)),
|
|
54
|
+
findings: findings.filter((finding) => touched.has(finding.path)),
|
|
55
|
+
advisory,
|
|
56
|
+
} satisfies Judged;
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
function describe({ path, line, message }: Finding): string {
|
|
60
|
+
return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function report({ held, findings, advisory }: Judged): string {
|
|
64
|
+
const verdict =
|
|
65
|
+
findings.length === 0
|
|
66
|
+
? [`${NAME}: ${held.length} doc file(s) the range touches hold to their templates`]
|
|
67
|
+
: [`${NAME}: ${findings.length} violation(s) in the doc files the range touches:`, ...findings.map(describe)];
|
|
68
|
+
const notice =
|
|
69
|
+
advisory.size === 0
|
|
70
|
+
? []
|
|
71
|
+
: [
|
|
72
|
+
`${NAME}: advisory, ${advisory.size} doc file(s) the range leaves alone do not hold to their templates yet:`,
|
|
73
|
+
...[...advisory].map(([path, count]) => ` ${path}: ${count} violation(s)`),
|
|
74
|
+
];
|
|
75
|
+
return [...verdict, ...notice].join("\n");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const docs = Effect.gen(function* () {
|
|
79
|
+
const [first, second, ...extra] = process.argv.slice(2);
|
|
80
|
+
if (first === undefined || extra.length > 0) return yield* new Usage({ message: USAGE });
|
|
81
|
+
|
|
82
|
+
const root = (yield* git(["rev-parse", "--show-toplevel"])).trim();
|
|
83
|
+
const { base, head } = yield* rangeEnds(first, second, root);
|
|
84
|
+
const judged = yield* runDocs(root, base, head);
|
|
85
|
+
|
|
86
|
+
yield* Console.log(report(judged));
|
|
87
|
+
return judged.findings.length === 0;
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
if (import.meta.main) runMain(NAME, docs);
|