dw-sdlc 0.1.0 → 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/config/agentic.config.schema.json +163 -0
- package/dist/agentic-config.js +3 -5
- package/dist/checks/agents-map.js +3 -10
- package/dist/checks/check.d.ts +10 -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 +5 -1
- package/dist/checks/index.js +25 -2
- 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.js +5 -30
- 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/commands/doctor.js +7 -7
- package/dist/commands/gate.js +73 -17
- package/dist/commands/references-setup.js +4 -15
- package/dist/commands/references-sync.js +3 -9
- package/dist/commands/setup.d.ts +3 -4
- package/dist/commands/setup.js +22 -39
- package/dist/json-schema.js +3 -4
- package/dist/markdown.d.ts +12 -0
- package/dist/markdown.js +40 -2
- package/dist/pins.js +2 -3
- package/dist/references-key.js +1 -2
- package/dist/router-block.js +1 -3
- package/package.json +1 -1
|
@@ -187,6 +187,169 @@
|
|
|
187
187
|
}
|
|
188
188
|
},
|
|
189
189
|
"additionalProperties": false
|
|
190
|
+
},
|
|
191
|
+
"markdown-budget": {
|
|
192
|
+
"description": "Each file a glob of `budgets` matches within that glob's `maxLines` and `maxBytes`, and every glob matching a file. A glob matches the files git tracks or would track, a symbolic link left out when the file it points at is matched too; `*` and `**` skip a name that starts with a dot.",
|
|
193
|
+
"type": ["boolean", "object"],
|
|
194
|
+
"properties": {
|
|
195
|
+
"budgets": {
|
|
196
|
+
"description": "Default `docs/agents/*.md` within 100 lines and 6144 bytes; a list replaces the default.",
|
|
197
|
+
"type": "array",
|
|
198
|
+
"items": {
|
|
199
|
+
"type": "object",
|
|
200
|
+
"properties": {
|
|
201
|
+
"glob": {
|
|
202
|
+
"type": "string",
|
|
203
|
+
"minLength": 1
|
|
204
|
+
},
|
|
205
|
+
"maxLines": {
|
|
206
|
+
"type": "integer",
|
|
207
|
+
"minimum": 1
|
|
208
|
+
},
|
|
209
|
+
"maxBytes": {
|
|
210
|
+
"type": "integer",
|
|
211
|
+
"minimum": 1
|
|
212
|
+
}
|
|
213
|
+
},
|
|
214
|
+
"required": ["glob", "maxLines", "maxBytes"],
|
|
215
|
+
"additionalProperties": false
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
},
|
|
219
|
+
"additionalProperties": false
|
|
220
|
+
},
|
|
221
|
+
"markdown-refs": {
|
|
222
|
+
"description": "Every relative link and image in the markdown files resolves: its path to a file or directory git tracks or would track, its heading anchor to a heading of the markdown file it names, and `#anchor` alone to a heading of its own file. A link with a scheme, such as `https:`, and a link inside code are not judged.",
|
|
223
|
+
"type": ["boolean", "object"],
|
|
224
|
+
"properties": {
|
|
225
|
+
"files": {
|
|
226
|
+
"description": "Globs of the markdown files whose links are judged, against the files git tracks or would track; `*` and `**` skip a name that starts with a dot. Default `**/*.md`.",
|
|
227
|
+
"type": "array",
|
|
228
|
+
"items": {
|
|
229
|
+
"type": "string",
|
|
230
|
+
"minLength": 1
|
|
231
|
+
},
|
|
232
|
+
"uniqueItems": true
|
|
233
|
+
},
|
|
234
|
+
"exempt": {
|
|
235
|
+
"description": "Globs of the files left out. Default none.",
|
|
236
|
+
"type": "array",
|
|
237
|
+
"items": {
|
|
238
|
+
"type": "string",
|
|
239
|
+
"minLength": 1
|
|
240
|
+
},
|
|
241
|
+
"uniqueItems": true
|
|
242
|
+
}
|
|
243
|
+
},
|
|
244
|
+
"additionalProperties": false
|
|
245
|
+
},
|
|
246
|
+
"temporal-language": {
|
|
247
|
+
"description": "No changelog phrase in a durable document, matched across a line break and never inside code: `now we`,`we now`, `we no longer`, `previously we`, `as before`, `were removed`, `was` or `were retired`, `no longer` before `support`, `accept`, `require`, `use`, `fan`, `carry`, `replay`, `incur`, `authoritative`, `insert` or `needed`,`is now a` or `the`, `are now`, `previously a` or `the`, `recently` before `changed`, `added`, `updated`, `removed` or `introduced`, and `used to support` or `require`.",
|
|
248
|
+
"type": ["boolean", "object"],
|
|
249
|
+
"properties": {
|
|
250
|
+
"files": {
|
|
251
|
+
"description": "Globs of the durable documents, against the files git tracks or would track; `*` and `**` skip a name that starts with a dot. Default `**/*.md`.",
|
|
252
|
+
"type": "array",
|
|
253
|
+
"items": {
|
|
254
|
+
"type": "string",
|
|
255
|
+
"minLength": 1
|
|
256
|
+
},
|
|
257
|
+
"uniqueItems": true
|
|
258
|
+
},
|
|
259
|
+
"exempt": {
|
|
260
|
+
"description": "Globs of the files that record a point in time. Default `docs/adr/**`, `docs/decisions/**`, `docs/research/**` and `**/CHANGELOG.md`; a list replaces the default.",
|
|
261
|
+
"type": "array",
|
|
262
|
+
"items": {
|
|
263
|
+
"type": "string",
|
|
264
|
+
"minLength": 1
|
|
265
|
+
},
|
|
266
|
+
"uniqueItems": true
|
|
267
|
+
}
|
|
268
|
+
},
|
|
269
|
+
"additionalProperties": false
|
|
270
|
+
},
|
|
271
|
+
"decision-records": {
|
|
272
|
+
"description": "The records of each directory of `dirs` that exists, the files in it git tracks or would track named `<number>-<slug>.md`: numbered from 1 with no gap and no number taken twice, each with a level-1 heading for its title and a frontmatter `status` whose first word is one of `statuses`, so `superseded by ADR 0008` reads as `superseded`. The Gate cannot run when none of `dirs` exists.",
|
|
273
|
+
"type": ["boolean", "object"],
|
|
274
|
+
"properties": {
|
|
275
|
+
"dirs": {
|
|
276
|
+
"description": "The directories of the records, relative to the repository root; one that does not exist holds none. Default `docs/adr` and `docs/decisions`.",
|
|
277
|
+
"type": "array",
|
|
278
|
+
"items": {
|
|
279
|
+
"type": "string",
|
|
280
|
+
"minLength": 1
|
|
281
|
+
},
|
|
282
|
+
"uniqueItems": true
|
|
283
|
+
},
|
|
284
|
+
"statuses": {
|
|
285
|
+
"description": "Default `proposed`, `accepted`, `active`, `deprecated` and `superseded`.",
|
|
286
|
+
"type": "array",
|
|
287
|
+
"items": {
|
|
288
|
+
"type": "string",
|
|
289
|
+
"minLength": 1
|
|
290
|
+
},
|
|
291
|
+
"uniqueItems": true
|
|
292
|
+
}
|
|
293
|
+
},
|
|
294
|
+
"additionalProperties": false
|
|
295
|
+
},
|
|
296
|
+
"file-size-ratchet": {
|
|
297
|
+
"description": "The file-size Ratchet: each file git tracks or would track within the line limit of its group, or, where `.ai/file-size-ratchet.baseline.json` lists it, within the count recorded there. A file belongs to the first group with a glob that matches it; a file no group matches has no limit, and a symbolic link is left out when the file it points at is matched too. The baseline is committed and lists the files over their limit; one that lists a file twice, at a count within its group's limit or in no group fails the Check. `dw-sdlc gate --update-baseline` records it where there is none and otherwise only tightens it, and only when every Step and every other Check passes: a file within its limit leaves it, a count that shrank is lowered, and no file joins.",
|
|
298
|
+
"type": ["boolean", "object"],
|
|
299
|
+
"properties": {
|
|
300
|
+
"groups": {
|
|
301
|
+
"description": "Default, in this order: `tests`, 2000 lines, a file under a `test`, `tests` or `__tests__` directory or named `*.test.*` or `*.spec.*`; `agent-docs`, 120 lines, `AGENTS.md`, `CLAUDE.md` and `GLOSSARY.md` anywhere and the markdown under `docs/agents`; `source`, 500 lines, JavaScript, TypeScript and shell.",
|
|
302
|
+
"type": "array",
|
|
303
|
+
"items": {
|
|
304
|
+
"type": "object",
|
|
305
|
+
"properties": {
|
|
306
|
+
"name": {
|
|
307
|
+
"description": "The group's name, which its failure lines carry: lowercase words joined by hyphens.",
|
|
308
|
+
"type": "string",
|
|
309
|
+
"pattern": "^[a-z][a-z0-9]*(-[a-z0-9]+)*$"
|
|
310
|
+
},
|
|
311
|
+
"maxLines": {
|
|
312
|
+
"type": "integer",
|
|
313
|
+
"minimum": 1
|
|
314
|
+
},
|
|
315
|
+
"files": {
|
|
316
|
+
"description": "Globs against the path from the repository root, as Node's `path.matchesGlob` reads them: `**` crosses directories, but no `*` matches a name that starts with a dot.",
|
|
317
|
+
"type": "array",
|
|
318
|
+
"items": {
|
|
319
|
+
"type": "string",
|
|
320
|
+
"minLength": 1
|
|
321
|
+
},
|
|
322
|
+
"minItems": 1,
|
|
323
|
+
"uniqueItems": true
|
|
324
|
+
}
|
|
325
|
+
},
|
|
326
|
+
"required": ["name", "maxLines", "files"],
|
|
327
|
+
"additionalProperties": false
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
},
|
|
331
|
+
"additionalProperties": false
|
|
332
|
+
},
|
|
333
|
+
"comment-budget": {
|
|
334
|
+
"description": "The comment budget: each comment block in the JavaScript and TypeScript files under `roots` carries at most `maxProseLines` lines of prose, a line with a letter or a digit once the comment markers are off. A block is one `/* … */` comment or a run of `//` comments each alone on consecutive lines; a comment after code on its line is none.",
|
|
335
|
+
"type": ["boolean", "object"],
|
|
336
|
+
"properties": {
|
|
337
|
+
"maxProseLines": {
|
|
338
|
+
"description": "Default 3.",
|
|
339
|
+
"type": "integer",
|
|
340
|
+
"minimum": 1
|
|
341
|
+
},
|
|
342
|
+
"roots": {
|
|
343
|
+
"description": "The directories whose files it reads, relative to the repository root; one that does not exist holds no file. Default `packages`, `plugins`, `scripts` and `src`.",
|
|
344
|
+
"type": "array",
|
|
345
|
+
"items": {
|
|
346
|
+
"type": "string",
|
|
347
|
+
"minLength": 1
|
|
348
|
+
},
|
|
349
|
+
"uniqueItems": true
|
|
350
|
+
}
|
|
351
|
+
},
|
|
352
|
+
"additionalProperties": false
|
|
190
353
|
}
|
|
191
354
|
},
|
|
192
355
|
"additionalProperties": false
|
package/dist/agentic-config.js
CHANGED
|
@@ -1,8 +1,6 @@
|
|
|
1
|
-
// Reads what a repository declares for an agent:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
4
|
-
// guards fall back to the strictest defaults until it is fixed. The guardrails plugin's `hooks/config.jq`
|
|
5
|
-
// is the same merge for the guards; this file is the reference, and the plugin's tests run both.
|
|
1
|
+
// Reads what a repository declares for an agent: the committed config, the machine's local layer over it,
|
|
2
|
+
// and what the repository implies; an invalid layer is dropped whole. The guardrails plugin's
|
|
3
|
+
// `hooks/config.jq` is the same merge for the guards; this file is the reference, and the tests run both.
|
|
6
4
|
import { spawnSync } from "node:child_process";
|
|
7
5
|
import { existsSync, readFileSync } from "node:fs";
|
|
8
6
|
import { dirname, join } from "node:path";
|
|
@@ -6,6 +6,7 @@ import { join } from "node:path";
|
|
|
6
6
|
import { resolveConfig } from "../agentic-config.js";
|
|
7
7
|
import { codeSpans, tableRows } from "../markdown.js";
|
|
8
8
|
import { CannotRun } from "./check.js";
|
|
9
|
+
import { overBudget } from "./markdown-budget.js";
|
|
9
10
|
import { catalogueFile } from "./primitives.js";
|
|
10
11
|
const ROUTER = "Task Router";
|
|
11
12
|
const AGENTS_SKILLS = ".agents/skills";
|
|
@@ -34,16 +35,8 @@ function escape(text) {
|
|
|
34
35
|
return text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
35
36
|
}
|
|
36
37
|
/** The budget, the Gate command, the row to the catalogue and every path the Task Router points at. */
|
|
37
|
-
function judgeMap(root, { file,
|
|
38
|
-
const findings =
|
|
39
|
-
const lines = text.endsWith("\n") ? text.split("\n").length - 1 : text.split("\n").length;
|
|
40
|
-
if (lines > maxLines) {
|
|
41
|
-
findings.push({ file, message: `${lines} lines, over the ${maxLines}-line budget`, remedy: REMEDIES.budget });
|
|
42
|
-
}
|
|
43
|
-
const bytes = Buffer.byteLength(text);
|
|
44
|
-
if (bytes > maxBytes) {
|
|
45
|
-
findings.push({ file, message: `${bytes} bytes, over the ${maxBytes}-byte budget`, remedy: REMEDIES.budget });
|
|
46
|
-
}
|
|
38
|
+
function judgeMap(root, { file, ...budget }, text) {
|
|
39
|
+
const findings = overBudget(text, budget).map((message) => ({ file, message, remedy: REMEDIES.budget }));
|
|
47
40
|
const { gate } = resolveConfig(root).config;
|
|
48
41
|
if (gate.command === undefined) {
|
|
49
42
|
findings.push({ message: "the Gate has no command for the map to name", remedy: REMEDIES.noCommand });
|
package/dist/checks/check.d.ts
CHANGED
|
@@ -6,12 +6,22 @@ export declare class CannotRun extends Error {
|
|
|
6
6
|
finding: Finding;
|
|
7
7
|
constructor(finding: Finding);
|
|
8
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
|
+
};
|
|
9
17
|
export type Check<Options extends object> = {
|
|
10
18
|
/** The Check's key under `gate.checks`. */
|
|
11
19
|
name: string;
|
|
12
20
|
defaults: Options;
|
|
13
21
|
/** Every failure of the tree at `root`, none when it passes; throws `CannotRun` when it cannot judge. */
|
|
14
22
|
run: (root: string, options: Options) => Finding[];
|
|
23
|
+
/** Present on a Ratchet. */
|
|
24
|
+
baseline?: Baseline<Options>;
|
|
15
25
|
};
|
|
16
26
|
/** The options `setting` turns the Check on with: the defaults under `true`, an object over them, else off. */
|
|
17
27
|
export declare function optionsOf<Options extends object>(check: Check<Options>, setting: unknown): Options | undefined;
|
|
@@ -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>;
|