dw-sdlc 0.3.0 → 0.4.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/README.md +3 -1
- package/dist/checks/index.d.ts +4 -0
- package/dist/checks/index.js +7 -0
- package/dist/cli.js +2 -0
- package/dist/commands/check.d.ts +2 -0
- package/dist/commands/check.js +59 -0
- package/dist/commands/gate.d.ts +36 -0
- package/dist/commands/gate.js +58 -24
- package/dist/commands/references-setup.js +3 -22
- package/dist/commands/setup.d.ts +5 -2
- package/dist/commands/setup.js +68 -32
- package/dist/json-text.d.ts +7 -0
- package/dist/json-text.js +26 -0
- package/dist/setup-gate.d.ts +15 -0
- package/dist/setup-gate.js +82 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -17,7 +17,8 @@ pnpm add -D dw-sdlc
|
|
|
17
17
|
|
|
18
18
|
`dw-sdlc gate` runs the Steps under `gate.steps` in order from the repository root, then every Check
|
|
19
19
|
under `gate.checks`. It exits 0 when everything passes, 1 when a Step or a Check fails, and 2 when the
|
|
20
|
-
Gate cannot run. `--json` prints the result as one document.
|
|
20
|
+
Gate cannot run. `--json` prints the result as one document. `dw-sdlc check <name>` runs one Check
|
|
21
|
+
alone, with the same lines, exit codes and document.
|
|
21
22
|
|
|
22
23
|
```json
|
|
23
24
|
{
|
|
@@ -38,6 +39,7 @@ Gate cannot run. `--json` prints the result as one document.
|
|
|
38
39
|
| command | what it does |
|
|
39
40
|
| ------------------ | ----------------------------------------------------------------------------------- |
|
|
40
41
|
| `gate` | the Steps of `gate.steps` in order, then every Check under `gate.checks` |
|
|
42
|
+
| `check <name>` | one Check of the Gate by its name, without the Steps and the other Checks |
|
|
41
43
|
| `primitives` | the Primitives that own each path, with their summaries and documents |
|
|
42
44
|
| `setup` | this machine's local config layer, the git excludes and the permission rules |
|
|
43
45
|
| `doctor` | one PASS / WARN / FAIL line per machine check the guardrails rely on |
|
package/dist/checks/index.d.ts
CHANGED
|
@@ -8,5 +8,9 @@ export type EnabledCheck = {
|
|
|
8
8
|
refresh: (root: string) => string;
|
|
9
9
|
};
|
|
10
10
|
};
|
|
11
|
+
/** The name of every Check the package ships, in the order the Gate runs them. */
|
|
12
|
+
export declare const CHECK_NAMES: readonly string[];
|
|
11
13
|
/** Every shipped Check a resolved `gate.checks` turns on with `true` or its options. */
|
|
12
14
|
export declare function enabledChecks(checks: Record<string, unknown>): EnabledCheck[];
|
|
15
|
+
/** The shipped Check `name` on `setting`, or on its defaults where `setting` leaves it off. */
|
|
16
|
+
export declare function shippedCheck(name: string, setting: unknown): EnabledCheck | undefined;
|
package/dist/checks/index.js
CHANGED
|
@@ -36,7 +36,14 @@ const SHIPPED = [
|
|
|
36
36
|
ship(fileSizeRatchet),
|
|
37
37
|
ship(commentBudget)
|
|
38
38
|
];
|
|
39
|
+
/** The name of every Check the package ships, in the order the Gate runs them. */
|
|
40
|
+
export const CHECK_NAMES = SHIPPED.map(({ name }) => name);
|
|
39
41
|
/** Every shipped Check a resolved `gate.checks` turns on with `true` or its options. */
|
|
40
42
|
export function enabledChecks(checks) {
|
|
41
43
|
return SHIPPED.flatMap(({ name, enable }) => enable(checks[name]) ?? []);
|
|
42
44
|
}
|
|
45
|
+
/** The shipped Check `name` on `setting`, or on its defaults where `setting` leaves it off. */
|
|
46
|
+
export function shippedCheck(name, setting) {
|
|
47
|
+
const on = setting === false || setting === undefined ? true : setting;
|
|
48
|
+
return SHIPPED.find((check) => check.name === name)?.enable(on);
|
|
49
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// The `dw-sdlc` bin: one row per subcommand, each a module under `commands/`. A plugin's hook or skill
|
|
3
3
|
// reaches it through the plugin's `dw-sdlc` link.
|
|
4
|
+
import * as check from "./commands/check.js";
|
|
4
5
|
import * as config from "./commands/config.js";
|
|
5
6
|
import * as doctor from "./commands/doctor.js";
|
|
6
7
|
import * as gate from "./commands/gate.js";
|
|
@@ -10,6 +11,7 @@ import * as referencesSync from "./commands/references-sync.js";
|
|
|
10
11
|
import * as setup from "./commands/setup.js";
|
|
11
12
|
const COMMANDS = new Map([
|
|
12
13
|
["gate", gate],
|
|
14
|
+
["check", check],
|
|
13
15
|
["primitives", primitives],
|
|
14
16
|
["setup", setup],
|
|
15
17
|
["doctor", doctor],
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// `dw-sdlc check <name>`: one Check of the Gate by its name, without the Steps and the other Checks.
|
|
2
|
+
import { parseArgs } from "node:util";
|
|
3
|
+
import { repositoryRoot } from "../agentic-config.js";
|
|
4
|
+
import { CHECK_NAMES } from "../checks/index.js";
|
|
5
|
+
import { printReport, runGate } from "./gate.js";
|
|
6
|
+
export const summary = "run one Check of the Gate by its name, without the Steps and the other Checks";
|
|
7
|
+
const USAGE = `Usage: dw-sdlc check <name> [--json] [--root <dir>]
|
|
8
|
+
|
|
9
|
+
Runs the Check named <name> in the Gate of the repository holding --root (default: the current
|
|
10
|
+
directory): a script under gate.checks.run, or a Check the package ships, one of
|
|
11
|
+
${CHECK_NAMES.join(", ")}. A name both carry runs both, as the Gate does. A
|
|
12
|
+
shipped Check runs on the setting gate.checks gives it, and on its defaults where the config sets it
|
|
13
|
+
false or leaves it out. No Step runs. Prints one line per failure, as the Gate does; --json prints
|
|
14
|
+
the Gate's document, with no Step.
|
|
15
|
+
|
|
16
|
+
A Ratchet's baseline is refreshed by dw-sdlc gate --update-baseline, which needs every other Check to
|
|
17
|
+
pass.
|
|
18
|
+
|
|
19
|
+
Exit 0 when the Check passes, 1 when it fails, 2 when it cannot run: no Check of that name, an
|
|
20
|
+
invalid config layer, a command the shell cannot find or execute, or an input the Check reads
|
|
21
|
+
missing.`;
|
|
22
|
+
function parseCli(argv) {
|
|
23
|
+
try {
|
|
24
|
+
const { values, positionals } = parseArgs({
|
|
25
|
+
args: argv,
|
|
26
|
+
allowPositionals: true,
|
|
27
|
+
options: {
|
|
28
|
+
help: { type: "boolean", short: "h" },
|
|
29
|
+
json: { type: "boolean" },
|
|
30
|
+
root: { type: "string" }
|
|
31
|
+
}
|
|
32
|
+
});
|
|
33
|
+
if (positionals.length > 1) {
|
|
34
|
+
return { error: `one Check at a time, not ${positionals.map((name) => JSON.stringify(name)).join(" and ")}` };
|
|
35
|
+
}
|
|
36
|
+
return { help: values.help === true, json: values.json === true, root: values.root, name: positionals[0] };
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
export function run(argv) {
|
|
43
|
+
const cli = parseCli(argv);
|
|
44
|
+
if ("error" in cli) {
|
|
45
|
+
process.stderr.write(`dw-sdlc check: ${cli.error}\n\n${USAGE}\n`);
|
|
46
|
+
return 2;
|
|
47
|
+
}
|
|
48
|
+
if (cli.help) {
|
|
49
|
+
process.stdout.write(`${USAGE}\n`);
|
|
50
|
+
return 0;
|
|
51
|
+
}
|
|
52
|
+
if (cli.name === undefined) {
|
|
53
|
+
process.stderr.write(`dw-sdlc check: name the Check to run\n\n${USAGE}\n`);
|
|
54
|
+
return 2;
|
|
55
|
+
}
|
|
56
|
+
const { json, name } = cli;
|
|
57
|
+
const report = runGate(repositoryRoot(cli.root ?? process.cwd()), { json, only: name });
|
|
58
|
+
return printReport(report, json, `check ${name}`);
|
|
59
|
+
}
|
package/dist/commands/gate.d.ts
CHANGED
|
@@ -1,2 +1,38 @@
|
|
|
1
|
+
import { type Failure } from "../report.ts";
|
|
1
2
|
export declare const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
|
|
3
|
+
type Status = "pass" | "fail" | "error";
|
|
4
|
+
type Outcome = {
|
|
5
|
+
status: Status | "skipped";
|
|
6
|
+
exitCode: number | null;
|
|
7
|
+
};
|
|
8
|
+
type StepReport = Outcome & {
|
|
9
|
+
command: string;
|
|
10
|
+
};
|
|
11
|
+
/** A Check the package ships runs in this process: it has no command. */
|
|
12
|
+
type CheckReport = Outcome & {
|
|
13
|
+
name: string;
|
|
14
|
+
command: string | null;
|
|
15
|
+
};
|
|
16
|
+
type GateReport = {
|
|
17
|
+
status: Status;
|
|
18
|
+
command: string | null;
|
|
19
|
+
steps: StepReport[];
|
|
20
|
+
checks: CheckReport[];
|
|
21
|
+
failures: Failure[];
|
|
22
|
+
};
|
|
23
|
+
/** A run of the whole Gate, or of the Checks named `only` alone: no Step, no progress line, no refresh. */
|
|
24
|
+
type RunOptions = {
|
|
25
|
+
json: boolean;
|
|
26
|
+
} & ({
|
|
27
|
+
updateBaseline: boolean;
|
|
28
|
+
} | {
|
|
29
|
+
only: string;
|
|
30
|
+
});
|
|
31
|
+
export declare function runGate(root: string, options: RunOptions): GateReport;
|
|
32
|
+
/**
|
|
33
|
+
* Prints `report`: a line per failure and the verdict under `label`, or one document under `json`; under
|
|
34
|
+
* GitHub Actions each failure is also an annotation. Returns the exit code.
|
|
35
|
+
*/
|
|
36
|
+
export declare function printReport(report: GateReport, json: boolean, label: string): number;
|
|
2
37
|
export declare function run(argv: string[]): number;
|
|
38
|
+
export {};
|
package/dist/commands/gate.js
CHANGED
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
// `dw-sdlc gate`: the Gate of a repository, from the `gate` key of its resolved agentic config.
|
|
1
|
+
// `dw-sdlc gate`: the Gate of a repository, from the `gate` key of its resolved agentic config. The
|
|
2
|
+
// run and the report serve `dw-sdlc check` too, which judges one Check of it.
|
|
2
3
|
import { spawnSync } from "node:child_process";
|
|
3
4
|
import { parseArgs } from "node:util";
|
|
4
5
|
import { repositoryRoot, resolveConfig } from "../agentic-config.js";
|
|
5
6
|
import { CannotRun } from "../checks/check.js";
|
|
6
|
-
import { enabledChecks } from "../checks/index.js";
|
|
7
|
+
import { CHECK_NAMES, enabledChecks, shippedCheck } from "../checks/index.js";
|
|
7
8
|
import { annotation, failureJson, failureLine } from "../report.js";
|
|
8
9
|
export const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
|
|
9
10
|
const USAGE = `Usage: dw-sdlc gate [--json] [--root <dir>] [--update-baseline]
|
|
@@ -26,6 +27,8 @@ const EXIT_CODES = { pass: 0, fail: 1, error: 2 };
|
|
|
26
27
|
/** What the shell answers for a command it cannot find, and for one it cannot execute. */
|
|
27
28
|
const NOT_RUNNABLE = { 127: "was not found", 126: "could not be executed" };
|
|
28
29
|
const SKIPPED = { status: "skipped", exitCode: null };
|
|
30
|
+
/** One Check run by name says nothing before its result. */
|
|
31
|
+
const QUIET = { write: () => true };
|
|
29
32
|
const REMEDIES = {
|
|
30
33
|
invalidLayer: "fix the file; the Gate runs once every layer is valid",
|
|
31
34
|
failed: "fix what it printed above",
|
|
@@ -142,13 +145,36 @@ function worst(outcomes) {
|
|
|
142
145
|
const statuses = new Set(outcomes.map(({ status }) => status));
|
|
143
146
|
return statuses.has("error") ? "error" : statuses.has("fail") ? "fail" : "pass";
|
|
144
147
|
}
|
|
145
|
-
|
|
146
|
-
|
|
148
|
+
/**
|
|
149
|
+
* Every Step and Check of `gate`, or with `only` no Step and the Checks of that name: the run Checks
|
|
150
|
+
* and the shipped one, which runs on its defaults where the config leaves it off. No Check of that
|
|
151
|
+
* name is a failure.
|
|
152
|
+
*/
|
|
153
|
+
function select(gate, only) {
|
|
154
|
+
if (only === undefined) {
|
|
155
|
+
return { steps: gate.steps, run: gate.checks.run, shipped: enabledChecks(gate.checks) };
|
|
156
|
+
}
|
|
157
|
+
const scripts = gate.checks.run.filter((check) => check.name === only);
|
|
158
|
+
const shipped = shippedCheck(only, gate.checks[only]);
|
|
159
|
+
if (scripts.length === 0 && shipped === undefined) {
|
|
160
|
+
const names = [...gate.checks.run.map((check) => check.name), ...CHECK_NAMES].join(", ");
|
|
161
|
+
return { rule: "check", message: `no Check is named ${JSON.stringify(only)}`, remedy: `name one of ${names}` };
|
|
162
|
+
}
|
|
163
|
+
return { steps: [], run: scripts, shipped: shipped === undefined ? [] : [shipped] };
|
|
164
|
+
}
|
|
165
|
+
export function runGate(root, options) {
|
|
166
|
+
const { json } = options;
|
|
167
|
+
const only = "only" in options ? options.only : undefined;
|
|
168
|
+
const log = only !== undefined ? QUIET : json ? process.stderr : process.stdout;
|
|
147
169
|
const { config, problems } = resolveConfig(root);
|
|
148
170
|
const gateCommand = config.gate.command ?? null;
|
|
149
171
|
if (problems.length > 0) {
|
|
150
172
|
return { status: "error", command: gateCommand, steps: [], checks: [], failures: problems.map(configFailure) };
|
|
151
173
|
}
|
|
174
|
+
const selected = select(config.gate, only);
|
|
175
|
+
if ("rule" in selected) {
|
|
176
|
+
return { status: "error", command: gateCommand, steps: [], checks: [], failures: [selected] };
|
|
177
|
+
}
|
|
152
178
|
const failures = [];
|
|
153
179
|
/** Runs one Step or Check and records its failure under `rule`, if it has one. */
|
|
154
180
|
const runOne = (command, rule, notRunnable) => {
|
|
@@ -161,7 +187,7 @@ function runGate(root, json, updateBaseline) {
|
|
|
161
187
|
};
|
|
162
188
|
const steps = [];
|
|
163
189
|
let stopped = false;
|
|
164
|
-
for (const step of
|
|
190
|
+
for (const step of selected.steps) {
|
|
165
191
|
if (stopped) {
|
|
166
192
|
steps.push({ command: step, ...SKIPPED });
|
|
167
193
|
continue;
|
|
@@ -172,7 +198,7 @@ function runGate(root, json, updateBaseline) {
|
|
|
172
198
|
stopped = outcome.status !== "pass";
|
|
173
199
|
}
|
|
174
200
|
const checks = [];
|
|
175
|
-
for (const check of
|
|
201
|
+
for (const check of selected.run) {
|
|
176
202
|
if (stopped) {
|
|
177
203
|
checks.push({ ...check, ...SKIPPED });
|
|
178
204
|
continue;
|
|
@@ -180,14 +206,14 @@ function runGate(root, json, updateBaseline) {
|
|
|
180
206
|
log.write(`gate: check ${check.name}\n`);
|
|
181
207
|
checks.push({ ...check, ...runOne(check.command, check.name, REMEDIES.checkNotRunnable) });
|
|
182
208
|
}
|
|
183
|
-
const judged =
|
|
209
|
+
const judged = selected.shipped.map((check) => {
|
|
184
210
|
if (stopped) {
|
|
185
211
|
return { check, outcome: SKIPPED, failures: [] };
|
|
186
212
|
}
|
|
187
213
|
log.write(`gate: check ${check.name}\n`);
|
|
188
214
|
return runShipped(check, root);
|
|
189
215
|
});
|
|
190
|
-
if (updateBaseline) {
|
|
216
|
+
if ("updateBaseline" in options && options.updateBaseline) {
|
|
191
217
|
refreshBaselines(judged, [...steps, ...checks], root, log);
|
|
192
218
|
}
|
|
193
219
|
for (const { check, outcome, failures: found } of judged) {
|
|
@@ -196,26 +222,21 @@ function runGate(root, json, updateBaseline) {
|
|
|
196
222
|
}
|
|
197
223
|
return { status: worst([...steps, ...checks]), command: gateCommand, steps, checks, failures };
|
|
198
224
|
}
|
|
199
|
-
|
|
225
|
+
/** The last line, after `label`: green with what ran, or failed or cannot run with how many failures. */
|
|
226
|
+
function verdict(label, report) {
|
|
200
227
|
if (report.status === "pass") {
|
|
201
|
-
return
|
|
228
|
+
return `${label}: green — ${plural(report.steps.length, "step")}, ${plural(report.checks.length, "check")}`;
|
|
202
229
|
}
|
|
203
230
|
const failures = plural(report.failures.length, "failure");
|
|
204
|
-
return report.status === "fail" ?
|
|
231
|
+
return report.status === "fail" ? `${label}: failed — ${failures}` : `${label}: cannot run — ${failures}`;
|
|
205
232
|
}
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
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);
|
|
233
|
+
/**
|
|
234
|
+
* Prints `report`: a line per failure and the verdict under `label`, or one document under `json`; under
|
|
235
|
+
* GitHub Actions each failure is also an annotation. Returns the exit code.
|
|
236
|
+
*/
|
|
237
|
+
export function printReport(report, json, label) {
|
|
217
238
|
const actions = process.env.GITHUB_ACTIONS === "true";
|
|
218
|
-
if (
|
|
239
|
+
if (json) {
|
|
219
240
|
const document = { ...report, failures: report.failures.map(failureJson) };
|
|
220
241
|
process.stdout.write(`${JSON.stringify(document, null, 2)}\n`);
|
|
221
242
|
// The runner reads workflow commands from stderr as from stdout, so stdout stays one document.
|
|
@@ -225,7 +246,20 @@ export function run(argv) {
|
|
|
225
246
|
}
|
|
226
247
|
else {
|
|
227
248
|
const lines = report.failures.flatMap((failure) => actions ? [failureLine(failure), annotation(failure)] : [failureLine(failure)]);
|
|
228
|
-
process.stdout.write(`${[...lines, verdict(report)].join("\n")}\n`);
|
|
249
|
+
process.stdout.write(`${[...lines, verdict(label, report)].join("\n")}\n`);
|
|
229
250
|
}
|
|
230
251
|
return EXIT_CODES[report.status];
|
|
231
252
|
}
|
|
253
|
+
export function run(argv) {
|
|
254
|
+
const cli = parseCli(argv);
|
|
255
|
+
if ("error" in cli) {
|
|
256
|
+
process.stderr.write(`dw-sdlc gate: ${cli.error}\n\n${USAGE}\n`);
|
|
257
|
+
return 2;
|
|
258
|
+
}
|
|
259
|
+
if (cli.help) {
|
|
260
|
+
process.stdout.write(`${USAGE}\n`);
|
|
261
|
+
return 0;
|
|
262
|
+
}
|
|
263
|
+
const { json, updateBaseline } = cli;
|
|
264
|
+
return printReport(runGate(repositoryRoot(cli.root ?? process.cwd()), { json, updateBaseline }), json, "gate");
|
|
265
|
+
}
|
|
@@ -5,7 +5,8 @@ import { spawnSync } from "node:child_process";
|
|
|
5
5
|
import { existsSync, lstatSync, mkdirSync, readFileSync, symlinkSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { dirname, join } from "node:path";
|
|
7
7
|
import { parseArgs } from "node:util";
|
|
8
|
-
import {
|
|
8
|
+
import { appendKey, parseObject, render } from "../json-text.js";
|
|
9
|
+
import { CONFIG, readReferences } from "../references-key.js";
|
|
9
10
|
import { renderBlock, withBlock } from "../router-block.js";
|
|
10
11
|
const ROUTER = "references/AGENTS.md";
|
|
11
12
|
/** A symlink to the Router, so Claude Code loads it. */
|
|
@@ -45,20 +46,6 @@ function fail(message) {
|
|
|
45
46
|
console.error(`references:setup: ${message}`);
|
|
46
47
|
process.exit(2);
|
|
47
48
|
}
|
|
48
|
-
function render(value) {
|
|
49
|
-
return `${JSON.stringify(value, null, 2)}\n`;
|
|
50
|
-
}
|
|
51
|
-
/** The JSON object `text` holds, or undefined when it holds anything else. */
|
|
52
|
-
function parseObject(text) {
|
|
53
|
-
let parsed;
|
|
54
|
-
try {
|
|
55
|
-
parsed = JSON.parse(text);
|
|
56
|
-
}
|
|
57
|
-
catch {
|
|
58
|
-
return undefined;
|
|
59
|
-
}
|
|
60
|
-
return isObject(parsed) ? parsed : undefined;
|
|
61
|
-
}
|
|
62
49
|
class Writer {
|
|
63
50
|
steps = [];
|
|
64
51
|
root;
|
|
@@ -138,13 +125,7 @@ function withEmptyKey(current) {
|
|
|
138
125
|
if (config === undefined) {
|
|
139
126
|
return { refusal: "is not a JSON object; fix it by hand first" };
|
|
140
127
|
}
|
|
141
|
-
|
|
142
|
-
return { text: current };
|
|
143
|
-
}
|
|
144
|
-
const close = current.lastIndexOf("}");
|
|
145
|
-
const body = current.slice(0, close).trimEnd();
|
|
146
|
-
const comma = body.endsWith("{") ? "" : ",";
|
|
147
|
-
return { text: `${body}${comma}\n "references": []\n${current.slice(close)}` };
|
|
128
|
+
return { text: Object.hasOwn(config, "references") ? current : appendKey(current, "references", []) };
|
|
148
129
|
}
|
|
149
130
|
/** The repository `dir` sits in, or `dir` itself outside one. */
|
|
150
131
|
function toplevel(dir) {
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { type CommitConvention, type CommitOnProtected, type ForcePush } from "../agentic-config.ts";
|
|
2
|
-
|
|
2
|
+
/** `by hand` is an edit the setup names and leaves to the user. */
|
|
3
|
+
export type Outcome = "wrote" | "unchanged" | "refused" | "by hand";
|
|
3
4
|
export type Step = {
|
|
4
5
|
file: string;
|
|
5
6
|
outcome: Outcome;
|
|
@@ -18,8 +19,10 @@ export type SetupOptions = {
|
|
|
18
19
|
forcePush?: ForcePush | undefined;
|
|
19
20
|
/** Pins `guardrails.commitOnProtected` in the local layer; refused where it loosens the committed value. */
|
|
20
21
|
commitOnProtected?: CommitOnProtected | undefined;
|
|
22
|
+
/** The owner's yes to pinning dw-sdlc as a devDependency where package.json lacks it. */
|
|
23
|
+
addPackage?: boolean;
|
|
21
24
|
};
|
|
22
|
-
export declare const summary = "write this machine's local config layer, the git excludes and the permission rules";
|
|
25
|
+
export declare const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
|
|
23
26
|
/**
|
|
24
27
|
* What the machine's layer points `$schema` at: the marketplace clone's schema as a `file://` URL, as an
|
|
25
28
|
* editor cannot fetch the `$id` from a private repository and the clone, unlike the install cache, keeps
|
package/dist/commands/setup.js
CHANGED
|
@@ -1,18 +1,20 @@
|
|
|
1
|
-
// `dw-sdlc setup` sets a repository and this machine up for the sdlc plugin: the
|
|
2
|
-
//
|
|
3
|
-
// merged, never replaced: other keys stay, and a
|
|
1
|
+
// `dw-sdlc setup` sets a repository and this machine up for the sdlc plugin: the files its USAGE names,
|
|
2
|
+
// 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 file that is not a JSON object is refused.
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
5
|
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
6
6
|
import { homedir } from "node:os";
|
|
7
7
|
import { dirname, join, relative } from "node:path";
|
|
8
8
|
import { pathToFileURL } from "node:url";
|
|
9
9
|
import { parseArgs } from "node:util";
|
|
10
|
-
import { COMMIT_CONVENTIONS, COMMITTED_FILE, DEFAULT_GUARDRAILS, LOCAL_FILE, SCHEMA_FILE, STRICTNESS, inferConfig, repositoryRoot, schemaId, validateDocument } from "../agentic-config.js";
|
|
10
|
+
import { COMMIT_CONVENTIONS, COMMITTED_FILE, DEFAULT_GUARDRAILS, LOCAL_FILE, SCHEMA_FILE, STRICTNESS, inferConfig, repositoryRoot, resolveConfig, schemaId, validateDocument } from "../agentic-config.js";
|
|
11
|
+
import { isObject, parseObject, render } from "../json-text.js";
|
|
12
|
+
import { withGate, withPackage } from "../setup-gate.js";
|
|
11
13
|
import { GLOBAL_DENY, LEASE_RULE, RETIRED_DENY, repositoryDeny } from "../setup-rules.js";
|
|
12
|
-
export const summary = "write this machine's local config layer, the git excludes and the permission rules";
|
|
14
|
+
export const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
|
|
13
15
|
const USAGE = `Usage: dw-sdlc setup [--root <dir>] [--home <dir>]
|
|
14
16
|
[--convention <preset>] [--force-push deny|ask|lease] [--commit-on-protected deny|allow]
|
|
15
|
-
[--force] [--dry-run]
|
|
17
|
+
[--add-package] [--force] [--dry-run]
|
|
16
18
|
or: pnpm guardrails:setup with the same flags, in a checkout of dw-sdlc
|
|
17
19
|
|
|
18
20
|
Writes .ai/agentic.config.local.json from inference, excludes it in .git/info/exclude, generates
|
|
@@ -23,7 +25,14 @@ written only when it would change; --convention ticket-key | conventional pins c
|
|
|
23
25
|
the local config, and a run without it keeps the pin as it is; a guardrails block in the local config
|
|
24
26
|
is kept, and --force-push and --commit-on-protected pin those two keys in it, refused where they would
|
|
25
27
|
loosen what the committed config sets; --force overwrites a local config that differs in anything
|
|
26
|
-
else; --dry-run reports without writing
|
|
28
|
+
else; --dry-run reports without writing.
|
|
29
|
+
|
|
30
|
+
Where the repository has a package.json, the setup adopts the Gate. --add-package pins dw-sdlc
|
|
31
|
+
there as a devDependency, at the version running the setup; a new dependency needs the owner's yes,
|
|
32
|
+
so without the flag the file is refused. Once the package is there, .ai/agentic.config.json gains
|
|
33
|
+
the gate key when it has none, and the file is created when absent. Both files go to the repository
|
|
34
|
+
holding --root, a linked worktree included. The agent map is never written. Settings that still
|
|
35
|
+
enable guardrails@dw-sdlc or references@dw-sdlc get a "by hand" line naming the enabledPlugins edit.`;
|
|
27
36
|
function isConvention(value) {
|
|
28
37
|
return COMMIT_CONVENTIONS.some((preset) => preset === value);
|
|
29
38
|
}
|
|
@@ -38,9 +47,8 @@ const MARKETPLACE = "dw-sdlc";
|
|
|
38
47
|
const PLUGIN = "sdlc";
|
|
39
48
|
/** The plugin's link to the dw-sdlc package, which carries the schema. */
|
|
40
49
|
const PACKAGE_LINK = "dw-sdlc";
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
}
|
|
50
|
+
/** The two plugins the sdlc plugin replaced, which the marketplace does not list (ADR 0010). */
|
|
51
|
+
const RETIRED_PLUGINS = ["guardrails", "references"].map((name) => `${name}@${MARKETPLACE}`);
|
|
44
52
|
/** A read-only git command, the one thing the setup runs in the repository. */
|
|
45
53
|
function git(root, args) {
|
|
46
54
|
const result = spawnSync("git", ["-C", root, ...args], { encoding: "utf8" });
|
|
@@ -169,24 +177,8 @@ function union(existing, additions) {
|
|
|
169
177
|
}
|
|
170
178
|
return list;
|
|
171
179
|
}
|
|
172
|
-
function readJsonText(text) {
|
|
173
|
-
try {
|
|
174
|
-
const parsed = JSON.parse(text);
|
|
175
|
-
return parsed;
|
|
176
|
-
}
|
|
177
|
-
catch {
|
|
178
|
-
return undefined;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
180
|
function readJson(path) {
|
|
182
|
-
|
|
183
|
-
return undefined;
|
|
184
|
-
}
|
|
185
|
-
const parsed = readJsonText(readFileSync(path, "utf8"));
|
|
186
|
-
return isObject(parsed) ? parsed : "invalid";
|
|
187
|
-
}
|
|
188
|
-
function render(value) {
|
|
189
|
-
return JSON.stringify(value, null, 2) + "\n";
|
|
181
|
+
return existsSync(path) ? (parseObject(readFileSync(path, "utf8")) ?? "invalid") : undefined;
|
|
190
182
|
}
|
|
191
183
|
class Writer {
|
|
192
184
|
steps = [];
|
|
@@ -194,8 +186,8 @@ class Writer {
|
|
|
194
186
|
constructor(dryRun) {
|
|
195
187
|
this.dryRun = dryRun;
|
|
196
188
|
}
|
|
197
|
-
/** Writes when the text differs from what is on disk; records the outcome under `label
|
|
198
|
-
put(label, path, text) {
|
|
189
|
+
/** Writes when the text differs from what is on disk; records the outcome under `label`, with what the write does. */
|
|
190
|
+
put(label, path, text, detail) {
|
|
199
191
|
const current = existsSync(path) ? readFileSync(path, "utf8") : undefined;
|
|
200
192
|
if (current === text) {
|
|
201
193
|
this.steps.push({ file: label, outcome: "unchanged" });
|
|
@@ -205,11 +197,36 @@ class Writer {
|
|
|
205
197
|
mkdirSync(dirname(path), { recursive: true });
|
|
206
198
|
writeFileSync(path, text);
|
|
207
199
|
}
|
|
208
|
-
this.steps.push({ file: label, outcome: "wrote" });
|
|
200
|
+
this.steps.push(detail === undefined ? { file: label, outcome: "wrote" } : { file: label, outcome: "wrote", detail });
|
|
209
201
|
}
|
|
210
202
|
refuse(label, detail) {
|
|
211
203
|
this.steps.push({ file: label, outcome: "refused", detail });
|
|
212
204
|
}
|
|
205
|
+
/** The step's file as `change` leaves it: written, unchanged or refused. */
|
|
206
|
+
apply(label, path, change) {
|
|
207
|
+
if ("refusal" in change) {
|
|
208
|
+
this.refuse(label, change.refusal);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
this.put(label, path, change.text, change.detail);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
byHand(label, detail) {
|
|
215
|
+
this.steps.push({ file: label, outcome: "by hand", detail });
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
/** The enabledPlugins edit settings that still enable a retired plugin need; none where they enable neither. */
|
|
219
|
+
function enabledPluginsEdit(settings) {
|
|
220
|
+
const enabled = isObject(settings.enabledPlugins) ? settings.enabledPlugins : {};
|
|
221
|
+
const retired = RETIRED_PLUGINS.filter((id) => enabled[id] === true);
|
|
222
|
+
if (retired.length === 0) {
|
|
223
|
+
return undefined;
|
|
224
|
+
}
|
|
225
|
+
const id = `${PLUGIN}@${MARKETPLACE}`;
|
|
226
|
+
const remove = `enabledPlugins: remove ${retired.map((name) => JSON.stringify(name)).join(" and ")}, which the marketplace does not list`;
|
|
227
|
+
return enabled[id] === true
|
|
228
|
+
? remove
|
|
229
|
+
: `${remove}, and add "${id}": true after claude plugin install ${id} --scope user`;
|
|
213
230
|
}
|
|
214
231
|
/**
|
|
215
232
|
* Where the repository's files live: the main checkout for a linked worktree, since every worktree reads
|
|
@@ -231,7 +248,7 @@ export function setup(options) {
|
|
|
231
248
|
// the guardrails block the file holds, with the keys the flags pin.
|
|
232
249
|
const localPath = join(checkout, LOCAL_FILE);
|
|
233
250
|
const localCurrent = existsSync(localPath) ? readFileSync(localPath, "utf8") : undefined;
|
|
234
|
-
const existing = localCurrent === undefined ? undefined :
|
|
251
|
+
const existing = localCurrent === undefined ? undefined : parseObject(localCurrent);
|
|
235
252
|
const convention = options.convention ?? pinnedConvention(existing);
|
|
236
253
|
const pinned = pinnedGuardrails(keptGuardrails(existing), committedConfig(checkout), options);
|
|
237
254
|
if ("refusal" in pinned) {
|
|
@@ -292,6 +309,23 @@ export function setup(options) {
|
|
|
292
309
|
const permissions = isObject(settings.permissions) ? settings.permissions : {};
|
|
293
310
|
settings.permissions = { ...permissions, deny: union(permissions.deny, GLOBAL_DENY) };
|
|
294
311
|
writer.put(userLabel, userPath, render(settings));
|
|
312
|
+
const edit = enabledPluginsEdit(settings);
|
|
313
|
+
if (edit !== undefined) {
|
|
314
|
+
writer.byHand(userLabel, edit);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// 5. The Gate, where the repository has a package.json: the pinned package, then the gate key once the
|
|
318
|
+
// package is there. Both are committed, so they go to the repository holding --root, never the main
|
|
319
|
+
// checkout, and stay on the branch the setup runs on.
|
|
320
|
+
const manifestPath = join(root, "package.json");
|
|
321
|
+
if (existsSync(manifestPath)) {
|
|
322
|
+
const manager = resolveConfig(root).config.packageManagers.node ?? "pnpm";
|
|
323
|
+
const pin = withPackage(readFileSync(manifestPath, "utf8"), options.addPackage === true, manager);
|
|
324
|
+
writer.apply("package.json", manifestPath, pin);
|
|
325
|
+
if (!("refusal" in pin)) {
|
|
326
|
+
const configPath = join(root, COMMITTED_FILE);
|
|
327
|
+
writer.apply(COMMITTED_FILE, configPath, withGate(existsSync(configPath) ? readFileSync(configPath, "utf8") : undefined));
|
|
328
|
+
}
|
|
295
329
|
}
|
|
296
330
|
return writer.steps;
|
|
297
331
|
}
|
|
@@ -311,6 +345,7 @@ export function run(argv) {
|
|
|
311
345
|
convention: { type: "string" },
|
|
312
346
|
"force-push": { type: "string" },
|
|
313
347
|
"commit-on-protected": { type: "string" },
|
|
348
|
+
"add-package": { type: "boolean" },
|
|
314
349
|
force: { type: "boolean" },
|
|
315
350
|
"dry-run": { type: "boolean" }
|
|
316
351
|
}
|
|
@@ -342,7 +377,8 @@ export function run(argv) {
|
|
|
342
377
|
dryRun: values["dry-run"] === true,
|
|
343
378
|
convention: values.convention,
|
|
344
379
|
forcePush,
|
|
345
|
-
commitOnProtected
|
|
380
|
+
commitOnProtected,
|
|
381
|
+
addPackage: values["add-package"] === true
|
|
346
382
|
});
|
|
347
383
|
for (const step of steps) {
|
|
348
384
|
const verb = step.outcome === "wrote" && values["dry-run"] === true ? "would write" : step.outcome;
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export type JsonObject = Record<string, unknown>;
|
|
2
|
+
export declare function isObject(value: unknown): value is JsonObject;
|
|
3
|
+
/** The JSON object `text` holds, or undefined when it holds anything else. */
|
|
4
|
+
export declare function parseObject(text: string): JsonObject | undefined;
|
|
5
|
+
export declare function render(value: unknown): string;
|
|
6
|
+
/** `text`, a JSON object, with `key` set to `value` after its last key, two spaces in. */
|
|
7
|
+
export declare function appendKey(text: string, key: string, value: unknown): string;
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// The text of a JSON object file as the setup commands read and write it: parsed only when it holds an
|
|
2
|
+
// object, rendered two spaces in, and a key appended so a hand-formatted file keeps its layout.
|
|
3
|
+
export function isObject(value) {
|
|
4
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
5
|
+
}
|
|
6
|
+
/** The JSON object `text` holds, or undefined when it holds anything else. */
|
|
7
|
+
export function parseObject(text) {
|
|
8
|
+
try {
|
|
9
|
+
const parsed = JSON.parse(text);
|
|
10
|
+
return isObject(parsed) ? parsed : undefined;
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return undefined;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export function render(value) {
|
|
17
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
18
|
+
}
|
|
19
|
+
/** `text`, a JSON object, with `key` set to `value` after its last key, two spaces in. */
|
|
20
|
+
export function appendKey(text, key, value) {
|
|
21
|
+
const close = text.lastIndexOf("}");
|
|
22
|
+
const body = text.slice(0, close).trimEnd();
|
|
23
|
+
const comma = body.endsWith("{") ? "" : ",";
|
|
24
|
+
const rendered = JSON.stringify(value, null, 2).replaceAll("\n", "\n ");
|
|
25
|
+
return `${body}${comma}\n ${JSON.stringify(key)}: ${rendered}\n${text.slice(close)}`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { NodePackageManager } from "./agentic-config.ts";
|
|
2
|
+
/** A file's text after the setup and what writing it does, or why the setup leaves the file alone. */
|
|
3
|
+
export type Change = {
|
|
4
|
+
text: string;
|
|
5
|
+
detail?: string;
|
|
6
|
+
} | {
|
|
7
|
+
refusal: string;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* package.json with the package pinned exactly as a devDependency, in name order, where neither list
|
|
11
|
+
* names it. A new dependency needs the owner's yes, so without `add` the file is refused.
|
|
12
|
+
*/
|
|
13
|
+
export declare function withPackage(current: string, add: boolean, manager: NodePackageManager): Change;
|
|
14
|
+
/** The committed config with the `gate` key when it has none, the file created when absent; a key it has stays. */
|
|
15
|
+
export declare function withGate(current: string | undefined): Change;
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
// What `dw-sdlc setup` changes to adopt the Gate, in files the repository commits: the `dw-sdlc`
|
|
2
|
+
// devDependency in package.json, pinned to the version that runs the setup, and the `gate` key of the
|
|
3
|
+
// committed config. Each answers with the file's next text, or why the file stays as it is.
|
|
4
|
+
import { readFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { CHECK_NAMES } from "./checks/index.js";
|
|
7
|
+
import { appendKey, isObject, parseObject, render } from "./json-text.js";
|
|
8
|
+
const PACKAGE = "dw-sdlc";
|
|
9
|
+
/**
|
|
10
|
+
* The Checks a new `gate` key turns on: each judges a tree as it is, the agent map aside, which the
|
|
11
|
+
* setup never writes and the first Gate names. The rest read an input a repository adds first, a
|
|
12
|
+
* catalogue, a records directory, docs a glob matches or a Ratchet's baseline, so they start `false`.
|
|
13
|
+
*/
|
|
14
|
+
const ON_BY_DEFAULT = new Set(["agents-map", "markdown-refs", "temporal-language", "comment-budget"]);
|
|
15
|
+
/** Where a committed config the setup creates points `$schema`: the schema the pinned package installs. */
|
|
16
|
+
const SCHEMA = `../node_modules/${PACKAGE}/config/agentic.config.schema.json`;
|
|
17
|
+
const NOT_AN_OBJECT = "is not a JSON object; fix it by hand first";
|
|
18
|
+
/** This package's version, the one a pin names, so the plugin and the repository run the same code. */
|
|
19
|
+
function packageVersion() {
|
|
20
|
+
const manifest = parseObject(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8"));
|
|
21
|
+
if (typeof manifest?.version !== "string") {
|
|
22
|
+
throw new Error(`${PACKAGE}'s package.json names no version`);
|
|
23
|
+
}
|
|
24
|
+
return manifest.version;
|
|
25
|
+
}
|
|
26
|
+
/** `a`, `a and b`, `a, b and c`. */
|
|
27
|
+
function list(items) {
|
|
28
|
+
return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
29
|
+
}
|
|
30
|
+
/** Whether either dependency list names the package, at any spec: a pin is the doctor's to judge. */
|
|
31
|
+
function dependsOn(manifest) {
|
|
32
|
+
return ["dependencies", "devDependencies"].some((key) => {
|
|
33
|
+
const section = manifest[key];
|
|
34
|
+
return isObject(section) && Object.hasOwn(section, PACKAGE);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* package.json with the package pinned exactly as a devDependency, in name order, where neither list
|
|
39
|
+
* names it. A new dependency needs the owner's yes, so without `add` the file is refused.
|
|
40
|
+
*/
|
|
41
|
+
export function withPackage(current, add, manager) {
|
|
42
|
+
const manifest = parseObject(current);
|
|
43
|
+
if (manifest === undefined) {
|
|
44
|
+
return { refusal: NOT_AN_OBJECT };
|
|
45
|
+
}
|
|
46
|
+
if (dependsOn(manifest)) {
|
|
47
|
+
return { text: current };
|
|
48
|
+
}
|
|
49
|
+
const version = packageVersion();
|
|
50
|
+
if (!add) {
|
|
51
|
+
return {
|
|
52
|
+
refusal: `no ${PACKAGE} devDependency, and a new one needs your yes: --add-package pins ${PACKAGE} ${version}`
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
const { devDependencies = {} } = manifest;
|
|
56
|
+
if (!isObject(devDependencies)) {
|
|
57
|
+
return { refusal: "devDependencies is not an object; fix it by hand first" };
|
|
58
|
+
}
|
|
59
|
+
const entries = Object.entries(devDependencies);
|
|
60
|
+
const at = entries.findIndex(([name]) => name > PACKAGE);
|
|
61
|
+
entries.splice(at === -1 ? entries.length : at, 0, [PACKAGE, version]);
|
|
62
|
+
// A manifest is JSON.stringify's layout, as package managers write it, at the indent the file opens with.
|
|
63
|
+
const indent = /^\{\r?\n([ \t]+)"/.exec(current)?.[1] ?? " ";
|
|
64
|
+
const next = { ...manifest, devDependencies: Object.fromEntries(entries) };
|
|
65
|
+
return {
|
|
66
|
+
text: JSON.stringify(next, null, indent) + (current.endsWith("\n") ? "\n" : ""),
|
|
67
|
+
detail: `pins ${PACKAGE} ${version} as a devDependency; ${manager} install brings it in`
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
/** The committed config with the `gate` key when it has none, the file created when absent; a key it has stays. */
|
|
71
|
+
export function withGate(current) {
|
|
72
|
+
const gate = { checks: Object.fromEntries(CHECK_NAMES.map((name) => [name, ON_BY_DEFAULT.has(name)])) };
|
|
73
|
+
const detail = `the gate key, ${list(CHECK_NAMES.filter((name) => ON_BY_DEFAULT.has(name)))} on and the other Checks off`;
|
|
74
|
+
if (current === undefined) {
|
|
75
|
+
return { text: render({ $schema: SCHEMA, version: 1, gate }), detail };
|
|
76
|
+
}
|
|
77
|
+
const config = parseObject(current);
|
|
78
|
+
if (config === undefined) {
|
|
79
|
+
return { refusal: NOT_AN_OBJECT };
|
|
80
|
+
}
|
|
81
|
+
return Object.hasOwn(config, "gate") ? { text: current } : { text: appendKey(current, "gate", gate), detail };
|
|
82
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dw-sdlc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "The Gate and the agentic config of a repository: the dw-sdlc bin with the Gate, the setup, the doctor, the config check, and the Sync and setup of References; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|