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.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +63 -2
  3. package/config/agentic.config.schema.json +375 -0
  4. package/config/references.schema.json +56 -0
  5. package/dist/agentic-config.d.ts +167 -0
  6. package/dist/agentic-config.js +305 -0
  7. package/dist/catalogue.d.ts +36 -0
  8. package/dist/catalogue.js +74 -0
  9. package/dist/checks/agents-map.d.ts +7 -0
  10. package/dist/checks/agents-map.js +159 -0
  11. package/dist/checks/check.d.ts +27 -0
  12. package/dist/checks/check.js +21 -0
  13. package/dist/checks/comment-budget.d.ts +6 -0
  14. package/dist/checks/comment-budget.js +141 -0
  15. package/dist/checks/decision-records.d.ts +6 -0
  16. package/dist/checks/decision-records.js +105 -0
  17. package/dist/checks/file-size-ratchet.d.ts +10 -0
  18. package/dist/checks/file-size-ratchet.js +194 -0
  19. package/dist/checks/index.d.ts +12 -0
  20. package/dist/checks/index.js +42 -0
  21. package/dist/checks/markdown-budget.d.ts +15 -0
  22. package/dist/checks/markdown-budget.js +42 -0
  23. package/dist/checks/markdown-refs.d.ts +6 -0
  24. package/dist/checks/markdown-refs.js +73 -0
  25. package/dist/checks/primitives.d.ts +8 -0
  26. package/dist/checks/primitives.js +103 -0
  27. package/dist/checks/temporal-language.d.ts +6 -0
  28. package/dist/checks/temporal-language.js +58 -0
  29. package/dist/checks/tree.d.ts +8 -0
  30. package/dist/checks/tree.js +30 -0
  31. package/dist/cli.d.ts +2 -0
  32. package/dist/cli.js +43 -0
  33. package/dist/commands/config.d.ts +2 -0
  34. package/dist/commands/config.js +76 -0
  35. package/dist/commands/doctor.d.ts +57 -0
  36. package/dist/commands/doctor.js +341 -0
  37. package/dist/commands/gate.d.ts +2 -0
  38. package/dist/commands/gate.js +231 -0
  39. package/dist/commands/primitives.d.ts +2 -0
  40. package/dist/commands/primitives.js +106 -0
  41. package/dist/commands/references-setup.d.ts +2 -0
  42. package/dist/commands/references-setup.js +202 -0
  43. package/dist/commands/references-sync.d.ts +2 -0
  44. package/dist/commands/references-sync.js +341 -0
  45. package/dist/commands/setup.d.ts +30 -0
  46. package/dist/commands/setup.js +352 -0
  47. package/dist/json-schema.d.ts +8 -0
  48. package/dist/json-schema.js +226 -0
  49. package/dist/markdown.d.ts +31 -0
  50. package/dist/markdown.js +115 -0
  51. package/dist/pins.d.ts +16 -0
  52. package/dist/pins.js +82 -0
  53. package/dist/references-key.d.ts +31 -0
  54. package/dist/references-key.js +149 -0
  55. package/dist/report.d.ts +13 -0
  56. package/dist/report.js +29 -0
  57. package/dist/router-block.d.ts +13 -0
  58. package/dist/router-block.js +60 -0
  59. package/dist/setup-rules.d.ts +22 -0
  60. package/dist/setup-rules.js +98 -0
  61. package/package.json +30 -3
  62. package/templates/router.md +38 -0
