dw-sdlc 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -12
- package/config/agentic.config.schema.json +56 -18
- package/dist/agentic-config.d.ts +23 -7
- package/dist/agentic-config.js +26 -12
- package/dist/checks/agents-map.d.ts +9 -1
- package/dist/checks/agents-map.js +60 -17
- package/dist/checks/decision-records.d.ts +5 -0
- package/dist/checks/decision-records.js +121 -16
- package/dist/checks/file-size-ratchet.js +7 -5
- package/dist/checks/index.js +2 -0
- package/dist/checks/markdown-budget.d.ts +2 -5
- package/dist/checks/markdown-budget.js +6 -15
- package/dist/checks/markdown-refs.js +1 -11
- package/dist/checks/routed-docs.d.ts +6 -0
- package/dist/checks/routed-docs.js +49 -0
- package/dist/checks/tree.d.ts +2 -0
- package/dist/checks/tree.js +13 -3
- package/dist/commands/check.d.ts +1 -1
- package/dist/commands/check.js +6 -4
- package/dist/commands/gate.d.ts +10 -4
- package/dist/commands/gate.js +202 -86
- package/dist/commands/pr-reviewed.js +5 -21
- package/dist/commands/pr-ship.js +37 -17
- package/dist/commands/references-sync.js +5 -6
- package/dist/drift.d.ts +17 -0
- package/dist/drift.js +68 -0
- package/dist/gate-cache.d.ts +13 -0
- package/dist/gate-cache.js +39 -0
- package/dist/git-env.d.ts +2 -0
- package/dist/git-env.js +10 -0
- package/dist/markdown.d.ts +19 -0
- package/dist/markdown.js +46 -2
- package/dist/package-version.d.ts +2 -0
- package/dist/package-version.js +13 -0
- package/dist/pr/decide.d.ts +31 -8
- package/dist/pr/decide.js +65 -31
- package/dist/router-block.d.ts +4 -1
- package/dist/router-block.js +31 -7
- package/dist/run-command.d.ts +24 -0
- package/dist/run-command.js +121 -0
- package/dist/setup-gate.js +3 -12
- package/dist/working-tree.d.ts +13 -0
- package/dist/working-tree.js +60 -0
- package/package.json +1 -1
- package/templates/router.md +1 -0
|
@@ -1,23 +1,42 @@
|
|
|
1
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
|
-
//
|
|
4
|
-
import { readFileSync, statSync } from "node:fs";
|
|
5
|
-
import { join } from "node:path";
|
|
6
|
-
import { headings } from "../markdown.js";
|
|
2
|
+
// `<number>-<slug>.md`, are numbered from 1 with no gap and no number taken twice, and each has a title,
|
|
3
|
+
// a frontmatter status from the allowed set and the frontmatter fields the config turns on.
|
|
4
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
5
|
+
import { join, matchesGlob, posix } from "node:path";
|
|
6
|
+
import { frontmatter, frontmatterList, headings } from "../markdown.js";
|
|
7
7
|
import { CannotRun } from "./check.js";
|
|
8
|
-
import { treeFiles } from "./tree.js";
|
|
8
|
+
import { directoriesOf, treeFiles } from "./tree.js";
|
|
9
|
+
/** The frontmatter fields beyond `status` a record can be held to, in the order they are judged. */
|
|
10
|
+
const FIELDS = ["date", "touches", "superseded-by", "amended-by"];
|
|
9
11
|
/** A record's name in its directory: its number, a slug, `.md`, and no directory between. */
|
|
10
12
|
const RECORD = /^(\d+)-[^/]+\.md$/;
|
|
13
|
+
/** The first words of a status that put a record in force, so it governs the paths under its `touches`. */
|
|
14
|
+
const IN_FORCE = new Set(["accepted", "active"]);
|
|
15
|
+
/** A character that makes a path under `touches` a glob. */
|
|
16
|
+
const GLOB = /[*?[\]{}]/;
|
|
11
17
|
const REMEDIES = {
|
|
12
18
|
missing: "create the records directory, or point gate.checks.decision-records.dirs at it",
|
|
13
19
|
gap: "restore the missing records, or renumber the ones after the gap",
|
|
14
20
|
taken: "give each record a number of its own",
|
|
15
|
-
title: "start it with a level-1 heading that names the decision"
|
|
21
|
+
title: "start it with a level-1 heading that names the decision",
|
|
22
|
+
noDate: "give its frontmatter the day the decision was made, as date: YYYY-MM-DD",
|
|
23
|
+
date: "write the day the decision was made as YYYY-MM-DD",
|
|
24
|
+
noTouches: "list under touches the paths it governs",
|
|
25
|
+
superseded: "drop touches: the record that replaces it governs those paths",
|
|
26
|
+
path: "fix the path or drop it from touches",
|
|
27
|
+
glob: "fix the glob or drop it from touches",
|
|
28
|
+
ignored: "name the paths git tracks that it governs, or drop it from touches",
|
|
29
|
+
noSuccessor: "name under superseded-by the number of each record that replaces it",
|
|
30
|
+
successor: "name the number of each record that replaces it",
|
|
31
|
+
amendment: "name the number of each record that amends it"
|
|
16
32
|
};
|
|
17
33
|
function isDirectory(path) {
|
|
18
34
|
return statSync(path, { throwIfNoEntry: false })?.isDirectory() === true;
|
|
19
35
|
}
|
|
20
|
-
/**
|
|
36
|
+
/**
|
|
37
|
+
* The `status` of the frontmatter `text` opens with, its quotes dropped, and its 1-based line. It keeps a
|
|
38
|
+
* reader apart from `frontmatter`, which a status never goes through: a ` #` is part of it.
|
|
39
|
+
*/
|
|
21
40
|
function statusOf(text) {
|
|
22
41
|
const lines = text.split("\n");
|
|
23
42
|
if (lines[0]?.trim() !== "---") {
|
|
@@ -31,6 +50,76 @@ function statusOf(text) {
|
|
|
31
50
|
}
|
|
32
51
|
return undefined;
|
|
33
52
|
}
|
|
53
|
+
/** Whether `value` is a day of the calendar written `YYYY-MM-DD`. */
|
|
54
|
+
function isDay(value) {
|
|
55
|
+
const date = new Date(`${value}T00:00:00Z`);
|
|
56
|
+
return /^\d{4}-\d{2}-\d{2}$/.test(value) && !Number.isNaN(date.getTime()) && date.toISOString().startsWith(value);
|
|
57
|
+
}
|
|
58
|
+
function judgeDate({ file, keys }) {
|
|
59
|
+
const date = keys.get("date");
|
|
60
|
+
if (date === undefined || date.value === "") {
|
|
61
|
+
return [{ file, message: "has no date", remedy: REMEDIES.noDate }];
|
|
62
|
+
}
|
|
63
|
+
if (!isDay(date.value)) {
|
|
64
|
+
const message = `the date ${JSON.stringify(date.value)} is not a YYYY-MM-DD day`;
|
|
65
|
+
return [{ file, line: date.line, message, remedy: REMEDIES.date }];
|
|
66
|
+
}
|
|
67
|
+
return [];
|
|
68
|
+
}
|
|
69
|
+
function judgeTouches({ file, state, keys }, { root, tree }) {
|
|
70
|
+
const touches = keys.get("touches");
|
|
71
|
+
const paths = frontmatterList(touches);
|
|
72
|
+
if (state === "superseded") {
|
|
73
|
+
if (touches === undefined || paths.length === 0) {
|
|
74
|
+
return [];
|
|
75
|
+
}
|
|
76
|
+
return [{ file, line: touches.line, message: "is superseded and still names touches", remedy: REMEDIES.superseded }];
|
|
77
|
+
}
|
|
78
|
+
if (paths.length === 0) {
|
|
79
|
+
return IN_FORCE.has(state) ? [{ file, message: "names no path under touches", remedy: REMEDIES.noTouches }] : [];
|
|
80
|
+
}
|
|
81
|
+
return paths.flatMap(({ value, line }) => {
|
|
82
|
+
const path = posix.normalize(value).replace(/(.)\/$/, "$1");
|
|
83
|
+
const glob = GLOB.test(path);
|
|
84
|
+
if (tree().paths.has(path) || (glob && tree().files.some((candidate) => matchesGlob(candidate, path)))) {
|
|
85
|
+
return [];
|
|
86
|
+
}
|
|
87
|
+
const quoted = JSON.stringify(value);
|
|
88
|
+
if (path !== ".." && !path.startsWith("../") && existsSync(join(root, path))) {
|
|
89
|
+
return [{ file, line, message: `touches ${quoted}, which git ignores`, remedy: REMEDIES.ignored }];
|
|
90
|
+
}
|
|
91
|
+
return glob
|
|
92
|
+
? [{ file, line, message: `touches ${quoted}, which matches no file`, remedy: REMEDIES.glob }]
|
|
93
|
+
: [{ file, line, message: `touches ${quoted}, which does not exist`, remedy: REMEDIES.path }];
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
/** Each value under the list key `name` of a record that is not the number of a record of its directory. */
|
|
97
|
+
function judgeNumbers(name, remedy, { file, keys }, numbers) {
|
|
98
|
+
return frontmatterList(keys.get(name)).flatMap(({ value, line }) => {
|
|
99
|
+
if (/^\d+$/.test(value) && numbers.has(Number(value))) {
|
|
100
|
+
return [];
|
|
101
|
+
}
|
|
102
|
+
const message = `${name} names ${JSON.stringify(value)}, which is not the number of a record`;
|
|
103
|
+
return [{ file, line, message, remedy }];
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
function judgeSuccessors(record, { numbers }) {
|
|
107
|
+
if (record.state === "superseded" && frontmatterList(record.keys.get("superseded-by")).length === 0) {
|
|
108
|
+
const message = "is superseded but names no successor under superseded-by";
|
|
109
|
+
return [{ file: record.file, message, remedy: REMEDIES.noSuccessor }];
|
|
110
|
+
}
|
|
111
|
+
return judgeNumbers("superseded-by", REMEDIES.successor, record, numbers);
|
|
112
|
+
}
|
|
113
|
+
function judgeAmendments(record, { numbers }) {
|
|
114
|
+
return judgeNumbers("amended-by", REMEDIES.amendment, record, numbers);
|
|
115
|
+
}
|
|
116
|
+
/** What is wrong with each field of a record, when the config turns that field on. */
|
|
117
|
+
const RULES = {
|
|
118
|
+
date: judgeDate,
|
|
119
|
+
touches: judgeTouches,
|
|
120
|
+
"superseded-by": judgeSuccessors,
|
|
121
|
+
"amended-by": judgeAmendments
|
|
122
|
+
};
|
|
34
123
|
/** Each run of numbers from 1 to the highest that no record takes, as `0004` or `0004 to 0006`. */
|
|
35
124
|
function gaps(taken, pad) {
|
|
36
125
|
const runs = [];
|
|
@@ -47,8 +136,8 @@ function gaps(taken, pad) {
|
|
|
47
136
|
}
|
|
48
137
|
return runs;
|
|
49
138
|
}
|
|
50
|
-
/** What is wrong with the records of `dir`: the numbers they skip, then each record's number, title and
|
|
51
|
-
function judge(root, dir, statuses) {
|
|
139
|
+
/** What is wrong with the records of `dir`: the numbers they skip, then each record's number, title, status and fields. */
|
|
140
|
+
function judge(root, dir, { statuses, fields }, tree) {
|
|
52
141
|
const prefix = `${dir.replace(/\/+$/, "")}/`;
|
|
53
142
|
const records = treeFiles(root, [dir])
|
|
54
143
|
.map((file) => ({ file, name: file.slice(prefix.length) }))
|
|
@@ -68,6 +157,8 @@ function judge(root, dir, statuses) {
|
|
|
68
157
|
remedy: REMEDIES.gap
|
|
69
158
|
}));
|
|
70
159
|
const allowed = `one of ${statuses.join(", ")}`;
|
|
160
|
+
const context = { root, tree, numbers: new Set(owners.keys()) };
|
|
161
|
+
const judgedFields = FIELDS.filter((field) => fields.includes(field));
|
|
71
162
|
for (const { file, digits } of records) {
|
|
72
163
|
const owner = owners.get(Number(digits));
|
|
73
164
|
if (owner !== file) {
|
|
@@ -78,28 +169,42 @@ function judge(root, dir, statuses) {
|
|
|
78
169
|
findings.push({ file, message: "has no title", remedy: REMEDIES.title });
|
|
79
170
|
}
|
|
80
171
|
const status = statusOf(text);
|
|
172
|
+
const state = status?.value.split(/\s+/)[0] ?? "";
|
|
81
173
|
if (status === undefined) {
|
|
82
174
|
findings.push({ file, message: "has no status", remedy: `give its frontmatter a status, ${allowed}` });
|
|
83
175
|
}
|
|
84
|
-
else if (!statuses.includes(
|
|
176
|
+
else if (!statuses.includes(state)) {
|
|
85
177
|
const message = `the status ${JSON.stringify(status.value)} is not allowed`;
|
|
86
178
|
findings.push({ file, line: status.line, message, remedy: `set it to ${allowed}` });
|
|
87
179
|
}
|
|
180
|
+
if (judgedFields.length > 0) {
|
|
181
|
+
const record = { file, state, keys: frontmatter(text) };
|
|
182
|
+
findings.push(...judgedFields.flatMap((field) => RULES[field](record, context)));
|
|
183
|
+
}
|
|
88
184
|
}
|
|
89
185
|
return findings;
|
|
90
186
|
}
|
|
91
|
-
function run(root,
|
|
92
|
-
const present = dirs.filter((dir) => isDirectory(join(root, dir)));
|
|
187
|
+
function run(root, options) {
|
|
188
|
+
const present = options.dirs.filter((dir) => isDirectory(join(root, dir)));
|
|
93
189
|
if (present.length === 0) {
|
|
94
|
-
throw new CannotRun({ message: `none of ${dirs.join(", ")} exists`, remedy: REMEDIES.missing });
|
|
190
|
+
throw new CannotRun({ message: `none of ${options.dirs.join(", ")} exists`, remedy: REMEDIES.missing });
|
|
95
191
|
}
|
|
96
|
-
|
|
192
|
+
let cached;
|
|
193
|
+
const tree = () => {
|
|
194
|
+
if (cached === undefined) {
|
|
195
|
+
const files = treeFiles(root);
|
|
196
|
+
cached = { files, paths: new Set([...files, ...directoriesOf(files)]) };
|
|
197
|
+
}
|
|
198
|
+
return cached;
|
|
199
|
+
};
|
|
200
|
+
return present.flatMap((dir) => judge(root, dir, options, tree));
|
|
97
201
|
}
|
|
98
202
|
export const decisionRecords = {
|
|
99
203
|
name: "decision-records",
|
|
100
204
|
defaults: {
|
|
101
205
|
dirs: ["docs/adr", "docs/decisions"],
|
|
102
|
-
statuses: ["proposed", "accepted", "active", "deprecated", "superseded"]
|
|
206
|
+
statuses: ["proposed", "accepted", "active", "deprecated", "superseded"],
|
|
207
|
+
fields: []
|
|
103
208
|
},
|
|
104
209
|
run
|
|
105
210
|
};
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
// `file-size-ratchet`: each file within the line limit of its group or, listed in the baseline,
|
|
2
|
-
// the count recorded there. A refresh only tightens the baseline: it lowers and drops, and adds no file.
|
|
1
|
+
// `file-size-ratchet`: each file of code within the line limit of its group or, listed in the baseline,
|
|
2
|
+
// within the count recorded there. A refresh only tightens the baseline: it lowers and drops, and adds no file.
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { dirname, join, matchesGlob } from "node:path";
|
|
6
6
|
import { CannotRun } from "./check.js";
|
|
7
|
-
import { lineCount } from "./markdown-budget.js";
|
|
8
7
|
import { matching, treeFiles } from "./tree.js";
|
|
9
8
|
const BASELINE = ".ai/file-size-ratchet.baseline.json";
|
|
10
9
|
const REFRESH = "dw-sdlc gate --update-baseline";
|
|
@@ -34,6 +33,11 @@ function inBaseline(line) {
|
|
|
34
33
|
function groupOf(path, groups) {
|
|
35
34
|
return groups.find(({ files }) => files.some((glob) => matchesGlob(path, glob)));
|
|
36
35
|
}
|
|
36
|
+
/** The lines of `text`, a last line without its newline counted, the empty line after a final newline not. */
|
|
37
|
+
function lineCount(text) {
|
|
38
|
+
const lines = text.split("\n").length;
|
|
39
|
+
return text.endsWith("\n") ? lines - 1 : lines;
|
|
40
|
+
}
|
|
37
41
|
function measure(root, groups) {
|
|
38
42
|
const measured = new Map();
|
|
39
43
|
for (const path of matching(root, treeFiles(root), groups.flatMap(({ files }) => files))) {
|
|
@@ -178,14 +182,12 @@ function refresh(root, { groups }) {
|
|
|
178
182
|
return `tightened — ${parts.join(", ")}`;
|
|
179
183
|
}
|
|
180
184
|
const TESTS = ["**/{test,tests,__tests__}/**", "**/*.{test,spec}.*"];
|
|
181
|
-
const AGENT_DOCS = ["**/{AGENTS,CLAUDE,GLOSSARY}.md", "docs/agents/**/*.md"];
|
|
182
185
|
const SOURCE = ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,sh}"];
|
|
183
186
|
export const fileSizeRatchet = {
|
|
184
187
|
name: "file-size-ratchet",
|
|
185
188
|
defaults: {
|
|
186
189
|
groups: [
|
|
187
190
|
{ name: "tests", maxLines: 2000, files: TESTS },
|
|
188
|
-
{ name: "agent-docs", maxLines: 120, files: AGENT_DOCS },
|
|
189
191
|
{ name: "source", maxLines: 500, files: SOURCE }
|
|
190
192
|
]
|
|
191
193
|
},
|
package/dist/checks/index.js
CHANGED
|
@@ -8,6 +8,7 @@ import { fileSizeRatchet } from "./file-size-ratchet.js";
|
|
|
8
8
|
import { markdownBudget } from "./markdown-budget.js";
|
|
9
9
|
import { markdownRefs } from "./markdown-refs.js";
|
|
10
10
|
import { primitives } from "./primitives.js";
|
|
11
|
+
import { routedDocs } from "./routed-docs.js";
|
|
11
12
|
import { temporalLanguage } from "./temporal-language.js";
|
|
12
13
|
function ship(check) {
|
|
13
14
|
return {
|
|
@@ -29,6 +30,7 @@ function ship(check) {
|
|
|
29
30
|
const SHIPPED = [
|
|
30
31
|
ship(primitives),
|
|
31
32
|
ship(agentsMap),
|
|
33
|
+
ship(routedDocs),
|
|
32
34
|
ship(markdownBudget),
|
|
33
35
|
ship(markdownRefs),
|
|
34
36
|
ship(temporalLanguage),
|
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import type { Check } from "./check.ts";
|
|
2
2
|
export type Budget = {
|
|
3
|
-
maxLines: number;
|
|
4
3
|
maxBytes: number;
|
|
5
4
|
};
|
|
6
5
|
export type MarkdownBudgetOptions = {
|
|
@@ -8,8 +7,6 @@ export type MarkdownBudgetOptions = {
|
|
|
8
7
|
glob: string;
|
|
9
8
|
})[];
|
|
10
9
|
};
|
|
11
|
-
/**
|
|
12
|
-
export declare function
|
|
13
|
-
/** What `text` spends past its budget: `156 lines, over the 150-line budget`, then the bytes the same way. */
|
|
14
|
-
export declare function overBudget(text: string, { maxLines, maxBytes }: Budget): string[];
|
|
10
|
+
/** What `text` spends past its budget, `10651 bytes, over the 10240-byte budget`; nothing within it. */
|
|
11
|
+
export declare function overBudget(text: string, { maxBytes }: Budget): string[];
|
|
15
12
|
export declare const markdownBudget: Check<MarkdownBudgetOptions>;
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
// `markdown-budget`: each file a glob matches stays within that glob's
|
|
2
|
-
//
|
|
1
|
+
// `markdown-budget`: each file a glob matches stays within that glob's byte budget, so the docs an
|
|
2
|
+
// agent reads stay short enough to read whole.
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import { matching, treeFiles } from "./tree.js";
|
|
@@ -7,19 +7,10 @@ const REMEDIES = {
|
|
|
7
7
|
budget: "cut it, or split a topic into a file of its own",
|
|
8
8
|
glob: "fix the glob, or drop its budget"
|
|
9
9
|
};
|
|
10
|
-
/**
|
|
11
|
-
export function
|
|
12
|
-
const lines = text.split("\n").length;
|
|
13
|
-
return text.endsWith("\n") ? lines - 1 : lines;
|
|
14
|
-
}
|
|
15
|
-
/** What `text` spends past its budget: `156 lines, over the 150-line budget`, then the bytes the same way. */
|
|
16
|
-
export function overBudget(text, { maxLines, maxBytes }) {
|
|
17
|
-
const lines = lineCount(text);
|
|
10
|
+
/** What `text` spends past its budget, `10651 bytes, over the 10240-byte budget`; nothing within it. */
|
|
11
|
+
export function overBudget(text, { maxBytes }) {
|
|
18
12
|
const bytes = Buffer.byteLength(text);
|
|
19
|
-
return [
|
|
20
|
-
...(lines > maxLines ? [`${lines} lines, over the ${maxLines}-line budget`] : []),
|
|
21
|
-
...(bytes > maxBytes ? [`${bytes} bytes, over the ${maxBytes}-byte budget`] : [])
|
|
22
|
-
];
|
|
13
|
+
return bytes > maxBytes ? [`${bytes} bytes, over the ${maxBytes}-byte budget`] : [];
|
|
23
14
|
}
|
|
24
15
|
function run(root, { budgets }) {
|
|
25
16
|
const files = treeFiles(root);
|
|
@@ -37,6 +28,6 @@ function run(root, { budgets }) {
|
|
|
37
28
|
}
|
|
38
29
|
export const markdownBudget = {
|
|
39
30
|
name: "markdown-budget",
|
|
40
|
-
defaults: { budgets: [{ glob: "docs/agents/*.md",
|
|
31
|
+
defaults: { budgets: [{ glob: "docs/agents/*.md", maxBytes: 6144 }] },
|
|
41
32
|
run
|
|
42
33
|
};
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
import { readFileSync } from "node:fs";
|
|
5
5
|
import { join, posix } from "node:path";
|
|
6
6
|
import { anchorsOf, links } from "../markdown.js";
|
|
7
|
-
import { matching, treeFiles } from "./tree.js";
|
|
7
|
+
import { directoriesOf, matching, treeFiles } from "./tree.js";
|
|
8
8
|
const REMEDIES = {
|
|
9
9
|
path: "fix the path or drop the link",
|
|
10
10
|
outside: "point it at a file of the repository",
|
|
@@ -20,16 +20,6 @@ function decode(text) {
|
|
|
20
20
|
return text;
|
|
21
21
|
}
|
|
22
22
|
}
|
|
23
|
-
/** Every directory above a file of `files`. */
|
|
24
|
-
function directoriesOf(files) {
|
|
25
|
-
const directories = new Set();
|
|
26
|
-
for (const file of files) {
|
|
27
|
-
for (let dir = posix.dirname(file); dir !== "." && !directories.has(dir); dir = posix.dirname(dir)) {
|
|
28
|
-
directories.add(dir);
|
|
29
|
-
}
|
|
30
|
-
}
|
|
31
|
-
return directories;
|
|
32
|
-
}
|
|
33
23
|
/** The repository path `path` names from `file`, the root `.`; a path out of the repository starts with `..`. */
|
|
34
24
|
function resolve(file, path) {
|
|
35
25
|
if (path === "") {
|
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
// `routed-docs`: a task leads an agent to every document that explains it. Each document is reached by
|
|
2
|
+
// a Task Router row of the agent map, through a path, a directory above it or a glob, or by the docs of
|
|
3
|
+
// a Primitive in the catalogue. It reads the file list, the Task Router and the catalogue, never prose.
|
|
4
|
+
import { matchesGlob, posix } from "node:path";
|
|
5
|
+
import { resolveConfig } from "../agentic-config.js";
|
|
6
|
+
import { readCatalogue } from "../catalogue.js";
|
|
7
|
+
import { mapFile, readMap, routes } from "./agents-map.js";
|
|
8
|
+
import { catalogueFile } from "./primitives.js";
|
|
9
|
+
import { matching, treeFiles } from "./tree.js";
|
|
10
|
+
const REMEDIES = {
|
|
11
|
+
table: "add a Task Router section whose table names the docs",
|
|
12
|
+
unreached: "add a Task Router row, name it in a Primitive's docs, or exempt it"
|
|
13
|
+
};
|
|
14
|
+
/** `entry` as a path from the root: its heading anchor, a leading `./` or `/` and a trailing `/` dropped. */
|
|
15
|
+
function fromRoot(entry) {
|
|
16
|
+
const [path = ""] = entry.split("#", 1);
|
|
17
|
+
return posix.join(".", path).replace(/\/+$/, "");
|
|
18
|
+
}
|
|
19
|
+
/** Whether `target`, a path a row points at, names `doc`, a directory above it or a glob that matches it. */
|
|
20
|
+
function reaches(target, doc) {
|
|
21
|
+
return target.includes("*") ? matchesGlob(doc, target) : doc === target || doc.startsWith(`${target}/`);
|
|
22
|
+
}
|
|
23
|
+
/** The docs entries of the catalogue's Primitives; none when it is missing or not valid. */
|
|
24
|
+
function catalogueDocs(root, file) {
|
|
25
|
+
const read = readCatalogue(root, file);
|
|
26
|
+
return read.state === "valid" ? read.catalogue.primitives.flatMap(({ docs }) => docs) : [];
|
|
27
|
+
}
|
|
28
|
+
function run(root, { files, exempt }) {
|
|
29
|
+
const { checks } = resolveConfig(root).config.gate;
|
|
30
|
+
const map = mapFile(checks);
|
|
31
|
+
const rows = routes(readMap(root, map));
|
|
32
|
+
const findings = [];
|
|
33
|
+
if (rows.length === 0) {
|
|
34
|
+
findings.push({ file: map, message: "has no Task Router table to reach a doc from", remedy: REMEDIES.table });
|
|
35
|
+
}
|
|
36
|
+
const targets = rows.flatMap(({ paths }) => paths).map(fromRoot);
|
|
37
|
+
const catalogued = new Set(catalogueDocs(root, catalogueFile(checks)).map(fromRoot));
|
|
38
|
+
for (const doc of matching(root, treeFiles(root), files, exempt)) {
|
|
39
|
+
if (!catalogued.has(doc) && !targets.some((target) => reaches(target, doc))) {
|
|
40
|
+
findings.push({ file: doc, message: "no Task Router row or Primitive reaches it", remedy: REMEDIES.unreached });
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
return findings;
|
|
44
|
+
}
|
|
45
|
+
export const routedDocs = {
|
|
46
|
+
name: "routed-docs",
|
|
47
|
+
defaults: { files: ["docs/**/*.md"], exempt: ["docs/research/**"] },
|
|
48
|
+
run
|
|
49
|
+
};
|
package/dist/checks/tree.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/** Every file under `pathspecs`, the whole tree when there are none, that git tracks or would track and that is on disk; sorted. */
|
|
2
2
|
export declare function treeFiles(root: string, pathspecs?: string[]): string[];
|
|
3
|
+
/** Every directory above a file of `files`. */
|
|
4
|
+
export declare function directoriesOf(files: Iterable<string>): Set<string>;
|
|
3
5
|
/**
|
|
4
6
|
* The files of `paths` that a glob of `globs` matches and none of `exempt` does. A symbolic link is left
|
|
5
7
|
* out when the file it points at is matched too, so one file is judged once. `*` and `**` skip a name
|
package/dist/checks/tree.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
|
-
// The files of a repository as git sees them, and the ones a Check's globs
|
|
2
|
-
// Checks that judge many files read, so a file git ignores is never judged.
|
|
1
|
+
// The files of a repository as git sees them, the directories above them, and the ones a Check's globs
|
|
2
|
+
// pick out of them: what the Checks that judge many files read, so a file git ignores is never judged.
|
|
3
3
|
import { spawnSync } from "node:child_process";
|
|
4
4
|
import { existsSync, lstatSync, realpathSync } from "node:fs";
|
|
5
|
-
import { join, matchesGlob, relative } from "node:path";
|
|
5
|
+
import { join, matchesGlob, posix, relative } from "node:path";
|
|
6
6
|
import { CannotRun } from "./check.js";
|
|
7
7
|
/** Every file under `pathspecs`, the whole tree when there are none, that git tracks or would track and that is on disk; sorted. */
|
|
8
8
|
export function treeFiles(root, pathspecs = []) {
|
|
@@ -17,6 +17,16 @@ export function treeFiles(root, pathspecs = []) {
|
|
|
17
17
|
.filter((path) => existsSync(join(root, path)))
|
|
18
18
|
.toSorted();
|
|
19
19
|
}
|
|
20
|
+
/** Every directory above a file of `files`. */
|
|
21
|
+
export function directoriesOf(files) {
|
|
22
|
+
const directories = new Set();
|
|
23
|
+
for (const file of files) {
|
|
24
|
+
for (let dir = posix.dirname(file); dir !== "." && !directories.has(dir); dir = posix.dirname(dir)) {
|
|
25
|
+
directories.add(dir);
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
return directories;
|
|
29
|
+
}
|
|
20
30
|
/**
|
|
21
31
|
* The files of `paths` that a glob of `globs` matches and none of `exempt` does. A symbolic link is left
|
|
22
32
|
* out when the file it points at is matched too, so one file is judged once. `*` and `**` skip a name
|
package/dist/commands/check.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
export declare const summary = "run one Check of the Gate by its name, without the Steps and the other Checks";
|
|
2
|
-
export declare function run(argv: string[]): number
|
|
2
|
+
export declare function run(argv: string[]): Promise<number>;
|
package/dist/commands/check.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { parseArgs } from "node:util";
|
|
3
3
|
import { repositoryRoot } from "../agentic-config.js";
|
|
4
4
|
import { CHECK_NAMES } from "../checks/index.js";
|
|
5
|
+
import { TAIL_LINES } from "../run-command.js";
|
|
5
6
|
import { printReport, runGate } from "./gate.js";
|
|
6
7
|
export const summary = "run one Check of the Gate by its name, without the Steps and the other Checks";
|
|
7
8
|
const USAGE = `Usage: dw-sdlc check <name> [--json] [--root <dir>]
|
|
@@ -10,8 +11,9 @@ Runs the Check named <name> in the Gate of the repository holding --root (defaul
|
|
|
10
11
|
directory): a script under gate.checks.run, or a Check the package ships, one of
|
|
11
12
|
${CHECK_NAMES.join(", ")}. A name both carry runs both, as the Gate does. A
|
|
12
13
|
shipped Check runs on the setting gate.checks gives it, and on its defaults where the config sets it
|
|
13
|
-
false or leaves it out. No Step runs.
|
|
14
|
-
|
|
14
|
+
false or leaves it out. No Step runs. A run script prints nothing of its own unless it does not
|
|
15
|
+
pass: then its last ${TAIL_LINES} lines, both streams in the order written. Prints one line per failure,
|
|
16
|
+
as the Gate does; --json prints the Gate's document alone, with no Step.
|
|
15
17
|
|
|
16
18
|
A Ratchet's baseline is refreshed by dw-sdlc gate --update-baseline, which needs every other Check to
|
|
17
19
|
pass.
|
|
@@ -39,7 +41,7 @@ function parseCli(argv) {
|
|
|
39
41
|
return { error: error instanceof Error ? error.message : String(error) };
|
|
40
42
|
}
|
|
41
43
|
}
|
|
42
|
-
export function run(argv) {
|
|
44
|
+
export async function run(argv) {
|
|
43
45
|
const cli = parseCli(argv);
|
|
44
46
|
if ("error" in cli) {
|
|
45
47
|
process.stderr.write(`dw-sdlc check: ${cli.error}\n\n${USAGE}\n`);
|
|
@@ -54,6 +56,6 @@ export function run(argv) {
|
|
|
54
56
|
return 2;
|
|
55
57
|
}
|
|
56
58
|
const { json, name } = cli;
|
|
57
|
-
const report = runGate(repositoryRoot(cli.root ?? process.cwd()), { json, only: name });
|
|
59
|
+
const report = await runGate(repositoryRoot(cli.root ?? process.cwd()), { json, only: name });
|
|
58
60
|
return printReport(report, json, `check ${name}`);
|
|
59
61
|
}
|
package/dist/commands/gate.d.ts
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
import { type Failure } from "../report.ts";
|
|
2
|
+
import { type Status } from "../run-command.ts";
|
|
2
3
|
export declare const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
|
|
3
|
-
type Status = "pass" | "fail" | "error";
|
|
4
4
|
type Outcome = {
|
|
5
5
|
status: Status | "skipped";
|
|
6
6
|
exitCode: number | null;
|
|
7
|
+
tail: string[] | null;
|
|
7
8
|
};
|
|
8
9
|
type StepReport = Outcome & {
|
|
9
10
|
command: string;
|
|
@@ -16,23 +17,28 @@ type CheckReport = Outcome & {
|
|
|
16
17
|
type GateReport = {
|
|
17
18
|
status: Status;
|
|
18
19
|
command: string | null;
|
|
20
|
+
/** Present on a run the cache skipped. */
|
|
21
|
+
cached?: true;
|
|
19
22
|
steps: StepReport[];
|
|
20
23
|
checks: CheckReport[];
|
|
21
24
|
failures: Failure[];
|
|
22
25
|
};
|
|
26
|
+
/** How a run of the whole Gate goes: plain, refreshing each Ratchet's baseline, or cached on a green tree. */
|
|
27
|
+
type Mode = "plain" | "update-baseline" | "cache";
|
|
23
28
|
/** A run of the whole Gate, or of the Checks named `only` alone: no Step, no progress line, no refresh. */
|
|
24
29
|
type RunOptions = {
|
|
25
30
|
json: boolean;
|
|
26
31
|
} & ({
|
|
27
|
-
|
|
32
|
+
mode: Mode;
|
|
33
|
+
verbose: boolean;
|
|
28
34
|
} | {
|
|
29
35
|
only: string;
|
|
30
36
|
});
|
|
31
|
-
export declare function runGate(root: string, options: RunOptions): GateReport
|
|
37
|
+
export declare function runGate(root: string, options: RunOptions): Promise<GateReport>;
|
|
32
38
|
/**
|
|
33
39
|
* Prints `report`: a line per failure and the verdict under `label`, or one document under `json`; under
|
|
34
40
|
* GitHub Actions each failure is also an annotation. Returns the exit code.
|
|
35
41
|
*/
|
|
36
42
|
export declare function printReport(report: GateReport, json: boolean, label: string): number;
|
|
37
|
-
export declare function run(argv: string[]): number
|
|
43
|
+
export declare function run(argv: string[]): Promise<number>;
|
|
38
44
|
export {};
|