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,12 @@
|
|
|
1
|
+
import { type Finding } from "./check.ts";
|
|
2
|
+
/** A Check turned on by its setting, ready to judge a tree, and to refresh its baseline if it is a Ratchet. */
|
|
3
|
+
export type EnabledCheck = {
|
|
4
|
+
name: string;
|
|
5
|
+
run: (root: string) => Finding[];
|
|
6
|
+
baseline?: {
|
|
7
|
+
file: string;
|
|
8
|
+
refresh: (root: string) => string;
|
|
9
|
+
};
|
|
10
|
+
};
|
|
11
|
+
/** Every shipped Check a resolved `gate.checks` turns on with `true` or its options. */
|
|
12
|
+
export declare function enabledChecks(checks: Record<string, unknown>): EnabledCheck[];
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The Checks the package ships, in the order the Gate runs them. A new Check is a module beside this
|
|
2
|
+
// file, one row below and one key under `gate.checks` in the schema, listed in the same order.
|
|
3
|
+
import { agentsMap } from "./agents-map.js";
|
|
4
|
+
import { optionsOf } from "./check.js";
|
|
5
|
+
import { commentBudget } from "./comment-budget.js";
|
|
6
|
+
import { decisionRecords } from "./decision-records.js";
|
|
7
|
+
import { fileSizeRatchet } from "./file-size-ratchet.js";
|
|
8
|
+
import { markdownBudget } from "./markdown-budget.js";
|
|
9
|
+
import { markdownRefs } from "./markdown-refs.js";
|
|
10
|
+
import { primitives } from "./primitives.js";
|
|
11
|
+
import { temporalLanguage } from "./temporal-language.js";
|
|
12
|
+
function ship(check) {
|
|
13
|
+
return {
|
|
14
|
+
name: check.name,
|
|
15
|
+
enable: (setting) => {
|
|
16
|
+
const options = optionsOf(check, setting);
|
|
17
|
+
if (options === undefined) {
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
const enabled = { name: check.name, run: (root) => check.run(root, options) };
|
|
21
|
+
const { baseline } = check;
|
|
22
|
+
if (baseline !== undefined) {
|
|
23
|
+
enabled.baseline = { file: baseline.file, refresh: (root) => baseline.refresh(root, options) };
|
|
24
|
+
}
|
|
25
|
+
return enabled;
|
|
26
|
+
}
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
const SHIPPED = [
|
|
30
|
+
ship(primitives),
|
|
31
|
+
ship(agentsMap),
|
|
32
|
+
ship(markdownBudget),
|
|
33
|
+
ship(markdownRefs),
|
|
34
|
+
ship(temporalLanguage),
|
|
35
|
+
ship(decisionRecords),
|
|
36
|
+
ship(fileSizeRatchet),
|
|
37
|
+
ship(commentBudget)
|
|
38
|
+
];
|
|
39
|
+
/** Every shipped Check a resolved `gate.checks` turns on with `true` or its options. */
|
|
40
|
+
export function enabledChecks(checks) {
|
|
41
|
+
return SHIPPED.flatMap(({ name, enable }) => enable(checks[name]) ?? []);
|
|
42
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Check } from "./check.ts";
|
|
2
|
+
export type Budget = {
|
|
3
|
+
maxLines: number;
|
|
4
|
+
maxBytes: number;
|
|
5
|
+
};
|
|
6
|
+
export type MarkdownBudgetOptions = {
|
|
7
|
+
budgets: (Budget & {
|
|
8
|
+
glob: string;
|
|
9
|
+
})[];
|
|
10
|
+
};
|
|
11
|
+
/** The lines of `text`, a last line without its newline counted, the empty line after a final newline not. */
|
|
12
|
+
export declare function lineCount(text: string): number;
|
|
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[];
|
|
15
|
+
export declare const markdownBudget: Check<MarkdownBudgetOptions>;
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// `markdown-budget`: each file a glob matches stays within that glob's line and byte budget, so the
|
|
2
|
+
// docs an agent reads stay short enough to read whole.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { matching, treeFiles } from "./tree.js";
|
|
6
|
+
const REMEDIES = {
|
|
7
|
+
budget: "cut it, or split a topic into a file of its own",
|
|
8
|
+
glob: "fix the glob, or drop its budget"
|
|
9
|
+
};
|
|
10
|
+
/** The lines of `text`, a last line without its newline counted, the empty line after a final newline not. */
|
|
11
|
+
export function lineCount(text) {
|
|
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);
|
|
18
|
+
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
|
+
];
|
|
23
|
+
}
|
|
24
|
+
function run(root, { budgets }) {
|
|
25
|
+
const files = treeFiles(root);
|
|
26
|
+
return budgets.flatMap(({ glob, ...budget }) => {
|
|
27
|
+
const matched = matching(root, files, [glob]);
|
|
28
|
+
if (matched.length === 0) {
|
|
29
|
+
return [{ message: `${JSON.stringify(glob)} matches no file`, remedy: REMEDIES.glob }];
|
|
30
|
+
}
|
|
31
|
+
return matched.flatMap((file) => overBudget(readFileSync(join(root, file), "utf8"), budget).map((over) => ({
|
|
32
|
+
file,
|
|
33
|
+
message: `${over} of ${glob}`,
|
|
34
|
+
remedy: REMEDIES.budget
|
|
35
|
+
})));
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
export const markdownBudget = {
|
|
39
|
+
name: "markdown-budget",
|
|
40
|
+
defaults: { budgets: [{ glob: "docs/agents/*.md", maxLines: 100, maxBytes: 6144 }] },
|
|
41
|
+
run
|
|
42
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// `markdown-refs`: every relative link and image in the markdown resolves, its path to a file or a
|
|
2
|
+
// directory git tracks or would track and its heading anchor to a heading of the file it names, so a
|
|
3
|
+
// link to an ignored file or to another case of a file's name fails here as it fails in CI.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join, posix } from "node:path";
|
|
6
|
+
import { anchorsOf, links } from "../markdown.js";
|
|
7
|
+
import { matching, treeFiles } from "./tree.js";
|
|
8
|
+
const REMEDIES = {
|
|
9
|
+
path: "fix the path or drop the link",
|
|
10
|
+
outside: "point it at a file of the repository",
|
|
11
|
+
anchor: "point it at a heading the file has"
|
|
12
|
+
};
|
|
13
|
+
/** A target with a scheme, such as `https:` or `mailto:`, or a host: no file of the tree answers it. */
|
|
14
|
+
const EXTERNAL = /^([a-z][a-z\d+.-]*:|\/\/)/i;
|
|
15
|
+
function decode(text) {
|
|
16
|
+
try {
|
|
17
|
+
return decodeURIComponent(text);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return text;
|
|
21
|
+
}
|
|
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
|
+
/** The repository path `path` names from `file`, the root `.`; a path out of the repository starts with `..`. */
|
|
34
|
+
function resolve(file, path) {
|
|
35
|
+
if (path === "") {
|
|
36
|
+
return file;
|
|
37
|
+
}
|
|
38
|
+
const joined = path.startsWith("/") ? path.slice(1) : posix.join(posix.dirname(file), path);
|
|
39
|
+
const normal = posix.normalize(joined === "" ? "." : joined);
|
|
40
|
+
return normal.length > 1 ? normal.replace(/\/$/, "") : normal;
|
|
41
|
+
}
|
|
42
|
+
/** What is wrong with one link of `file`: a path out of the repository or not in it, or an anchor with no heading. */
|
|
43
|
+
function judge(repository, file, { target, line }) {
|
|
44
|
+
if (EXTERNAL.test(target)) {
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
const [beforeAnchor = "", anchor = ""] = target.split("#", 2);
|
|
48
|
+
const resolved = resolve(file, decode(beforeAnchor.split("?", 1)[0] ?? ""));
|
|
49
|
+
const quoted = JSON.stringify(target);
|
|
50
|
+
if (resolved === ".." || resolved.startsWith("../")) {
|
|
51
|
+
return [{ file, line, message: `links to ${quoted}, which is outside the repository`, remedy: REMEDIES.outside }];
|
|
52
|
+
}
|
|
53
|
+
if (resolved !== "." && !repository.files.has(resolved) && !repository.directories.has(resolved)) {
|
|
54
|
+
return [{ file, line, message: `links to ${quoted}, which does not exist`, remedy: REMEDIES.path }];
|
|
55
|
+
}
|
|
56
|
+
const namesHeading = anchor !== "" && repository.files.has(resolved) && /\.md$/i.test(resolved);
|
|
57
|
+
if (namesHeading && !anchorsOf(repository.root, resolved, repository.anchors).has(decode(anchor))) {
|
|
58
|
+
const message = `links to ${quoted}, but ${resolved} has no heading with that anchor`;
|
|
59
|
+
return [{ file, line, message, remedy: REMEDIES.anchor }];
|
|
60
|
+
}
|
|
61
|
+
return [];
|
|
62
|
+
}
|
|
63
|
+
function run(root, { files, exempt }) {
|
|
64
|
+
const tree = treeFiles(root);
|
|
65
|
+
const known = new Set(tree);
|
|
66
|
+
const repository = { root, files: known, directories: directoriesOf(known), anchors: new Map() };
|
|
67
|
+
return matching(root, tree, files, exempt).flatMap((file) => links(readFileSync(join(root, file), "utf8")).flatMap((link) => judge(repository, file, link)));
|
|
68
|
+
}
|
|
69
|
+
export const markdownRefs = {
|
|
70
|
+
name: "markdown-refs",
|
|
71
|
+
defaults: { files: ["**/*.md"], exempt: [] },
|
|
72
|
+
run
|
|
73
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type Check } from "./check.ts";
|
|
2
|
+
export type PrimitivesOptions = {
|
|
3
|
+
file: string;
|
|
4
|
+
roots: string[];
|
|
5
|
+
};
|
|
6
|
+
export declare const primitives: Check<PrimitivesOptions>;
|
|
7
|
+
/** The catalogue a resolved `gate.checks` points at: the `primitives` Check's file, on or off. */
|
|
8
|
+
export declare function catalogueFile(checks: Record<string, unknown>): string;
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// `primitives`: the catalogue says what is true of the tree. Unique ids, a known group, a one-line
|
|
2
|
+
// summary, every code and docs path on disk with its anchor resolving, and every file under the roots
|
|
3
|
+
// named by a Primitive.
|
|
4
|
+
import { existsSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CATALOGUE_FILE, primitivesNaming, readCatalogue } from "../catalogue.js";
|
|
7
|
+
import { anchorsOf } from "../markdown.js";
|
|
8
|
+
import { CannotRun, optionsOf } from "./check.js";
|
|
9
|
+
import { treeFiles } from "./tree.js";
|
|
10
|
+
const MAX_SUMMARY = 120;
|
|
11
|
+
/** A file a test runs, never shipped: under a test directory, or named as a test. */
|
|
12
|
+
const TEST_FILE = /(^|\/)(test|tests|__tests__)\/|\.(test|spec)\.[^/]+$/;
|
|
13
|
+
const REMEDIES = {
|
|
14
|
+
missing: "create it, or point gate.checks.primitives.file at the catalogue",
|
|
15
|
+
shape: "give it the catalogue's shape: version 1, groups of id and name, primitives of id, name, group, summary, code and docs",
|
|
16
|
+
duplicate: "give each Primitive an id of its own",
|
|
17
|
+
group: "add the group to groups, or move the Primitive into one listed there",
|
|
18
|
+
summary: `keep the summary to one line of ${MAX_SUMMARY} characters or fewer`,
|
|
19
|
+
path: "fix the path or drop it",
|
|
20
|
+
anchor: "point the entry at a heading the file has"
|
|
21
|
+
};
|
|
22
|
+
function show(value) {
|
|
23
|
+
return JSON.stringify(value);
|
|
24
|
+
}
|
|
25
|
+
/** The line of the `nth` (0-based) `"id": "<id>"` under the `primitives` key, where a Primitive is declared. */
|
|
26
|
+
function lineOf(text, id, nth) {
|
|
27
|
+
const start = Math.max(0, text.indexOf('"primitives"'));
|
|
28
|
+
const pattern = new RegExp(`"id"\\s*:\\s*${JSON.stringify(id).replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}`, "g");
|
|
29
|
+
const match = Array.from(text.slice(start).matchAll(pattern))[nth];
|
|
30
|
+
return match?.index === undefined ? undefined : text.slice(0, start + match.index).split("\n").length;
|
|
31
|
+
}
|
|
32
|
+
/** What is wrong with one Primitive, declared at `at`: its group, its summary, each code and docs entry. */
|
|
33
|
+
function judge(root, catalogue, primitive, at, cache) {
|
|
34
|
+
const { id, group, summary, code, docs } = primitive;
|
|
35
|
+
const findings = [];
|
|
36
|
+
const fail = (message, remedy) => findings.push({ ...at, message, remedy });
|
|
37
|
+
if (!catalogue.groups.some((known) => known.id === group)) {
|
|
38
|
+
fail(`${show(id)} is in group ${show(group)}, which groups does not list`, REMEDIES.group);
|
|
39
|
+
}
|
|
40
|
+
const summaryLines = summary.split("\n").length;
|
|
41
|
+
if (summaryLines > 1) {
|
|
42
|
+
fail(`the summary of ${show(id)} runs over ${summaryLines} lines`, REMEDIES.summary);
|
|
43
|
+
}
|
|
44
|
+
else if ([...summary].length > MAX_SUMMARY) {
|
|
45
|
+
fail(`the summary of ${show(id)} is ${[...summary].length} characters, over ${MAX_SUMMARY}`, REMEDIES.summary);
|
|
46
|
+
}
|
|
47
|
+
for (const entry of code.filter((path) => !existsSync(join(root, path)))) {
|
|
48
|
+
fail(`${show(id)} names code at ${show(entry)}, which does not exist`, REMEDIES.path);
|
|
49
|
+
}
|
|
50
|
+
for (const entry of docs) {
|
|
51
|
+
const [path = "", anchor] = entry.split("#", 2);
|
|
52
|
+
if (!existsSync(join(root, path))) {
|
|
53
|
+
fail(`${show(id)} names the doc ${show(entry)}, which does not exist`, REMEDIES.path);
|
|
54
|
+
}
|
|
55
|
+
else if (anchor !== undefined && !anchorsOf(root, path, cache).has(anchor)) {
|
|
56
|
+
fail(`${show(id)} names the doc ${show(entry)}, which has no heading with that anchor`, REMEDIES.anchor);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return findings;
|
|
60
|
+
}
|
|
61
|
+
function run(root, { file, roots }) {
|
|
62
|
+
const read = readCatalogue(root, file);
|
|
63
|
+
if (read.state === "missing") {
|
|
64
|
+
throw new CannotRun({ file, message: "the catalogue does not exist", remedy: REMEDIES.missing });
|
|
65
|
+
}
|
|
66
|
+
if (read.state === "invalid") {
|
|
67
|
+
return read.problems.map((message) => ({ file, message, remedy: REMEDIES.shape }));
|
|
68
|
+
}
|
|
69
|
+
const { catalogue, text } = read;
|
|
70
|
+
const findings = [];
|
|
71
|
+
const seen = new Map();
|
|
72
|
+
const cache = new Map();
|
|
73
|
+
for (const primitive of catalogue.primitives) {
|
|
74
|
+
const nth = seen.get(primitive.id) ?? 0;
|
|
75
|
+
seen.set(primitive.id, nth + 1);
|
|
76
|
+
const line = lineOf(text, primitive.id, nth);
|
|
77
|
+
const at = line === undefined ? { file } : { file, line };
|
|
78
|
+
if (nth > 0) {
|
|
79
|
+
findings.push({
|
|
80
|
+
...at,
|
|
81
|
+
message: `id ${show(primitive.id)} is taken by an earlier Primitive`,
|
|
82
|
+
remedy: REMEDIES.duplicate
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
findings.push(...judge(root, catalogue, primitive, at, cache));
|
|
86
|
+
}
|
|
87
|
+
for (const path of treeFiles(root, roots)) {
|
|
88
|
+
if (!TEST_FILE.test(path) && primitivesNaming(catalogue, path).length === 0) {
|
|
89
|
+
const remedy = `name it, or a directory above it, in the code of a Primitive in ${file}`;
|
|
90
|
+
findings.push({ file: path, message: "no Primitive names this file", remedy });
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
return findings;
|
|
94
|
+
}
|
|
95
|
+
export const primitives = {
|
|
96
|
+
name: "primitives",
|
|
97
|
+
defaults: { file: CATALOGUE_FILE, roots: ["packages", "plugins", "scripts", "src"] },
|
|
98
|
+
run
|
|
99
|
+
};
|
|
100
|
+
/** The catalogue a resolved `gate.checks` points at: the `primitives` Check's file, on or off. */
|
|
101
|
+
export function catalogueFile(checks) {
|
|
102
|
+
return (optionsOf(primitives, checks.primitives) ?? primitives.defaults).file;
|
|
103
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// `temporal-language`: a durable document says what is true, not what changed. A phrase of kody's list,
|
|
2
|
+
// as `docs/research/gate/kody.md` records it, fails it across a line break and never inside code;
|
|
3
|
+
// decision records, research notes and changelogs record a point in time and are exempt by default.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { prose } from "../markdown.js";
|
|
7
|
+
import { matching, treeFiles } from "./tree.js";
|
|
8
|
+
const REMEDY = "describe what is true, in the present tense, as if written from scratch";
|
|
9
|
+
/** Between two words: one space, or a line break and the next line's indent. */
|
|
10
|
+
const GAP = String.raw `(?: |\n[ \t]*)`;
|
|
11
|
+
function phrase(...words) {
|
|
12
|
+
return new RegExp(String.raw `\b${words.join(GAP)}\b`, "gi");
|
|
13
|
+
}
|
|
14
|
+
const PHRASES = [
|
|
15
|
+
phrase("now", "we"),
|
|
16
|
+
phrase("we", "now"),
|
|
17
|
+
phrase("we", "no", "longer"),
|
|
18
|
+
phrase("previously", "we"),
|
|
19
|
+
phrase("as", "before"),
|
|
20
|
+
phrase("were", String.raw `\*{0,2}removed`),
|
|
21
|
+
phrase("(?:was|were)", String.raw `\*{0,2}retired`),
|
|
22
|
+
phrase("no", "longer", "(?:support|accept|require|use|fan|carry|replay|incur|authoritative|insert|needed)s?"),
|
|
23
|
+
phrase("is", "now", "(?:a|an|the)"),
|
|
24
|
+
phrase("are", "now"),
|
|
25
|
+
phrase("previously", "(?:a|the)"),
|
|
26
|
+
phrase("recently", "(?:changed|added|updated|removed|introduced)"),
|
|
27
|
+
phrase("used", "to", "(?:support|require)")
|
|
28
|
+
];
|
|
29
|
+
/** Each phrase in `text` in the order it reads, one that overlaps an earlier hit, as `we no longer support` does, folded into it. */
|
|
30
|
+
function hits(text) {
|
|
31
|
+
let end = 0;
|
|
32
|
+
return PHRASES.flatMap((pattern) => Array.from(text.matchAll(pattern)))
|
|
33
|
+
.toSorted((a, b) => a.index - b.index)
|
|
34
|
+
.filter((hit) => {
|
|
35
|
+
const overlaps = hit.index < end;
|
|
36
|
+
end = Math.max(end, hit.index + hit[0].length);
|
|
37
|
+
return !overlaps;
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
function run(root, { files, exempt }) {
|
|
41
|
+
return matching(root, treeFiles(root), files, exempt).flatMap((file) => {
|
|
42
|
+
const text = prose(readFileSync(join(root, file), "utf8"));
|
|
43
|
+
return hits(text).map((hit) => ({
|
|
44
|
+
file,
|
|
45
|
+
line: text.slice(0, hit.index).split("\n").length,
|
|
46
|
+
message: `${JSON.stringify(hit[0].replace(/\s+/g, " "))} reads as a changelog`,
|
|
47
|
+
remedy: REMEDY
|
|
48
|
+
}));
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
export const temporalLanguage = {
|
|
52
|
+
name: "temporal-language",
|
|
53
|
+
defaults: {
|
|
54
|
+
files: ["**/*.md"],
|
|
55
|
+
exempt: ["docs/adr/**", "docs/decisions/**", "docs/research/**", "**/CHANGELOG.md"]
|
|
56
|
+
},
|
|
57
|
+
run
|
|
58
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
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
|
+
export declare function treeFiles(root: string, pathspecs?: string[]): string[];
|
|
3
|
+
/**
|
|
4
|
+
* The files of `paths` that a glob of `globs` matches and none of `exempt` does. A symbolic link is left
|
|
5
|
+
* out when the file it points at is matched too, so one file is judged once. `*` and `**` skip a name
|
|
6
|
+
* that starts with a dot, as a shell does.
|
|
7
|
+
*/
|
|
8
|
+
export declare function matching(root: string, paths: string[], globs: string[], exempt?: string[]): string[];
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
// The files of a repository as git sees them, and the ones a Check's globs pick out of them: what the
|
|
2
|
+
// Checks that judge many files read, so a file git ignores is never judged.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { existsSync, lstatSync, realpathSync } from "node:fs";
|
|
5
|
+
import { join, matchesGlob, relative } from "node:path";
|
|
6
|
+
import { CannotRun } from "./check.js";
|
|
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
|
+
export function treeFiles(root, pathspecs = []) {
|
|
9
|
+
const args = ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspecs];
|
|
10
|
+
const listed = spawnSync("git", args, { encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
|
|
11
|
+
if (listed.status !== 0) {
|
|
12
|
+
const under = pathspecs.length === 0 ? "" : ` under ${pathspecs.join(", ")}`;
|
|
13
|
+
const message = `cannot list the files${under}: not a git repository`;
|
|
14
|
+
throw new CannotRun({ message, remedy: "run the Gate inside a git repository" });
|
|
15
|
+
}
|
|
16
|
+
return [...new Set(listed.stdout.split("\0").filter((path) => path !== ""))]
|
|
17
|
+
.filter((path) => existsSync(join(root, path)))
|
|
18
|
+
.toSorted();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The files of `paths` that a glob of `globs` matches and none of `exempt` does. A symbolic link is left
|
|
22
|
+
* out when the file it points at is matched too, so one file is judged once. `*` and `**` skip a name
|
|
23
|
+
* that starts with a dot, as a shell does.
|
|
24
|
+
*/
|
|
25
|
+
export function matching(root, paths, globs, exempt = []) {
|
|
26
|
+
const matched = paths.filter((path) => globs.some((glob) => matchesGlob(path, glob)) && !exempt.some((glob) => matchesGlob(path, glob)));
|
|
27
|
+
const judged = new Set(matched);
|
|
28
|
+
const realRoot = realpathSync(root);
|
|
29
|
+
return matched.filter((path) => !lstatSync(join(root, path)).isSymbolicLink() || !judged.has(relative(realRoot, realpathSync(join(root, path)))));
|
|
30
|
+
}
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// The `dw-sdlc` bin: one row per subcommand, each a module under `commands/`. A plugin's hook or skill
|
|
3
|
+
// reaches it through the plugin's `dw-sdlc` link.
|
|
4
|
+
import * as config from "./commands/config.js";
|
|
5
|
+
import * as doctor from "./commands/doctor.js";
|
|
6
|
+
import * as gate from "./commands/gate.js";
|
|
7
|
+
import * as primitives from "./commands/primitives.js";
|
|
8
|
+
import * as referencesSetup from "./commands/references-setup.js";
|
|
9
|
+
import * as referencesSync from "./commands/references-sync.js";
|
|
10
|
+
import * as setup from "./commands/setup.js";
|
|
11
|
+
const COMMANDS = new Map([
|
|
12
|
+
["gate", gate],
|
|
13
|
+
["primitives", primitives],
|
|
14
|
+
["setup", setup],
|
|
15
|
+
["doctor", doctor],
|
|
16
|
+
["config", config],
|
|
17
|
+
["references sync", referencesSync],
|
|
18
|
+
["references setup", referencesSetup]
|
|
19
|
+
]);
|
|
20
|
+
function usage() {
|
|
21
|
+
const width = Math.max(...[...COMMANDS.keys()].map((name) => name.length)) + 2;
|
|
22
|
+
const rows = [...COMMANDS].map(([name, { summary }]) => ` ${name.padEnd(width)}${summary}`);
|
|
23
|
+
return `Usage: dw-sdlc <command> [options]\n\nCommands:\n${rows.join("\n")}\n\nRun dw-sdlc <command> --help for its options.`;
|
|
24
|
+
}
|
|
25
|
+
async function main(argv) {
|
|
26
|
+
const [first, second] = argv;
|
|
27
|
+
if (first === "--help" || first === "-h") {
|
|
28
|
+
process.stdout.write(`${usage()}\n`);
|
|
29
|
+
return 0;
|
|
30
|
+
}
|
|
31
|
+
// A two-word name, as `references sync`, before its first word alone.
|
|
32
|
+
const words = second !== undefined && COMMANDS.has(`${first} ${second}`) ? 2 : 1;
|
|
33
|
+
const command = COMMANDS.get(argv.slice(0, words).join(" "));
|
|
34
|
+
if (command === undefined) {
|
|
35
|
+
const problem = first === undefined ? "no command given" : `unknown command ${JSON.stringify(first)}`;
|
|
36
|
+
process.stderr.write(`dw-sdlc: ${problem}\n\n${usage()}\n`);
|
|
37
|
+
return 2;
|
|
38
|
+
}
|
|
39
|
+
return command.run(argv.slice(words));
|
|
40
|
+
}
|
|
41
|
+
if (import.meta.main) {
|
|
42
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
43
|
+
}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
// `dw-sdlc config`, the config check: resolves a repository's agentic config, or validates one file
|
|
2
|
+
// against the schema, and prints what the guards see.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { parseArgs } from "node:util";
|
|
5
|
+
import { COMMITTED_FILE, findRepository, LOCAL_FILE, repositoryRoot, resolveConfig, validateDocument } from "../agentic-config.js";
|
|
6
|
+
export const summary = "resolve the repository's agentic config, or validate one file against the schema";
|
|
7
|
+
const USAGE = `Usage: dw-sdlc config [--root <dir>] [file]
|
|
8
|
+
or: pnpm agentic:check [--root <dir>] [file], in a checkout of dw-sdlc
|
|
9
|
+
|
|
10
|
+
Without a file: resolves the agentic config of the repository holding --root (default: the current
|
|
11
|
+
directory) — local over committed over inferred — and prints what the guards see. With a file:
|
|
12
|
+
validates that one document against the schema. Exit 1 on any problem.`;
|
|
13
|
+
function messageOf(error) {
|
|
14
|
+
return error instanceof Error ? error.message : String(error);
|
|
15
|
+
}
|
|
16
|
+
function fail(message, code) {
|
|
17
|
+
console.error(`agentic:check: ${message}`);
|
|
18
|
+
process.exit(code);
|
|
19
|
+
}
|
|
20
|
+
function parseCli(argv) {
|
|
21
|
+
try {
|
|
22
|
+
const { values, positionals } = parseArgs({
|
|
23
|
+
args: argv,
|
|
24
|
+
allowPositionals: true,
|
|
25
|
+
options: { help: { type: "boolean", short: "h" }, root: { type: "string" } }
|
|
26
|
+
});
|
|
27
|
+
if (positionals.length > 1) {
|
|
28
|
+
throw new Error("at most one file");
|
|
29
|
+
}
|
|
30
|
+
return { help: values.help === true, root: values.root, file: positionals[0] };
|
|
31
|
+
}
|
|
32
|
+
catch (error) {
|
|
33
|
+
fail(`${messageOf(error)}\n\n${USAGE}`, 2);
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
function report(problems) {
|
|
37
|
+
for (const { file, path, message } of problems) {
|
|
38
|
+
console.error(path === "" ? `${file}: ${message}` : `${file}: ${path}: ${message}`);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
export function run(argv) {
|
|
42
|
+
const cli = parseCli(argv);
|
|
43
|
+
if (cli.help) {
|
|
44
|
+
console.log(USAGE);
|
|
45
|
+
return 0;
|
|
46
|
+
}
|
|
47
|
+
if (cli.file !== undefined) {
|
|
48
|
+
let text;
|
|
49
|
+
try {
|
|
50
|
+
text = readFileSync(cli.file, "utf8");
|
|
51
|
+
}
|
|
52
|
+
catch (error) {
|
|
53
|
+
fail(messageOf(error), 1);
|
|
54
|
+
}
|
|
55
|
+
const result = validateDocument(cli.file, text);
|
|
56
|
+
if ("problems" in result) {
|
|
57
|
+
report(result.problems);
|
|
58
|
+
return 1;
|
|
59
|
+
}
|
|
60
|
+
console.log(`${cli.file}: valid`);
|
|
61
|
+
return 0;
|
|
62
|
+
}
|
|
63
|
+
const root = cli.root === undefined
|
|
64
|
+
? (findRepository(process.cwd()) ?? fail("not inside a git repository; pass --root <dir>", 1))
|
|
65
|
+
: repositoryRoot(cli.root);
|
|
66
|
+
const { config, problems, layers, sources } = resolveConfig(root);
|
|
67
|
+
const from = (source) => source === undefined || source === root ? "" : `, from ${source}`;
|
|
68
|
+
console.log(`committed ${COMMITTED_FILE} ${layers.committed}${from(sources.committed)}`);
|
|
69
|
+
console.log(`local ${LOCAL_FILE} ${layers.local}${from(sources.local)}`);
|
|
70
|
+
console.log(JSON.stringify(config, null, 2));
|
|
71
|
+
if (problems.length > 0) {
|
|
72
|
+
report(problems);
|
|
73
|
+
fail(`${problems.length} problem(s) above; the guards run on the strictest defaults until the file is fixed`, 1);
|
|
74
|
+
}
|
|
75
|
+
return 0;
|
|
76
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type Level = "PASS" | "WARN" | "FAIL" | "INFO";
|
|
2
|
+
export type Check = {
|
|
3
|
+
name: string;
|
|
4
|
+
level: Level;
|
|
5
|
+
detail: string;
|
|
6
|
+
};
|
|
7
|
+
/** What the doctor asks the machine; the tests answer from canned data. */
|
|
8
|
+
export type Probe = {
|
|
9
|
+
/** A command run directly, from `cwd` when given, with its exit status and stdout. */
|
|
10
|
+
run(command: string, args: string[], cwd?: string): {
|
|
11
|
+
status: number;
|
|
12
|
+
stdout: string;
|
|
13
|
+
};
|
|
14
|
+
/** A file's text, or undefined when it is absent. */
|
|
15
|
+
read(path: string): string | undefined;
|
|
16
|
+
/** One line of `command -v <tool>` from a non-interactive login shell, the shell the agent gets. */
|
|
17
|
+
shell(script: string): string;
|
|
18
|
+
};
|
|
19
|
+
export type RepositoryConfig = {
|
|
20
|
+
config: {
|
|
21
|
+
commit: {
|
|
22
|
+
convention: string;
|
|
23
|
+
};
|
|
24
|
+
};
|
|
25
|
+
problems: string[];
|
|
26
|
+
/** The checkout each layer was read from, as `resolveConfig` reports it. */
|
|
27
|
+
sources?: {
|
|
28
|
+
committed: string | undefined;
|
|
29
|
+
local: string | undefined;
|
|
30
|
+
};
|
|
31
|
+
};
|
|
32
|
+
export type Inputs = {
|
|
33
|
+
root: string;
|
|
34
|
+
home: string;
|
|
35
|
+
/** Claude Code's config dir; `<home>/.claude` when absent. */
|
|
36
|
+
claudeDir?: string;
|
|
37
|
+
probe: Probe;
|
|
38
|
+
/** Undefined when no repository holds `root`. */
|
|
39
|
+
config: RepositoryConfig | undefined;
|
|
40
|
+
};
|
|
41
|
+
/** Claude Code's config dir: `$CLAUDE_CONFIG_DIR` when set, the way Claude Code reads it, else `~/.claude`. */
|
|
42
|
+
export declare function claudeConfigDir(env: Record<string, string | undefined>, home: string): string;
|
|
43
|
+
/** The marketplace Claude Code registers this repository under, and the plugin id inside it. */
|
|
44
|
+
export declare const MARKETPLACE = "dw-sdlc";
|
|
45
|
+
export declare const PLUGIN_ID = "guardrails@dw-sdlc";
|
|
46
|
+
/** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
|
|
47
|
+
export declare const RTK_FIND_EXEC_FIXED = "0.46.0";
|
|
48
|
+
export declare const PNPM_MINIMUM_RELEASE_AGE = 10080;
|
|
49
|
+
export declare const realProbe: Probe;
|
|
50
|
+
/** rtk's config.toml: Application Support on macOS, XDG or ~/.config elsewhere. */
|
|
51
|
+
export declare function rtkConfigPath(home: string, platform?: string, env?: Record<string, string | undefined>): string;
|
|
52
|
+
/** The `exclude_commands` list of an rtk config.toml, read with a regex: the file is small and ours. */
|
|
53
|
+
export declare function rtkExcludes(toml: string | undefined): string[];
|
|
54
|
+
export declare function diagnose(inputs: Inputs): Check[];
|
|
55
|
+
export declare function render(checks: Check[]): string;
|
|
56
|
+
export declare const summary = "one PASS / WARN / FAIL line per machine check the guardrails rely on";
|
|
57
|
+
export declare function run(argv: string[]): number;
|