@@ -0,0 +1,341 @@
1
+ // `dw-sdlc doctor`: one PASS / WARN / FAIL line per machine check the guardrails rely on, for the
2
+ // repository holding --root and the user's home; it installs nothing. FAIL is a guard that does not guard
3
+ // (no jq, plugin absent or disabled, an invalid config), WARN a layer missing or loose, INFO one that does not apply.
4
+ import { spawnSync } from "node:child_process";
5
+ import { existsSync, readFileSync } from "node:fs";
6
+ import { homedir } from "node:os";
7
+ import { join } from "node:path";
8
+ import { parseArgs } from "node:util";
9
+ import { findRepository, resolveConfig } from "../agentic-config.js";
10
+ /** Claude Code's config dir: `$CLAUDE_CONFIG_DIR` when set, the way Claude Code reads it, else `~/.claude`. */
11
+ export function claudeConfigDir(env, home) {
12
+ return env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
13
+ }
14
+ /** The marketplace Claude Code registers this repository under, and the plugin id inside it. */
15
+ export const MARKETPLACE = "dw-sdlc";
16
+ export const PLUGIN_ID = `guardrails@${MARKETPLACE}`;
17
+ /** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
18
+ export const RTK_FIND_EXEC_FIXED = "0.46.0";
19
+ export const PNPM_MINIMUM_RELEASE_AGE = 10080;
20
+ export const realProbe = {
21
+ run(command, args, cwd) {
22
+ const result = spawnSync(command, args, { cwd, encoding: "utf8" });
23
+ return { status: result.status ?? 127, stdout: result.stdout ?? "" };
24
+ },
25
+ read(path) {
26
+ return existsSync(path) ? readFileSync(path, "utf8") : undefined;
27
+ },
28
+ shell(script) {
29
+ const shell = process.env.SHELL ?? "/bin/sh";
30
+ const result = spawnSync(shell, ["-l", "-c", script], { encoding: "utf8" });
31
+ return (result.stdout ?? "").trim().split("\n")[0] ?? "";
32
+ }
33
+ };
34
+ function isObject(value) {
35
+ return typeof value === "object" && value !== null && !Array.isArray(value);
36
+ }
37
+ function parseJson(text) {
38
+ if (text === undefined) {
39
+ return undefined;
40
+ }
41
+ try {
42
+ return JSON.parse(text);
43
+ }
44
+ catch {
45
+ return undefined;
46
+ }
47
+ }
48
+ function checkJq(probe) {
49
+ const { status, stdout } = probe.run("jq", ["--version"]);
50
+ if (status === 0) {
51
+ return { name: "jq", level: "PASS", detail: stdout.trim() };
52
+ }
53
+ return { name: "jq", level: "FAIL", detail: "missing: every guard is silent without it. brew install jq" };
54
+ }
55
+ /** Where the plugin's hooks run: live from the checkout for a marketplace added from a directory, a
56
+ * cached snapshot for one added from git. A marker placed in only one of the two settled it. */
57
+ /** The checkout a directory marketplace runs the plugin from, or undefined for a snapshot. */
58
+ function liveCheckout(probe, claude) {
59
+ const known = parseJson(probe.read(join(claude, "plugins", "known_marketplaces.json")));
60
+ const entry = isObject(known) && isObject(known[MARKETPLACE]) ? known[MARKETPLACE] : undefined;
61
+ const source = entry !== undefined && isObject(entry.source) ? entry.source : undefined;
62
+ return source?.source === "directory" && typeof source.path === "string" ? source.path : undefined;
63
+ }
64
+ function pluginOrigin(probe, claude, installPath) {
65
+ const checkout = liveCheckout(probe, claude);
66
+ if (checkout !== undefined) {
67
+ return `runs live from ${checkout}/plugins/guardrails; a change there is in the next session`;
68
+ }
69
+ return `installed at ${installPath}; a change reaches it through claude plugin update`;
70
+ }
71
+ /** The directory whose hooks run on this machine: the checkout of a directory marketplace, else the installed snapshot. */
72
+ function runningPluginRoot(probe, claude) {
73
+ const checkout = liveCheckout(probe, claude);
74
+ if (checkout !== undefined) {
75
+ return join(checkout, "plugins", "guardrails");
76
+ }
77
+ const { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
78
+ const list = status === 0 ? parseJson(stdout) : undefined;
79
+ if (!Array.isArray(list)) {
80
+ return undefined;
81
+ }
82
+ const entry = list.find((item) => isObject(item) && item.id === PLUGIN_ID);
83
+ return entry !== undefined && typeof entry.installPath === "string" ? entry.installPath : undefined;
84
+ }
85
+ function checkPlugin(probe, claude) {
86
+ const { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
87
+ const list = status === 0 ? parseJson(stdout) : undefined;
88
+ if (!Array.isArray(list)) {
89
+ return { name: "plugin", level: "FAIL", detail: "claude plugin list did not answer; is Claude Code installed?" };
90
+ }
91
+ const entry = list.find((item) => isObject(item) && item.id === PLUGIN_ID);
92
+ if (entry === undefined) {
93
+ return {
94
+ name: "plugin",
95
+ level: "FAIL",
96
+ detail: `${PLUGIN_ID} is not installed. claude plugin marketplace add dominikwozniak/dw-sdlc --sparse .claude-plugin plugins packages && claude plugin install ${PLUGIN_ID} --scope user`
97
+ };
98
+ }
99
+ const version = typeof entry.version === "string" ? entry.version : "?";
100
+ if (entry.enabled !== true) {
101
+ return {
102
+ name: "plugin",
103
+ level: "FAIL",
104
+ detail: `${version} installed but disabled, so no guard runs. claude plugin enable ${PLUGIN_ID}`
105
+ };
106
+ }
107
+ return {
108
+ name: "plugin",
109
+ level: "PASS",
110
+ detail: `${version} enabled; ${pluginOrigin(probe, claude, String(entry.installPath ?? ""))}`
111
+ };
112
+ }
113
+ /** Every hook command string in a settings file, whatever the event and matcher. */
114
+ function hookCommands(settings) {
115
+ if (!isObject(settings) || !isObject(settings.hooks)) {
116
+ return [];
117
+ }
118
+ const commands = [];
119
+ for (const groups of Object.values(settings.hooks)) {
120
+ if (!Array.isArray(groups)) {
121
+ continue;
122
+ }
123
+ for (const group of groups) {
124
+ if (!isObject(group) || !Array.isArray(group.hooks)) {
125
+ continue;
126
+ }
127
+ for (const hook of group.hooks) {
128
+ if (isObject(hook) && typeof hook.command === "string") {
129
+ commands.push(hook.command);
130
+ }
131
+ }
132
+ }
133
+ }
134
+ return commands;
135
+ }
136
+ /** rtk's config.toml: Application Support on macOS, XDG or ~/.config elsewhere. */
137
+ export function rtkConfigPath(home, platform = process.platform, env = process.env) {
138
+ if (platform === "darwin") {
139
+ return join(home, "Library", "Application Support", "rtk", "config.toml");
140
+ }
141
+ return join(env.XDG_CONFIG_HOME ?? join(home, ".config"), "rtk", "config.toml");
142
+ }
143
+ /** The `exclude_commands` list of an rtk config.toml, read with a regex: the file is small and ours. */
144
+ export function rtkExcludes(toml) {
145
+ const match = /^\s*exclude_commands\s*=\s*\[([^\]]*)\]/m.exec(toml ?? "");
146
+ if (match === null) {
147
+ return [];
148
+ }
149
+ return [...(match[1] ?? "").matchAll(/"([^"]*)"/g)].map((entry) => entry[1] ?? "");
150
+ }
151
+ function versionBefore(version, threshold) {
152
+ const a = version.split(".").map((part) => Number.parseInt(part, 10));
153
+ const b = threshold.split(".").map((part) => Number.parseInt(part, 10));
154
+ for (let i = 0; i < Math.max(a.length, b.length); i += 1) {
155
+ const x = a[i] ?? 0;
156
+ const y = b[i] ?? 0;
157
+ if (x !== y) {
158
+ return x < y;
159
+ }
160
+ }
161
+ return false;
162
+ }
163
+ // The plugin's rtk-rewrite.sh is the one wiring of `rtk hook claude`; a second one in the user
164
+ // settings rewrites again, and in a linked worktree that copy is what the isolation check refuses.
165
+ function checkRtk(probe, home, claude) {
166
+ const rtk = probe.shell("command -v rtk");
167
+ if (rtk === "") {
168
+ return { name: "rtk", level: "PASS", detail: "no rtk on this machine, the plugin's rewrite hook stays silent" };
169
+ }
170
+ const settingsPath = join(claude, "settings.json");
171
+ const settings = parseJson(probe.read(settingsPath));
172
+ const byHand = hookCommands(settings).find((command) => /\brtk\b/.test(command));
173
+ const root = runningPluginRoot(probe, claude);
174
+ if (root === undefined || probe.read(join(root, "hooks", "rtk-rewrite.sh")) === undefined) {
175
+ const meanwhile = byHand === undefined ? "nothing rewrites" : `the hand-wired ${byHand} carries it until then`;
176
+ return {
177
+ name: "rtk",
178
+ level: "WARN",
179
+ detail: `rtk at ${rtk} but the plugin running here has no hooks/rtk-rewrite.sh: claude plugin update; ${meanwhile}`
180
+ };
181
+ }
182
+ if (byHand !== undefined) {
183
+ return {
184
+ name: "rtk",
185
+ level: "FAIL",
186
+ detail: `${settingsPath} wires rtk by hand (${byHand}), and the plugin's rtk-rewrite.sh already runs it: remove that hook, or every git command in a linked worktree is refused by the isolation check`
187
+ };
188
+ }
189
+ // The filters that misbehave on the installed release have to sit in exclude_commands.
190
+ const version = probe.run("rtk", ["--version"]).stdout.trim().split(/\s+/)[1] ?? "0";
191
+ const excludes = rtkExcludes(probe.read(rtkConfigPath(home)));
192
+ const missing = [];
193
+ if (versionBefore(version, RTK_FIND_EXEC_FIXED) && !excludes.includes("find")) {
194
+ missing.push(`find (rtk ${version} refuses find -exec; fixed in ${RTK_FIND_EXEC_FIXED})`);
195
+ }
196
+ if (missing.length > 0) {
197
+ return {
198
+ name: "rtk",
199
+ level: "WARN",
200
+ detail: `rtk ${version} behind the plugin's rewrite hook, but [hooks] exclude_commands lacks ${missing.join("; ")}`
201
+ };
202
+ }
203
+ return {
204
+ name: "rtk",
205
+ level: "PASS",
206
+ detail: `rtk ${version} behind the plugin's rewrite hook; exclude_commands: ${excludes.join(", ") || "none"}`
207
+ };
208
+ }
209
+ function checkSafeChain(probe) {
210
+ const binary = probe.shell("command -v safe-chain");
211
+ if (binary === "") {
212
+ return {
213
+ name: "safe chain",
214
+ level: "WARN",
215
+ detail: "absent: the agent shell reaches the registry without Aikido's blocklist. github.com/AikidoSec/safe-chain"
216
+ };
217
+ }
218
+ const npm = probe.shell("command -v npm");
219
+ // The --ci shims put a wrapper on PATH; the laptop install defines a shell function, which
220
+ // `command -v` reports as the bare name.
221
+ const through = npm === "npm" || npm.includes(".safe-chain");
222
+ if (!through) {
223
+ return {
224
+ name: "safe chain",
225
+ level: "WARN",
226
+ detail: `installed (${binary}) but npm resolves to ${npm}, not through it; the shell function or the --ci shims are not on this shell's PATH`
227
+ };
228
+ }
229
+ return { name: "safe chain", level: "PASS", detail: `${binary}, npm goes through it` };
230
+ }
231
+ /**
232
+ * pnpm's global `minimumReleaseAge`, asked from the home directory: from a checkout, its own
233
+ * pnpm-workspace.yaml would answer for the machine.
234
+ */
235
+ function checkPnpmReleaseAge(probe, home) {
236
+ if (probe.shell("command -v pnpm") === "") {
237
+ return { name: "pnpm release age", level: "INFO", detail: "no pnpm in the shell" };
238
+ }
239
+ const { stdout } = probe.run("pnpm", ["config", "get", "minimumReleaseAge"], home);
240
+ const minutes = Number.parseInt(stdout.trim(), 10);
241
+ if (Number.isNaN(minutes) || minutes < PNPM_MINIMUM_RELEASE_AGE) {
242
+ const state = Number.isNaN(minutes) ? "unset" : `${minutes} minutes`;
243
+ return {
244
+ name: "pnpm release age",
245
+ level: "WARN",
246
+ detail: `${state} globally: below the ${PNPM_MINIMUM_RELEASE_AGE}-minute baseline. pnpm config set --global minimumReleaseAge ${PNPM_MINIMUM_RELEASE_AGE}`
247
+ };
248
+ }
249
+ return { name: "pnpm release age", level: "PASS", detail: `${minutes} minutes globally` };
250
+ }
251
+ function checkCommitSigning(probe) {
252
+ const { status, stdout } = probe.run("git", ["config", "--global", "commit.gpgsign"]);
253
+ if (status === 0 && stdout.trim() === "true") {
254
+ return { name: "commit signing", level: "PASS", detail: "commit.gpgsign true" };
255
+ }
256
+ return {
257
+ name: "commit signing",
258
+ level: "WARN",
259
+ detail: "commit.gpgsign is off, so commits go out unsigned. git config --global commit.gpgsign true"
260
+ };
261
+ }
262
+ function checkConfig(config, root) {
263
+ if (config === undefined) {
264
+ return { name: "config", level: "INFO", detail: `no repository found at ${root}; run from one or pass --root` };
265
+ }
266
+ if (config.problems.length > 0) {
267
+ return {
268
+ name: "config",
269
+ level: "FAIL",
270
+ detail: `the guards run on the strictest defaults: ${config.problems.join("; ")}`
271
+ };
272
+ }
273
+ // A worktree that lacks a layer reads the main checkout's; say so, since the file is not here.
274
+ const borrowed = Object.entries(config.sources ?? {})
275
+ .filter((entry) => entry[1] !== undefined && entry[1] !== root)
276
+ .map(([layer, source]) => `; ${layer} layer from ${source}`);
277
+ return {
278
+ name: "config",
279
+ level: "PASS",
280
+ detail: `commit ${config.config.commit.convention}${borrowed.join("")}`
281
+ };
282
+ }
283
+ export function diagnose(inputs) {
284
+ const { probe, home } = inputs;
285
+ const claude = inputs.claudeDir ?? join(home, ".claude");
286
+ return [
287
+ checkJq(probe),
288
+ checkPlugin(probe, claude),
289
+ checkRtk(probe, home, claude),
290
+ checkSafeChain(probe),
291
+ checkPnpmReleaseAge(probe, home),
292
+ checkCommitSigning(probe),
293
+ checkConfig(inputs.config, inputs.root)
294
+ ];
295
+ }
296
+ export function render(checks) {
297
+ return checks.map((check) => `${check.level.padEnd(5)} ${check.name.padEnd(17)} ${check.detail}`).join("\n");
298
+ }
299
+ export const summary = "one PASS / WARN / FAIL line per machine check the guardrails rely on";
300
+ const USAGE = `Usage: dw-sdlc doctor [--root <dir>] [--home <dir>]
301
+ or: pnpm guardrails:doctor [--root <dir>] [--home <dir>], in a checkout of dw-sdlc
302
+
303
+ One PASS / WARN / FAIL line per machine check the guardrails rely on; read-only. Exit 1 when any
304
+ check fails. Claude Code's settings are read from $CLAUDE_CONFIG_DIR, or ~/.claude without it; a
305
+ --home reads <home>/.claude.`;
306
+ function repositoryConfig(root) {
307
+ const resolution = resolveConfig(root);
308
+ return {
309
+ config: resolution.config,
310
+ problems: resolution.problems.map(({ file, path, message }) => path === "" ? `${file}: ${message}` : `${file}: ${path}: ${message}`),
311
+ sources: resolution.sources
312
+ };
313
+ }
314
+ export function run(argv) {
315
+ let values;
316
+ try {
317
+ values = parseArgs({
318
+ args: argv,
319
+ options: { help: { type: "boolean", short: "h" }, root: { type: "string" }, home: { type: "string" } }
320
+ }).values;
321
+ }
322
+ catch (error) {
323
+ console.error(`guardrails:doctor: ${error instanceof Error ? error.message : String(error)}\n\n${USAGE}`);
324
+ return 2;
325
+ }
326
+ if (values.help === true) {
327
+ console.log(USAGE);
328
+ return 0;
329
+ }
330
+ const dir = values.root ?? process.cwd();
331
+ const root = findRepository(dir);
332
+ const checks = diagnose({
333
+ root: root ?? dir,
334
+ home: values.home ?? homedir(),
335
+ claudeDir: values.home === undefined ? claudeConfigDir(process.env, homedir()) : join(values.home, ".claude"),
336
+ probe: realProbe,
337
+ config: root === undefined ? undefined : repositoryConfig(root)
338
+ });
339
+ console.log(render(checks));
340
+ return checks.some((check) => check.level === "FAIL") ? 1 : 0;
341
+ }
@@ -0,0 +1,2 @@
1
+ export declare const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
2
+ export declare function run(argv: string[]): number;
@@ -0,0 +1,231 @@
1
+ // `dw-sdlc gate`: the Gate of a repository, from the `gate` key of its resolved agentic config.
2
+ import { spawnSync } from "node:child_process";
3
+ import { parseArgs } from "node:util";
4
+ import { repositoryRoot, resolveConfig } from "../agentic-config.js";
5
+ import { CannotRun } from "../checks/check.js";
6
+ import { enabledChecks } from "../checks/index.js";
7
+ import { annotation, failureJson, failureLine } from "../report.js";
8
+ export const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
9
+ const USAGE = `Usage: dw-sdlc gate [--json] [--root <dir>] [--update-baseline]
10
+
11
+ Runs the Gate of the repository holding --root (default: the current directory): each Step of
12
+ gate.steps in order from the repository root, its output passed through, stopping at the first that
13
+ fails; then every Check under gate.checks, the run scripts and then the Checks the package ships,
14
+ none stopping the next. Prints one line per failure: the file where there is one, the rule, the
15
+ remedy; under GitHub Actions each line is also a workflow annotation. --json prints one document
16
+ instead and sends the commands' own output to stderr.
17
+
18
+ --update-baseline then rewrites the baseline of each Ratchet, only when every Step and every other
19
+ Check passed, and only tighter: a file now within its limit leaves it and no file joins. Where there
20
+ is no baseline, it records one. The Ratchet is judged again on what it wrote.
21
+
22
+ Exit 0 when everything passes, 1 when a Step or a Check fails, 2 when the Gate cannot run: an
23
+ invalid config layer, a Step or Check whose command the shell cannot find or execute, or a Check
24
+ missing an input it reads.`;
25
+ const EXIT_CODES = { pass: 0, fail: 1, error: 2 };
26
+ /** What the shell answers for a command it cannot find, and for one it cannot execute. */
27
+ const NOT_RUNNABLE = { 127: "was not found", 126: "could not be executed" };
28
+ const SKIPPED = { status: "skipped", exitCode: null };
29
+ const REMEDIES = {
30
+ invalidLayer: "fix the file; the Gate runs once every layer is valid",
31
+ failed: "fix what it printed above",
32
+ stepNotRunnable: "install the command or fix gate.steps in the agentic config",
33
+ checkNotRunnable: "install the command or fix gate.checks.run in the agentic config",
34
+ checkStopped: "fix what stopped it, or report it to dw-sdlc"
35
+ };
36
+ function plural(count, noun) {
37
+ return `${count} ${noun}${count === 1 ? "" : "s"}`;
38
+ }
39
+ function parseCli(argv) {
40
+ try {
41
+ const { values } = parseArgs({
42
+ args: argv,
43
+ options: {
44
+ help: { type: "boolean", short: "h" },
45
+ json: { type: "boolean" },
46
+ root: { type: "string" },
47
+ "update-baseline": { type: "boolean" }
48
+ }
49
+ });
50
+ return {
51
+ help: values.help === true,
52
+ json: values.json === true,
53
+ root: values.root,
54
+ updateBaseline: values["update-baseline"] === true
55
+ };
56
+ }
57
+ catch (error) {
58
+ return { error: error instanceof Error ? error.message : String(error) };
59
+ }
60
+ }
61
+ function configFailure({ file, path, message }) {
62
+ const where = path === "" ? message : `${path}: ${message}`;
63
+ return { file, rule: "config", message: where, remedy: REMEDIES.invalidLayer };
64
+ }
65
+ /**
66
+ * Runs `command` through `sh -c` from `root`, stdin closed and stderr passed through; stdout too, or
67
+ * onto stderr when `--json` keeps stdout for the document. `detail` says what went wrong, when it did.
68
+ */
69
+ function runCommand(command, root, json) {
70
+ const result = spawnSync(command, { shell: true, cwd: root, stdio: ["ignore", json ? 2 : "inherit", "inherit"] });
71
+ if (result.error !== undefined) {
72
+ return { status: "error", exitCode: null, detail: `could not start: ${result.error.message}` };
73
+ }
74
+ if (result.status === 0) {
75
+ return { status: "pass", exitCode: 0 };
76
+ }
77
+ if (result.status === null) {
78
+ return { status: "fail", exitCode: null, detail: `was stopped by ${result.signal ?? "a signal"}` };
79
+ }
80
+ const notRunnable = NOT_RUNNABLE[result.status];
81
+ if (notRunnable !== undefined) {
82
+ return { status: "error", exitCode: result.status, detail: `${notRunnable} (exit ${result.status})` };
83
+ }
84
+ return { status: "fail", exitCode: result.status, detail: `exited ${result.status}` };
85
+ }
86
+ /** What a Check that threw reports: the input it lacks, or what stopped it. */
87
+ function erred(check, error) {
88
+ const finding = error instanceof CannotRun
89
+ ? error.finding
90
+ : { message: `stopped: ${error instanceof Error ? error.message : String(error)}`, remedy: REMEDIES.checkStopped };
91
+ return {
92
+ check,
93
+ outcome: { status: "error", exitCode: EXIT_CODES.error },
94
+ failures: [{ rule: check.name, ...finding }]
95
+ };
96
+ }
97
+ /** Runs one Check the package ships, its failures named by the Check; one it cannot judge is an error. */
98
+ function runShipped(check, root) {
99
+ try {
100
+ const failures = check.run(root).map((finding) => ({ rule: check.name, ...finding }));
101
+ const status = failures.length === 0 ? "pass" : "fail";
102
+ return { check, outcome: { status, exitCode: EXIT_CODES[status] }, failures };
103
+ }
104
+ catch (error) {
105
+ return erred(check, error);
106
+ }
107
+ }
108
+ /**
109
+ * Rewrites the baseline of each Ratchet while every Step, every run Check in `others` and every other
110
+ * shipped Check passed, and judges the Ratchet again on what it wrote.
111
+ */
112
+ function refreshBaselines(judged, others, root, log) {
113
+ if (!judged.some(({ check }) => check.baseline !== undefined)) {
114
+ log.write("gate: no Ratchet is turned on, so no baseline to refresh\n");
115
+ return;
116
+ }
117
+ for (const [index, { check }] of judged.entries()) {
118
+ if (check.baseline === undefined) {
119
+ continue;
120
+ }
121
+ const { file, refresh } = check.baseline;
122
+ const rest = [...others, ...judged.filter((_, at) => at !== index).map(({ outcome }) => outcome)];
123
+ if (rest.some(({ status }) => status !== "pass")) {
124
+ log.write(`gate: baseline ${file} not refreshed — a Step or another Check did not pass\n`);
125
+ continue;
126
+ }
127
+ try {
128
+ log.write(`gate: baseline ${file} ${refresh(root)}\n`);
129
+ judged[index] = runShipped(check, root);
130
+ }
131
+ catch (error) {
132
+ if (error instanceof CannotRun) {
133
+ log.write(`gate: baseline ${file} not refreshed — ${error.finding.message}; ${error.finding.remedy}\n`);
134
+ }
135
+ else {
136
+ judged[index] = erred(check, error);
137
+ }
138
+ }
139
+ }
140
+ }
141
+ function worst(outcomes) {
142
+ const statuses = new Set(outcomes.map(({ status }) => status));
143
+ return statuses.has("error") ? "error" : statuses.has("fail") ? "fail" : "pass";
144
+ }
145
+ function runGate(root, json, updateBaseline) {
146
+ const log = json ? process.stderr : process.stdout;
147
+ const { config, problems } = resolveConfig(root);
148
+ const gateCommand = config.gate.command ?? null;
149
+ if (problems.length > 0) {
150
+ return { status: "error", command: gateCommand, steps: [], checks: [], failures: problems.map(configFailure) };
151
+ }
152
+ const failures = [];
153
+ /** Runs one Step or Check and records its failure under `rule`, if it has one. */
154
+ const runOne = (command, rule, notRunnable) => {
155
+ const { detail, ...outcome } = runCommand(command, root, json);
156
+ if (detail !== undefined) {
157
+ const remedy = outcome.status === "error" ? notRunnable : REMEDIES.failed;
158
+ failures.push({ rule, message: `${JSON.stringify(command)} ${detail}`, remedy });
159
+ }
160
+ return outcome;
161
+ };
162
+ const steps = [];
163
+ let stopped = false;
164
+ for (const step of config.gate.steps) {
165
+ if (stopped) {
166
+ steps.push({ command: step, ...SKIPPED });
167
+ continue;
168
+ }
169
+ log.write(`gate: step ${step}\n`);
170
+ const outcome = runOne(step, "step", REMEDIES.stepNotRunnable);
171
+ steps.push({ command: step, ...outcome });
172
+ stopped = outcome.status !== "pass";
173
+ }
174
+ const checks = [];
175
+ for (const check of config.gate.checks.run) {
176
+ if (stopped) {
177
+ checks.push({ ...check, ...SKIPPED });
178
+ continue;
179
+ }
180
+ log.write(`gate: check ${check.name}\n`);
181
+ checks.push({ ...check, ...runOne(check.command, check.name, REMEDIES.checkNotRunnable) });
182
+ }
183
+ const judged = enabledChecks(config.gate.checks).map((check) => {
184
+ if (stopped) {
185
+ return { check, outcome: SKIPPED, failures: [] };
186
+ }
187
+ log.write(`gate: check ${check.name}\n`);
188
+ return runShipped(check, root);
189
+ });
190
+ if (updateBaseline) {
191
+ refreshBaselines(judged, [...steps, ...checks], root, log);
192
+ }
193
+ for (const { check, outcome, failures: found } of judged) {
194
+ failures.push(...found);
195
+ checks.push({ name: check.name, command: null, ...outcome });
196
+ }
197
+ return { status: worst([...steps, ...checks]), command: gateCommand, steps, checks, failures };
198
+ }
199
+ function verdict(report) {
200
+ if (report.status === "pass") {
201
+ return `gate: green — ${plural(report.steps.length, "step")}, ${plural(report.checks.length, "check")}`;
202
+ }
203
+ const failures = plural(report.failures.length, "failure");
204
+ return report.status === "fail" ? `gate: failed — ${failures}` : `gate: cannot run — ${failures}`;
205
+ }
206
+ export function run(argv) {
207
+ const cli = parseCli(argv);
208
+ if ("error" in cli) {
209
+ process.stderr.write(`dw-sdlc gate: ${cli.error}\n\n${USAGE}\n`);
210
+ return 2;
211
+ }
212
+ if (cli.help) {
213
+ process.stdout.write(`${USAGE}\n`);
214
+ return 0;
215
+ }
216
+ const report = runGate(repositoryRoot(cli.root ?? process.cwd()), cli.json, cli.updateBaseline);
217
+ const actions = process.env.GITHUB_ACTIONS === "true";
218
+ if (cli.json) {
219
+ const document = { ...report, failures: report.failures.map(failureJson) };
220
+ process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
221
+ // The runner reads workflow commands from stderr as from stdout, so stdout stays one document.
222
+ for (const failure of actions ? report.failures : []) {
223
+ process.stderr.write(`${annotation(failure)}\n`);
224
+ }
225
+ }
226
+ else {
227
+ const lines = report.failures.flatMap((failure) => actions ? [failureLine(failure), annotation(failure)] : [failureLine(failure)]);
228
+ process.stdout.write(`${[...lines, verdict(report)].join("\n")}\n`);
229
+ }
230
+ return EXIT_CODES[report.status];
231
+ }
@@ -0,0 +1,2 @@
1
+ export declare const summary = "name the Primitives that own each path, with their summaries and documents";
2
+ export declare function run(argv: string[]): number;
@@ -0,0 +1,106 @@
1
+ // `dw-sdlc primitives --paths <files>`: for each path, the Primitives of the catalogue that name it,
2
+ // with their summaries and the documents to read, or that none does.
3
+ import { existsSync, realpathSync } from "node:fs";
4
+ import { basename, dirname, join, relative, resolve } from "node:path";
5
+ import { parseArgs } from "node:util";
6
+ import { repositoryRoot, resolveConfig } from "../agentic-config.js";
7
+ import { primitivesNaming, readCatalogue } from "../catalogue.js";
8
+ import { catalogueFile } from "../checks/primitives.js";
9
+ export const summary = "name the Primitives that own each path, with their summaries and documents";
10
+ const USAGE = `Usage: dw-sdlc primitives --paths <file>... [--json] [--root <dir>]
11
+
12
+ Prints, for each path, the Primitives of the catalogue whose code names it, the file itself or the
13
+ nearest directory above it: the id, the name, the summary and the documents to read; or that no
14
+ Primitive names it. A path is read from the current directory. The catalogue is the one the
15
+ primitives Check reads, in the repository holding --root (default: the current directory). --json
16
+ prints one document instead.
17
+
18
+ Exit 0 with an answer for every path, 2 when there is no catalogue to read or it is not one.`;
19
+ const REMEDIES = {
20
+ missing: "create it, or point gate.checks.primitives.file at the catalogue",
21
+ invalid: "fix the catalogue; dw-sdlc gate names each problem under the primitives Check"
22
+ };
23
+ function parseCli(argv) {
24
+ try {
25
+ const { values, positionals } = parseArgs({
26
+ args: argv,
27
+ allowPositionals: true,
28
+ options: {
29
+ help: { type: "boolean", short: "h" },
30
+ json: { type: "boolean" },
31
+ paths: { type: "boolean" },
32
+ root: { type: "string" }
33
+ }
34
+ });
35
+ const cli = {
36
+ help: values.help === true,
37
+ json: values.json === true,
38
+ paths: values.paths === true,
39
+ files: positionals,
40
+ root: values.root
41
+ };
42
+ return cli.help || cli.paths ? cli : { error: "name the paths with --paths" };
43
+ }
44
+ catch (error) {
45
+ return { error: error instanceof Error ? error.message : String(error) };
46
+ }
47
+ }
48
+ /**
49
+ * `path` with every symbolic link in it resolved, as git reports the root: the nearest part on disk
50
+ * resolved and the rest kept, so a deleted file still maps into the repository.
51
+ */
52
+ function realPath(path) {
53
+ if (existsSync(path)) {
54
+ return realpathSync(path);
55
+ }
56
+ const parent = dirname(path);
57
+ return parent === path ? path : join(realPath(parent), basename(path));
58
+ }
59
+ function brief({ id, name, summary: what, docs }) {
60
+ return { id, name, summary: what, docs };
61
+ }
62
+ function plain(path, primitives) {
63
+ if (primitives.length === 0) {
64
+ return [`${path}: no Primitive names this path`];
65
+ }
66
+ return primitives.flatMap(({ id, name, summary: what, docs }) => [
67
+ `${path}: ${id} (${name}) — ${what}`,
68
+ ` read: ${docs.join(", ")}`
69
+ ]);
70
+ }
71
+ export function run(argv) {
72
+ const cli = parseCli(argv);
73
+ if ("error" in cli) {
74
+ process.stderr.write(`dw-sdlc primitives: ${cli.error}\n\n${USAGE}\n`);
75
+ return 2;
76
+ }
77
+ if (cli.help) {
78
+ process.stdout.write(`${USAGE}\n`);
79
+ return 0;
80
+ }
81
+ const root = repositoryRoot(cli.root ?? process.cwd());
82
+ const file = catalogueFile(resolveConfig(root).config.gate.checks);
83
+ const read = readCatalogue(root, file);
84
+ if (read.state !== "valid") {
85
+ const problems = read.state === "missing"
86
+ ? [`the catalogue does not exist — ${REMEDIES.missing}`]
87
+ : read.problems.map((problem) => `${problem} — ${REMEDIES.invalid}`);
88
+ process.stderr.write(problems.map((problem) => `dw-sdlc primitives: ${file}: ${problem}\n`).join(""));
89
+ return 2;
90
+ }
91
+ const answers = cli.files.map((path) => {
92
+ const fromRoot = relative(root, realPath(resolve(path)));
93
+ return { path, primitives: primitivesNaming(read.catalogue, fromRoot) };
94
+ });
95
+ if (cli.json) {
96
+ const paths = answers.map(({ path, primitives }) => ({ path, primitives: primitives.map(brief) }));
97
+ process.stdout.write(`${JSON.stringify({ paths }, null, 2)}\n`);
98
+ }
99
+ else {
100
+ process.stdout.write(answers
101
+ .flatMap(({ path, primitives }) => plain(path, primitives))
102
+ .map((line) => `${line}\n`)
103
+ .join(""));
104
+ }
105
+ return 0;
106
+ }