dw-sdlc 0.7.0 → 0.9.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 (45) hide show
  1. package/README.md +20 -12
  2. package/config/agentic.config.schema.json +56 -18
  3. package/dist/agentic-config.d.ts +23 -7
  4. package/dist/agentic-config.js +26 -12
  5. package/dist/checks/agents-map.d.ts +9 -1
  6. package/dist/checks/agents-map.js +60 -17
  7. package/dist/checks/decision-records.d.ts +5 -0
  8. package/dist/checks/decision-records.js +121 -16
  9. package/dist/checks/file-size-ratchet.js +7 -5
  10. package/dist/checks/index.js +2 -0
  11. package/dist/checks/markdown-budget.d.ts +2 -5
  12. package/dist/checks/markdown-budget.js +6 -15
  13. package/dist/checks/markdown-refs.js +1 -11
  14. package/dist/checks/routed-docs.d.ts +6 -0
  15. package/dist/checks/routed-docs.js +49 -0
  16. package/dist/checks/tree.d.ts +2 -0
  17. package/dist/checks/tree.js +13 -3
  18. package/dist/commands/check.d.ts +1 -1
  19. package/dist/commands/check.js +6 -4
  20. package/dist/commands/gate.d.ts +10 -4
  21. package/dist/commands/gate.js +202 -86
  22. package/dist/commands/pr-reviewed.js +5 -21
  23. package/dist/commands/pr-ship.js +37 -17
  24. package/dist/commands/references-sync.js +5 -6
  25. package/dist/drift.d.ts +17 -0
  26. package/dist/drift.js +68 -0
  27. package/dist/gate-cache.d.ts +13 -0
  28. package/dist/gate-cache.js +39 -0
  29. package/dist/git-env.d.ts +2 -0
  30. package/dist/git-env.js +10 -0
  31. package/dist/markdown.d.ts +19 -0
  32. package/dist/markdown.js +46 -2
  33. package/dist/package-version.d.ts +2 -0
  34. package/dist/package-version.js +13 -0
  35. package/dist/pr/decide.d.ts +31 -8
  36. package/dist/pr/decide.js +65 -31
  37. package/dist/router-block.d.ts +4 -1
  38. package/dist/router-block.js +31 -7
  39. package/dist/run-command.d.ts +24 -0
  40. package/dist/run-command.js +121 -0
  41. package/dist/setup-gate.js +3 -12
  42. package/dist/working-tree.d.ts +13 -0
  43. package/dist/working-tree.js +60 -0
  44. package/package.json +1 -1
  45. package/templates/router.md +1 -0
@@ -1,39 +1,67 @@
1
1
  // `dw-sdlc gate`: the Gate of a repository, from the `gate` key of its resolved agentic config. The
2
2
  // run and the report serve `dw-sdlc check` too, which judges one Check of it.
3
- import { spawnSync } from "node:child_process";
4
3
  import { parseArgs } from "node:util";
5
4
  import { repositoryRoot, resolveConfig } from "../agentic-config.js";
6
5
  import { CannotRun } from "../checks/check.js";
7
6
  import { CHECK_NAMES, enabledChecks, shippedCheck } from "../checks/index.js";
7
+ import { watchDrift } from "../drift.js";
8
+ import { fingerprint, isGreen, stampGreen } from "../gate-cache.js";
8
9
  import { annotation, failureJson, failureLine } from "../report.js";
10
+ import { holdingSignals, runCommand, stoppedBy, TAIL_LINES } from "../run-command.js";
9
11
  export const summary = "run the Gate: the Steps of gate.steps in order, then every Check under gate.checks";
10
- const USAGE = `Usage: dw-sdlc gate [--json] [--root <dir>] [--update-baseline]
12
+ const USAGE = `Usage: dw-sdlc gate [--json] [--verbose] [--root <dir>] [--update-baseline | --cache]
11
13
 
12
14
  Runs the Gate of the repository holding --root (default: the current directory): each Step of
