dw-sdlc 0.20.0 → 0.21.1

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.
Files changed (97) hide show
  1. package/README.md +13 -13
  2. package/config/agentic.config.schema.json +1 -1
  3. package/dist/agentic-config.d.ts +16 -0
  4. package/dist/agentic-config.js +28 -23
  5. package/dist/cannot-run.d.ts +27 -0
  6. package/dist/cannot-run.js +48 -0
  7. package/dist/catalogue.js +2 -1
  8. package/dist/checks/agents-map.js +7 -8
  9. package/dist/checks/check.d.ts +10 -8
  10. package/dist/checks/check.js +9 -7
  11. package/dist/checks/decision-records.js +3 -2
  12. package/dist/checks/file-size-ratchet.js +13 -17
  13. package/dist/checks/index.d.ts +6 -5
  14. package/dist/checks/index.js +10 -9
  15. package/dist/checks/markdown-refs.js +58 -6
  16. package/dist/checks/primitives.js +5 -5
  17. package/dist/checks/routed-docs.js +1 -3
  18. package/dist/checks/skill-sections.js +12 -20
  19. package/dist/checks/tree.js +7 -7
  20. package/dist/cli.d.ts +2 -1
  21. package/dist/cli.js +26 -5
  22. package/dist/commands/check.js +27 -42
  23. package/dist/commands/config.js +22 -27
  24. package/dist/commands/doctor.d.ts +2 -0
  25. package/dist/commands/doctor.js +11 -18
  26. package/dist/commands/gate.js +35 -40
  27. package/dist/commands/lead-start.js +28 -42
  28. package/dist/commands/pr-reviewed.js +41 -59
  29. package/dist/commands/pr-ship.js +72 -87
  30. package/dist/commands/primitives.js +38 -40
  31. package/dist/commands/recap.js +23 -74
  32. package/dist/commands/references-setup.js +28 -76
  33. package/dist/commands/references-sync.js +29 -53
  34. package/dist/commands/setup.d.ts +2 -7
  35. package/dist/commands/setup.js +38 -90
  36. package/dist/commands/sweep.js +46 -157
  37. package/dist/commands/worker-ls.js +15 -31
  38. package/dist/commands/worker-report.js +36 -52
  39. package/dist/commands/worker-start.js +43 -57
  40. package/dist/drift.js +2 -1
  41. package/dist/exec.d.ts +20 -0
  42. package/dist/exec.js +37 -4
  43. package/dist/gate-cache.js +2 -1
  44. package/dist/git.d.ts +32 -0
  45. package/dist/git.js +60 -0
  46. package/dist/json-schema.js +1 -3
  47. package/dist/landed.js +11 -22
  48. package/dist/paused-operation.d.ts +2 -0
  49. package/dist/paused-operation.js +17 -0
  50. package/dist/pins.js +2 -1
  51. package/dist/pr/decide.d.ts +4 -16
  52. package/dist/pr/decide.js +48 -83
  53. package/dist/pr/delta.d.ts +3 -4
  54. package/dist/pr/delta.js +11 -20
  55. package/dist/pr/read.d.ts +27 -30
  56. package/dist/pr/read.js +96 -158
  57. package/dist/pr/reply.d.ts +34 -0
  58. package/dist/pr/reply.js +112 -0
  59. package/dist/pr/route.d.ts +11 -3
  60. package/dist/pr/route.js +5 -5
  61. package/dist/pr/standing.d.ts +138 -0
  62. package/dist/pr/standing.js +181 -0
  63. package/dist/pr/state.d.ts +14 -69
  64. package/dist/pr/state.js +75 -148
  65. package/dist/pr/upstream.d.ts +1 -6
  66. package/dist/pr/upstream.js +2 -2
  67. package/dist/primitives-diff.js +1 -1
  68. package/dist/recap-next.js +34 -47
  69. package/dist/recap-primitives.js +1 -1
  70. package/dist/references-key.d.ts +0 -1
  71. package/dist/references-key.js +3 -4
  72. package/dist/run-command.d.ts +4 -1
  73. package/dist/run-command.js +32 -11
  74. package/dist/setup-gate.d.ts +1 -7
  75. package/dist/setup-rules.js +2 -6
  76. package/dist/setup-writer.d.ts +32 -0
  77. package/dist/setup-writer.js +60 -0
  78. package/dist/shell-line.d.ts +2 -0
  79. package/dist/shell-line.js +5 -0
  80. package/dist/sweep-removal.d.ts +31 -0
  81. package/dist/sweep-removal.js +133 -0
  82. package/dist/sweep-terminals.d.ts +1 -1
  83. package/dist/sweep-terminals.js +21 -57
  84. package/dist/sweep-trees.d.ts +19 -3
  85. package/dist/sweep-trees.js +67 -31
  86. package/dist/worker/herdr.d.ts +67 -23
  87. package/dist/worker/herdr.js +126 -77
  88. package/dist/worker/launch.js +32 -45
  89. package/dist/worker/origin.d.ts +1 -1
  90. package/dist/worker/origin.js +9 -11
  91. package/dist/worker/tabs.js +11 -7
  92. package/dist/working-tree.d.ts +0 -8
  93. package/dist/working-tree.js +6 -22
  94. package/dist/worktree-lock.js +1 -1
  95. package/package.json +1 -1
  96. package/dist/git-env.d.ts +0 -2
  97. package/dist/git-env.js +0 -10
