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.
- package/config/agentic.config.schema.json +163 -0
- package/dist/agentic-config.js +3 -5
- package/dist/checks/agents-map.js +3 -10
- package/dist/checks/check.d.ts +10 -0
- package/dist/checks/comment-budget.d.ts +6 -0
- package/dist/checks/comment-budget.js +141 -0
- package/dist/checks/decision-records.d.ts +6 -0
- package/dist/checks/decision-records.js +105 -0
- package/dist/checks/file-size-ratchet.d.ts +10 -0
- package/dist/checks/file-size-ratchet.js +194 -0
- package/dist/checks/index.d.ts +5 -1
- package/dist/checks/index.js +25 -2
- package/dist/checks/markdown-budget.d.ts +15 -0
- package/dist/checks/markdown-budget.js +42 -0
- package/dist/checks/markdown-refs.d.ts +6 -0
- package/dist/checks/markdown-refs.js +73 -0
- package/dist/checks/primitives.js +5 -30
- package/dist/checks/temporal-language.d.ts +6 -0
- package/dist/checks/temporal-language.js +58 -0
- package/dist/checks/tree.d.ts +8 -0
- package/dist/checks/tree.js +30 -0
- package/dist/commands/doctor.d.ts +5 -3
- package/dist/commands/doctor.js +68 -13
- package/dist/commands/gate.js +73 -17
- package/dist/commands/references-setup.js +5 -16
- package/dist/commands/references-sync.js +3 -9
- package/dist/commands/setup.d.ts +3 -4
- package/dist/commands/setup.js +23 -40
- package/dist/json-schema.js +3 -4
- package/dist/markdown.d.ts +12 -0
- package/dist/markdown.js +40 -2
- package/dist/pins.d.ts +2 -0
- package/dist/pins.js +4 -4
- package/dist/references-key.js +1 -2
- package/dist/router-block.js +1 -3
- package/package.json +1 -1
- package/templates/router.md +1 -1
package/dist/commands/doctor.js
CHANGED
|
@@ -1,23 +1,22 @@
|
|
|
1
|
-
// `dw-sdlc doctor`: one PASS / WARN / FAIL line per machine check the
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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",
|
|
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
|
|
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
|
|
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) {
|
package/dist/commands/gate.js
CHANGED
|
@@ -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: {
|
|
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 {
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
-
|
|
183
|
+
const judged = enabledChecks(config.gate.checks).map((check) => {
|
|
132
184
|
if (stopped) {
|
|
133
|
-
|
|
134
|
-
continue;
|
|
185
|
+
return { check, outcome: SKIPPED, failures: [] };
|
|
135
186
|
}
|
|
136
187
|
log.write(`gate: check ${check.name}\n`);
|
|
137
|
-
|
|
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
|
|
2
|
-
// when it would change
|
|
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
|
|
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:
|
|
2
|
-
//
|
|
3
|
-
//
|
|
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";
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -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
|
|
25
|
-
*
|
|
26
|
-
* across plugin updates
|
|
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[];
|
package/dist/commands/setup.js
CHANGED
|
@@ -1,28 +1,6 @@
|
|
|
1
|
-
// `dw-sdlc setup` sets a repository and this machine up for the
|
|
2
|
-
// written only when its content would change, so a
|
|
3
|
-
//
|
|
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 = "
|
|
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
|
|
81
|
-
*
|
|
82
|
-
* across plugin updates
|
|
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,
|
|
161
|
-
* the `guardrails` block the file
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
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);
|
package/dist/json-schema.js
CHANGED
|
@@ -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") {
|
package/dist/markdown.d.ts
CHANGED
|
@@ -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
|
-
//
|
|
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
|
|
3
|
-
//
|
|
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
|
-
|
|
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
|
/**
|
package/dist/references-key.js
CHANGED
|
@@ -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. */
|