13
- gate.steps in order from the repository root, its output passed through, stopping at the first that
14
- fails; then every Check under gate.checks, the run scripts and then the Checks the package ships,
15
- none stopping the next. Prints one line per failure: the file where there is one, the rule, the
16
- remedy; under GitHub Actions each line is also a workflow annotation. --json prints one document
17
- instead and sends the commands' own output to stderr.
15
+ gate.steps in order from the repository root, stopping at the first that fails; then every Check
16
+ under gate.checks, the run scripts and then the Checks the package ships, none stopping the next.
17
+
18
+ Prints one line per Step with its time, one per Check, one per failure and a last line, green or
19
+ failed. A failure line names the file where there is one, the rule and the remedy; under GitHub
20
+ Actions it is also a workflow annotation. A Step or run script prints nothing of its own unless it
21
+ does not pass: then its last ${TAIL_LINES} lines, both streams in the order written, under its own
22
+ line. In a terminal a Step's line shows when it starts and takes its result when it ends.
23
+
24
+ Each Step runs between two records of every file git tracks or would track: a file the Step created,
25
+ changed or deleted is Drift, one failure per file, and the Steps after it still run. Ignored files
26
+ and edits made before the run never drift, and a Step a signal stopped is not compared. gate.drift
27
+ "allow" turns the watch off.
28
+
29
+ SIGINT or SIGTERM sent while a command runs stops it and everything after it, and the report still
30
+ prints: the stopped command's last lines, its failure line and the last line. A second signal, or one
31
+ sent while the Checks the package ships run, ends the Gate at once.
32
+
33
+ --verbose passes each command's output through as it runs instead, under a line naming the command,
34
+ and keeps no tail. --json prints the document alone; each Step and Check in it carries tail, the last
35
+ ${TAIL_LINES} lines of a command that did not pass, null otherwise. With --verbose, under --json, the
36
+ commands' output goes to stderr and every tail is null.
18
37
 
19
38
  --update-baseline then rewrites the baseline of each Ratchet, only when every Step and every other
20
- Check passed, and only tighter: a file now within its limit leaves it and no file joins. Where there
21
- is no baseline, it records one. The Ratchet is judged again on what it wrote.
39
+ Check passed and no Step drifted, and only tighter: a file now within its limit leaves it and no
40
+ file joins. Where there is no baseline, it records one. The Ratchet is judged again on what it wrote.
41
+
42
+ --cache skips the run when the working tree and the Gate match the last green run in this checkout.
43
+ Untracked files count and ignored files do not. Each worktree keeps its own stamp. A tree it cannot
44
+ hash runs the whole Gate.
22
45
 
23
- Exit 0 when everything passes, 1 when a Step or a Check fails, 2 when the Gate cannot run: an
24
- invalid config layer, a Step or Check whose command the shell cannot find or execute, or a Check
25
- missing an input it reads.`;
46
+ Exit 0 when everything passes, 1 when a Step or a Check fails or a Step drifts, 2 when the Gate
47
+ cannot run: an invalid config layer, a Step or Check whose command the shell cannot find or execute,
48
+ a Check missing an input it reads, or a tree it cannot record for Drift.`;
26
49
  const EXIT_CODES = { pass: 0, fail: 1, error: 2 };
27
- /** What the shell answers for a command it cannot find, and for one it cannot execute. */
28
- const NOT_RUNNABLE = { 127: "was not found", 126: "could not be executed" };
29
- const SKIPPED = { status: "skipped", exitCode: null };
30
- /** One Check run by name says nothing before its result. */
31
- const QUIET = { write: () => true };
50
+ const SKIPPED = { status: "skipped", exitCode: null, tail: null };
51
+ const SILENT = { write: () => true };
52
+ const STATUS_WORDS = {
53
+ pass: "pass",
54
+ fail: "failed",
55
+ error: "cannot run",
56
+ skipped: "skipped"
57
+ };
32
58
  const REMEDIES = {
33
59
  invalidLayer: "fix the file; the Gate runs once every layer is valid",
34
- failed: "fix what it printed above",
35
- stepNotRunnable: "install the command or fix gate.steps in the agentic config",
36
- checkNotRunnable: "install the command or fix gate.checks.run in the agentic config",
60
+ failed: "fix what it printed above; run it alone for the full output",
61
+ notRunnable: {
62
+ step: "install the command or fix gate.steps in the agentic config",
63
+ check: "install the command or fix gate.checks.run in the agentic config"
64
+ },
37
65
  checkStopped: "fix what stopped it, or report it to dw-sdlc"
38
66
  };
39
67
  function plural(count, noun) {
@@ -46,16 +74,20 @@ function parseCli(argv) {
46
74
  options: {
47
75
  help: { type: "boolean", short: "h" },
48
76
  json: { type: "boolean" },
77
+ verbose: { type: "boolean" },
49
78
  root: { type: "string" },
50
- "update-baseline": { type: "boolean" }
79
+ "update-baseline": { type: "boolean" },
80
+ cache: { type: "boolean" }
51
81
  }
52
82
  });
53
- return {
54
- help: values.help === true,
55
- json: values.json === true,
56
- root: values.root,
57
- updateBaseline: values["update-baseline"] === true
58
- };
83
+ const refresh = values["update-baseline"] === true;
84
+ const cache = values.cache === true;
85
+ if (refresh && cache) {
86
+ return { error: "--cache and --update-baseline do not combine: a refresh needs the Gate to run" };
87
+ }
88
+ const mode = refresh ? "update-baseline" : cache ? "cache" : "plain";
89
+ const flags = { help: values.help === true, json: values.json === true, verbose: values.verbose === true };
90
+ return { ...flags, root: values.root, mode };
59
91
  }
