dw-sdlc 0.1.0 → 0.3.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 (37) hide show
  1. package/config/agentic.config.schema.json +163 -0
  2. package/dist/agentic-config.js +3 -5
  3. package/dist/checks/agents-map.js +3 -10
  4. package/dist/checks/check.d.ts +10 -0
  5. package/dist/checks/comment-budget.d.ts +6 -0
  6. package/dist/checks/comment-budget.js +141 -0
  7. package/dist/checks/decision-records.d.ts +6 -0
  8. package/dist/checks/decision-records.js +105 -0
  9. package/dist/checks/file-size-ratchet.d.ts +10 -0
  10. package/dist/checks/file-size-ratchet.js +194 -0
  11. package/dist/checks/index.d.ts +5 -1
  12. package/dist/checks/index.js +25 -2
  13. package/dist/checks/markdown-budget.d.ts +15 -0
  14. package/dist/checks/markdown-budget.js +42 -0
  15. package/dist/checks/markdown-refs.d.ts +6 -0
  16. package/dist/checks/markdown-refs.js +73 -0
  17. package/dist/checks/primitives.js +5 -30
  18. package/dist/checks/temporal-language.d.ts +6 -0
  19. package/dist/checks/temporal-language.js +58 -0
  20. package/dist/checks/tree.d.ts +8 -0
  21. package/dist/checks/tree.js +30 -0
  22. package/dist/commands/doctor.d.ts +5 -3
  23. package/dist/commands/doctor.js +68 -13
  24. package/dist/commands/gate.js +73 -17
  25. package/dist/commands/references-setup.js +5 -16
  26. package/dist/commands/references-sync.js +3 -9
  27. package/dist/commands/setup.d.ts +3 -4
  28. package/dist/commands/setup.js +23 -40
  29. package/dist/json-schema.js +3 -4
  30. package/dist/markdown.d.ts +12 -0
  31. package/dist/markdown.js +40 -2
  32. package/dist/pins.d.ts +2 -0
  33. package/dist/pins.js +4 -4
  34. package/dist/references-key.js +1 -2
  35. package/dist/router-block.js +1 -3
  36. package/package.json +1 -1
  37. package/templates/router.md +1 -1
@@ -1,23 +1,22 @@
1
- // `dw-sdlc doctor`: one PASS / WARN / FAIL line per machine check the guardrails rely on, read-only.
2
- // It diagnoses the repository holding --root and the user's home; it installs nothing. FAIL is a
3
- // guard that does not guard (no jq, plugin absent or disabled, an invalid config), WARN a layer that
4
- // is missing or loose (Safe Chain, the pnpm cooldown, signing, rtk excludes), INFO a check that does
5
- // not apply here. Every probe goes through one interface, so the tests fake each answer. The
6
- // machine-wide values are asked from the home directory: from a checkout, its own
7
- // pnpm-workspace.yaml would answer for the machine.
1
+ // `dw-sdlc doctor`: one PASS / WARN / FAIL line per machine check the sdlc plugin relies 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, loose or out of step, INFO one that does not apply.
8
4
  import { spawnSync } from "node:child_process";
9
5
  import { existsSync, readFileSync } from "node:fs";
10
6
  import { homedir } from "node:os";
11
7
  import { join } from "node:path";
12
8
  import { parseArgs } from "node:util";
13
9
  import { findRepository, resolveConfig } from "../agentic-config.js";
10
+ import { EXACT_VERSION, parseCatalog, parsePins } from "../pins.js";
14
11
  /** Claude Code's config dir: `$CLAUDE_CONFIG_DIR` when set, the way Claude Code reads it, else `~/.claude`. */
15
12
  export function claudeConfigDir(env, home) {
16
13
  return env.CLAUDE_CONFIG_DIR ?? join(home, ".claude");
17
14
  }
18
- /** The marketplace Claude Code registers this repository under, and the plugin id inside it. */
15
+ /** The marketplace Claude Code registers this repository under, the plugin in it, and the package the plugin links to. */
19
16
  export const MARKETPLACE = "dw-sdlc";
20
- export const PLUGIN_ID = `guardrails@${MARKETPLACE}`;
17
+ export const PLUGIN = "sdlc";
18
+ export const PLUGIN_ID = `${PLUGIN}@${MARKETPLACE}`;
19
+ export const PACKAGE = "dw-sdlc";
21
20
  /** rtk find passes -exec through verbatim from this release; before it, `rtk find … -exec` exits 1. */
22
21
  export const RTK_FIND_EXEC_FIXED = "0.46.0";
23
22
  export const PNPM_MINIMUM_RELEASE_AGE = 10080;