@@ -1,8 +1,9 @@
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.
1
+ // `markdown-refs`: every relative link and image, and every URL into origin's default branch even in
2
+ // code, resolves to a file or directory git tracks or would track and its anchor to a heading there, so
3
+ // a link to an ignored file or to another case of a file's name fails here as it fails in CI.
4
4
  import { readFileSync } from "node:fs";
5
5
  import { join, posix } from "node:path";
6
+ import { asked } from "../git.js";
6
7
  import { anchorsOf, links } from "../markdown.js";
7
8
  import { directoriesOf, matching, treeFiles } from "./tree.js";
8
9
  const REMEDIES = {
@@ -12,6 +13,51 @@ const REMEDIES = {
12
13
  };
13
14
  /** A target with a scheme, such as `https:` or `mailto:`, or a host: no file of the tree answers it. */
14
15
  const EXTERNAL = /^([a-z][a-z\d+.-]*:|\/\/)/i;
16
+ /** `owner/name` in the URL of a GitHub remote, over SSH or HTTPS, with a user, a token or a port, `.git` or not. */
17
+ const GITHUB_REMOTE = /^(?:git@github\.com:|(?:ssh|https?):\/\/(?:[^@/]+@)?github\.com(?::\d+)?\/)([^/]+\/[^/]+?)(?:\.git)?\/?$/i;
18
+ const REMOTE_HEAD = "refs/remotes/origin/HEAD";
19
+ /** The branch a URL is judged at when origin/HEAD names none, as in a CI checkout. */
20
+ const FALLBACK_BRANCH = "main";
21
+ /** A URL into a GitHub repository: a file page, a directory page or a raw file, up to a space, a quote or a bracket. */
22
+ const REPOSITORY_URL = /https:\/\/(?:github\.com\/[^/\s]+\/[^/\s]+\/(?:blob|tree)|raw\.githubusercontent\.com\/[^/\s]+\/[^/\s]+)\/[^\s"'`<>()[\]]+/gi;
23
+ /** Closing punctuation and emphasis markers after a URL, which GitHub leaves out of it. */
24
+ const TRAILING = /[.,;:!?*_~]+$/;
25
+ /** A GitHub anchor to a line or a range of lines, which names no heading. */
26
+ const LINE_ANCHOR = /#L\d+(C\d+)?(-L\d+(C\d+)?)?$/;
27
+ /** The prefixes of URLs into origin's default branch; none when origin is not on GitHub. */
28
+ function prefixesOf(root) {
29
+ const slug = GITHUB_REMOTE.exec(asked(root, ["remote", "get-url", "origin"]) ?? "")?.[1]?.toLowerCase();
30
+ if (slug === undefined) {
31
+ return [];
32
+ }
33
+ const head = asked(root, ["symbolic-ref", "-q", REMOTE_HEAD])?.replace(/^refs\/remotes\/origin\//, "");
34
+ const branch = head === undefined || head === "" ? FALLBACK_BRANCH : head;
35
+ return [
36
+ `https://github.com/${slug}/blob/`,
37
+ `https://github.com/${slug}/tree/`,
38
+ `https://raw.githubusercontent.com/${slug}/`
39
+ ].map((repository) => ({ repository, branch }));
40
+ }
41
+ /** The path from the root `url` names under one of `prefixes`, its line anchor dropped; undefined under none. */
42
+ function pathOf(url, prefixes) {
43
+ const prefix = prefixes.find(({ repository, branch }) => url.toLowerCase().startsWith(repository) && url.slice(repository.length).startsWith(`${branch}/`));
44
+ return prefix === undefined
45
+ ? undefined
46
+ : `/${url.slice(prefix.repository.length + prefix.branch.length + 1).replace(LINE_ANCHOR, "")}`;
47
+ }
48
+ /** Every URL of `text` into the repository's default branch, a fenced line's included, once per line. */
49
+ function repositoryLinks(text, prefixes) {
50
+ if (prefixes.length === 0) {
51
+ return [];
52
+ }
53
+ return text.split("\n").flatMap((line, index) => {
54
+ const urls = new Set(Array.from(line.matchAll(REPOSITORY_URL), ([url]) => url.replace(TRAILING, "")));
55
+ return [...urls].flatMap((url) => {
56
+ const target = pathOf(url, prefixes);
57
+ return target === undefined ? [] : [{ target, line: index + 1, shown: url }];
58
+ });
59
+ });
60
+ }
15
61
  function decode(text) {
16
62
  try {
17
63
  return decodeURIComponent(text);
@@ -30,13 +76,13 @@ function resolve(file, path) {
30
76
  return normal.length > 1 ? normal.replace(/\/$/, "") : normal;
31
77
  }
32
78
  /** What is wrong with one link of `file`: a path out of the repository or not in it, or an anchor with no heading. */
33
- function judge(repository, file, { target, line }) {
79
+ function judge(repository, file, { target, line, shown = target }) {
34
80
  if (EXTERNAL.test(target)) {
35
81
  return [];
36
82
  }
37
83
  const [beforeAnchor = "", anchor = ""] = target.split("#", 2);
38
84
  const resolved = resolve(file, decode(beforeAnchor.split("?", 1)[0] ?? ""));
39
- const quoted = JSON.stringify(target);
85
+ const quoted = JSON.stringify(shown);
40
86
  if (resolved === ".." || resolved.startsWith("../")) {
41
87
  return [{ file, line, message: `links to ${quoted}, which is outside the repository`, remedy: REMEDIES.outside }];
42
88
  }
@@ -53,8 +99,14 @@ function judge(repository, file, { target, line }) {
53
99
  function run(root, { files, exempt }) {
54
100
  const tree = treeFiles(root);
55
101
  const known = new Set(tree);
102
+ const prefixes = prefixesOf(root);
56
103
  const repository = { root, files: known, directories: directoriesOf(known), anchors: new Map() };
57
- return matching(root, tree, files, exempt).flatMap((file) => links(readFileSync(join(root, file), "utf8")).flatMap((link) => judge(repository, file, link)));
104
+ return matching(root, tree, files, exempt).flatMap((file) => {
105
+ const text = readFileSync(join(root, file), "utf8");
106
+ return [...links(text), ...repositoryLinks(text, prefixes)]
107
+ .toSorted((left, right) => left.line - right.line)
108
+ .flatMap((link) => judge(repository, file, link));
109
+ });
58
110
  }
59
111
  export const markdownRefs = {
60
112
  name: "markdown-refs",
@@ -2,10 +2,10 @@
2
2
  // one-line summary, real code and docs paths, no decision record out of force, every file named.
3
3
  import { existsSync } from "node:fs";
4
4
  import { join } from "node:path";
5
- import { resolveConfig } from "../agentic-config.js";
5
+ import { CannotRun } from "../cannot-run.js";
6
6
  import { CATALOGUE_FILE, primitivesNaming, readCatalogue } from "../catalogue.js";
7
7
  import { anchorsOf } from "../markdown.js";
8
- import { CannotRun, optionsOf } from "./check.js";
8
+ import { optionsOf } from "./check.js";
9
9
  import { decisionRecords, OUT_OF_FORCE, recordStatus } from "./decision-records.js";
10
10
  import { treeFiles } from "./tree.js";
11
11
  const MAX_SUMMARY = 120;
@@ -68,16 +68,16 @@ function judge(root, catalogue, primitive, at, cache, records) {
68
68
  }
69
69
  return findings;
70
70
  }
71
- function run(root, { file, roots }) {
71
+ function run(root, { file, roots }, gate) {
72
72
  const read = readCatalogue(root, file);
73
73
  if (read.state === "missing") {
74
- throw new CannotRun({ file, message: "the catalogue does not exist", remedy: REMEDIES.missing });
74
+ throw new CannotRun("the catalogue does not exist", REMEDIES.missing, { file });
75
75
  }
76
76
  if (read.state === "invalid") {
77
77
  return read.problems.map((message) => ({ file, message, remedy: REMEDIES.shape }));
78
78
  }
79
79
  const { catalogue, text } = read;
80
- const records = (optionsOf(decisionRecords, resolveConfig(root).config.gate.checks["decision-records"]) ?? decisionRecords.defaults).dirs;
80
+ const records = (optionsOf(decisionRecords, gate.checks["decision-records"]) ?? decisionRecords.defaults).dirs;
81
81
  const findings = [];
82
82
  const seen = new Map();
83
83
  const cache = new Map();
@@ -2,7 +2,6 @@
2
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
3
  // a Primitive in the catalogue. It reads the file list, the Task Router and the catalogue, never prose.
4
4
  import { matchesGlob, posix } from "node:path";
5
- import { resolveConfig } from "../agentic-config.js";
6
5
  import { readCatalogue } from "../catalogue.js";
7
6
  import { mapFile, readMap, routes } from "./agents-map.js";
8
7
  import { catalogueFile } from "./primitives.js";
@@ -25,8 +24,7 @@ function catalogueDocs(root, file) {
25
24
  const read = readCatalogue(root, file);
26
25
  return read.state === "valid" ? read.catalogue.primitives.flatMap(({ docs }) => docs) : [];
27
26
  }
28
- function run(root, { files, exempt }) {
29
- const { checks } = resolveConfig(root).config.gate;
27
+ function run(root, { files, exempt }, { checks }) {
30
28
  const map = mapFile(checks);
31
29
  const rows = routes(readMap(root, map));
32
30
  const findings = [];
@@ -3,10 +3,10 @@
3
3
  // docs name. `dw-sdlc check skill-sections --write` writes each part from its section.
4
4
  import { existsSync, readFileSync, writeFileSync } from "node:fs";
5
5
  import { join, posix } from "node:path";
6
- import { resolveConfig } from "../agentic-config.js";
6
+ import { CannotRun } from "../cannot-run.js";
7
7
  import { readCatalogue } from "../catalogue.js";
8
8
  import { headings, openFence, prose } from "../markdown.js";
9
- import { CannotRun } from "./check.js";
9
+ import {} from "./check.js";
10
10
  import { catalogueFile } from "./primitives.js";
11
11
  import { matching, treeFiles } from "./tree.js";
12
12
  /** Either marker of a part: `/` before `README.md` closes it. */
@@ -111,41 +111,33 @@ function render(root, path, sources) {
111
111
  pieces.push(text.slice(copied));
112
112
  return { text: pieces.join(""), findings, rewritten };
113
113
  }
114
- /** The docs entries the catalogue names, each read from the root; the catalogue is read on the first call. */
115
- function listedDocs(root) {
114
+ /** The docs entries of the catalogue `gate` points at, each read from the root; it is read on the first call. */
115
+ function listedDocs(root, gate) {
116
116
  let listed;
117
117
  return () => {
118
118
  if (listed === undefined) {
119
- const file = catalogueFile(resolveConfig(root).config.gate.checks);
119
+ const file = catalogueFile(gate.checks);
120
120
  const read = readCatalogue(root, file);
121
121
  if (read.state === "missing") {
122
- throw new CannotRun({
123
- file,
124
- message: "the catalogue does not exist, and a skill holds a part",
125
- remedy: REMEDIES.missing
126
- });
122
+ throw new CannotRun("the catalogue does not exist, and a skill holds a part", REMEDIES.missing, { file });
127
123
  }
128
124
  if (read.state === "invalid") {
129
- throw new CannotRun({
130
- file,
131
- message: "the catalogue is not valid, and a skill holds a part",
132
- remedy: REMEDIES.invalid
133
- });
125
+ throw new CannotRun("the catalogue is not valid, and a skill holds a part", REMEDIES.invalid, { file });
134
126
  }
135
127
  listed = new Set(read.catalogue.primitives.flatMap(({ docs }) => docs).map((entry) => posix.join(".", entry)));
136
128
  }
137
129
  return listed;
138
130
  };
139
131
  }
140
- function renderAll(root, { files }) {
141
- const sources = { listed: listedDocs(root), readmes: new Map() };
132
+ function renderAll(root, { files }, gate) {
133
+ const sources = { listed: listedDocs(root, gate), readmes: new Map() };
142
134
  return matching(root, treeFiles(root), files).map((path) => ({ path, rendered: render(root, path, sources) }));
143
135
  }
144
136
  function plural(count, noun) {
145
137
  return `${count} ${noun}${count === 1 ? "" : "s"}`;
146
138
  }
147
- function write(root, options) {
148
- const changed = renderAll(root, options).filter(({ rendered }) => rendered.rewritten > 0);
139
+ function write(root, options, gate) {
140
+ const changed = renderAll(root, options, gate).filter(({ rendered }) => rendered.rewritten > 0);
149
141
  for (const { path, rendered } of changed) {
150
142
  writeFileSync(join(root, path), rendered.text);
151
143
  }
@@ -158,6 +150,6 @@ function write(root, options) {
158
150
  export const skillSections = {
159
151
  name: "skill-sections",
160
152
  defaults: { files: ["**/skills/**/*.md"] },
161
- run: (root, options) => renderAll(root, options).flatMap(({ rendered }) => rendered.findings),
153
+ run: (root, options, gate) => renderAll(root, options, gate).flatMap(({ rendered }) => rendered.findings),
162
154
  write
163
155
  };
@@ -2,18 +2,18 @@
2
2
  // pick out of them: what the Checks that judge many files read, so a file git ignores is never judged.
3
3
  import { existsSync, lstatSync, realpathSync } from "node:fs";
4
4
  import { join, matchesGlob, posix, relative } from "node:path";
5
- import { exec } from "../exec.js";
6
- import { CannotRun } from "./check.js";
5
+ import { CannotRun } from "../cannot-run.js";
6
+ import { git } from "../git.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 = []) {
9
- const args = ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspecs];
10
- const listed = exec("git", args);
11
- if (listed.status !== 0) {
9
+ const args = ["ls-files", "-z", "--cached", "--others", "--exclude-standard", "--", ...pathspecs];
10
+ const listed = git(root, args, { raw: true });
11
+ if ("error" in listed) {
12
12
  const under = pathspecs.length === 0 ? "" : ` under ${pathspecs.join(", ")}`;
13
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" });
14
+ throw new CannotRun(message, "run the Gate inside a git repository");
15
15
  }
16
- return [...new Set(listed.stdout.split("\0").filter((path) => path !== ""))]
16
+ return [...new Set(listed.out.split("\0").filter((path) => path !== ""))]
17
17
  .filter((path) => existsSync(join(root, path)))
18
18
  .toSorted();
19
19
  }
package/dist/cli.d.ts CHANGED
@@ -1,2 +1,3 @@
1
1
  #!/usr/bin/env node
2
- export {};
2
+ /** `dw-sdlc <argv>`: the command's exit code, or 2 with its line when it throws `CannotRun`. */
3
+ export declare function dispatch(argv: string[]): number | Promise<number>;
package/dist/cli.js CHANGED
@@ -1,6 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  // The `dw-sdlc` bin: one row per subcommand, each a module under `commands/`. A plugin's hook or skill
3
3
  // reaches it through the plugin's `dw-sdlc` link.
4
+ import { CannotRun, messageOf } from "./cannot-run.js";
4
5
  import * as check from "./commands/check.js";
5
6
  import * as config from "./commands/config.js";
6
7
  import * as doctor from "./commands/doctor.js";
@@ -40,7 +41,13 @@ function usage() {
40
41
  const rows = [...COMMANDS].map(([name, { summary }]) => ` ${name.padEnd(width)}${summary}`);
41
42
  return `Usage: dw-sdlc <command> [options]\n\nCommands:\n${rows.join("\n")}\n\nRun dw-sdlc <command> --help for its options.`;
42
43
  }
43
- async function main(argv) {
44
+ /** What the bin prints for `error`, which stopped `command`: the problem, its remedy, the lines after it. */
45
+ function stopText(command, { message, remedy, detail }) {
46
+ const line = `dw-sdlc ${command}: ${message}${remedy === undefined ? "" : ` — ${remedy}`}\n`;
47
+ return detail.after === undefined ? line : `${line}${detail.after}\n`;
48
+ }
49
+ /** `dw-sdlc <argv>`: the command's exit code, or 2 with its line when it throws `CannotRun`. */
50
+ export function dispatch(argv) {
44
51
  const [first, second] = argv;
45
52
  if (first === "--help" || first === "-h") {
46
53
  process.stdout.write(`${usage()}\n`);
@@ -48,21 +55,35 @@ async function main(argv) {
48
55
  }
49
56
  // A two-word name, as `references sync`, before its first word alone.
50
57
  const words = second !== undefined && COMMANDS.has(`${first} ${second}`) ? 2 : 1;
51
- const command = COMMANDS.get(argv.slice(0, words).join(" "));
58
+ const name = argv.slice(0, words).join(" ");
59
+ const command = COMMANDS.get(name);
52
60
  if (command === undefined) {
53
61
  const problem = first === undefined ? "no command given" : `unknown command ${JSON.stringify(first)}`;
54
62
  process.stderr.write(`dw-sdlc: ${problem}\n\n${usage()}\n`);
55
63
  return 2;
56
64
  }
57
- return command.run(argv.slice(words));
65
+ const stopped = (error) => {
66
+ if (!(error instanceof CannotRun)) {
67
+ throw error;
68
+ }
69
+ process.stderr.write(stopText(name, error));
70
+ return 2;
71
+ };
72
+ try {
73
+ const code = command.run(argv.slice(words));
74
+ return typeof code === "number" ? code : code.catch(stopped);
75
+ }
76
+ catch (error) {
77
+ return stopped(error);
78
+ }
58
79
  }
59
80
  if (import.meta.main) {
60
81
  try {
61
- process.exitCode = await main(process.argv.slice(2));
82
+ process.exitCode = await dispatch(process.argv.slice(2));
62
83
  }
63
84
  catch (error) {
64
85
  // What no command answered for stopped it before a verdict: exit 1 would read as one.
65
- process.stderr.write(`dw-sdlc: ${error instanceof Error ? error.message : String(error)}\n`);
86
+ process.stderr.write(`dw-sdlc: ${messageOf(error)}\n`);
66
87
  process.exitCode = 2;
67
88
  }
68
89
  }
@@ -1,7 +1,6 @@
1
1
  // `dw-sdlc check <name>`: one Check of the Gate by its name, without the Steps and the other Checks.
2
- import { parseArgs } from "node:util";
3
2
  import { repositoryRoot, resolveConfig } from "../agentic-config.js";
4
- import { CannotRun } from "../checks/check.js";
3
+ import { CannotRun, readArgs, rootDir, usageError } from "../cannot-run.js";
5
4
  import { CHECK_NAMES, shippedCheck } from "../checks/index.js";
6
5
  import { TAIL_LINES } from "../run-command.js";
7
6
  import { printReport, runGate } from "./gate.js";
@@ -23,49 +22,45 @@ skill-sections writes each part of a skill from its README section.
23
22
  A Ratchet's baseline is refreshed by dw-sdlc gate --update-baseline, which needs every other Check to
24
23
  pass.
25
24
 
26
- Exit 0 when the Check passes, 1 when it fails, 2 when it cannot run: no Check of that name, an
27
- invalid config layer, a command the shell cannot find or execute, or an input the Check reads
28
- missing.`;
25
+ Exit 0 when the Check passes, 1 when it fails, 2 when it cannot run: a --root that names no
26
+ directory, no Check of that name, an invalid config layer, a command the shell cannot find or
27
+ execute, or an input the Check reads missing.`;
29
28
  function parseCli(argv) {
30
- try {
31
- const { values, positionals } = parseArgs({
32
- args: argv,
33
- allowPositionals: true,
34
- options: {
35
- help: { type: "boolean", short: "h" },
36
- json: { type: "boolean" },
37
- write: { type: "boolean" },
38
- root: { type: "string" }
39
- }
40
- });
41
- if (positionals.length > 1) {
42
- return { error: `one Check at a time, not ${positionals.map((name) => JSON.stringify(name)).join(" and ")}` };
29
+ const { values, positionals } = readArgs({
30
+ args: argv,
31
+ allowPositionals: true,
32
+ options: {
33
+ help: { type: "boolean", short: "h" },
34
+ json: { type: "boolean" },
35
+ write: { type: "boolean" },
36
+ root: { type: "string" }
43
37
  }
44
- const flags = { help: values.help === true, json: values.json === true, write: values.write === true };
45
- return { ...flags, root: values.root, name: positionals[0] };
46
- }
47
- catch (error) {
48
- return { error: error instanceof Error ? error.message : String(error) };
38
+ }, USAGE);
39
+ if (positionals.length > 1) {
40
+ const names = positionals.map((name) => JSON.stringify(name)).join(" and ");
41
+ throw usageError(`one Check at a time, not ${names}`, USAGE);
49
42
  }
43
+ const flags = { help: values.help === true, json: values.json === true, write: values.write === true };
44
+ return { ...flags, root: values.root, name: positionals[0] };
50
45
  }
51
46
  /**
52
47
  * Writes what the shipped Check `name` derives, on the setting the config gives it, and prints what it
53
- * wrote; why it cannot when it takes no --write. An invalid config or a missing input writes nothing,
54
- * and the run after it reports why.
48
+ * wrote; stops the command when it takes no --write. An invalid config or a missing input writes
49
+ * nothing, and the run after it reports why.
55
50
  */
56
51
  function writeDerived(root, name, json) {
57
52
  const { config, problems } = resolveConfig(root);
58
53
  if (problems.length > 0) {
59
- return undefined;
54
+ return;
60
55
  }
61
- const shipped = shippedCheck(name, config.gate.checks[name]);
56
+ const shipped = shippedCheck(name, config.gate);
62
57
  if (shipped === undefined && !config.gate.checks.run.some((check) => check.name === name)) {
63
58
  // No Check of that name: the run after it says so, with the names there are.
64
- return undefined;
59
+ return;
65
60
  }
66
61
  const write = shipped?.write;
67
62
  if (write === undefined) {
68
- return `${JSON.stringify(name)} takes no --write: it derives no file`;
63
+ throw new CannotRun(`${JSON.stringify(name)} takes no --write: it derives no file`);
69
64
  }
70
65
  try {
71
66
  const out = json ? process.stderr : process.stdout;
@@ -76,30 +71,20 @@ function writeDerived(root, name, json) {
76
71
  throw error;
77
72
  }
78
73
  }
79
- return undefined;
80
74
  }
81
75
  export async function run(argv) {
82
76
  const cli = parseCli(argv);
83
- if ("error" in cli) {
84
- process.stderr.write(`dw-sdlc check: ${cli.error}\n\n${USAGE}\n`);
85
- return 2;
86
- }
87
77
  if (cli.help) {
88
78
  process.stdout.write(`${USAGE}\n`);
89
79
  return 0;
90
80
  }
91
81
  if (cli.name === undefined) {
92
- process.stderr.write(`dw-sdlc check: name the Check to run\n\n${USAGE}\n`);
93
- return 2;
82
+ throw usageError("name the Check to run", USAGE);
94
83
  }
95
84
  const { json, name } = cli;
96
- const root = repositoryRoot(cli.root ?? process.cwd());
85
+ const root = repositoryRoot(rootDir(cli.root));
97
86
  if (cli.write) {
98
- const refused = writeDerived(root, name, json);
99
- if (refused !== undefined) {
100
- process.stderr.write(`dw-sdlc check: ${refused}\n`);
101
- return 2;
102
- }
87
+ writeDerived(root, name, json);
103
88
  }
104
89
  const report = await runGate(root, { json, only: name });
105
90
  return printReport(report, json, `check ${name}`);
@@ -1,37 +1,31 @@
1
1
  // `dw-sdlc config`, the config check: resolves a repository's agentic config, or validates one file
2
2
  // against the schema, and prints what the guards see.
3
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";
4
+ import { COMMITTED_FILE, findRepository, invalidLayerEffect, LOCAL_FILE, repositoryRoot, resolveConfig, validateDocument } from "../agentic-config.js";
5
+ import { messageOf, readArgs, rootDir, usageError } from "../cannot-run.js";
6
6
  export const summary = "resolve the repository's agentic config, or validate one file against the schema";
7
7
  const USAGE = `Usage: dw-sdlc config [--root <dir>] [file]
8
8
  or: pnpm agentic:check [--root <dir>] [file], in a checkout of dw-sdlc
9
9
 
10
10
  Without a file: resolves the agentic config of the repository holding --root (default: the current
11
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);
12
+ validates that one document against the schema. Exit 1 on any problem, 2 when --root names no
13
+ directory.`;
14
+ /** A problem the check reports beside the config's own: exit 1, as for an invalid layer. */
15
+ function failed(message) {
16
+ console.error(`dw-sdlc config: ${message}`);
17
+ return 1;
19
18
  }
20
19
  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);
20
+ const { values, positionals } = readArgs({
21
+ args: argv,
22
+ allowPositionals: true,
23
+ options: { help: { type: "boolean", short: "h" }, root: { type: "string" } }
24
+ }, USAGE);
25
+ if (positionals.length > 1) {
26
+ throw usageError("at most one file", USAGE);
34
27
  }
28
+ return { help: values.help === true, root: values.root, file: positionals[0] };
35
29
  }
36
30
  function report(problems) {
37
31
  for (const { file, path, message } of problems) {
@@ -50,7 +44,7 @@ export function run(argv) {
50
44
  text = readFileSync(cli.file, "utf8");
51
45
  }
52
46
  catch (error) {
53
- fail(messageOf(error), 1);
47
+ return failed(messageOf(error));
54
48
  }
55
49
  const result = validateDocument(cli.file, text);
56
50
  if ("problems" in result) {
@@ -60,9 +54,10 @@ export function run(argv) {
60
54
  console.log(`${cli.file}: valid`);
61
55
  return 0;
62
56
  }
63
- const root = cli.root === undefined
64
- ? (findRepository(process.cwd()) ?? fail("not inside a git repository; pass --root <dir>", 1))
65
- : repositoryRoot(cli.root);
57
+ const root = cli.root === undefined ? findRepository(process.cwd()) : repositoryRoot(rootDir(cli.root));
58
+ if (root === undefined) {
59
+ return failed("not inside a git repository; pass --root <dir>");
60
+ }
66
61
  const { config, problems, layers, sources } = resolveConfig(root);
67
62
  const from = (source) => source === undefined || source === root ? "" : `, from ${source}`;
68
63
  console.log(`committed ${COMMITTED_FILE} ${layers.committed}${from(sources.committed)}`);
@@ -70,7 +65,7 @@ export function run(argv) {
70
65
  console.log(JSON.stringify(config, null, 2));
71
66
  if (problems.length > 0) {
72
67
  report(problems);
73
- fail(`${problems.length} problem(s) above; the guards run on the strictest defaults until the file is fixed`, 1);
68
+ return failed(`${problems.length} problem(s) above; ${invalidLayerEffect(config)} until the file is fixed`);
74
69
  }
75
70
  return 0;
76
71
  }
@@ -20,6 +20,8 @@ export type RepositoryConfig = {
20
20
  config: {
21
21
  commit: {
22
22
  convention?: string;
23
+ pattern?: string;
24
+ trailer?: string;
23
25
  };
24
26
  };
25
27
  problems: string[];
@@ -4,10 +4,10 @@
4
4
  import { existsSync, readFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { join } from "node:path";
7
- import { parseArgs } from "node:util";
8
- import { findRepository, resolveConfig } from "../agentic-config.js";
7
+ import { findRepository, invalidLayerEffect, resolveConfig } from "../agentic-config.js";
8
+ import { readArgs, rootDir } from "../cannot-run.js";
9
9
  import { checkPackage, checkPlugin, checkSessions, parseJson, runningPluginRoot, sessionsEnabled, versionBefore } from "../doctor-plugins.js";
10
- import { exec, TimedOut } from "../exec.js";
10
+ import { exec, firstLine, TimedOut } from "../exec.js";
11
11
  import { isObject } from "../json-text.js";
12
12
  /** Claude Code's config dir: `$CLAUDE_CONFIG_DIR` when set, the way Claude Code reads it, else `~/.claude`. */
13
13
  export function claudeConfigDir(env, home) {
@@ -39,7 +39,7 @@ export const realProbe = {
39
39
  shell(script) {
40
40
  const shell = process.env.SHELL ?? "/bin/sh";
41
41
  const result = probed(shell, ["-l", "-c", script]);
42
- return (result?.stdout ?? "").trim().split("\n")[0] ?? "";
42
+ return firstLine(result?.stdout ?? "") ?? "";
43
43
  }
44
44
  };
45
45
  function checkJq(probe) {
@@ -213,7 +213,7 @@ function checkConfig(config, root) {
213
213
  return {
214
214
  name: "config",
215
215
  level: "FAIL",
216
- detail: `the guards run on the strictest defaults: ${config.problems.join("; ")}`
216
+ detail: `${invalidLayerEffect(config.config)}: ${config.problems.join("; ")}`
217
217
  };
218
218
  }
219
219
  // A worktree that lacks a layer reads the main checkout's; say so, since the file is not here.
@@ -250,7 +250,7 @@ const USAGE = `Usage: dw-sdlc doctor [--root <dir>] [--home <dir>]
250
250
  or: pnpm guardrails:doctor [--root <dir>] [--home <dir>], in a checkout of dw-sdlc
251
251
 
252
252
  One PASS / WARN / FAIL line per machine check the sdlc plugin relies on; read-only. Exit 1 when any
253
- check fails. Claude Code's settings are read from $CLAUDE_CONFIG_DIR, or ~/.claude without it; a
253
+ check fails, 2 when --root names no directory. Claude Code's settings are read from $CLAUDE_CONFIG_DIR, or ~/.claude without it; a
254
254
  --home reads <home>/.claude.`;
255
255
  function repositoryConfig(root) {
256
256
  const resolution = resolveConfig(root);
@@ -261,22 +261,15 @@ function repositoryConfig(root) {
261
261
  };
262
262
  }
263
263
  export function run(argv) {
264
- let values;
265
- try {
266
- values = parseArgs({
267
- args: argv,
268
- options: { help: { type: "boolean", short: "h" }, root: { type: "string" }, home: { type: "string" } }
269
- }).values;
270
- }
271
- catch (error) {
272
- console.error(`guardrails:doctor: ${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
273
- return 2;
274
- }
264
+ const { values } = readArgs({
265
+ args: argv,
266
+ options: { help: { type: "boolean", short: "h" }, root: { type: "string" }, home: { type: "string" } }
267
+ }, USAGE);
275
268
  if (values.help === true) {
276
269
  console.log(USAGE);
277
270
  return 0;
278
271
  }
279
- const dir = values.root ?? process.cwd();
272
+ const dir = rootDir(values.root);
280
273
  const root = findRepository(dir);
281
274
  const checks = diagnose({
282
275
  root: root ?? dir,