60
92
  catch (error) {
61
93
  return { error: error instanceof Error ? error.message : String(error) };
@@ -66,25 +98,64 @@ function configFailure({ file, path, message }) {
66
98
  return { file, rule: "config", message: where, remedy: REMEDIES.invalidLayer };
67
99
  }
68
100
  /**
69
- * Runs `command` through `sh -c` from `root`, stdin closed and stderr passed through; stdout too, or
70
- * onto stderr when `--json` keeps stdout for the document. `detail` says what went wrong, when it did.
101
+ * What follows a Step's or Check's name once it ran: pass, failed or cannot run, with a command's exit,
102
+ * a Step's time, and a captured tail indented under the line.
71
103
  */
72
- function runCommand(command, root, json) {
73
- const result = spawnSync(command, { shell: true, cwd: root, stdio: ["ignore", json ? 2 : "inherit", "inherit"] });
74
- if (result.error !== undefined) {
75
- return { status: "error", exitCode: null, detail: `could not start: ${result.error.message}` };
104
+ function outcomeText(kind, outcome) {
105
+ const isCommand = "seconds" in outcome;
106
+ const exit = isCommand && outcome.status !== "pass" ? howItEnded(outcome) : "";
107
+ const time = isCommand && kind === "step" ? ` (${Math.round(outcome.seconds)} s)` : "";
108
+ const head = `${STATUS_WORDS[outcome.status]}${exit}${time}`;
109
+ if (outcome.tail === null) {
110
+ return head;
111
+ }
112
+ if (outcome.tail.length === 0) {
113
+ return `${head}; it printed nothing`;
76
114
  }
77
- if (result.status === 0) {
78
- return { status: "pass", exitCode: 0 };
115
+ const tail = outcome.tail.map((line) => (line === "" ? "" : ` ${line}`));
116
+ return [`${head}; its last ${TAIL_LINES} lines:`, ...tail].join("\n");
117
+ }
118
+ /** How a command that did not pass ended: the signal that stopped it, or its exit code. */
119
+ function howItEnded({ signal, exitCode }) {
120
+ if (signal !== null) {
121
+ return `, stopped by ${signal}`;
79
122
  }
80
- if (result.status === null) {
81
- return { status: "fail", exitCode: null, detail: `was stopped by ${result.signal ?? "a signal"}` };
123
+ return exitCode === null ? "" : `, exit ${exitCode}`;
124
+ }
125
+ function none() { }
126
+ /** Whether a Step's line goes out before it runs, to take its result in place: only in a terminal. */
127
+ function live(kind) {
128
+ return kind === "step" && process.stdout.isTTY;
129
+ }
130
+ /**
131
+ * Quiet, the Gate prints each result, a Step's name first when stdout is a terminal; `--verbose` names
132
+ * each command before its output; `--json` prints neither. One Check run by name prints only a tail.
133
+ */
134
+ function progressOf(options, log) {
135
+ if ("only" in options) {
136
+ const end = (kind, subject, outcome) => {
137
+ if (!options.json && outcome.tail !== null) {
138
+ process.stdout.write(`${kind} ${subject} — ${outcomeText(kind, outcome)}\n`);
139
+ }
140
+ };
141
+ return { start: none, end };
82
142
  }
83
- const notRunnable = NOT_RUNNABLE[result.status];
84
- if (notRunnable !== undefined) {
85
- return { status: "error", exitCode: result.status, detail: `${notRunnable} (exit ${result.status})` };
143
+ if (options.verbose) {
144
+ return { start: (kind, subject) => log.write(`gate: ${kind} ${subject}\n`), end: none };
86
145
  }
87
- return { status: "fail", exitCode: result.status, detail: `exited ${result.status}` };
146
+ if (options.json) {
147
+ return { start: none, end: none };
148
+ }
149
+ return {
150
+ start: (kind, subject) => {
151
+ if (live(kind)) {
152
+ process.stdout.write(`gate: ${kind} ${subject}`);
153
+ }
154
+ },
155
+ end: (kind, subject, outcome) => {
156
+ process.stdout.write(`${live(kind) ? "" : `gate: ${kind} ${subject}`} — ${outcomeText(kind, outcome)}\n`);
157
+ }
158
+ };
88
159
  }
89
160
  /** What a Check that threw reports: the input it lacks, or what stopped it. */
90
161
  function erred(check, error) {
@@ -93,7 +164,7 @@ function erred(check, error) {
93
164
  : { message: `stopped: ${error instanceof Error ? error.message : String(error)}`, remedy: REMEDIES.checkStopped };
94
165
  return {
95
166
  check,
96
- outcome: { status: "error", exitCode: EXIT_CODES.error },
167
+ outcome: { status: "error", exitCode: EXIT_CODES.error, tail: null },
97
168
  failures: [{ rule: check.name, ...finding }]
98
169
  };
99
170
  }
@@ -102,21 +173,22 @@ function runShipped(check, root) {
102
173
  try {
103
174
  const failures = check.run(root).map((finding) => ({ rule: check.name, ...finding }));
104
175
  const status = failures.length === 0 ? "pass" : "fail";
105
- return { check, outcome: { status, exitCode: EXIT_CODES[status] }, failures };
176
+ return { check, outcome: { status, exitCode: EXIT_CODES[status], tail: null }, failures };
106
177
  }
107
178
  catch (error) {
108
179
  return erred(check, error);
109
180
  }
110
181
  }
111
182
  /**
112
- * Rewrites the baseline of each Ratchet while every Step, every run Check in `others` and every other
113
- * shipped Check passed, and judges the Ratchet again on what it wrote.
183
+ * Rewrites the baseline of each Ratchet while every Step, every run Check and the watch for Drift in
184
+ * `others` and every other shipped Check passed, and judges the Ratchet again on what it wrote.
185
+ * Returns a line on each.
114
186
  */
115
- function refreshBaselines(judged, others, root, log) {
187
+ function refreshBaselines(judged, others, root) {
116
188
  if (!judged.some(({ check }) => check.baseline !== undefined)) {
117
- log.write("gate: no Ratchet is turned on, so no baseline to refresh\n");
118
- return;
189
+ return ["gate: no Ratchet is turned on, so no baseline to refresh"];
119
190
  }
191
+ const notes = [];
120
192
  for (const [index, { check }] of judged.entries()) {
121
193
  if (check.baseline === undefined) {
122
194
  continue;
@@ -124,22 +196,23 @@ function refreshBaselines(judged, others, root, log) {
124
196
  const { file, refresh } = check.baseline;
125
197
  const rest = [...others, ...judged.filter((_, at) => at !== index).map(({ outcome }) => outcome)];
126
198
  if (rest.some(({ status }) => status !== "pass")) {
127
- log.write(`gate: baseline ${file} not refreshed — a Step or another Check did not pass\n`);
199
+ notes.push(`gate: baseline ${file} not refreshed — a Step or another Check did not pass`);
128
200
  continue;
129
201
  }
130
202
  try {
131
- log.write(`gate: baseline ${file} ${refresh(root)}\n`);
203
+ notes.push(`gate: baseline ${file} ${refresh(root)}`);
132
204
  judged[index] = runShipped(check, root);
133
205
  }
134
206
  catch (error) {
135
207
  if (error instanceof CannotRun) {
136
- log.write(`gate: baseline ${file} not refreshed — ${error.finding.message}; ${error.finding.remedy}\n`);
208
+ notes.push(`gate: baseline ${file} not refreshed — ${error.finding.message}; ${error.finding.remedy}`);
137
209
  }
138
210
  else {
139
211
  judged[index] = erred(check, error);
140
212
  }
141
213
  }
142
214
  }
215
+ return notes;
143
216
  }
144
217
  function worst(outcomes) {
145
218
  const statuses = new Set(outcomes.map(({ status }) => status));
@@ -162,10 +235,13 @@ function select(gate, only) {
162
235
  }
163
236
  return { steps: [], run: scripts, shipped: shipped === undefined ? [] : [shipped] };
164
237
  }
165
- export function runGate(root, options) {
238
+ export async function runGate(root, options) {
166
239
  const { json } = options;
167
240
  const only = "only" in options ? options.only : undefined;
168
- const log = only !== undefined ? QUIET : json ? process.stderr : process.stdout;
241
+ const mode = "mode" in options ? options.mode : undefined;
242
+ const log = only !== undefined ? SILENT : json ? process.stderr : process.stdout;
243
+ const progress = progressOf(options, log);
244
+ const output = "verbose" in options && options.verbose ? (json ? "stderr" : "stdout") : "capture";
169
245
  const { config, problems } = resolveConfig(root);
170
246
  const gateCommand = config.gate.command ?? null;
171
247
  if (problems.length > 0) {
@@ -175,55 +251,94 @@ export function runGate(root, options) {
175
251
  if ("rule" in selected) {
176
252
  return { status: "error", command: gateCommand, steps: [], checks: [], failures: [selected] };
177
253
  }
254
+ let toStamp;
255
+ if (mode === "cache") {
256
+ const found = fingerprint(root, config.gate);
257
+ if ("error" in found) {
258
+ log.write(`gate: cannot fingerprint the tree, so the whole Gate runs — ${found.error}\n`);
259
+ }
260
+ else if (isGreen(found)) {
261
+ return { status: "pass", command: gateCommand, cached: true, steps: [], checks: [], failures: [] };
262
+ }
263
+ else {
264
+ toStamp = found;
265
+ }
266
+ }
178
267
  const failures = [];
179
- /** Runs one Step or Check and records its failure under `rule`, if it has one. */
180
- const runOne = (command, rule, notRunnable) => {
181
- const { detail, ...outcome } = runCommand(command, root, json);
182
- if (detail !== undefined) {
183
- const remedy = outcome.status === "error" ? notRunnable : REMEDIES.failed;
184
- failures.push({ rule, message: `${JSON.stringify(command)} ${detail}`, remedy });
268
+ const watch = watchDrift(root, config.gate);
269
+ /** Runs one Step or Check, prints its progress and records its failure under `rule`, if it has one. */
270
+ const runOne = async (kind, subject, command) => {
271
+ progress.start(kind, subject);
272
+ const ran = await runCommand(command, root, output);
273
+ progress.end(kind, subject, ran);
274
+ if (ran.detail !== undefined) {
275
+ const remedy = ran.status === "error" ? REMEDIES.notRunnable[kind] : REMEDIES.failed;
276
+ failures.push({
277
+ rule: kind === "step" ? "step" : subject,
278
+ message: `${JSON.stringify(command)} ${ran.detail}`,
279
+ remedy
280
+ });
185
281
  }
186
- return outcome;
282
+ return { status: ran.status, exitCode: ran.exitCode, tail: ran.tail };
187
283
  };
188
- const steps = [];
189
- let stopped = false;
190
- for (const step of selected.steps) {
191
- if (stopped) {
192
- steps.push({ command: step, ...SKIPPED });
193
- continue;
284
+ // While commands run, SIGINT and SIGTERM stop the one running and every one after it, and the report
285
+ // still prints. The Checks the package ships run in this process, where a signal ends the Gate at once.
286
+ const commands = await holdingSignals(async () => {
287
+ let failedStep = false;
288
+ const halted = () => failedStep || stoppedBy() !== undefined;
289
+ const steps = [];
290
+ for (const step of selected.steps) {
291
+ const watched = halted() ? undefined : await watch.step(step, () => runOne("step", step, step));
292
+ failures.push(...(watched?.failures ?? []));
293
+ const outcome = watched?.result ?? SKIPPED;
294
+ steps.push({ command: step, ...outcome });
295
+ failedStep ||= outcome.status !== "pass";
194
296
  }
195
- log.write(`gate: step ${step}\n`);
196
- const outcome = runOne(step, "step", REMEDIES.stepNotRunnable);
197
- steps.push({ command: step, ...outcome });
198
- stopped = outcome.status !== "pass";
199
- }
200
- const checks = [];
201
- for (const check of selected.run) {
202
- if (stopped) {
203
- checks.push({ ...check, ...SKIPPED });
204
- continue;
297
+ const checks = [];
298
+ for (const check of selected.run) {
299
+ checks.push({ ...check, ...(halted() ? SKIPPED : await runOne("check", check.name, check.command)) });
205
300
  }
206
- log.write(`gate: check ${check.name}\n`);
207
- checks.push({ ...check, ...runOne(check.command, check.name, REMEDIES.checkNotRunnable) });
301
+ return { steps, checks, halted: halted() };
302
+ });
303
+ const { steps, checks } = commands;
304
+ const late = commands.halted ? undefined : stoppedBy();
305
+ if (late !== undefined) {
306
+ // It landed after the last command, which it could not stop: it ends the Gate as in the shipped Checks.
307
+ process.kill(process.pid, late);
208
308
  }
309
+ const stopped = commands.halted;
209
310
  const judged = selected.shipped.map((check) => {
210
311
  if (stopped) {
211
312
  return { check, outcome: SKIPPED, failures: [] };
212
313
  }
213
- log.write(`gate: check ${check.name}\n`);
314
+ progress.start("check", check.name);
214
315
  return runShipped(check, root);
215
316
  });
216
- if ("updateBaseline" in options && options.updateBaseline) {
217
- refreshBaselines(judged, [...steps, ...checks], root, log);
218
- }
317
+ const watchOutcome = { status: watch.status() };
318
+ const notes = mode === "update-baseline" ? refreshBaselines(judged, [...steps, ...checks, watchOutcome], root) : [];
319
+ // A Ratchet's result is printed after its refresh, which judges it again.
219
320
  for (const { check, outcome, failures: found } of judged) {
321
+ if (outcome.status !== "skipped") {
322
+ progress.end("check", check.name, outcome);
323
+ }
220
324
  failures.push(...found);
221
325
  checks.push({ name: check.name, command: null, ...outcome });
222
326
  }
223
- return { status: worst([...steps, ...checks]), command: gateCommand, steps, checks, failures };
327
+ for (const note of notes) {
328
+ log.write(`${note}\n`);
329
+ }
330
+ const status = worst([...steps, ...checks, watchOutcome]);
331
+ // Stamped with the fingerprint taken before the run: a Gate that changed the tree runs again next time.
332
+ if (status === "pass" && toStamp !== undefined) {
333
+ stampGreen(toStamp);
334
+ }
335
+ return { status, command: gateCommand, steps, checks, failures };
224
336
  }
225
337
  /** The last line, after `label`: green with what ran, or failed or cannot run with how many failures. */
226
338
  function verdict(label, report) {
339
+ if (report.cached === true) {
340
+ return `${label}: green — cached, the tree matches the last green run`;
341
+ }
227
342
  if (report.status === "pass") {
228
343
  return `${label}: green — ${plural(report.steps.length, "step")}, ${plural(report.checks.length, "check")}`;
229
344
  }
@@ -250,7 +365,7 @@ export function printReport(report, json, label) {
250
365
  }
251
366
  return EXIT_CODES[report.status];
252
367
  }
253
- export function run(argv) {
368
+ export async function run(argv) {
254
369
  const cli = parseCli(argv);
255
370
  if ("error" in cli) {
256
371
  process.stderr.write(`dw-sdlc gate: ${cli.error}\n\n${USAGE}\n`);
@@ -260,6 +375,7 @@ export function run(argv) {
260
375
  process.stdout.write(`${USAGE}\n`);
261
376
  return 0;
262
377
  }
263
- const { json, updateBaseline } = cli;
264
- return printReport(runGate(repositoryRoot(cli.root ?? process.cwd()), { json, updateBaseline }), json, "gate");
378
+ const { json, verbose, mode } = cli;
379
+ const report = await runGate(repositoryRoot(cli.root ?? process.cwd()), { json, verbose, mode });
380
+ return printReport(report, json, "gate");
265
381
  }
@@ -1,6 +1,7 @@
1
1
  // `dw-sdlc pr reviewed <n> --head <sha>`: pins a clean review to the head it read, as the commit status
2
2
  // `dw-sdlc/review` in state success, so a push after the review leaves the new head unreviewed.
3
3
  import { parseArgs } from "node:util";
4
+ import { REVIEW, REVIEW_STATE, reviewStatusArgs } from "../pr/decide.js";
4
5
  import { CannotRun, gh, namedProblem, read, said } from "../pr/read.js";
5
6
  export const summary = "pin a clean review to the head it read, as a commit status";
6
7
  const USAGE = `Usage: dw-sdlc pr reviewed <n> [-R owner/name] --head <sha> [--json]
@@ -11,9 +12,6 @@ a push moved the head since the review, it posts nothing. One line, or one docum
11
12
  Needs an authenticated gh.
12
13
 
13
14
  Exit 0 posted, 2 when it cannot run or the head moved.`;
14
- const CONTEXT = "dw-sdlc/review";
15
- /** The one state it posts: a failure or a pending status would turn the pull request unstable. */
16
- const STATE = "success";
17
15
  const DESCRIPTION = "a clean review of this head";
18
16
  function parseCli(argv) {
19
17
  try {
@@ -60,23 +58,9 @@ function post({ number, repo, head }) {
60
58
  if (pr.head !== head) {
61
59
  throw new CannotRun(`${pr.repo}#${number}: the head is ${pr.head}, not ${head}: a push landed since the review`, "review the new head, then call again with its sha");
62
60
  }
63
- const posted = gh([
64
- "api",
65
- `repos/${pr.repo}/statuses/${head}`,
66
- "--method",
67
- "POST",
68
- "--silent",
69
- "-f",
70
- `state=${STATE}`,
71
- "-f",
72
- `context=${CONTEXT}`,
73
- "-f",
74
- `target_url=${pr.url}`,
75
- "-f",
76
- `description=${DESCRIPTION}`
77
- ]);
61
+ const posted = gh(reviewStatusArgs(pr, DESCRIPTION));
78
62
  if (posted.status !== 0) {
79
- throw new CannotRun(`${pr.repo}#${number}: posting ${CONTEXT} failed: ${said(posted.stderr)}`, `check that gh's token may write commit statuses to ${pr.repo}, then call again`);
63
+ throw new CannotRun(`${pr.repo}#${number}: posting ${REVIEW} failed: ${said(posted.stderr)}`, `check that gh's token may write commit statuses to ${pr.repo}, then call again`);
80
64
  }
81
65
  }
82
66
  export function run(argv) {
@@ -101,8 +85,8 @@ export function run(argv) {
101
85
  }
102
86
  const { head } = cli;
103
87
  const answer = cli.json
104
- ? JSON.stringify({ head, context: CONTEXT, state: STATE }, null, 2)
105
- : `posted ${CONTEXT} ${STATE} on ${head}`;
88
+ ? JSON.stringify({ head, context: REVIEW, state: REVIEW_STATE }, null, 2)
89
+ : `posted ${REVIEW} ${REVIEW_STATE} on ${head}`;
106
90
  process.stdout.write(`${answer}\n`);
107
91
  return 0;
108
92
  }
@@ -2,20 +2,24 @@
2
2
  // needs next, performs it when it is the script's and reads again, then answers the first Action that
3
3
  // is not, as a line or one JSON document, with an exit code that names whose turn it is.
4
4
  import { parseArgs } from "node:util";
5
- import { repositoryRoot, resolveConfig } from "../agentic-config.js";
6
- import { decide, devCommand, hostArgs, isScriptAction, readyToMerge, shellLine } from "../pr/decide.js";
5
+ import { repositoryRoot, resolveConfig, STRICTNESS } from "../agentic-config.js";
6
+ import { decide, devCommand, hostArgs, isScriptAction, readyToMerge, REVIEW, shellLine } from "../pr/decide.js";
7
7
  import { CannotRun, gh, namedProblem, read, readAgain, said } from "../pr/read.js";
8
8
  export const summary = "decide the Action one pull request needs next, as a line or JSON";
9
- const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--dry-run] [--json] [--verbose]
9
+ const USAGE = `Usage: dw-sdlc pr ship [<n>] [-R owner/name] [--merge] [--review full|off] [--dry-run] [--json] [--verbose]
10
10
 
11
11
  Reads pull request <n> through gh, the one of the current branch as gh pr view resolves it when <n>
12
12
  is omitted, and answers the Action it needs next: the status, the action, whose turn it is and why.
13
13
  The script performs its own Actions, each at most once and then reads again: it updates a branch
14
14
  behind its base, marks a draft ready once nothing else holds it back, and squash merges a pull
15
15
  request ready to merge under pr.merge auto in .ai/agentic.config.json or with --merge for this call;
16
- under ask, the default, it parks with the merge command. --dry-run performs nothing: it parks at the
17
- first Action the script would perform, listed under performed. One line, or one document with
18
- --json; --verbose adds the state as read to the document under facts. Needs an authenticated gh.
16
+ under ask, the default, it parks with the merge command. A head without a ${REVIEW} success
17
+ gets the Review depth --review names for this call, else pr.review: under full, the default, the
18
+ agent reviews it with the command pr.reviewCommand names, /code-review by default; under off the
19
+ script posts the success itself, its description saying why.
20
+ --dry-run performs nothing: it parks at the first Action the script would perform, listed under
21
+ performed. One line, or one document with --json; --verbose adds the state as read to the document
22
+ under facts. Needs an authenticated gh.
19
23
 
20
24
  Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot run.`;
21
25
  const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
@@ -30,12 +34,14 @@ function parseCli(argv) {
30
34
  json: { type: "boolean" },
31
35
  verbose: { type: "boolean" },
32
36
  merge: { type: "boolean" },
37
+ review: { type: "string" },
33
38
  "dry-run": { type: "boolean" },
34
39
  repo: { type: "string", short: "R" }
35
40
  }
36
41
  });
37
42
  const [number, ...more] = positionals;
38
43
  const { repo } = values;
44
+ const review = STRICTNESS.review.find((depth) => depth === values.review);
39
45
  if (values.help !== true) {
40
46
  if (more.length > 0) {
41
47
  return { error: `name one pull request, not ${positionals.length}` };
@@ -44,12 +50,16 @@ function parseCli(argv) {
44
50
  if (problem !== undefined) {
45
51
  return { error: problem };
46
52
  }
53
+ if (values.review !== undefined && review === undefined) {
54
+ return { error: `--review takes ${STRICTNESS.review.join(" or ")}, not ${JSON.stringify(values.review)}` };
55
+ }
47
56
  }
48
57
  return {
49
58
  help: values.help === true,
50
59
  json: values.json === true,
51
60
  verbose: values.verbose === true,
52
61
  merge: values.merge === true,
62
+ review,
53
63
  dryRun: values["dry-run"] === true,
54
64
  number: number === undefined ? undefined : Number(number),
55
65
  repo
@@ -75,45 +85,50 @@ function now() {
75
85
  const NAMED = {
76
86
  "update-branch": { would: "update the branch", act: "the update", still: "the branch still reads behind its base" },
77
87
  "mark-ready": { would: "mark it ready", act: "the mark-ready", still: "the pull request still reads as a draft" },
88
+ "mark-reviewed": {
89
+ would: "mark it reviewed",
90
+ act: "the mark-reviewed",
91
+ still: `the head still carries no ${REVIEW} success`
92
+ },
78
93
  merge: { would: "merge it", act: "the merge", still: "the pull request still reads open" }
79
94
  };
80
95
  /** A script Action the call leaves to the dev, `reason` saying why; a merge parks as ready-to-merge. */
81
- function handedOver(pr, answer, action, reason) {
96
+ function handedOver(pr, policy, answer, action, reason) {
82
97
  if (action === "merge") {
83
- return { ...readyToMerge(pr, reason), blockers: answer.blockers };
98
+ return { ...readyToMerge(pr, policy, reason), blockers: answer.blockers };
84
99
  }
85
- return { ...answer, status: "park", actor: "dev", reason, command: devCommand(pr, action) };
100
+ return { ...answer, status: "park", actor: "dev", reason, command: devCommand(pr, action, policy) };
86
101
  }
87
102
  /**
88
103
  * Reads and decides until the Action is not the script's. Each script Action runs at most once, then the
89
104
  * pull request is read again; under --dry-run, or when an Action comes round again, it is the dev's.
90
105
  */
91
- function ship(cli, merge) {
106
+ function ship(cli, policy) {
92
107
  let pr = read(cli, "pr ship");
93
108
  const performed = [];
94
109
  const refusals = {};
95
110
  for (;;) {
96
- const answer = decide(pr, now(), merge);
111
+ const answer = decide(pr, now(), policy);
97
112
  const { action } = answer;
98
113
  if (!isScriptAction(action)) {
99
114
  return { pr, answer, performed };
100
115
  }
101
116
  // -R names the repository, so the checkout the script runs in plays no part.
102
- const args = hostArgs(pr, action, true);
117
+ const args = hostArgs(pr, action, policy, true);
103
118
  const command = shellLine(["gh", ...args]);
104
119
  const { would, act, still } = NAMED[action];
105
120
  if (cli.dryRun) {
106
121
  performed.push({ action, command, ok: null });
107
122
  return {
108
123
  pr,
109
- answer: handedOver(pr, answer, action, `the script would ${would}; --dry-run performs nothing`),
124
+ answer: handedOver(pr, policy, answer, action, `the script would ${would}; --dry-run performs nothing`),
110
125
  performed
111
126
  };
112
127
  }
113
128
  if (performed.some((done) => done.action === action)) {
114
129
  const refusal = refusals[action];
115
130
  const reason = refusal === undefined ? `${act} ran, and ${still}` : `gh refused ${act}: ${refusal}`;
116
- return { pr, answer: handedOver(pr, answer, action, reason), performed };
131
+ return { pr, answer: handedOver(pr, policy, answer, action, reason), performed };
117
132
  }
118
133
  const result = gh(args);
119
134
  performed.push({ action, command, ok: result.status === 0 });
@@ -143,11 +158,16 @@ export function run(argv) {
143
158
  process.stdout.write(`${USAGE}\n`);
144
159
  return 0;
145
160
  }
146
- // An invalid config layer resolves to ask, like every other strictest setting.
147
- const merge = cli.merge ? "auto" : resolveConfig(repositoryRoot(process.cwd())).config.pr.merge;
161
+ // An invalid config layer resolves to ask and full, like every other strictest setting.
162
+ const declared = resolveConfig(repositoryRoot(process.cwd())).config.pr;
163
+ const policy = {
164
+ merge: cli.merge ? "auto" : declared.merge,
165
+ review: cli.review === undefined ? { depth: declared.review, source: "config" } : { depth: cli.review, source: "flag" },
166
+ reviewCommand: declared.reviewCommand
167
+ };
148
168
  let shipped;
149
169
  try {
150
- shipped = ship(cli, merge);
170
+ shipped = ship(cli, policy);
151
171
  }
152
172
  catch (error) {
153
173
  if (error instanceof CannotRun) {
@@ -6,6 +6,7 @@ import { existsSync, linkSync, mkdirSync, readdirSync, readFileSync, renameSync,
6
6
  import { extname, join } from "node:path";
7
7
  import { setTimeout as sleep } from "node:timers/promises";
8
8
  import { parseArgs } from "node:util";
9
+ import { repositoryFreeEnv } from "../git-env.js";
9
10
  import { readPins, resolveRef } from "../pins.js";
10
11
  import { REFERENCES_FILE, readReferences } from "../references-key.js";
11
12
  import { renderBlock, withBlock } from "../router-block.js";
@@ -133,13 +134,11 @@ async function lock(root, seconds) {
133
134
  rmSync(own, { force: true });
134
135
  }
135
136
  }
136
- /** The variables that tie git to one repository, as git itself lists them. */
137
- const REPOSITORY_VARS = new Set((spawnSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" }).stdout ?? "").split("\n").filter(Boolean));
138
137
  function git(args, cwd) {
139
- // Each call finds its repository from `cwd`: the GIT_DIR a git hook exports would aim it at the
140
- // hook's repository. A missing credential fails the fetch instead of waiting on a prompt nobody sees.
141
- const env = Object.fromEntries(Object.entries(process.env).filter(([name]) => !REPOSITORY_VARS.has(name)));
142
- const result = spawnSync("git", args, { cwd, encoding: "utf8", env: { ...env, GIT_TERMINAL_PROMPT: "0" } });
138
+ // Each call finds its repository from `cwd`. A missing credential fails the fetch instead of waiting
139
+ // on a prompt nobody sees.
140
+ const env = { ...repositoryFreeEnv(), GIT_TERMINAL_PROMPT: "0" };
141
+ const result = spawnSync("git", args, { cwd, encoding: "utf8", env });
143
142
  // git's error on one line, so each Reference stays one line of the report.
144
143
  const err = (result.stderr ?? "")
145
144
  .split("\n")