@@ -68,7 +67,7 @@ function liveCheckout(probe, claude) {
68
67
  function pluginOrigin(probe, claude, installPath) {
69
68
  const checkout = liveCheckout(probe, claude);
70
69
  if (checkout !== undefined) {
71
- return `runs live from ${checkout}/plugins/guardrails; a change there is in the next session`;
70
+ return `runs live from ${checkout}/plugins/${PLUGIN}; a change there is in the next session`;
72
71
  }
73
72
  return `installed at ${installPath}; a change reaches it through claude plugin update`;
74
73
  }
@@ -76,7 +75,7 @@ function pluginOrigin(probe, claude, installPath) {
76
75
  function runningPluginRoot(probe, claude) {
77
76
  const checkout = liveCheckout(probe, claude);
78
77
  if (checkout !== undefined) {
79
- return join(checkout, "plugins", "guardrails");
78
+ return join(checkout, "plugins", PLUGIN);
80
79
  }
81
80
  const { status, stdout } = probe.run("claude", ["plugin", "list", "--json"]);
82
81
  const list = status === 0 ? parseJson(stdout) : undefined;
@@ -114,6 +113,57 @@ function checkPlugin(probe, claude) {
114
113
  detail: `${version} enabled; ${pluginOrigin(probe, claude, String(entry.installPath ?? ""))}`
115
114
  };
116
115
  }
116
+ /** The `version` of the package.json in `dir`, or undefined when there is none. */
117
+ function manifestVersion(probe, dir) {
118
+ const manifest = parseJson(probe.read(join(dir, "package.json")));
119
+ return isObject(manifest) && typeof manifest.version === "string" ? manifest.version : undefined;
120
+ }
121
+ /** Whether the plugin's hooks and skills run the dw-sdlc the repository pins, the one its Gate runs. */
122
+ function checkPackage(probe, claude, root) {
123
+ const name = "package";
124
+ const manifest = root === undefined ? undefined : probe.read(join(root, "package.json"));
125
+ if (root === undefined || manifest === undefined) {
126
+ return { name, level: "INFO", detail: `no package.json here to pin ${PACKAGE}` };
127
+ }
128
+ let spec;
129
+ try {
130
+ spec = parsePins(manifest, parseCatalog(probe.read(join(root, "pnpm-workspace.yaml")) ?? ""))[PACKAGE];
131
+ }
132
+ catch {
133
+ return { name, level: "INFO", detail: `${join(root, "package.json")} is not JSON` };
134
+ }
135
+ if (spec === undefined) {
136
+ return { name, level: "INFO", detail: `the repository does not pin ${PACKAGE}` };
137
+ }
138
+ // `workspace:` or a range names no one version: the copy installed under node_modules is the one that runs.
139
+ const pinned = EXACT_VERSION.test(spec) ? spec : manifestVersion(probe, join(root, "node_modules", PACKAGE));
140
+ if (pinned === undefined) {
141
+ return { name, level: "WARN", detail: `the repository pins ${PACKAGE} ${spec}, not installed: pnpm install` };
142
+ }
143
+ const plugin = runningPluginRoot(probe, claude);
144
+ if (plugin === undefined) {
145
+ return { name, level: "INFO", detail: `no ${PLUGIN_ID} runs here to compare with the pinned ${pinned}` };
146
+ }
147
+ const linked = manifestVersion(probe, join(plugin, PACKAGE));
148
+ if (linked === undefined) {
149
+ return {
150
+ name,
151
+ level: "WARN",
152
+ detail: `the plugin at ${plugin} carries no ${PACKAGE}: an install from GitHub needs packages in its --sparse set`
153
+ };
154
+ }
155
+ if (linked !== pinned) {
156
+ const remedy = versionBefore(linked, pinned)
157
+ ? `claude plugin update ${PLUGIN_ID}`
158
+ : `pnpm add --save-dev --save-exact ${PACKAGE}@${linked}`;
159
+ return {
160
+ name,
161
+ level: "WARN",
162
+ detail: `drift: the plugin runs ${PACKAGE} ${linked} and the repository pins ${pinned}, so its hooks and skills run other code than the Gate. ${remedy}`
163
+ };
164
+ }
165
+ return { name, level: "PASS", detail: `${PACKAGE} ${linked} in the plugin and in the repository` };
166
+ }
117
167
  /** Every hook command string in a settings file, whatever the event and matcher. */
118
168
  function hookCommands(settings) {
119
169
  if (!isObject(settings) || !isObject(settings.hooks)) {
@@ -232,6 +282,10 @@ function checkSafeChain(probe) {
232
282
  }
233
283
  return { name: "safe chain", level: "PASS", detail: `${binary}, npm goes through it` };
234
284
  }
285
+ /**
286
+ * pnpm's global `minimumReleaseAge`, asked from the home directory: from a checkout, its own
287
+ * pnpm-workspace.yaml would answer for the machine.
288
+ */
235
289
  function checkPnpmReleaseAge(probe, home) {
236
290
  if (probe.shell("command -v pnpm") === "") {
237
291
  return { name: "pnpm release age", level: "INFO", detail: "no pnpm in the shell" };
@@ -286,6 +340,7 @@ export function diagnose(inputs) {
286
340
  return [
287
341
  checkJq(probe),
288
342
  checkPlugin(probe, claude),
343
+ checkPackage(probe, claude, inputs.config === undefined ? undefined : inputs.root),
289
344
  checkRtk(probe, home, claude),
290
345
  checkSafeChain(probe),
291
346
  checkPnpmReleaseAge(probe, home),
@@ -296,11 +351,11 @@ export function diagnose(inputs) {
296
351
  export function render(checks) {
297
352
  return checks.map((check) => `${check.level.padEnd(5)} ${check.name.padEnd(17)} ${check.detail}`).join("\n");
298
353
  }
299
- export const summary = "one PASS / WARN / FAIL line per machine check the guardrails rely on";
354
+ export const summary = "one PASS / WARN / FAIL line per machine check the sdlc plugin relies on";
300
355
  const USAGE = `Usage: dw-sdlc doctor [--root <dir>] [--home <dir>]
301
356
  or: pnpm guardrails:doctor [--root <dir>] [--home <dir>], in a checkout of dw-sdlc
302
357
 
303
- One PASS / WARN / FAIL line per machine check the guardrails rely on; read-only. Exit 1 when any
358
+ One PASS / WARN / FAIL line per machine check the sdlc plugin relies on; read-only. Exit 1 when any
304
359
  check fails. Claude Code's settings are read from $CLAUDE_CONFIG_DIR, or ~/.claude without it; a
305
360
  --home reads <home>/.claude.`;
306
361
  function repositoryConfig(root) {
@@ -6,7 +6,7 @@ import { CannotRun } from "../checks/check.js";
6
6
  import { enabledChecks } from "../checks/index.js";
7
7
  import { annotation, failureJson, failureLine } from "../report.js";
8
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>]
9
+ const USAGE = `Usage: dw-sdlc gate [--json] [--root <dir>] [--update-baseline]
10
10
 
11
11
  Runs the Gate of the repository holding --root (default: the current directory): each Step of
12
12
  gate.steps in order from the repository root, its output passed through, stopping at the first that
@@ -15,6 +15,10 @@ none stopping the next. Prints one line per failure: the file where there is one
15
15
  remedy; under GitHub Actions each line is also a workflow annotation. --json prints one document
16
16
  instead and sends the commands' own output to stderr.
17
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
+
18
22
  Exit 0 when everything passes, 1 when a Step or a Check fails, 2 when the Gate cannot run: an
19
23
  invalid config layer, a Step or Check whose command the shell cannot find or execute, or a Check
20
24
  missing an input it reads.`;
@@ -36,9 +40,19 @@ function parseCli(argv) {
36
40
  try {
37
41
  const { values } = parseArgs({
38
42
  args: argv,
39
- options: { help: { type: "boolean", short: "h" }, json: { type: "boolean" }, root: { type: "string" } }
43
+ options: {
44
+ help: { type: "boolean", short: "h" },
45
+ json: { type: "boolean" },
46
+ root: { type: "string" },
47
+ "update-baseline": { type: "boolean" }
48
+ }
40
49
  });
41
- return { help: values.help === true, json: values.json === true, root: values.root };
50
+ return {
51
+ help: values.help === true,
52
+ json: values.json === true,
53
+ root: values.root,
54
+ updateBaseline: values["update-baseline"] === true
55
+ };
42
56
  }
43
57
  catch (error) {
44
58
  return { error: error instanceof Error ? error.message : String(error) };
@@ -69,28 +83,66 @@ function runCommand(command, root, json) {
69
83
  }
70
84
  return { status: "fail", exitCode: result.status, detail: `exited ${result.status}` };
71
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
+ }
72
97
  /** Runs one Check the package ships, its failures named by the Check; one it cannot judge is an error. */
73
98
  function runShipped(check, root) {
74
99
  try {
75
100
  const failures = check.run(root).map((finding) => ({ rule: check.name, ...finding }));
76
101
  const status = failures.length === 0 ? "pass" : "fail";
77
- return { status, exitCode: EXIT_CODES[status], failures };
102
+ return { check, outcome: { status, exitCode: EXIT_CODES[status] }, failures };
78
103
  }
79
104
  catch (error) {
80
- const finding = error instanceof CannotRun
81
- ? error.finding
82
- : {
83
- message: `stopped: ${error instanceof Error ? error.message : String(error)}`,
84
- remedy: REMEDIES.checkStopped
85
- };
86
- return { status: "error", exitCode: EXIT_CODES.error, failures: [{ rule: check.name, ...finding }] };
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
+ }
87
139
  }
88
140
  }
89
141
  function worst(outcomes) {
90
142
  const statuses = new Set(outcomes.map(({ status }) => status));
91
143
  return statuses.has("error") ? "error" : statuses.has("fail") ? "fail" : "pass";
92
144
  }
93
- function runGate(root, json) {
145
+ function runGate(root, json, updateBaseline) {
94
146
  const log = json ? process.stderr : process.stdout;
95
147
  const { config, problems } = resolveConfig(root);
96
148
  const gateCommand = config.gate.command ?? null;
@@ -128,13 +180,17 @@ function runGate(root, json) {
128
180
  log.write(`gate: check ${check.name}\n`);
129
181
  checks.push({ ...check, ...runOne(check.command, check.name, REMEDIES.checkNotRunnable) });
130
182
  }
131
- for (const check of enabledChecks(config.gate.checks)) {
183
+ const judged = enabledChecks(config.gate.checks).map((check) => {
132
184
  if (stopped) {
133
- checks.push({ name: check.name, command: null, ...SKIPPED });
134
- continue;
185
+ return { check, outcome: SKIPPED, failures: [] };
135
186
  }
136
187
  log.write(`gate: check ${check.name}\n`);
137
- const { failures: found, ...outcome } = runShipped(check, root);
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) {
138
194
  failures.push(...found);
139
195
  checks.push({ name: check.name, command: null, ...outcome });
140
196
  }
@@ -157,7 +213,7 @@ export function run(argv) {
157
213
  process.stdout.write(`${USAGE}\n`);
158
214
  return 0;
159
215
  }
160
- const report = runGate(repositoryRoot(cli.root ?? process.cwd()), cli.json);
216
+ const report = runGate(repositoryRoot(cli.root ?? process.cwd()), cli.json, cli.updateBaseline);
161
217
  const actions = process.env.GITHUB_ACTIONS === "true";
162
218
  if (cli.json) {
163
219
  const document = { ...report, failures: report.failures.map(failureJson) };
@@ -1,18 +1,6 @@
1
- // `dw-sdlc references setup` sets a repository up to declare References. Five files, each written only
2
- // when it would change, so a repeat with nothing changed writes nothing:
3
- //
4
- // references/AGENTS.md the Router, from templates/router.md when there is none; on every run the
5
- // block between its two markers, a table of each Reference's dir and when
6
- // from the key, while the text around the block stays the author's
7
- // references/CLAUDE.md a symlink to AGENTS.md, so Claude Code loads the Router; written once
8
- // .claude/settings.json claudeMdExcludes for the clones' own CLAUDE.md and AGENTS.md
9
- // .gitignore references/* and the two exceptions that keep the Router tracked
10
- // .ai/agentic.config.json an empty references key when the config has none
11
- //
12
- // Existing content stays: a list gains the entries it lacks at its end, and nothing is removed or
13
- // reordered. A settings file or a config that is not a JSON object is refused, not rewritten, and so
14
- // is an ignore file that ignores references/ itself and a Router with one marker and not the other,
15
- // or whose key fails the parser. --check writes nothing and exits 1 when any file would change.
1
+ // `dw-sdlc references setup` sets a repository up to declare References: the five files its USAGE names,
2
+ // each written only when it would change. Existing content stays: a list gains the entries it lacks at
3
+ // its end, nothing is removed or reordered, and a file the step cannot read as it expects is refused.
16
4
  import { spawnSync } from "node:child_process";
17
5
  import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
18
6
  import { dirname, join } from "node:path";
@@ -20,6 +8,7 @@ import { parseArgs } from "node:util";
20
8
  import { CONFIG, isObject, readReferences } from "../references-key.js";
21
9
  import { renderBlock, withBlock } from "../router-block.js";
22
10
  const ROUTER = "references/AGENTS.md";
11
+ /** A symlink to the Router, so Claude Code loads it. */
23
12
  const ROUTER_LINK = "references/CLAUDE.md";
24
13
  const SETTINGS = ".claude/settings.json";
25
14
  const IGNORE = ".gitignore";
@@ -36,7 +25,7 @@ const IGNORED = "references/*";
36
25
  const TRACKED = ["!references/AGENTS.md", "!references/CLAUDE.md"];
37
26
  /** Lines that ignore references/ itself: git never looks inside it, so no exception tracks the Router. */
38
27
  const WHOLE = new Set(["references", "references/", "/references", "/references/"]);
39
- const IGNORE_COMMENT = "# References are cloned on each machine by the references plugin; only the Router is tracked.";
28
+ const IGNORE_COMMENT = "# References are cloned on each machine by the sdlc plugin; only the Router is tracked.";
40
29
  export const summary = "set a repository up to declare References: the Router, its table and the ignore rules";
41
30
  const USAGE = `Usage: dw-sdlc references setup [--root <dir>] [--dry-run | --check]
42
31
  or: pnpm references:setup with the same flags, in a checkout of dw-sdlc
@@ -1,12 +1,6 @@
1
- // `dw-sdlc references sync`, the Sync: brings every Reference the `references` key of
2
- // .ai/agentic.config.json declares to its ref under references/<dir> — a missing one cloned, an
3
- // existing one moved to a branch's head or to a tag.
4
- // Each clone is shallow and blobless, detached at what was fetched, and sparse when its entry names
5
- // only some paths or skips some. Only the committed config is read. A key the parser refuses syncs
6
- // nothing; an entry that fails to resolve fails alone. One Sync runs in a checkout at a time: a
7
- // second one waits on the lock under references/. When the Router or its table is missing or behind
8
- // the key, one line names the setup to run. A repository that declares no References gets no output
9
- // at all.
1
+ // `dw-sdlc references sync`, the Sync: every Reference the committed config declares brought to its ref
2
+ // under references/<dir>, each clone shallow, blobless, detached and sparse where its entry says. An
3
+ // entry that fails fails alone, and a repository that declares no References gets no output at all.
10
4
  import { spawnSync } from "node:child_process";
11
5
  import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
12
6
  import { extname, join } from "node:path";
@@ -21,10 +21,9 @@ export type SetupOptions = {
21
21
  };
22
22
  export declare const summary = "write this machine's local config layer, the git excludes and the permission rules";
23
23
  /**
24
- * What the machine's layer points `$schema` at: the schema in the marketplace's clone as a `file://`
25
- * URL, since the `$id` sits in a private repository an editor cannot fetch. The clone keeps its path
26
- * across plugin updates, where the install cache moves with every version. No registered clone
27
- * holding the schema falls back to the `$id`.
24
+ * What the machine's layer points `$schema` at: the marketplace clone's schema as a `file://` URL, as an
25
+ * editor cannot fetch the `$id` from a private repository and the clone, unlike the install cache, keeps
26
+ * its path across plugin updates. Without a registered clone holding the schema, the `$id`.
28
27
  */
29
28
  export declare function schemaReference(claudeDir: string): string;
30
29
  export declare function setup(options: SetupOptions): Step[];
@@ -1,28 +1,6 @@
1
- // `dw-sdlc setup` sets a repository and this machine up for the guardrails plugin. Four files, each
2
- // written only when its content would change, so a repeat with nothing changed is silent and a run is
3
- // safe to repeat:
4
- //
5
- // .ai/agentic.config.local.json the machine's layer, from inference: the Node package manager when
6
- // the committed config leaves it unset, $schema at the marketplace
7
- // clone's schema, commit.convention when --convention names one
8
- // or the file already pins one, and the guardrails block the file
9
- // holds, as it is. Refused when one exists with other content than
10
- // those, unless --force.
11
- // .git/info/exclude keeps that file and .claude/settings.local.json out of git without
12
- // touching the repository's tracked .gitignore
13
- // .claude/settings.local.json permissions.deny as the jq-less backstop for the hooks: the
14
- // destructive git and gh sets in plain and rtk form, and Read/Edit
15
- // rules for every env file the resolved guardrails.envFiles refuses:
16
- // listed in deny, or ignored by git and not listed in allow
17
- // ~/.claude/settings.json autoMode.allow plus the lease-push exception ("$defaults" opens a
18
- // list this run creates), and permissions.deny with the irreversible set;
19
- // under $CLAUDE_CONFIG_DIR when it is set, as Claude Code reads it
20
- //
21
- // Existing content is merged, never replaced: lists grow by the entries they lack, the repository's
22
- // deny list loses the rules RETIRED_DENY names, other keys stay. A settings file that is not JSON is
23
- // refused, not rewritten. From a linked worktree all three repository files go to the main checkout:
24
- // every worktree reads the local config from there when it has none of its own, and Claude Code reads
25
- // the local settings there. The only thing run in the repository is read-only git.
1
+ // `dw-sdlc setup` sets a repository and this machine up for the sdlc plugin: the four files its USAGE
2
+ // names, each written only when its content would change, so a run is safe to repeat. Existing content is
3
+ // merged, never replaced: other keys stay, and a settings file that is not JSON is refused.
26
4
  import { spawnSync } from "node:child_process";
27
5
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
28
6
  import { homedir } from "node:os";
@@ -57,12 +35,13 @@ function isCommitOnProtected(value) {
57
35
  }
58
36
  /** Where Claude Code registers the plugin: the marketplace name and the plugin's name inside it. */
59
37
  const MARKETPLACE = "dw-sdlc";
60
- const PLUGIN = "guardrails";
38
+ const PLUGIN = "sdlc";
61
39
  /** The plugin's link to the dw-sdlc package, which carries the schema. */
62
40
  const PACKAGE_LINK = "dw-sdlc";
63
41
  function isObject(value) {
64
42
  return typeof value === "object" && value !== null && !Array.isArray(value);
65
43
  }
44
+ /** A read-only git command, the one thing the setup runs in the repository. */
66
45
  function git(root, args) {
67
46
  const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
68
47
  return result.status === 0 ? result.stdout.trim() : undefined;
@@ -77,10 +56,9 @@ function committedConfig(root) {
77
56
  return "value" in result ? result.value : {};
78
57
  }
79
58
  /**
80
- * What the machine's layer points `$schema` at: the schema in the marketplace's clone as a `file://`
81
- * URL, since the `$id` sits in a private repository an editor cannot fetch. The clone keeps its path
82
- * across plugin updates, where the install cache moves with every version. No registered clone
83
- * holding the schema falls back to the `$id`.
59
+ * What the machine's layer points `$schema` at: the marketplace clone's schema as a `file://` URL, as an
60
+ * editor cannot fetch the `$id` from a private repository and the clone, unlike the install cache, keeps
61
+ * its path across plugin updates. Without a registered clone holding the schema, the `$id`.
84
62
  */
85
63
  export function schemaReference(claudeDir) {
86
64
  const registry = readJson(join(claudeDir, "plugins", "known_marketplaces.json"));
@@ -157,12 +135,8 @@ function sameContent(existing, config) {
157
135
  return isObject(existing) && render(unowned(existing)) === render(unowned(config));
158
136
  }
159
137
  /**
160
- * The machine's layer: what inference finds, minus every key the committed config already sets, plus
161
- * the `guardrails` block the file already holds. The local layer outranks the committed one, so an
162
- * inferred key written here would override it. The commit convention is written only when one is
163
- * asked for, by `--convention` or by the pin the file already holds: inference always gives the
164
- * `conventional` default, and pinning it here overrides a convention the repository commits later,
165
- * so that is a choice, never a guess.
138
+ * The machine's layer: what inference finds, minus every key the committed config already sets, which an
139
+ * inferred key here would override, plus the `guardrails` block the file holds and the convention asked for.
166
140
  */
167
141
  function localConfig(root, claudeDir, convention, guardrails) {
168
142
  const inferred = inferConfig(root);
@@ -178,11 +152,14 @@ function localConfig(root, claudeDir, convention, guardrails) {
178
152
  if (guardrails !== undefined) {
179
153
  config.guardrails = guardrails;
180
154
  }
155
+ // Only one asked for, by `--convention` or the file's pin: inference always gives `conventional`, and
156
+ // pinning it here would override a convention the repository commits later.
181
157
  if (convention !== undefined) {
182
158
  config.commit = { convention };
183
159
  }
184
160
  return config;
185
161
  }
162
+ /** `existing` with the entries of `additions` it lacks appended: a list grows and never loses one. */
186
163
  function union(existing, additions) {
187
164
  const list = Array.isArray(existing) ? existing.filter((item) => typeof item === "string") : [];
188
165
  for (const rule of additions) {
@@ -234,7 +211,10 @@ class Writer {
234
211
  this.steps.push({ file: label, outcome: "refused", detail });
235
212
  }
236
213
  }
237
- /** Where the repository's files live: the main checkout for a linked worktree. */
214
+ /**
215
+ * Where the repository's files live: the main checkout for a linked worktree, since every worktree reads
216
+ * the local config from there when it has none of its own, and Claude Code reads the local settings there.
217
+ */
238
218
  function mainCheckout(root) {
239
219
  const common = git(root, ["rev-parse", "--path-format=absolute", "--git-common-dir"]);
240
220
  if (common === undefined) {
@@ -266,7 +246,8 @@ export function setup(options) {
266
246
  writer.put(LOCAL_FILE, localPath, render(local));
267
247
  }
268
248
  }
269
- // 2. The exclude entries, appended to whatever the file holds.
249
+ // 2. The exclude entries, appended to whatever the file holds: they keep both local files out of git
250
+ // without touching the repository's tracked .gitignore.
270
251
  const excludePath = join(gitDir, "info", "exclude");
271
252
  const excludeText = existsSync(excludePath) ? readFileSync(excludePath, "utf8") : "";
272
253
  const lines = excludeText.split("\n");
@@ -279,7 +260,8 @@ export function setup(options) {
279
260
  const separator = excludeText === "" || excludeText.endsWith("\n") ? "" : "\n";
280
261
  writer.put(excludeLabel, excludePath, excludeText + separator + missing.join("\n") + "\n");
281
262
  }
282
- // 3. The repository's deny rules, merged into the main checkout's local settings.
263
+ // 3. The repository's deny rules, the backstop for the hooks where jq is missing, merged into the main
264
+ // checkout's local settings.
283
265
  const localSettingsPath = join(checkout, ".claude", "settings.local.json");
284
266
  const localSettings = readJson(localSettingsPath);
285
267
  if (localSettings === "invalid") {
@@ -292,7 +274,8 @@ export function setup(options) {
292
274
  settings.permissions = { ...permissions, deny };
293
275
  writer.put(".claude/settings.local.json", localSettingsPath, render(settings));
294
276
  }
295
- // 4. The user file: the lease exception, "$defaults" kept, and the irreversible non-git set.
277
+ // 4. The user file, under $CLAUDE_CONFIG_DIR when it is set: the lease exception, "$defaults" kept, and
278
+ // the irreversible non-git set.
296
279
  const userPath = join(claudeDir, "settings.json");
297
280
  const userLabel = options.claudeDir === undefined ? "~/.claude/settings.json" : userPath;
298
281
  const userSettings = readJson(userPath);
@@ -1,10 +1,6 @@
1
1
  // The subset of JSON Schema draft 2020-12 the shipped schemas use, validated without a dependency: Node
2
2
  // has no validator built in, and a config file does not justify pulling one in. A keyword outside the
3
3
  // subset throws instead of passing silently, so a schema never promises more than this file enforces.
4
- // `format` is enforced, not annotative: `regex` is the one format, compiled with `RegExp` and refused
5
- // when it holds a construct POSIX ERE lacks, because `grep -E` runs the pattern and BSD and GNU grep
6
- // read those differently. `enum` and `const` compare by JSON text, exact for the scalars and arrays
7
- // the schemas use.
8
4
  export class SchemaError extends Error {
9
5
  }
10
6
  const ANNOTATIONS = new Set(["$schema", "$id", "$comment", "title", "description", "default", "examples"]);
@@ -14,6 +10,7 @@ function isObject(value) {
14
10
  function show(value) {
15
11
  return value === undefined ? "undefined" : JSON.stringify(value);
16
12
  }
13
+ /** Equal by JSON text, as `enum` and `const` compare: exact for the scalars and arrays the schemas use. */
17
14
  function sameJson(a, b) {
18
15
  return JSON.stringify(a) === JSON.stringify(b);
19
16
  }
@@ -194,6 +191,8 @@ function checkKeyword(schema, keyword, operand, value, path, problems) {
194
191
  }
195
192
  return;
196
193
  }
194
+ // Enforced, not annotative: `regex` compiles with `RegExp` and refuses a construct POSIX ERE lacks,
195
+ // because `grep -E` runs the pattern and BSD and GNU grep read those differently.
197
196
  case "format": {
198
197
  operandMust(operand === "regex", path, keyword, '"regex", the one format enforced here');
199
198
  if (typeof value !== "string") {
@@ -8,6 +8,10 @@ export type Row = {
8
8
  cells: string[];
9
9
  line: number;
10
10
  };
11
+ export type Link = {
12
+ target: string;
13
+ line: number;
14
+ };
11
15
  /** Every ATX heading in `text`, a repeated anchor numbered `-1`, `-2` as GitHub numbers it. */
12
16
  export declare function headings(text: string): Heading[];
13
17
  /**
@@ -17,3 +21,11 @@ export declare function headings(text: string): Heading[];
17
21
  export declare function tableRows(text: string, title: string): Row[];
18
22
  /** Every backticked span in `text`, without its backticks. */
19
23
  export declare function codeSpans(text: string): string[];
24
+ /** `text` with each fenced line and each code span blanked to spaces, so every offset and line is kept. */
25
+ export declare function prose(text: string): string;
26
+ /** Every link target in the prose of `text`, its angle brackets dropped, with its 1-based line. */
27
+ export declare function links(text: string): Link[];
28
+ /** The anchors of each markdown file read so far, by path. */
29
+ export type Anchors = Map<string, Set<string>>;
30
+ /** The anchors of the headings of `path` under `root`; none when it is not a file. */
31
+ export declare function anchorsOf(root: string, path: string, cache: Anchors): Set<string>;
package/dist/markdown.js CHANGED
@@ -1,6 +1,10 @@
1
1
  // The parts of a markdown file the Checks read: its ATX headings with the anchors GitHub gives them,
2
- // and the rows of a table under a heading. A line inside a fenced code block is never either.
3
- const FENCE = /^ {0,3}(`{3,}|~{3,})/;
2
+ // the rows of a table under a heading, its links, and its prose with the code masked; and the anchors
3
+ // of a file on disk. A line inside a fenced code block is never any of them.
4
+ import { readFileSync, statSync } from "node:fs";
5
+ import { join } from "node:path";
6
+ /** A code fence at any indent, so one inside a list item counts. */
7
+ const FENCE = /^[ \t]*(`{3,}|~{3,})/;
4
8
  const ATX = /^ {0,3}(#{1,6})[ \t]+(.*?)(?:[ \t]+#+)?[ \t]*$/;
5
9
  /** Each line with its 1-based number, `fenced` when a code fence opens, closes or encloses it. */
6
10
  function scan(text) {
@@ -75,3 +79,37 @@ export function tableRows(text, title) {
75
79
  export function codeSpans(text) {
76
80
  return Array.from(text.matchAll(/`([^`]+)`/g), ([, span = ""]) => span);
77
81
  }
82
+ /** A code span: a run of backticks, then the text up to the next run of as many. */
83
+ const CODE_SPAN = /(?<!`)(`+)(?!`).*?(?<!`)\1(?!`)/g;
84
+ /** `text` with each fenced line and each code span blanked to spaces, so every offset and line is kept. */
85
+ export function prose(text) {
86
+ return scan(text)
87
+ .map(({ text: line, fenced }) => fenced ? " ".repeat(line.length) : line.replace(CODE_SPAN, (span) => " ".repeat(span.length)))
88
+ .join("\n");
89
+ }
90
+ /** The target of an inline link or image, `<…>` or up to a space or `)`, and of a link definition, `[label]: target`. */
91
+ const INLINE_TARGET = /\]\(\s*(<[^>]*>|[^\s)]+)/g;
92
+ const DEFINITION = /^ {0,3}\[(?!\^)[^\]]+\]:[ \t]*(<[^>]*>|\S+)/;
93
+ /** Every link target in the prose of `text`, its angle brackets dropped, with its 1-based line. */
94
+ export function links(text) {
95
+ return prose(text)
96
+ .split("\n")
97
+ .flatMap((line, index) => {
98
+ const definition = DEFINITION.exec(line)?.[1];
99
+ const targets = [
100
+ ...Array.from(line.matchAll(INLINE_TARGET), ([, target = ""]) => target),
101
+ ...(definition === undefined ? [] : [definition])
102
+ ];
103
+ return targets.map((target) => ({ target: target.replace(/^<(.*)>$/, "$1"), line: index + 1 }));
104
+ });
105
+ }
106
+ /** The anchors of the headings of `path` under `root`; none when it is not a file. */
107
+ export function anchorsOf(root, path, cache) {
108
+ let anchors = cache.get(path);
109
+ if (anchors === undefined) {
110
+ const full = join(root, path);
111
+ anchors = new Set(statSync(full).isFile() ? headings(readFileSync(full, "utf8")).map(({ anchor }) => anchor) : []);
112
+ cache.set(path, anchors);
113
+ }
114
+ return anchors;
115
+ }
package/dist/pins.d.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  /** Versions by package name: what package.json pins, or what a catalog lists. */
2
2
  export type Pins = Record<string, string>;
3
+ /** A pin that names one version: `x.y.z`, a pre-release tag allowed. */
4
+ export declare const EXACT_VERSION: RegExp;
3
5
  /**
4
6
  * The default catalog in pnpm-workspace.yaml's text: each `name: version` line of its top-level
5
7
  * `catalog:` block, a comment after the key allowed. Without the block, it is empty.
package/dist/pins.js CHANGED
@@ -1,10 +1,10 @@
1
1
  // The exact version a repository pins for a package, for a ref that names one: the root package.json's
2
- // dependencies and devDependencies, a `catalog:` or `catalog:default` pin read through the default
3
- // catalog in pnpm-workspace.yaml. A ref without `{name}` reads neither file, so a broken manifest
4
- // fails only the References that name a package.
2
+ // dependencies and devDependencies, a catalog pin read through pnpm-workspace.yaml's default catalog. A
3
+ // ref without `{name}` reads neither file, so a broken manifest fails only the References that name one.
5
4
  import { existsSync, readFileSync } from "node:fs";
6
5
  import { join } from "node:path";
7
- const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
6
+ /** A pin that names one version: `x.y.z`, a pre-release tag allowed. */
7
+ export const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
8
8
  /** The two ways a pin names the default catalog: by its name, or by pnpm's shorthand. */
9
9
  const DEFAULT_CATALOG = new Set(["catalog:", "catalog:default"]);
10
10
  /**
@@ -1,7 +1,6 @@
1
1
  // The strict parser of the `references` key, the authority at runtime, and the reader of the committed
2
2
  // config that holds it. `config/references.schema.json` documents the same contract, and
3
- // `test/references-key.test.ts` runs one case list through both. The first problem stops the
4
- // parse: a key that fails it syncs nothing.
3
+ // `test/references-key.test.ts` runs one case list through both. The first problem stops the parse.
5
4
  import { existsSync, readFileSync } from "node:fs";
6
5
  import { join } from "node:path";
7
6
  /** The committed config, the only file the key is read from. */