@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,190 @@
|
|
|
1
|
+
import { scanMarkdown, type MarkdownLine } from "./prose-matchers.ts";
|
|
2
|
+
|
|
3
|
+
export type ReferenceKind = "path" | "link" | "command";
|
|
4
|
+
|
|
5
|
+
export type Unresolved = {
|
|
6
|
+
readonly kind: ReferenceKind;
|
|
7
|
+
readonly line: number;
|
|
8
|
+
readonly named: string;
|
|
9
|
+
readonly message: string;
|
|
10
|
+
readonly missing:
|
|
11
|
+
| { readonly type: "file"; readonly path: string }
|
|
12
|
+
| { readonly type: "script"; readonly name: string; readonly packageDirectory: string }
|
|
13
|
+
| { readonly type: "anchor" };
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
export type Snapshot = {
|
|
17
|
+
readonly files: ReadonlySet<string>;
|
|
18
|
+
readonly directories: ReadonlySet<string>;
|
|
19
|
+
readonly roots: ReadonlySet<string>;
|
|
20
|
+
readonly anchors: ReadonlyMap<string, ReadonlySet<string>>;
|
|
21
|
+
readonly scripts: ReadonlyMap<string, ReadonlySet<string>>;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const PACKAGE_MANIFEST = "package.json";
|
|
25
|
+
|
|
26
|
+
export function rootsOf(files: readonly string[]): readonly string[] {
|
|
27
|
+
return files.flatMap((file) => (file.includes("/") ? [file.slice(0, file.indexOf("/"))] : []));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function snapshotOf(
|
|
31
|
+
files: readonly string[],
|
|
32
|
+
anchors: ReadonlyMap<string, ReadonlySet<string>>,
|
|
33
|
+
scripts: ReadonlyMap<string, ReadonlySet<string>>,
|
|
34
|
+
alsoRoots: readonly string[] = [],
|
|
35
|
+
): Snapshot {
|
|
36
|
+
const directories = new Set<string>([""]);
|
|
37
|
+
for (const file of files) for (let at = file.indexOf("/"); at >= 0; at = file.indexOf("/", at + 1)) directories.add(file.slice(0, at));
|
|
38
|
+
return { files: new Set(files), directories, roots: new Set([...rootsOf(files), ...alsoRoots]), anchors, scripts };
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function directoryOf(path: string): string {
|
|
42
|
+
return path.includes("/") ? path.slice(0, path.lastIndexOf("/")) : "";
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function normalize(path: string): string | undefined {
|
|
46
|
+
const parts: string[] = [];
|
|
47
|
+
for (const part of path.split("/")) {
|
|
48
|
+
if (part === "" || part === ".") continue;
|
|
49
|
+
if (part !== "..") parts.push(part);
|
|
50
|
+
else if (parts.pop() === undefined) return undefined;
|
|
51
|
+
}
|
|
52
|
+
return parts.join("/");
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function within(directory: string, path: string): string | undefined {
|
|
56
|
+
return normalize(directory === "" ? path : `${directory}/${path}`);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function exists(snapshot: Snapshot, path: string): boolean {
|
|
60
|
+
return snapshot.files.has(path) || snapshot.directories.has(path);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const PARENT = /^\.\.\//;
|
|
64
|
+
const PATH = /^((?:\.{1,2}\/)*(?:[\w.@-]+\/)+[\w.@-]+\.(?:ts|tsx|mts|cts|js|jsx|cjs|mjs|md|mdx|json|jsonc|sh|bash|zsh|toml|nix|lua|ya?ml|txt|py|rs|go|css|html))(?::\d+(?::\d+)?)?$/;
|
|
65
|
+
|
|
66
|
+
function unresolvedPath(doc: string, line: number, span: string, snapshot: Snapshot): Unresolved | undefined {
|
|
67
|
+
const named = PATH.exec(span)?.[1];
|
|
68
|
+
if (named === undefined) return undefined;
|
|
69
|
+
const fromDoc = within(directoryOf(doc), named);
|
|
70
|
+
const fromRoot = PARENT.test(named) ? undefined : normalize(named);
|
|
71
|
+
if ([fromRoot, fromDoc].some((path) => path !== undefined && exists(snapshot, path))) return undefined;
|
|
72
|
+
const checked = fromRoot ?? fromDoc;
|
|
73
|
+
// A path whose top directory this repository lacks names a file in another one, such as a consumer's.
|
|
74
|
+
if (checked === undefined || (fromRoot !== undefined && !snapshot.roots.has(fromRoot.split("/")[0] ?? ""))) return undefined;
|
|
75
|
+
return { kind: "path", line, named, message: `names \`${named}\`, which is not in the repository`, missing: { type: "file", path: checked } };
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const SCHEME = /^(?:[a-z][a-z0-9+.-]*:|\/\/)/i;
|
|
79
|
+
const ASCII_ESCAPE = /%([0-7][0-9a-f])/gi;
|
|
80
|
+
|
|
81
|
+
function unresolvedLink(doc: string, line: number, target: string, snapshot: Snapshot): Unresolved | undefined {
|
|
82
|
+
if (target === "" || SCHEME.test(target)) return undefined;
|
|
83
|
+
const hash = target.indexOf("#");
|
|
84
|
+
const written = (hash < 0 ? target : target.slice(0, hash)).split("?", 1)[0] ?? "";
|
|
85
|
+
const decoded = written.replace(ASCII_ESCAPE, (_, code: string) => String.fromCharCode(Number.parseInt(code, 16)));
|
|
86
|
+
const path = decoded === "" ? doc : decoded.startsWith("/") ? normalize(decoded) : within(directoryOf(doc), decoded);
|
|
87
|
+
if (path === undefined) return undefined;
|
|
88
|
+
const named = target;
|
|
89
|
+
if (!exists(snapshot, path)) {
|
|
90
|
+
return { kind: "link", line, named, message: `links to \`${named}\`, which is not in the repository`, missing: { type: "file", path } };
|
|
91
|
+
}
|
|
92
|
+
const anchor = hash < 0 ? "" : target.slice(hash + 1);
|
|
93
|
+
const anchors = snapshot.anchors.get(path);
|
|
94
|
+
if (anchor === "" || anchors === undefined || anchors.has(anchor) || anchors.has(anchor.toLowerCase())) return undefined;
|
|
95
|
+
return { kind: "link", line, named, message: `links to \`${named}\`, and \`${path}\` has no heading with that anchor`, missing: { type: "anchor" } };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const RUN = /\bbun run\s+([^\s`'"]+)/g;
|
|
99
|
+
const RUNS_A_FILE = /\/|\.[cm]?[jt]sx?$/;
|
|
100
|
+
const NOT_A_NAME = /^[-$<{]/;
|
|
101
|
+
|
|
102
|
+
function packageOf(doc: string, snapshot: Snapshot): string | undefined {
|
|
103
|
+
for (let directory: string | undefined = directoryOf(doc); directory !== undefined; directory = directory === "" ? undefined : directoryOf(directory)) {
|
|
104
|
+
if (snapshot.files.has(directory === "" ? PACKAGE_MANIFEST : `${directory}/${PACKAGE_MANIFEST}`)) return directory;
|
|
105
|
+
}
|
|
106
|
+
return undefined;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function unresolvedCommand(doc: string, line: number, name: string, snapshot: Snapshot): Unresolved | undefined {
|
|
110
|
+
const packageDirectory = packageOf(doc, snapshot);
|
|
111
|
+
if (packageDirectory === undefined || NOT_A_NAME.test(name)) return undefined;
|
|
112
|
+
const named = `bun run ${name}`;
|
|
113
|
+
if (RUNS_A_FILE.test(name)) {
|
|
114
|
+
const path = within(packageDirectory, name);
|
|
115
|
+
if (path === undefined || exists(snapshot, path)) return undefined;
|
|
116
|
+
return { kind: "command", line, named, message: `runs \`${named}\`, and \`${path}\` is not in the repository`, missing: { type: "file", path } };
|
|
117
|
+
}
|
|
118
|
+
if (snapshot.scripts.get(packageDirectory)?.has(name) === true) return undefined;
|
|
119
|
+
const manifest = packageDirectory === "" ? PACKAGE_MANIFEST : `${packageDirectory}/${PACKAGE_MANIFEST}`;
|
|
120
|
+
return {
|
|
121
|
+
kind: "command",
|
|
122
|
+
line,
|
|
123
|
+
named,
|
|
124
|
+
message: `runs \`${named}\`, and \`${name}\` is not a script in \`${manifest}\``,
|
|
125
|
+
missing: { type: "script", name, packageDirectory },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function commandsOn({ kind, raw, code }: MarkdownLine): readonly string[] {
|
|
130
|
+
const texts = kind === "code" ? [raw] : code;
|
|
131
|
+
return texts.flatMap((text) => [...text.matchAll(RUN)].map(([, name = ""]) => name.replace(/[),.;:]+$/, "")));
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export type Judging = { readonly commands: boolean };
|
|
135
|
+
|
|
136
|
+
export function unresolvedIn(doc: string, text: string, snapshot: Snapshot, { commands }: Judging): readonly Unresolved[] {
|
|
137
|
+
return scanMarkdown(text).flatMap((markdown) => {
|
|
138
|
+
const { line } = markdown;
|
|
139
|
+
const prose = markdown.kind === "code" || markdown.kind === "front-matter" ? [] : markdown.code.map((span) => unresolvedPath(doc, line, span, snapshot));
|
|
140
|
+
const links = markdown.links.map((target) => unresolvedLink(doc, line, target, snapshot));
|
|
141
|
+
const runs = commands && markdown.kind !== "front-matter" ? commandsOn(markdown).map((name) => unresolvedCommand(doc, line, name, snapshot)) : [];
|
|
142
|
+
return [...prose, ...links, ...runs].filter((found) => found !== undefined);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function anchoredTargets(doc: string, text: string): readonly string[] {
|
|
147
|
+
return scanMarkdown(text).flatMap(({ links }) =>
|
|
148
|
+
links.flatMap((target) => {
|
|
149
|
+
const hash = target.indexOf("#");
|
|
150
|
+
if (hash < 0 || SCHEME.test(target)) return [];
|
|
151
|
+
const written = target.slice(0, hash);
|
|
152
|
+
const path = written === "" ? doc : written.startsWith("/") ? normalize(written) : within(directoryOf(doc), written);
|
|
153
|
+
return path?.endsWith(".md") === true ? [path] : [];
|
|
154
|
+
}),
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
export function packagesOf(docs: readonly string[], snapshot: Snapshot): readonly string[] {
|
|
159
|
+
return [...new Set(docs.flatMap((doc) => packageOf(doc, snapshot) ?? []))];
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const EXPLICIT_ANCHOR = /<a\s[^>]*\b(?:id|name)="([^"]+)"/g;
|
|
163
|
+
const HEADING_MARKERS = /^\s{0,3}#{1,6}\s*|\s+#+\s*$/g;
|
|
164
|
+
|
|
165
|
+
function headingText(raw: string): string {
|
|
166
|
+
return raw
|
|
167
|
+
.replace(HEADING_MARKERS, "")
|
|
168
|
+
.replace(/!?\[([^\]]*)\]\([^)]*\)/g, "$1")
|
|
169
|
+
.replace(/<[^>]+>/g, "")
|
|
170
|
+
.replace(/[`*]/g, "")
|
|
171
|
+
.trim();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function anchorsOf(text: string): ReadonlySet<string> {
|
|
175
|
+
const anchors = new Set<string>();
|
|
176
|
+
const seen = new Map<string, number>();
|
|
177
|
+
for (const { kind, raw } of scanMarkdown(text)) {
|
|
178
|
+
if (kind === "code" || kind === "front-matter") continue;
|
|
179
|
+
for (const [, id = ""] of raw.matchAll(EXPLICIT_ANCHOR)) anchors.add(id);
|
|
180
|
+
if (kind !== "heading") continue;
|
|
181
|
+
const slug = headingText(raw)
|
|
182
|
+
.toLowerCase()
|
|
183
|
+
.replace(/[^\p{L}\p{M}\p{N}\p{Pc} -]/gu, "")
|
|
184
|
+
.replaceAll(" ", "-");
|
|
185
|
+
const count = seen.get(slug) ?? 0;
|
|
186
|
+
anchors.add(count === 0 ? slug : `${slug}-${count}`);
|
|
187
|
+
seen.set(slug, count + 1);
|
|
188
|
+
}
|
|
189
|
+
return anchors;
|
|
190
|
+
}
|
package/scripts/doc-rules.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
VERSION,
|
|
12
12
|
} from "./doc-outline.ts";
|
|
13
13
|
import { ADR_STATUSES, TEMPLATES, templateFile, type Kind, type Title } from "./doc-templates.ts";
|
|
14
|
+
import { ADR_DIRECTORY, DOCS_DIRECTORY } from "./prose-matchers.ts";
|
|
14
15
|
import { MODES, type Docs, type Mode } from "./quality-file.ts";
|
|
15
16
|
|
|
16
17
|
export type Placement =
|
|
@@ -24,16 +25,16 @@ export type Doc = {
|
|
|
24
25
|
readonly text: string;
|
|
25
26
|
};
|
|
26
27
|
|
|
27
|
-
export
|
|
28
|
+
export { ADR_DIRECTORY };
|
|
28
29
|
// A generated index takes its shape from its generator.
|
|
29
|
-
const ADR_INDEX = `${ADR_DIRECTORY}README.md`;
|
|
30
|
-
const DOCS_DIRECTORY = "docs/";
|
|
30
|
+
export const ADR_INDEX = `${ADR_DIRECTORY}README.md`;
|
|
31
31
|
|
|
32
|
-
const ROOT_FILES = new Map<string, Kind>([
|
|
32
|
+
export const ROOT_FILES: ReadonlyMap<string, Kind> = new Map<string, Kind>([
|
|
33
33
|
["README.md", "readme"],
|
|
34
34
|
["CHANGELOG.md", "changelog"],
|
|
35
35
|
["AGENTS.md", "agents"],
|
|
36
36
|
["CLAUDE.md", "claude"],
|
|
37
|
+
["CONTRIBUTING.md", "how-to"],
|
|
37
38
|
]);
|
|
38
39
|
|
|
39
40
|
export function placementOf(path: string, docs: Docs | undefined): Placement {
|
|
@@ -113,7 +114,7 @@ function adrProblems(path: string, outline: Outline, records: readonly string[])
|
|
|
113
114
|
return violations;
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
const RELEASED = /^Released (\S+)\.$/;
|
|
117
|
+
export const RELEASED = /^Released (\S+)\.$/;
|
|
117
118
|
|
|
118
119
|
function changelogProblems({ sections }: Outline): readonly Violation[] {
|
|
119
120
|
const releases = sections.filter(({ heading }) => VERSION.test(heading.title));
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { Effect, FileSystem, Path, Schema } from "effect";
|
|
2
|
+
import { anchoredTargets, anchorsOf, PACKAGE_MANIFEST, packagesOf, snapshotOf, type Unresolved } from "./doc-references.ts";
|
|
3
|
+
import { git, pathsAt } from "./git.ts";
|
|
4
|
+
|
|
5
|
+
export class ManifestUnreadable extends Schema.TaggedError<ManifestUnreadable>()("ManifestUnreadable", {
|
|
6
|
+
message: Schema.String,
|
|
7
|
+
}) {}
|
|
8
|
+
|
|
9
|
+
const Scripts = Schema.fromJsonString(Schema.Struct({ scripts: Schema.optionalKey(Schema.Record(Schema.String, Schema.String)) }));
|
|
10
|
+
const decodeScripts = Schema.decodeUnknownEffect(Scripts);
|
|
11
|
+
|
|
12
|
+
export const readTexts = Effect.fn("readTexts")(function* (root: string, rev: string, paths: readonly string[]) {
|
|
13
|
+
const texts = yield* Effect.forEach(paths, (path) => git(["show", `${rev}:${path}`], root), { concurrency: 8 });
|
|
14
|
+
return new Map(paths.map((path, index) => [path, texts[index] ?? ""]));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
function manifestIn(directory: string): string {
|
|
18
|
+
return directory === "" ? PACKAGE_MANIFEST : `${directory}/${PACKAGE_MANIFEST}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const scriptsAt = Effect.fn("scriptsAt")(function* (root: string, rev: string, directory: string) {
|
|
22
|
+
const manifest = manifestIn(directory);
|
|
23
|
+
const { scripts = {} } = yield* git(["show", `${rev}:${manifest}`], root).pipe(
|
|
24
|
+
Effect.flatMap(decodeScripts),
|
|
25
|
+
Effect.mapError((cause) => new ManifestUnreadable({ message: `${manifest} at ${rev}: ${cause.message}` })),
|
|
26
|
+
);
|
|
27
|
+
return new Set(Object.keys(scripts));
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
export const snapshotAt = Effect.fn("snapshotAt")(function* (
|
|
31
|
+
root: string,
|
|
32
|
+
rev: string,
|
|
33
|
+
docs: ReadonlyMap<string, string>,
|
|
34
|
+
alsoRoots: readonly string[],
|
|
35
|
+
) {
|
|
36
|
+
const files = yield* pathsAt(rev, [], root);
|
|
37
|
+
const tracked = snapshotOf(files, new Map(), new Map());
|
|
38
|
+
const targets = [...new Set([...docs].flatMap(([doc, text]) => anchoredTargets(doc, text)))].filter((path) => tracked.files.has(path));
|
|
39
|
+
const read = yield* readTexts(root, rev, targets.filter((path) => !docs.has(path)));
|
|
40
|
+
const anchors = new Map(targets.map((path) => [path, anchorsOf(docs.get(path) ?? read.get(path) ?? "")]));
|
|
41
|
+
const packages = packagesOf([...docs.keys()], tracked);
|
|
42
|
+
const scripts = yield* Effect.forEach(packages, (directory) => scriptsAt(root, rev, directory), { concurrency: 8 });
|
|
43
|
+
return snapshotOf(files, anchors, new Map(packages.map((directory, index) => [directory, scripts[index] ?? new Set<string>()])), alsoRoots);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const ignored = (root: string, path: string) =>
|
|
47
|
+
git(["check-ignore", "-q", "--no-index", "--", path], root).pipe(
|
|
48
|
+
Effect.as(true),
|
|
49
|
+
Effect.catchTag("GitFailure", () => Effect.succeed(false)),
|
|
50
|
+
);
|
|
51
|
+
|
|
52
|
+
// A script bun runs from an installed dependency's bin names no entry in package.json.
|
|
53
|
+
const installedBin = Effect.fn("installedBin")(function* (root: string, directory: string, name: string) {
|
|
54
|
+
const fs = yield* FileSystem.FileSystem;
|
|
55
|
+
const path = yield* Path.Path;
|
|
56
|
+
const bins = [...new Set([directory, ""])].map((from) => path.join(root, from, "node_modules", ".bin", name));
|
|
57
|
+
const found = yield* Effect.forEach(bins, (bin) => fs.exists(bin).pipe(Effect.orElseSucceed(() => false)));
|
|
58
|
+
return found.some(Boolean);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
// A path git ignores is generated or fetched, so a clean checkout lacks it by design.
|
|
62
|
+
const stillBroken = Effect.fn("stillBroken")(function* (root: string, { missing }: Unresolved) {
|
|
63
|
+
if (missing.type === "file") return !(yield* ignored(root, missing.path));
|
|
64
|
+
if (missing.type === "script") return !(yield* installedBin(root, missing.packageDirectory, missing.name));
|
|
65
|
+
return true;
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
export const stillMissing = Effect.fn("stillMissing")(function* (root: string, unresolved: readonly Unresolved[]) {
|
|
69
|
+
return yield* Effect.forEach(unresolved, (one) => stillBroken(root, one), { concurrency: 8 });
|
|
70
|
+
});
|
package/scripts/doc-templates.ts
CHANGED
|
@@ -40,7 +40,7 @@ function open(placeholder: string, rule: HeadingRule, presence: Presence, body:
|
|
|
40
40
|
return { type: "open", placeholder, rule, presence, body, ...more };
|
|
41
41
|
}
|
|
42
42
|
|
|
43
|
-
function listed(words: readonly string[]): string {
|
|
43
|
+
export function listed(words: readonly string[]): string {
|
|
44
44
|
return words.length < 2 ? words.join("") : `${words.slice(0, -1).join(", ")} or ${words.at(-1) ?? ""}`;
|
|
45
45
|
}
|
|
46
46
|
|
|
@@ -64,7 +64,8 @@ const MAINTAINING = [
|
|
|
64
64
|
"When updating this file, preserve this bar for all agents and keep entries concise.",
|
|
65
65
|
];
|
|
66
66
|
|
|
67
|
-
const CHANGE_GROUPS = ["Breaking changes", "Features", "Fixes", "Performance", "Reverts"];
|
|
67
|
+
export const CHANGE_GROUPS = ["Breaking changes", "Features", "Fixes", "Performance", "Reverts"] as const;
|
|
68
|
+
export type ChangeGroup = (typeof CHANGE_GROUPS)[number];
|
|
68
69
|
|
|
69
70
|
export const TEMPLATES: Readonly<Record<Kind, Template>> = {
|
|
70
71
|
readme: {
|
|
@@ -74,7 +75,11 @@ export const TEMPLATES: Readonly<Record<Kind, Template>> = {
|
|
|
74
75
|
sections: [
|
|
75
76
|
BEFORE_YOU_BEGIN,
|
|
76
77
|
fixed("Install", REQUIRED, ["To install <name>:", "", "1. <step>", "1. <step>", "", "<What you see when it worked.>"]),
|
|
77
|
-
open("<Everyday task, verb first>", "any", REQUIRED,
|
|
78
|
+
open("<Everyday task, verb first, or what the reader looks up>", "any", REQUIRED, [
|
|
79
|
+
...STEPS,
|
|
80
|
+
"",
|
|
81
|
+
"<Or, for what the reader looks up, a table or a list with no steps.>",
|
|
82
|
+
]),
|
|
78
83
|
fixed("Where things are", REQUIRED, ["| Path | What it holds |", "| --- | --- |"]),
|
|
79
84
|
TROUBLESHOOTING,
|
|
80
85
|
RELATED_TOPICS,
|
|
@@ -87,7 +92,7 @@ export const TEMPLATES: Readonly<Record<Kind, Template>> = {
|
|
|
87
92
|
sections: [
|
|
88
93
|
open("<version>", "version", REQUIRED, ["Released <YYYY-MM-DD>."], {
|
|
89
94
|
subsections: CHANGE_GROUPS.map((group) =>
|
|
90
|
-
fixed(group, optional("the release holds no such commit"), ["-
|
|
95
|
+
fixed(group, optional("the release holds no such commit"), ["- **<the commit's scope, when it has one>:** <its description>"]),
|
|
91
96
|
),
|
|
92
97
|
}),
|
|
93
98
|
],
|
|
@@ -121,7 +126,7 @@ export const TEMPLATES: Readonly<Record<Kind, Template>> = {
|
|
|
121
126
|
},
|
|
122
127
|
claude: {
|
|
123
128
|
shape: "exact",
|
|
124
|
-
text: "<!-- Points Claude at AGENTS.md via import
|
|
129
|
+
text: "<!-- Points Claude at AGENTS.md via import; edit AGENTS.md, not this file. -->\n@AGENTS.md\n",
|
|
125
130
|
},
|
|
126
131
|
tutorial: {
|
|
127
132
|
shape: "outline",
|
package/scripts/docs.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
import { Console, Effect } from "effect";
|
|
3
|
+
import { rootsOf, unresolvedIn, type Judging, type Unresolved } from "./doc-references.ts";
|
|
3
4
|
import { ADR_DIRECTORY, judge, placementOf, placementProblem, type Placement } from "./doc-rules.ts";
|
|
4
|
-
import {
|
|
5
|
+
import { readTexts, snapshotAt, stillMissing } from "./doc-snapshot.ts";
|
|
6
|
+
import { changedLines, changedPaths, git, pathsAt, rangeEnds } from "./git.ts";
|
|
5
7
|
import { runMain, Usage } from "./main.ts";
|
|
8
|
+
import { isLivingDoc, proseFindings, readerOf } from "./prose-matchers.ts";
|
|
6
9
|
import { readQuality } from "./quality-file.ts";
|
|
7
10
|
|
|
8
11
|
type Finding = {
|
|
@@ -13,46 +16,111 @@ type Finding = {
|
|
|
13
16
|
|
|
14
17
|
type Judged = {
|
|
15
18
|
readonly held: readonly string[];
|
|
19
|
+
readonly edited: { readonly docs: number; readonly lines: number };
|
|
20
|
+
readonly living: number;
|
|
16
21
|
readonly findings: readonly Finding[];
|
|
17
22
|
readonly advisory: ReadonlyMap<string, number>;
|
|
23
|
+
readonly brokenBefore: readonly Finding[];
|
|
18
24
|
};
|
|
19
25
|
|
|
26
|
+
type Range = {
|
|
27
|
+
readonly root: string;
|
|
28
|
+
readonly base: string;
|
|
29
|
+
readonly head: string;
|
|
30
|
+
readonly roots: readonly string[];
|
|
31
|
+
readonly changed: ReadonlyMap<string, ReadonlySet<number>>;
|
|
32
|
+
readonly renamedFrom: ReadonlyMap<string, string>;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
type Located = Unresolved & { readonly path: string };
|
|
36
|
+
|
|
20
37
|
const NAME = "docs";
|
|
21
38
|
const USAGE = "usage: docs.ts <ref> | <base-ref> <head-ref>";
|
|
22
39
|
const MARKDOWN = [":(glob)**/*.md"];
|
|
23
40
|
|
|
24
|
-
|
|
25
|
-
root: string,
|
|
26
|
-
head: string,
|
|
27
|
-
path: string,
|
|
28
|
-
placement: Placement,
|
|
29
|
-
records: readonly string[],
|
|
30
|
-
) {
|
|
41
|
+
function templateFindings(path: string, text: string, placement: Placement, records: readonly string[]): readonly Finding[] {
|
|
31
42
|
const misplaced = placementProblem(placement);
|
|
32
43
|
if (misplaced !== undefined) return [{ path, line: undefined, message: misplaced }];
|
|
33
44
|
if (placement.type !== "judged") return [];
|
|
34
|
-
const text = yield* git(["show", `${head}:${path}`], root);
|
|
35
45
|
return judge(placement.kind, { path, text }, records).map(({ line, message }) => ({ path, line, message }));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function inPathOrder(a: Finding, b: Finding): number {
|
|
49
|
+
return a.path === b.path ? (a.line ?? 0) - (b.line ?? 0) : a.path < b.path ? -1 : 1;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function locate(path: string, text: string, snapshot: Parameters<typeof unresolvedIn>[2], judging: Judging): readonly Located[] {
|
|
53
|
+
return unresolvedIn(path, text, snapshot, judging).map((unresolved) => ({ ...unresolved, path }));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const keyOf = ({ path, kind, named }: Located): string => `${path}\0${kind}\0${named}`;
|
|
57
|
+
|
|
58
|
+
// A reference on a line the range leaves alone fails only when the range broke it, as by deleting the file it names.
|
|
59
|
+
const brokenBeforeRange = Effect.fn("brokenBeforeRange")(function* (range: Range, found: readonly Located[], judging: (path: string) => Judging) {
|
|
60
|
+
const earlier = new Set(yield* pathsAt(range.base, MARKDOWN, range.root));
|
|
61
|
+
const docs = [...new Set(found.map(({ path }) => path))]
|
|
62
|
+
.map((path) => ({ path, from: range.renamedFrom.get(path) ?? path }))
|
|
63
|
+
.filter(({ from }) => earlier.has(from));
|
|
64
|
+
const texts = yield* readTexts(range.root, range.base, docs.map(({ from }) => from));
|
|
65
|
+
const snapshot = yield* snapshotAt(range.root, range.base, texts, range.roots);
|
|
66
|
+
return new Set(docs.flatMap(({ path, from }) => locate(from, texts.get(from) ?? "", snapshot, judging(path)).map((one) => keyOf({ ...one, path }))));
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const referenceFindings = Effect.fn("referenceFindings")(function* (range: Range, texts: ReadonlyMap<string, string>, judging: (path: string) => Judging) {
|
|
70
|
+
const snapshot = yield* snapshotAt(range.root, range.head, texts, range.roots);
|
|
71
|
+
const unresolved = [...texts].flatMap(([path, text]) => locate(path, text, snapshot, judging(path)));
|
|
72
|
+
const missing = yield* stillMissing(range.root, unresolved);
|
|
73
|
+
const found = unresolved.filter((_, index) => missing[index] === true);
|
|
74
|
+
const onChangedLines = (one: Located): boolean => range.changed.get(one.path)?.has(one.line) === true;
|
|
75
|
+
const elsewhere = found.filter((one) => !onChangedLines(one));
|
|
76
|
+
const before = elsewhere.length === 0 ? new Set<string>() : yield* brokenBeforeRange(range, elsewhere, judging);
|
|
77
|
+
const finding = ({ path, line, message }: Located): Finding => ({ path, line, message });
|
|
78
|
+
return {
|
|
79
|
+
failing: found.filter((one) => onChangedLines(one) || !before.has(keyOf(one))).map(finding),
|
|
80
|
+
brokenBefore: elsewhere.filter((one) => before.has(keyOf(one))).map(finding),
|
|
81
|
+
};
|
|
36
82
|
});
|
|
37
83
|
|
|
38
84
|
const runDocs = Effect.fn("runDocs")(function* (root: string, base: string, head: string) {
|
|
39
85
|
const { quality } = yield* readQuality(root);
|
|
40
|
-
const
|
|
41
|
-
|
|
42
|
-
);
|
|
86
|
+
const changes = yield* changedPaths(base, head, MARKDOWN, root);
|
|
87
|
+
const touched = new Set(changes.flatMap((change) => (change.kind === "deleted" ? [] : [change.path])));
|
|
88
|
+
const renamedFrom = new Map(changes.flatMap((change) => (change.kind === "renamed" ? [[change.path, change.from] as const] : [])));
|
|
89
|
+
const changed = yield* changedLines(base, head, MARKDOWN, root);
|
|
43
90
|
const present = yield* pathsAt(head, MARKDOWN, root);
|
|
44
91
|
const records = present.filter((path) => path.startsWith(ADR_DIRECTORY));
|
|
45
|
-
const
|
|
46
|
-
const
|
|
47
|
-
const
|
|
48
|
-
|
|
49
|
-
|
|
92
|
+
const judged = present.map((path) => ({ path, placement: placementOf(path, quality.docs) })).filter(({ placement }) => placement.type !== "unjudged");
|
|
93
|
+
const living = present.filter(isLivingDoc);
|
|
94
|
+
const proseDocs = present.flatMap((path) => {
|
|
95
|
+
const reader = readerOf(path);
|
|
96
|
+
return reader === undefined ? [] : [{ path, reader }];
|
|
97
|
+
});
|
|
98
|
+
const texts = yield* readTexts(root, head, [...new Set([...judged.map(({ path }) => path), ...proseDocs.map(({ path }) => path)])]);
|
|
99
|
+
const text = (path: string): string => texts.get(path) ?? "";
|
|
100
|
+
|
|
101
|
+
const templated = judged.flatMap(({ path, placement }) => templateFindings(path, text(path), placement, records));
|
|
102
|
+
const edited = proseDocs.filter(({ path }) => changed.has(path));
|
|
103
|
+
const prose = edited.flatMap(({ path, reader }) =>
|
|
104
|
+
proseFindings(text(path), reader, changed.get(path)).map(({ line, message }) => ({ path, line, message })),
|
|
105
|
+
);
|
|
106
|
+
const forConsumers = (quality.docs?.forConsumers ?? []).map((glob) => new Bun.Glob(glob));
|
|
107
|
+
const judging = (path: string): Judging => ({ commands: !forConsumers.some((glob) => glob.match(path)) });
|
|
108
|
+
// A directory the range deletes still belongs to this repository, so a path under it is stale rather than another repository's.
|
|
109
|
+
const roots = rootsOf(yield* pathsAt(base, [], root));
|
|
110
|
+
const references = yield* referenceFindings(
|
|
111
|
+
{ root, base, head, roots, changed, renamedFrom },
|
|
112
|
+
new Map(living.map((path) => [path, text(path)])),
|
|
113
|
+
judging,
|
|
114
|
+
);
|
|
50
115
|
const advisory = new Map<string, number>();
|
|
51
|
-
for (const { path } of
|
|
116
|
+
for (const { path } of templated.filter((finding) => !touched.has(finding.path))) advisory.set(path, (advisory.get(path) ?? 0) + 1);
|
|
52
117
|
return {
|
|
53
118
|
held: judged.map(({ path }) => path).filter((path) => touched.has(path)),
|
|
54
|
-
|
|
119
|
+
edited: { docs: edited.length, lines: edited.reduce((sum, { path }) => sum + (changed.get(path)?.size ?? 0), 0) },
|
|
120
|
+
living: living.length,
|
|
121
|
+
findings: [...templated.filter((finding) => touched.has(finding.path)), ...prose, ...references.failing].toSorted(inPathOrder),
|
|
55
122
|
advisory,
|
|
123
|
+
brokenBefore: references.brokenBefore.toSorted(inPathOrder),
|
|
56
124
|
} satisfies Judged;
|
|
57
125
|
});
|
|
58
126
|
|
|
@@ -60,19 +128,27 @@ function describe({ path, line, message }: Finding): string {
|
|
|
60
128
|
return ` ${path}${line === undefined ? "" : `:${line}`}: ${message}`;
|
|
61
129
|
}
|
|
62
130
|
|
|
63
|
-
export function report({ held, findings, advisory }: Judged): string {
|
|
131
|
+
export function report({ held, edited, living, findings, advisory, brokenBefore }: Judged): string {
|
|
64
132
|
const verdict =
|
|
65
133
|
findings.length === 0
|
|
66
|
-
? [
|
|
67
|
-
|
|
68
|
-
|
|
134
|
+
? [
|
|
135
|
+
`${NAME}: ${held.length} doc file(s) the range touches hold to their templates`,
|
|
136
|
+
`${NAME}: ${edited.lines} line(s) the range adds or edits in ${edited.docs} living doc(s) or agent file(s) hold to the prose rules`,
|
|
137
|
+
`${NAME}: the range breaks no path, link or command the ${living} living doc(s) name`,
|
|
138
|
+
]
|
|
139
|
+
: [`${NAME}: ${findings.length} violation(s):`, ...findings.map(describe)];
|
|
140
|
+
const unconformed =
|
|
69
141
|
advisory.size === 0
|
|
70
142
|
? []
|
|
71
143
|
: [
|
|
72
144
|
`${NAME}: advisory, ${advisory.size} doc file(s) the range leaves alone do not hold to their templates yet:`,
|
|
73
145
|
...[...advisory].map(([path, count]) => ` ${path}: ${count} violation(s)`),
|
|
74
146
|
];
|
|
75
|
-
|
|
147
|
+
const broken =
|
|
148
|
+
brokenBefore.length === 0
|
|
149
|
+
? []
|
|
150
|
+
: [`${NAME}: advisory, ${brokenBefore.length} path(s), link(s) or command(s) the living docs name were broken before the range:`, ...brokenBefore.map(describe)];
|
|
151
|
+
return [...verdict, ...unconformed, ...broken].join("\n");
|
|
76
152
|
}
|
|
77
153
|
|
|
78
154
|
const docs = Effect.gen(function* () {
|
package/scripts/git.ts
CHANGED
|
@@ -71,6 +71,70 @@ export const changedPaths = Effect.fn("changedPaths")(function* (
|
|
|
71
71
|
return parseNameStatus(yield* git(["diff", "--name-status", "-z", "-M", base, head, "--", ...pathspecs], cwd));
|
|
72
72
|
});
|
|
73
73
|
|
|
74
|
+
const ESCAPES: Readonly<Record<string, string>> = { a: "\x07", b: "\b", f: "\f", n: "\n", r: "\r", t: "\t", v: "\v" };
|
|
75
|
+
|
|
76
|
+
// Git quotes a path holding a quote, a backslash or a control character, and ends one holding a space with a tab.
|
|
77
|
+
function newPathOf(line: string): string | undefined {
|
|
78
|
+
const named = line.slice("+++ ".length).replace(/\t$/, "");
|
|
79
|
+
const path = named.startsWith('"')
|
|
80
|
+
? named.slice(1, -1).replace(/\\(?:([0-7]{3})|(.))/g, (_, octal: string | undefined, char: string) =>
|
|
81
|
+
octal === undefined ? (ESCAPES[char] ?? char) : String.fromCharCode(Number.parseInt(octal, 8)),
|
|
82
|
+
)
|
|
83
|
+
: named;
|
|
84
|
+
return path === "/dev/null" ? undefined : path;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const HUNK = /^@@ -\d+(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/;
|
|
88
|
+
|
|
89
|
+
// A hunk's header counts its lines, so an added line reading `++ x` is not taken for the next file's `+++` header.
|
|
90
|
+
export function parseAddedLines(diff: string): Map<string, Set<number>> {
|
|
91
|
+
const added = new Map<string, Set<number>>();
|
|
92
|
+
let path: string | undefined;
|
|
93
|
+
let line = 0;
|
|
94
|
+
let oldLeft = 0;
|
|
95
|
+
let newLeft = 0;
|
|
96
|
+
for (const row of diff.split("\n")) {
|
|
97
|
+
if (oldLeft > 0 || newLeft > 0) {
|
|
98
|
+
if (row.startsWith("+")) {
|
|
99
|
+
if (path !== undefined) added.set(path, (added.get(path) ?? new Set<number>()).add(line));
|
|
100
|
+
line += 1;
|
|
101
|
+
newLeft -= 1;
|
|
102
|
+
} else if (row.startsWith("-")) {
|
|
103
|
+
oldLeft -= 1;
|
|
104
|
+
} else if (row.startsWith(" ")) {
|
|
105
|
+
line += 1;
|
|
106
|
+
oldLeft -= 1;
|
|
107
|
+
newLeft -= 1;
|
|
108
|
+
}
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (row.startsWith("+++ ")) {
|
|
112
|
+
path = newPathOf(row);
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const hunk = HUNK.exec(row);
|
|
116
|
+
if (hunk !== null) {
|
|
117
|
+
oldLeft = Number(hunk[1] ?? 1);
|
|
118
|
+
line = Number(hunk[2]);
|
|
119
|
+
newLeft = Number(hunk[3] ?? 1);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return added;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export const changedLines = Effect.fn("changedLines")(function* (
|
|
126
|
+
base: string,
|
|
127
|
+
head: string,
|
|
128
|
+
pathspecs: readonly string[],
|
|
129
|
+
cwd?: string,
|
|
130
|
+
) {
|
|
131
|
+
const diff = yield* git(
|
|
132
|
+
["-c", "core.quotePath=false", "diff", "-U0", "--no-color", "--no-prefix", "-M", base, head, "--", ...pathspecs],
|
|
133
|
+
cwd,
|
|
134
|
+
);
|
|
135
|
+
return parseAddedLines(diff);
|
|
136
|
+
});
|
|
137
|
+
|
|
74
138
|
const emptyTree = (cwd?: string) => git(["hash-object", "-t", "tree", "/dev/null"], cwd).pipe(Effect.map((sha) => sha.trim()));
|
|
75
139
|
|
|
76
140
|
export const pathsAt = Effect.fn("pathsAt")(function* (rev: string, pathspecs: readonly string[], cwd?: string) {
|