dw-sdlc 0.0.0-stage → 0.2.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/LICENSE +21 -0
- package/README.md +63 -2
- package/config/agentic.config.schema.json +375 -0
- package/config/references.schema.json +56 -0
- package/dist/agentic-config.d.ts +167 -0
- package/dist/agentic-config.js +305 -0
- package/dist/catalogue.d.ts +36 -0
- package/dist/catalogue.js +74 -0
- package/dist/checks/agents-map.d.ts +7 -0
- package/dist/checks/agents-map.js +159 -0
- package/dist/checks/check.d.ts +27 -0
- package/dist/checks/check.js +21 -0
- package/dist/checks/comment-budget.d.ts +6 -0
- package/dist/checks/comment-budget.js +141 -0
- package/dist/checks/decision-records.d.ts +6 -0
- package/dist/checks/decision-records.js +105 -0
- package/dist/checks/file-size-ratchet.d.ts +10 -0
- package/dist/checks/file-size-ratchet.js +194 -0
- package/dist/checks/index.d.ts +12 -0
- package/dist/checks/index.js +42 -0
- package/dist/checks/markdown-budget.d.ts +15 -0
- package/dist/checks/markdown-budget.js +42 -0
- package/dist/checks/markdown-refs.d.ts +6 -0
- package/dist/checks/markdown-refs.js +73 -0
- package/dist/checks/primitives.d.ts +8 -0
- package/dist/checks/primitives.js +103 -0
- package/dist/checks/temporal-language.d.ts +6 -0
- package/dist/checks/temporal-language.js +58 -0
- package/dist/checks/tree.d.ts +8 -0
- package/dist/checks/tree.js +30 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +43 -0
- package/dist/commands/config.d.ts +2 -0
- package/dist/commands/config.js +76 -0
- package/dist/commands/doctor.d.ts +57 -0
- package/dist/commands/doctor.js +341 -0
- package/dist/commands/gate.d.ts +2 -0
- package/dist/commands/gate.js +231 -0
- package/dist/commands/primitives.d.ts +2 -0
- package/dist/commands/primitives.js +106 -0
- package/dist/commands/references-setup.d.ts +2 -0
- package/dist/commands/references-setup.js +202 -0
- package/dist/commands/references-sync.d.ts +2 -0
- package/dist/commands/references-sync.js +341 -0
- package/dist/commands/setup.d.ts +30 -0
- package/dist/commands/setup.js +352 -0
- package/dist/json-schema.d.ts +8 -0
- package/dist/json-schema.js +226 -0
- package/dist/markdown.d.ts +31 -0
- package/dist/markdown.js +115 -0
- package/dist/pins.d.ts +16 -0
- package/dist/pins.js +82 -0
- package/dist/references-key.d.ts +31 -0
- package/dist/references-key.js +149 -0
- package/dist/report.d.ts +13 -0
- package/dist/report.js +29 -0
- package/dist/router-block.d.ts +13 -0
- package/dist/router-block.js +60 -0
- package/dist/setup-rules.d.ts +22 -0
- package/dist/setup-rules.js +98 -0
- package/package.json +30 -3
- package/templates/router.md +38 -0
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
// `agents-map`: the agent map stays a map. Within its budget, naming the Gate command, routing to the
|
|
2
|
+
// Primitives catalogue, every path its Task Router points at existing, and the skills directory, its
|
|
3
|
+
// lock and its links in step.
|
|
4
|
+
import { existsSync, globSync, lstatSync, readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { resolveConfig } from "../agentic-config.js";
|
|
7
|
+
import { codeSpans, tableRows } from "../markdown.js";
|
|
8
|
+
import { CannotRun } from "./check.js";
|
|
9
|
+
import { overBudget } from "./markdown-budget.js";
|
|
10
|
+
import { catalogueFile } from "./primitives.js";
|
|
11
|
+
const ROUTER = "Task Router";
|
|
12
|
+
const AGENTS_SKILLS = ".agents/skills";
|
|
13
|
+
const CLAUDE_SKILLS = ".claude/skills";
|
|
14
|
+
const LOCK = "skills-lock.json";
|
|
15
|
+
const REMEDIES = {
|
|
16
|
+
missing: "write it, or point gate.checks.agents-map.file at the map",
|
|
17
|
+
budget: `cut it, or move a topic into a doc a ${ROUTER} row points at`,
|
|
18
|
+
command: "name it where the map lists the commands",
|
|
19
|
+
noCommand: "set gate.command in the agentic config, or give package.json a check script",
|
|
20
|
+
catalogue: "add a row that sends an agent to the catalogue",
|
|
21
|
+
route: "fix the path or drop it from the row",
|
|
22
|
+
unlocked: "add it through the skills CLI, or remove the directory",
|
|
23
|
+
unheld: "restore the skill, or drop it from the lock",
|
|
24
|
+
lock: "fix it, or write it again through the skills CLI",
|
|
25
|
+
stray: `remove it, or move the skill into ${AGENTS_SKILLS}`
|
|
26
|
+
};
|
|
27
|
+
/** A code span that names a path: no spaces, and a slash or a file extension; `*` makes it a glob. */
|
|
28
|
+
function isPath(span) {
|
|
29
|
+
return /^[\w.*/-]+$/.test(span) && (span.includes("/") || /\.\w+$/.test(span));
|
|
30
|
+
}
|
|
31
|
+
function exists(root, path) {
|
|
32
|
+
return path.includes("*") ? globSync(path, { cwd: root }).length > 0 : existsSync(join(root, path));
|
|
33
|
+
}
|
|
34
|
+
function escape(text) {
|
|
35
|
+
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
36
|
+
}
|
|
37
|
+
/** The budget, the Gate command, the row to the catalogue and every path the Task Router points at. */
|
|
38
|
+
function judgeMap(root, { file, ...budget }, text) {
|
|
39
|
+
const findings = overBudget(text, budget).map((message) => ({ file, message, remedy: REMEDIES.budget }));
|
|
40
|
+
const { gate } = resolveConfig(root).config;
|
|
41
|
+
if (gate.command === undefined) {
|
|
42
|
+
findings.push({ message: "the Gate has no command for the map to name", remedy: REMEDIES.noCommand });
|
|
43
|
+
}
|
|
44
|
+
else if (!new RegExp(`(^|[^\\w-])${escape(gate.command)}($|[^\\w-])`, "m").test(text)) {
|
|
45
|
+
const message = `does not name the Gate command ${JSON.stringify(gate.command)}`;
|
|
46
|
+
findings.push({ file, message, remedy: REMEDIES.command });
|
|
47
|
+
}
|
|
48
|
+
// The first column says when a row applies; the others say what to read.
|
|
49
|
+
const routes = tableRows(text, ROUTER).map(({ cells, line }) => ({
|
|
50
|
+
line,
|
|
51
|
+
paths: codeSpans(cells.slice(1).join(" ")).filter(isPath)
|
|
52
|
+
}));
|
|
53
|
+
const catalogue = catalogueFile(gate.checks);
|
|
54
|
+
if (existsSync(join(root, catalogue)) && !routes.some(({ paths }) => paths.includes(catalogue))) {
|
|
55
|
+
const message = `no ${ROUTER} row points at ${catalogue}`;
|
|
56
|
+
findings.push({ file, message, remedy: REMEDIES.catalogue });
|
|
57
|
+
}
|
|
58
|
+
for (const { line, paths } of routes) {
|
|
59
|
+
for (const missing of paths.filter((path) => !exists(root, path))) {
|
|
60
|
+
const message = `the ${ROUTER} points at ${JSON.stringify(missing)}, which does not exist`;
|
|
61
|
+
findings.push({ file, line, message, remedy: REMEDIES.route });
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return findings;
|
|
65
|
+
}
|
|
66
|
+
/** The names of the directories in `dir`, sorted; empty when there is no `dir`. */
|
|
67
|
+
function directories(dir) {
|
|
68
|
+
if (!existsSync(dir)) {
|
|
69
|
+
return [];
|
|
70
|
+
}
|
|
71
|
+
return readdirSync(dir)
|
|
72
|
+
.filter((name) => statSync(join(dir, name), { throwIfNoEntry: false })?.isDirectory() === true)
|
|
73
|
+
.toSorted();
|
|
74
|
+
}
|
|
75
|
+
/** The skills `skills-lock.json` pins, or why it cannot be read. */
|
|
76
|
+
function lockedSkills(root) {
|
|
77
|
+
let lock;
|
|
78
|
+
try {
|
|
79
|
+
lock = JSON.parse(readFileSync(join(root, LOCK), "utf8"));
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
return { problem: `not JSON: ${error instanceof Error ? error.message : String(error)}` };
|
|
83
|
+
}
|
|
84
|
+
if (typeof lock !== "object" || lock === null || !("skills" in lock)) {
|
|
85
|
+
return { problem: "it has no skills object" };
|
|
86
|
+
}
|
|
87
|
+
const { skills } = lock;
|
|
88
|
+
if (typeof skills !== "object" || skills === null || Array.isArray(skills)) {
|
|
89
|
+
return { problem: "it has no skills object" };
|
|
90
|
+
}
|
|
91
|
+
return Object.keys(skills).toSorted();
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The skills directory, `.agents/skills` or else `.claude/skills`, against the lock where there is one,
|
|
95
|
+
* and `.agents/skills` against the links `.claude/skills` holds to it where there are both.
|
|
96
|
+
*/
|
|
97
|
+
function judgeSkills(root) {
|
|
98
|
+
const dir = existsSync(join(root, AGENTS_SKILLS)) ? AGENTS_SKILLS : CLAUDE_SKILLS;
|
|
99
|
+
const skills = directories(join(root, dir));
|
|
100
|
+
const findings = [];
|
|
101
|
+
if (existsSync(join(root, LOCK))) {
|
|
102
|
+
const locked = lockedSkills(root);
|
|
103
|
+
if ("problem" in locked) {
|
|
104
|
+
findings.push({ file: LOCK, message: `not a skills lock: ${locked.problem}`, remedy: REMEDIES.lock });
|
|
105
|
+
}
|
|
106
|
+
else {
|
|
107
|
+
for (const skill of skills.filter((name) => !locked.includes(name))) {
|
|
108
|
+
const message = `skill ${JSON.stringify(skill)} is not in ${LOCK}`;
|
|
109
|
+
findings.push({ file: `${dir}/${skill}`, message, remedy: REMEDIES.unlocked });
|
|
110
|
+
}
|
|
111
|
+
for (const skill of locked.filter((name) => !skills.includes(name))) {
|
|
112
|
+
const message = `locks skill ${JSON.stringify(skill)}, which ${dir} does not hold`;
|
|
113
|
+
findings.push({ file: LOCK, message, remedy: REMEDIES.unheld });
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
if (dir === AGENTS_SKILLS && existsSync(join(root, CLAUDE_SKILLS))) {
|
|
118
|
+
const entries = readdirSync(join(root, CLAUDE_SKILLS)).toSorted();
|
|
119
|
+
for (const skill of skills) {
|
|
120
|
+
const file = `${CLAUDE_SKILLS}/${skill}`;
|
|
121
|
+
const remedy = `link it to ../../${AGENTS_SKILLS}/${skill}`;
|
|
122
|
+
if (!entries.includes(skill)) {
|
|
123
|
+
findings.push({ file, message: `skill ${JSON.stringify(skill)} has no link here`, remedy });
|
|
124
|
+
}
|
|
125
|
+
else if (!linksTo(join(root, file), join(root, AGENTS_SKILLS, skill))) {
|
|
126
|
+
findings.push({ file, message: `is not a link to ${AGENTS_SKILLS}/${skill}`, remedy });
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
for (const entry of entries.filter((name) => !skills.includes(name))) {
|
|
130
|
+
const message = `${AGENTS_SKILLS} holds no skill ${JSON.stringify(entry)}`;
|
|
131
|
+
findings.push({ file: `${CLAUDE_SKILLS}/${entry}`, message, remedy: REMEDIES.stray });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
return findings;
|
|
135
|
+
}
|
|
136
|
+
/** Whether `link` is a symbolic link that resolves to `target`. */
|
|
137
|
+
function linksTo(link, target) {
|
|
138
|
+
if (!lstatSync(link).isSymbolicLink()) {
|
|
139
|
+
return false;
|
|
140
|
+
}
|
|
141
|
+
try {
|
|
142
|
+
return realpathSync(link) === realpathSync(target);
|
|
143
|
+
}
|
|
144
|
+
catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
function run(root, options) {
|
|
149
|
+
const path = join(root, options.file);
|
|
150
|
+
if (!existsSync(path)) {
|
|
151
|
+
throw new CannotRun({ file: options.file, message: "the map does not exist", remedy: REMEDIES.missing });
|
|
152
|
+
}
|
|
153
|
+
return [...judgeMap(root, options, readFileSync(path, "utf8")), ...judgeSkills(root)];
|
|
154
|
+
}
|
|
155
|
+
export const agentsMap = {
|
|
156
|
+
name: "agents-map",
|
|
157
|
+
defaults: { file: "AGENTS.md", maxLines: 150, maxBytes: 10240 },
|
|
158
|
+
run
|
|
159
|
+
};
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import type { Failure } from "../report.ts";
|
|
2
|
+
/** A failure as a Check finds it: the file and line where it has one, what is wrong, the remedy. */
|
|
3
|
+
export type Finding = Omit<Failure, "rule">;
|
|
4
|
+
/** An input the Check needs is missing or unreadable, so the Gate cannot run: exit 2, not 1. */
|
|
5
|
+
export declare class CannotRun extends Error {
|
|
6
|
+
finding: Finding;
|
|
7
|
+
constructor(finding: Finding);
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* A Ratchet's committed baseline: its file, and its refresh from the tree, which writes it tighter or
|
|
11
|
+
* records it where there is none, says what it did, and throws `CannotRun` when it cannot.
|
|
12
|
+
*/
|
|
13
|
+
export type Baseline<Options extends object> = {
|
|
14
|
+
file: string;
|
|
15
|
+
refresh: (root: string, options: Options) => string;
|
|
16
|
+
};
|
|
17
|
+
export type Check<Options extends object> = {
|
|
18
|
+
/** The Check's key under `gate.checks`. */
|
|
19
|
+
name: string;
|
|
20
|
+
defaults: Options;
|
|
21
|
+
/** Every failure of the tree at `root`, none when it passes; throws `CannotRun` when it cannot judge. */
|
|
22
|
+
run: (root: string, options: Options) => Finding[];
|
|
23
|
+
/** Present on a Ratchet. */
|
|
24
|
+
baseline?: Baseline<Options>;
|
|
25
|
+
};
|
|
26
|
+
/** The options `setting` turns the Check on with: the defaults under `true`, an object over them, else off. */
|
|
27
|
+
export declare function optionsOf<Options extends object>(check: Check<Options>, setting: unknown): Options | undefined;
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
// What every Check the package ships is: a name, the options `true` stands for, and a run over the
|
|
2
|
+
// tree that answers with its failures. The Gate names the Check as each failure's rule.
|
|
3
|
+
/** An input the Check needs is missing or unreadable, so the Gate cannot run: exit 2, not 1. */
|
|
4
|
+
export class CannotRun extends Error {
|
|
5
|
+
finding;
|
|
6
|
+
constructor(finding) {
|
|
7
|
+
super(finding.message);
|
|
8
|
+
this.finding = finding;
|
|
9
|
+
}
|
|
10
|
+
}
|
|
11
|
+
/** The options `setting` turns the Check on with: the defaults under `true`, an object over them, else off. */
|
|
12
|
+
export function optionsOf(check, setting) {
|
|
13
|
+
if (setting === true) {
|
|
14
|
+
return check.defaults;
|
|
15
|
+
}
|
|
16
|
+
if (typeof setting === "object" && setting !== null && !Array.isArray(setting)) {
|
|
17
|
+
// The schema has refused every key the Check does not take and every value of the wrong type.
|
|
18
|
+
return { ...check.defaults, ...setting };
|
|
19
|
+
}
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// `comment-budget`: a comment says what the code cannot, in a few lines. A block, one `/* … */` or a run
|
|
2
|
+
// of `//` lines alone on theirs, carries at most `maxProseLines` lines with a letter or a digit.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { matching, treeFiles } from "./tree.js";
|
|
6
|
+
const SOURCE = ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}"];
|
|
7
|
+
const REMEDY = "cut it to what the code cannot say, or move the reasoning into a doc and point at it";
|
|
8
|
+
/** The characters after which a `/` opens a regular expression, not a division. */
|
|
9
|
+
const BEFORE_REGEX = new Set("(,=:[!&|?{};+-*%<>~^");
|
|
10
|
+
const KEYWORD_BEFORE_REGEX = /(^|[^\w$])(return|typeof|instanceof|in|of|new|delete|void|throw|case|do|else|yield|await)$/;
|
|
11
|
+
/** Where the regular expression whose `/` is at `start` ends, or undefined when the line ends first. */
|
|
12
|
+
function regexEnd(text, start) {
|
|
13
|
+
let inClass = false;
|
|
14
|
+
for (let index = start + 1; index < text.length; index += 1) {
|
|
15
|
+
const char = text[index];
|
|
16
|
+
if (char === "\\") {
|
|
17
|
+
index += 1;
|
|
18
|
+
}
|
|
19
|
+
else if (char === "\n") {
|
|
20
|
+
return undefined;
|
|
21
|
+
}
|
|
22
|
+
else if (char === "[" || char === "]") {
|
|
23
|
+
inClass = char === "[";
|
|
24
|
+
}
|
|
25
|
+
else if (char === "/" && !inClass) {
|
|
26
|
+
return index + 1;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
/** Whether the `/` at `index` opens a regular expression: the code before it ends where a value starts. */
|
|
32
|
+
function opensRegex(text, index) {
|
|
33
|
+
const before = text.slice(0, index).trimEnd();
|
|
34
|
+
const last = before.at(-1);
|
|
35
|
+
return last === undefined || BEFORE_REGEX.has(last) || KEYWORD_BEFORE_REGEX.test(before);
|
|
36
|
+
}
|
|
37
|
+
/** Every comment in JavaScript or TypeScript `text`, skipping strings, template literals and regular expressions. */
|
|
38
|
+
function commentsOf(text) {
|
|
39
|
+
const comments = [];
|
|
40
|
+
// The open quotes, and a `{` for each brace opened inside a template literal's `${…}`.
|
|
41
|
+
const stack = [];
|
|
42
|
+
let line = 1;
|
|
43
|
+
let index = 0;
|
|
44
|
+
while (index < text.length) {
|
|
45
|
+
const char = text[index];
|
|
46
|
+
const next = text[index + 1];
|
|
47
|
+
const top = stack.at(-1);
|
|
48
|
+
if (top === "'" || top === '"' || top === "`") {
|
|
49
|
+
if (char === "\\") {
|
|
50
|
+
line += next === "\n" ? 1 : 0;
|
|
51
|
+
index += 2;
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
if (top === "`" && char === "$" && next === "{") {
|
|
55
|
+
stack.push("{");
|
|
56
|
+
index += 2;
|
|
57
|
+
continue;
|
|
58
|
+
}
|
|
59
|
+
if (char === top) {
|
|
60
|
+
stack.pop();
|
|
61
|
+
}
|
|
62
|
+
line += char === "\n" ? 1 : 0;
|
|
63
|
+
index += 1;
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (char === "'" || char === '"' || char === "`" || (char === "{" && top === "{")) {
|
|
67
|
+
stack.push(char);
|
|
68
|
+
}
|
|
69
|
+
else if (char === "}" && top === "{") {
|
|
70
|
+
stack.pop();
|
|
71
|
+
}
|
|
72
|
+
else if (char === "/" && next === "/") {
|
|
73
|
+
const stop = text.indexOf("\n", index) === -1 ? text.length : text.indexOf("\n", index);
|
|
74
|
+
const alone = text.slice(text.lastIndexOf("\n", index - 1) + 1, index).trim() === "";
|
|
75
|
+
comments.push({ kind: alone ? "line" : "trailing", start: line, end: line, text: text.slice(index, stop) });
|
|
76
|
+
index = stop;
|
|
77
|
+
continue;
|
|
78
|
+
}
|
|
79
|
+
else if (char === "/" && next === "*") {
|
|
80
|
+
const close = text.indexOf("*/", index + 2);
|
|
81
|
+
const body = text.slice(index, close === -1 ? text.length : close + 2);
|
|
82
|
+
const start = line;
|
|
83
|
+
line += body.split("\n").length - 1;
|
|
84
|
+
comments.push({ kind: "block", start, end: line, text: body });
|
|
85
|
+
index += body.length;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
else if (char === "/" && opensRegex(text, index)) {
|
|
89
|
+
index = regexEnd(text, index) ?? index + 1;
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
line += char === "\n" ? 1 : 0;
|
|
93
|
+
index += 1;
|
|
94
|
+
}
|
|
95
|
+
return comments;
|
|
96
|
+
}
|
|
97
|
+
/** The comments as blocks: a run of `//` lines alone on consecutive lines is one; a comment after code is none. */
|
|
98
|
+
function blocksOf(comments) {
|
|
99
|
+
const blocks = [];
|
|
100
|
+
for (const comment of comments) {
|
|
101
|
+
const previous = blocks.at(-1);
|
|
102
|
+
if (comment.kind === "line" && previous?.kind === "line" && previous.end === comment.start - 1) {
|
|
103
|
+
previous.end = comment.end;
|
|
104
|
+
previous.text += `\n${comment.text}`;
|
|
105
|
+
}
|
|
106
|
+
else if (comment.kind !== "trailing") {
|
|
107
|
+
blocks.push({ ...comment });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return blocks;
|
|
111
|
+
}
|
|
112
|
+
/** The lines of a block that hold a letter or a digit once the comment markers around them are off. */
|
|
113
|
+
function proseLines(block) {
|
|
114
|
+
return block
|
|
115
|
+
.replace(/^\/\*\*?/, "")
|
|
116
|
+
.replace(/\*\/$/, "")
|
|
117
|
+
.split("\n")
|
|
118
|
+
.map((line) => line
|
|
119
|
+
.trim()
|
|
120
|
+
.replace(/^\*\s?/, "")
|
|
121
|
+
.replace(/^\/\/\s?/, ""))
|
|
122
|
+
.filter((line) => /[\p{L}\p{N}]/u.test(line)).length;
|
|
123
|
+
}
|
|
124
|
+
function run(root, { maxProseLines, roots }) {
|
|
125
|
+
const findings = [];
|
|
126
|
+
for (const file of matching(root, treeFiles(root, roots), SOURCE)) {
|
|
127
|
+
for (const block of blocksOf(commentsOf(readFileSync(join(root, file), "utf8")))) {
|
|
128
|
+
const prose = proseLines(block.text);
|
|
129
|
+
if (prose > maxProseLines) {
|
|
130
|
+
const message = `a comment block of ${prose} prose lines, over the ${maxProseLines}-line budget`;
|
|
131
|
+
findings.push({ file, line: block.start, message, remedy: REMEDY });
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return findings;
|
|
136
|
+
}
|
|
137
|
+
export const commentBudget = {
|
|
138
|
+
name: "comment-budget",
|
|
139
|
+
defaults: { maxProseLines: 3, roots: ["packages", "plugins", "scripts", "src"] },
|
|
140
|
+
run
|
|
141
|
+
};
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// `decision-records`: the decisions directory stays a reliable index. Its records, the files named
|
|
2
|
+
// `<number>-<slug>.md`, are numbered from 1 with no gap and no number taken twice, and each has a title
|
|
3
|
+
// and a frontmatter status from the allowed set.
|
|
4
|
+
import { readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { headings } from "../markdown.js";
|
|
7
|
+
import { CannotRun } from "./check.js";
|
|
8
|
+
import { treeFiles } from "./tree.js";
|
|
9
|
+
/** A record's name in its directory: its number, a slug, `.md`, and no directory between. */
|
|
10
|
+
const RECORD = /^(\d+)-[^/]+\.md$/;
|
|
11
|
+
const REMEDIES = {
|
|
12
|
+
missing: "create the records directory, or point gate.checks.decision-records.dirs at it",
|
|
13
|
+
gap: "restore the missing records, or renumber the ones after the gap",
|
|
14
|
+
taken: "give each record a number of its own",
|
|
15
|
+
title: "start it with a level-1 heading that names the decision"
|
|
16
|
+
};
|
|
17
|
+
function isDirectory(path) {
|
|
18
|
+
return statSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
19
|
+
}
|
|
20
|
+
/** The `status` of the frontmatter `text` opens with, its quotes dropped, and its 1-based line. */
|
|
21
|
+
function statusOf(text) {
|
|
22
|
+
const lines = text.split("\n");
|
|
23
|
+
if (lines[0]?.trim() !== "---") {
|
|
24
|
+
return undefined;
|
|
25
|
+
}
|
|
26
|
+
for (let index = 1; index < lines.length && lines[index]?.trim() !== "---"; index++) {
|
|
27
|
+
const value = /^status:\s*(.*?)\s*$/.exec(lines[index] ?? "")?.[1]?.replace(/^(["'])(.*)\1$/, "$2");
|
|
28
|
+
if (value !== undefined && value !== "") {
|
|
29
|
+
return { value, line: index + 1 };
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
return undefined;
|
|
33
|
+
}
|
|
34
|
+
/** Each run of numbers from 1 to the highest that no record takes, as `0004` or `0004 to 0006`. */
|
|
35
|
+
function gaps(taken, pad) {
|
|
36
|
+
const runs = [];
|
|
37
|
+
const highest = Math.max(0, ...taken);
|
|
38
|
+
let start;
|
|
39
|
+
for (let number = 1; number <= highest; number++) {
|
|
40
|
+
if (!taken.has(number)) {
|
|
41
|
+
start ??= number;
|
|
42
|
+
}
|
|
43
|
+
else if (start !== undefined) {
|
|
44
|
+
runs.push(start === number - 1 ? pad(start) : `${pad(start)} to ${pad(number - 1)}`);
|
|
45
|
+
start = undefined;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
return runs;
|
|
49
|
+
}
|
|
50
|
+
/** What is wrong with the records of `dir`: the numbers they skip, then each record's number, title and status. */
|
|
51
|
+
function judge(root, dir, statuses) {
|
|
52
|
+
const prefix = `${dir.replace(/\/+$/, "")}/`;
|
|
53
|
+
const records = treeFiles(root, [dir])
|
|
54
|
+
.map((file) => ({ file, name: file.slice(prefix.length) }))
|
|
55
|
+
.filter(({ name }) => RECORD.test(name))
|
|
56
|
+
.map(({ file, name }) => ({ file, digits: RECORD.exec(name)?.[1] ?? "" }));
|
|
57
|
+
const width = records[0]?.digits.length ?? 4;
|
|
58
|
+
const pad = (number) => String(number).padStart(width, "0");
|
|
59
|
+
const owners = new Map();
|
|
60
|
+
for (const { file, digits } of records) {
|
|
61
|
+
if (!owners.has(Number(digits))) {
|
|
62
|
+
owners.set(Number(digits), file);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
const findings = gaps(new Set(owners.keys()), pad).map((gap) => ({
|
|
66
|
+
file: dir,
|
|
67
|
+
message: `no record is numbered ${gap}`,
|
|
68
|
+
remedy: REMEDIES.gap
|
|
69
|
+
}));
|
|
70
|
+
const allowed = `one of ${statuses.join(", ")}`;
|
|
71
|
+
for (const { file, digits } of records) {
|
|
72
|
+
const owner = owners.get(Number(digits));
|
|
73
|
+
if (owner !== file) {
|
|
74
|
+
findings.push({ file, message: `${digits} is taken by ${owner}`, remedy: REMEDIES.taken });
|
|
75
|
+
}
|
|
76
|
+
const text = readFileSync(join(root, file), "utf8");
|
|
77
|
+
if (!headings(text).some(({ level, text: title }) => level === 1 && title.trim() !== "")) {
|
|
78
|
+
findings.push({ file, message: "has no title", remedy: REMEDIES.title });
|
|
79
|
+
}
|
|
80
|
+
const status = statusOf(text);
|
|
81
|
+
if (status === undefined) {
|
|
82
|
+
findings.push({ file, message: "has no status", remedy: `give its frontmatter a status, ${allowed}` });
|
|
83
|
+
}
|
|
84
|
+
else if (!statuses.includes(status.value.split(/\s+/)[0] ?? "")) {
|
|
85
|
+
const message = `the status ${JSON.stringify(status.value)} is not allowed`;
|
|
86
|
+
findings.push({ file, line: status.line, message, remedy: `set it to ${allowed}` });
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return findings;
|
|
90
|
+
}
|
|
91
|
+
function run(root, { dirs, statuses }) {
|
|
92
|
+
const present = dirs.filter((dir) => isDirectory(join(root, dir)));
|
|
93
|
+
if (present.length === 0) {
|
|
94
|
+
throw new CannotRun({ message: `none of ${dirs.join(", ")} exists`, remedy: REMEDIES.missing });
|
|
95
|
+
}
|
|
96
|
+
return present.flatMap((dir) => judge(root, dir, statuses));
|
|
97
|
+
}
|
|
98
|
+
export const decisionRecords = {
|
|
99
|
+
name: "decision-records",
|
|
100
|
+
defaults: {
|
|
101
|
+
dirs: ["docs/adr", "docs/decisions"],
|
|
102
|
+
statuses: ["proposed", "accepted", "active", "deprecated", "superseded"]
|
|
103
|
+
},
|
|
104
|
+
run
|
|
105
|
+
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Check } from "./check.ts";
|
|
2
|
+
export type Group = {
|
|
3
|
+
name: string;
|
|
4
|
+
maxLines: number;
|
|
5
|
+
files: string[];
|
|
6
|
+
};
|
|
7
|
+
export type FileSizeRatchetOptions = {
|
|
8
|
+
groups: Group[];
|
|
9
|
+
};
|
|
10
|
+
export declare const fileSizeRatchet: Check<FileSizeRatchetOptions>;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
// `file-size-ratchet`: each file within the line limit of its group or, listed in the baseline, within
|
|
2
|
+
// the count recorded there. A refresh only tightens the baseline: it lowers and drops, and adds no file.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { dirname, join, matchesGlob } from "node:path";
|
|
6
|
+
import { CannotRun } from "./check.js";
|
|
7
|
+
import { lineCount } from "./markdown-budget.js";
|
|
8
|
+
import { matching, treeFiles } from "./tree.js";
|
|
9
|
+
const BASELINE = ".ai/file-size-ratchet.baseline.json";
|
|
10
|
+
const REFRESH = "dw-sdlc gate --update-baseline";
|
|
11
|
+
const REMEDIES = {
|
|
12
|
+
record: `record it with ${REFRESH}`,
|
|
13
|
+
shape: `make it an object of file paths and line counts, as ${REFRESH} writes it`,
|
|
14
|
+
twice: "keep one entry for it, the lower count",
|
|
15
|
+
stale: `run ${REFRESH}, which drops it`,
|
|
16
|
+
unreadable: "fix the failures it names"
|
|
17
|
+
};
|
|
18
|
+
/** A committed baseline gone from the tree: recording a new one would let every grown file back in. */
|
|
19
|
+
const DELETED = {
|
|
20
|
+
file: BASELINE,
|
|
21
|
+
message: "the baseline is committed but gone from the tree",
|
|
22
|
+
remedy: `restore it with git restore ${BASELINE}`
|
|
23
|
+
};
|
|
24
|
+
function show(value) {
|
|
25
|
+
return JSON.stringify(value);
|
|
26
|
+
}
|
|
27
|
+
function plural(count, noun) {
|
|
28
|
+
return `${count} ${noun}${count === 1 ? "" : "s"}`;
|
|
29
|
+
}
|
|
30
|
+
function inBaseline(line) {
|
|
31
|
+
return line === undefined ? { file: BASELINE } : { file: BASELINE, line };
|
|
32
|
+
}
|
|
33
|
+
/** The first group with a glob that matches `path`. */
|
|
34
|
+
function groupOf(path, groups) {
|
|
35
|
+
return groups.find(({ files }) => files.some((glob) => matchesGlob(path, glob)));
|
|
36
|
+
}
|
|
37
|
+
function measure(root, groups) {
|
|
38
|
+
const measured = new Map();
|
|
39
|
+
for (const path of matching(root, treeFiles(root), groups.flatMap(({ files }) => files))) {
|
|
40
|
+
const group = groupOf(path, groups);
|
|
41
|
+
if (group !== undefined && statSync(join(root, path)).isFile()) {
|
|
42
|
+
measured.set(path, { group, lines: lineCount(readFileSync(join(root, path), "utf8")) });
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return measured;
|
|
46
|
+
}
|
|
47
|
+
/** The line of each `"<path>":` key in `text`; JSON keeps only the last of a key it reads twice. */
|
|
48
|
+
function keyLines(text, path) {
|
|
49
|
+
const key = show(path).replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
50
|
+
return Array.from(text.matchAll(new RegExp(`(?<!\\\\)${key}\\s*:`, "g")), ({ index }) => {
|
|
51
|
+
return text.slice(0, index).split("\n").length;
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
function readBaseline(root) {
|
|
55
|
+
const path = join(root, BASELINE);
|
|
56
|
+
if (!existsSync(path)) {
|
|
57
|
+
const committed = spawnSync("git", ["-C", root, "cat-file", "-e", `HEAD:${BASELINE}`]).status === 0;
|
|
58
|
+
return { state: "missing", committed };
|
|
59
|
+
}
|
|
60
|
+
const text = readFileSync(path, "utf8");
|
|
61
|
+
let parsed;
|
|
62
|
+
try {
|
|
63
|
+
parsed = JSON.parse(text);
|
|
64
|
+
}
|
|
65
|
+
catch (error) {
|
|
66
|
+
const message = `not JSON: ${error instanceof Error ? error.message : String(error)}`;
|
|
67
|
+
return { state: "invalid", findings: [{ file: BASELINE, message, remedy: REMEDIES.shape }] };
|
|
68
|
+
}
|
|
69
|
+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
|
|
70
|
+
return { state: "invalid", findings: [{ file: BASELINE, message: "not an object", remedy: REMEDIES.shape }] };
|
|
71
|
+
}
|
|
72
|
+
const findings = [];
|
|
73
|
+
const entries = new Map();
|
|
74
|
+
for (const [file, count] of Object.entries(parsed)) {
|
|
75
|
+
const lines = keyLines(text, file);
|
|
76
|
+
if (lines.length > 1) {
|
|
77
|
+
const times = lines.length === 2 ? "twice" : `${lines.length} times`;
|
|
78
|
+
findings.push({ ...inBaseline(lines[1]), message: `lists ${show(file)} ${times}`, remedy: REMEDIES.twice });
|
|
79
|
+
}
|
|
80
|
+
if (typeof count === "number" && Number.isInteger(count) && count >= 1) {
|
|
81
|
+
entries.set(file, { count, line: lines[0] });
|
|
82
|
+
}
|
|
83
|
+
else {
|
|
84
|
+
const message = `${show(file)} is at ${show(count)}, not a line count`;
|
|
85
|
+
findings.push({ ...inBaseline(lines.at(-1)), message, remedy: REMEDIES.shape });
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return findings.length > 0 ? { state: "invalid", findings } : { state: "valid", entries };
|
|
89
|
+
}
|
|
90
|
+
/** The entries that excuse nothing: a file no group holds, or a count within its group's limit. */
|
|
91
|
+
function contradictions(entries, groups) {
|
|
92
|
+
const findings = [];
|
|
93
|
+
for (const [file, { count, line }] of entries) {
|
|
94
|
+
const group = groupOf(file, groups);
|
|
95
|
+
if (group === undefined) {
|
|
96
|
+
const message = `lists ${show(file)}, which no group holds`;
|
|
97
|
+
findings.push({ ...inBaseline(line), message, remedy: REMEDIES.stale });
|
|
98
|
+
}
|
|
99
|
+
else if (count <= group.maxLines) {
|
|
100
|
+
const message = `lists ${show(file)} at ${count} lines, within the ${group.maxLines}-line limit of the ${group.name} group`;
|
|
101
|
+
findings.push({ ...inBaseline(line), message, remedy: REMEDIES.stale });
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return findings;
|
|
105
|
+
}
|
|
106
|
+
function run(root, { groups }) {
|
|
107
|
+
const read = readBaseline(root);
|
|
108
|
+
if (read.state === "missing") {
|
|
109
|
+
const missing = { file: BASELINE, message: "the baseline does not exist", remedy: REMEDIES.record };
|
|
110
|
+
throw new CannotRun(read.committed ? DELETED : missing);
|
|
111
|
+
}
|
|
112
|
+
if (read.state === "invalid") {
|
|
113
|
+
return read.findings;
|
|
114
|
+
}
|
|
115
|
+
const findings = contradictions(read.entries, groups);
|
|
116
|
+
for (const [file, { group, lines }] of measure(root, groups)) {
|
|
117
|
+
const recorded = read.entries.get(file)?.count;
|
|
118
|
+
if (recorded !== undefined && lines > recorded) {
|
|
119
|
+
const message = `${lines} lines, over the ${recorded} the baseline holds it to`;
|
|
120
|
+
findings.push({ file, message, remedy: `cut it back to ${recorded} lines or fewer` });
|
|
121
|
+
}
|
|
122
|
+
else if (recorded === undefined && lines > group.maxLines) {
|
|
123
|
+
const message = `${lines} lines, over the ${group.maxLines}-line limit of the ${group.name} group`;
|
|
124
|
+
findings.push({ file, message, remedy: `split it, or cut it to ${group.maxLines} lines` });
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
return findings;
|
|
128
|
+
}
|
|
129
|
+
function write(root, entries) {
|
|
130
|
+
const sorted = entries.toSorted(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
|
|
131
|
+
mkdirSync(dirname(join(root, BASELINE)), { recursive: true });
|
|
132
|
+
writeFileSync(join(root, BASELINE), `${JSON.stringify(Object.fromEntries(sorted), null, 2)}\n`);
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Records every file over its limit where there is no baseline; else lowers each count that shrank
|
|
136
|
+
* and drops each entry that excuses nothing, adding none.
|
|
137
|
+
*/
|
|
138
|
+
function refresh(root, { groups }) {
|
|
139
|
+
const read = readBaseline(root);
|
|
140
|
+
if (read.state === "invalid") {
|
|
141
|
+
throw new CannotRun({
|
|
142
|
+
file: BASELINE,
|
|
143
|
+
message: "it is not a baseline the Ratchet can read",
|
|
144
|
+
remedy: REMEDIES.unreadable
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
if (read.state === "missing" && read.committed) {
|
|
148
|
+
throw new CannotRun(DELETED);
|
|
149
|
+
}
|
|
150
|
+
const measured = measure(root, groups);
|
|
151
|
+
if (read.state === "missing") {
|
|
152
|
+
const over = [...measured].filter(([, { group, lines }]) => lines > group.maxLines);
|
|
153
|
+
write(root, over.map(([file, { lines }]) => [file, lines]));
|
|
154
|
+
return `recorded — ${plural(over.length, "file")} over ${over.length === 1 ? "its" : "their"} limit`;
|
|
155
|
+
}
|
|
156
|
+
const kept = [];
|
|
157
|
+
let dropped = 0;
|
|
158
|
+
let lowered = 0;
|
|
159
|
+
for (const [file, { count }] of read.entries) {
|
|
160
|
+
const now = measured.get(file);
|
|
161
|
+
const tighter = now === undefined ? 0 : Math.min(now.lines, count);
|
|
162
|
+
if (now === undefined || tighter <= now.group.maxLines) {
|
|
163
|
+
dropped += 1;
|
|
164
|
+
}
|
|
165
|
+
else {
|
|
166
|
+
lowered += tighter < count ? 1 : 0;
|
|
167
|
+
kept.push([file, tighter]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (dropped === 0 && lowered === 0) {
|
|
171
|
+
return "unchanged";
|
|
172
|
+
}
|
|
173
|
+
write(root, kept);
|
|
174
|
+
const parts = [
|
|
175
|
+
...(dropped > 0 ? [`${plural(dropped, "file")} dropped`] : []),
|
|
176
|
+
...(lowered > 0 ? [`${plural(lowered, "count")} lowered`] : [])
|
|
177
|
+
];
|
|
178
|
+
return `tightened — ${parts.join(", ")}`;
|
|
179
|
+
}
|
|
180
|
+
const TESTS = ["**/{test,tests,__tests__}/**", "**/*.{test,spec}.*"];
|
|
181
|
+
const AGENT_DOCS = ["**/{AGENTS,CLAUDE,GLOSSARY}.md", "docs/agents/**/*.md"];
|
|
182
|
+
const SOURCE = ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,sh}"];
|
|
183
|
+
export const fileSizeRatchet = {
|
|
184
|
+
name: "file-size-ratchet",
|
|
185
|
+
defaults: {
|
|
186
|
+
groups: [
|
|
187
|
+
{ name: "tests", maxLines: 2000, files: TESTS },
|
|
188
|
+
{ name: "agent-docs", maxLines: 120, files: AGENT_DOCS },
|
|
189
|
+
{ name: "source", maxLines: 500, files: SOURCE }
|
|
190
|
+
]
|
|
191
|
+
},
|
|
192
|
+
run,
|
|
193
|
+
baseline: { file: BASELINE, refresh }
|
|
194
|
+
};
|