dw-sdlc 0.7.0 → 0.10.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 +23 -12
- package/config/agentic.config.schema.json +77 -18
- package/dist/agentic-config.d.ts +41 -7
- package/dist/agentic-config.js +36 -12
- package/dist/checks/agents-map.d.ts +9 -1
- package/dist/checks/agents-map.js +60 -17
- package/dist/checks/decision-records.d.ts +5 -0
- package/dist/checks/decision-records.js +121 -16
- package/dist/checks/file-size-ratchet.js +7 -5
- package/dist/checks/index.js +2 -0
- package/dist/checks/markdown-budget.d.ts +2 -5
- package/dist/checks/markdown-budget.js +6 -15
- package/dist/checks/markdown-refs.js +1 -11
- package/dist/checks/routed-docs.d.ts +6 -0
- package/dist/checks/routed-docs.js +49 -0
- package/dist/checks/tree.d.ts +2 -0
- package/dist/checks/tree.js +13 -3
- package/dist/commands/check.d.ts +1 -1
- package/dist/commands/check.js +6 -4
- package/dist/commands/gate.d.ts +10 -4
- package/dist/commands/gate.js +202 -86
- package/dist/commands/pr-reviewed.js +6 -22
- package/dist/commands/pr-ship.js +119 -30
- package/dist/commands/references-sync.js +5 -6
- package/dist/commands/setup.d.ts +2 -0
- package/dist/commands/setup.js +17 -9
- package/dist/drift.d.ts +17 -0
- package/dist/drift.js +68 -0
- package/dist/gate-cache.d.ts +13 -0
- package/dist/gate-cache.js +39 -0
- package/dist/git-env.d.ts +2 -0
- package/dist/git-env.js +10 -0
- package/dist/markdown.d.ts +19 -0
- package/dist/markdown.js +46 -2
- package/dist/package-version.d.ts +2 -0
- package/dist/package-version.js +13 -0
- package/dist/pr/decide.d.ts +37 -9
- package/dist/pr/decide.js +113 -32
- package/dist/pr/delta.d.ts +11 -0
- package/dist/pr/delta.js +60 -0
- package/dist/pr/read.d.ts +8 -3
- package/dist/pr/read.js +56 -10
- package/dist/pr/route.d.ts +14 -0
- package/dist/pr/route.js +52 -0
- package/dist/pr/state.d.ts +48 -2
- package/dist/pr/state.js +42 -9
- package/dist/router-block.d.ts +4 -1
- package/dist/router-block.js +31 -7
- package/dist/run-command.d.ts +24 -0
- package/dist/run-command.js +121 -0
- package/dist/setup-gate.js +3 -12
- package/dist/setup-worker.d.ts +11 -0
- package/dist/setup-worker.js +37 -0
- package/dist/working-tree.d.ts +13 -0
- package/dist/working-tree.js +60 -0
- package/package.json +1 -1
- package/templates/router.md +1 -0
package/dist/pr/state.js
CHANGED
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
// One pull request as the Ship script reads it: the one GraphQL query, and its reply parsed into the
|
|
2
2
|
// state the decision reads. Every field the state needs is checked; a reply that fails names the field.
|
|
3
|
+
/** The commit status a clean review of the head leaves on it. */
|
|
4
|
+
export const REVIEW = "dw-sdlc/review";
|
|
5
|
+
/** How the description of a success the script posts under off begins: no one read that head. */
|
|
6
|
+
export const OFF_PREFIX = "off:";
|
|
3
7
|
export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
|
|
4
8
|
repository(owner: $owner, name: $name) {
|
|
5
9
|
pullRequest(number: $number) {
|
|
6
|
-
url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName
|
|
10
|
+
url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName baseRefName baseRefOid
|
|
7
11
|
viewerDidAuthor mergeCommit { oid }
|
|
8
12
|
reviewThreads(first: 100) { nodes { id isResolved isOutdated path line
|
|
9
13
|
comments(first: 1) { nodes { author { login } body } }
|
|
@@ -12,6 +16,7 @@ export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
|
|
|
12
16
|
contexts(first: 50) { nodes {
|
|
13
17
|
... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }
|
|
14
18
|
... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }
|
|
19
|
+
history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: "${REVIEW}") { state description } } } } }
|
|
15
20
|
}
|
|
16
21
|
}
|
|
17
22
|
}`;
|
|
@@ -24,11 +29,11 @@ export class ReplyError extends Error {
|
|
|
24
29
|
function isRecord(value) {
|
|
25
30
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
26
31
|
}
|
|
27
|
-
function shown(value) {
|
|
32
|
+
export function shown(value) {
|
|
28
33
|
return value === undefined ? "missing" : JSON.stringify(value);
|
|
29
34
|
}
|
|
30
35
|
/** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
|
|
31
|
-
function rootOf(reply, what) {
|
|
36
|
+
export function rootOf(reply, what) {
|
|
32
37
|
let json;
|
|
33
38
|
try {
|
|
34
39
|
json = JSON.parse(reply);
|
|
@@ -41,10 +46,10 @@ function rootOf(reply, what) {
|
|
|
41
46
|
}
|
|
42
47
|
return { value: json, at: "" };
|
|
43
48
|
}
|
|
44
|
-
function fieldOf(node, key) {
|
|
49
|
+
export function fieldOf(node, key) {
|
|
45
50
|
return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
|
|
46
51
|
}
|
|
47
|
-
function object(value, at) {
|
|
52
|
+
export function object(value, at) {
|
|
48
53
|
if (!isRecord(value)) {
|
|
49
54
|
throw new ReplyError(`${at} is ${shown(value)}, not an object`);
|
|
50
55
|
}
|
|
@@ -58,7 +63,7 @@ function childOrNull(node, key) {
|
|
|
58
63
|
const { value, at } = fieldOf(node, key);
|
|
59
64
|
return value === null ? null : object(value, at);
|
|
60
65
|
}
|
|
61
|
-
function string(node, key) {
|
|
66
|
+
export function string(node, key) {
|
|
62
67
|
const { value, at } = fieldOf(node, key);
|
|
63
68
|
if (typeof value !== "string") {
|
|
64
69
|
throw new ReplyError(`${at} is ${shown(value)}, not a string`);
|
|
@@ -75,7 +80,7 @@ function boolean(node, key) {
|
|
|
75
80
|
}
|
|
76
81
|
return value;
|
|
77
82
|
}
|
|
78
|
-
function integer(node, key) {
|
|
83
|
+
export function integer(node, key) {
|
|
79
84
|
const { value, at } = fieldOf(node, key);
|
|
80
85
|
if (typeof value !== "number" || !Number.isInteger(value)) {
|
|
81
86
|
throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
|
|
@@ -182,6 +187,31 @@ function threadOf(thread) {
|
|
|
182
187
|
lastByViewer: boolean(firstNode(thread, "latest"), "viewerDidAuthor")
|
|
183
188
|
};
|
|
184
189
|
}
|
|
190
|
+
/** The viewer's own pull request without a review success on its head: the one a Review reads. */
|
|
191
|
+
export function awaitsReview(pr) {
|
|
192
|
+
return pr.viewerDidAuthor && !pr.checks.some((check) => check.name === REVIEW && check.bucket === "pass");
|
|
193
|
+
}
|
|
194
|
+
/** A commit whose review success says someone read it: a status posted under off never does. */
|
|
195
|
+
function isReviewed(commit) {
|
|
196
|
+
const status = childOrNull(commit, "status");
|
|
197
|
+
const context = status === null ? null : childOrNull(status, "context");
|
|
198
|
+
if (context === null || string(context, "state") !== "SUCCESS") {
|
|
199
|
+
return false;
|
|
200
|
+
}
|
|
201
|
+
return !(stringOrNull(context, "description") ?? "").startsWith(OFF_PREFIX);
|
|
202
|
+
}
|
|
203
|
+
/** The last commit before `head` that `isReviewed` takes, the history oldest first. */
|
|
204
|
+
function lastReviewedOf(pr, head) {
|
|
205
|
+
const commits = nodes(pr, "history").map((node) => child(node, "commit"));
|
|
206
|
+
const index = commits.findLastIndex((commit) => string(commit, "oid") !== head && isReviewed(commit));
|
|
207
|
+
const reviewed = commits[index];
|
|
208
|
+
if (reviewed === undefined) {
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
const after = commits.slice(index + 1);
|
|
212
|
+
const mergesOnly = after.every((commit) => integer(child(commit, "parents"), "totalCount") > 1);
|
|
213
|
+
return { commit: string(reviewed, "oid"), mergesOnly };
|
|
214
|
+
}
|
|
185
215
|
/** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
|
|
186
216
|
export function parseView(reply) {
|
|
187
217
|
const url = string(rootOf(reply, "the reply of gh pr view"), "url");
|
|
@@ -197,6 +227,7 @@ export function parsePr(reply, repo) {
|
|
|
197
227
|
const commit = child(firstNode(pr, "commits"), "commit");
|
|
198
228
|
const rollup = childOrNull(commit, "statusCheckRollup");
|
|
199
229
|
const mergeCommit = childOrNull(pr, "mergeCommit");
|
|
230
|
+
const head = string(pr, "headRefOid");
|
|
200
231
|
return {
|
|
201
232
|
url: string(pr, "url"),
|
|
202
233
|
repo,
|
|
@@ -207,14 +238,16 @@ export function parsePr(reply, repo) {
|
|
|
207
238
|
mergeStateStatus: string(pr, "mergeStateStatus"),
|
|
208
239
|
reviewDecision: pr.value.reviewDecision === null ? null : oneOf(pr, "reviewDecision", REVIEW_DECISIONS),
|
|
209
240
|
viewerDidAuthor: boolean(pr, "viewerDidAuthor"),
|
|
210
|
-
head
|
|
241
|
+
head,
|
|
211
242
|
headCommittedAt: time(commit, "committedDate"),
|
|
212
243
|
branch: string(pr, "headRefName"),
|
|
213
244
|
base: string(pr, "baseRefName"),
|
|
245
|
+
baseOid: string(pr, "baseRefOid"),
|
|
214
246
|
mergeCommit: mergeCommit === null ? null : string(mergeCommit, "oid"),
|
|
215
247
|
checks: rollup === null ? [] : nodes(rollup, "contexts").map((context) => checkOf(context, repo)),
|
|
216
248
|
unresolvedThreads: nodes(pr, "reviewThreads")
|
|
217
249
|
.filter((thread) => !boolean(thread, "isResolved") && !boolean(thread, "isOutdated"))
|
|
218
|
-
.map(threadOf)
|
|
250
|
+
.map(threadOf),
|
|
251
|
+
lastReviewed: lastReviewedOf(pr, head)
|
|
219
252
|
};
|
|
220
253
|
}
|
package/dist/router-block.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { Reference } from "./references-key.ts";
|
|
2
|
-
/**
|
|
2
|
+
/**
|
|
3
|
+
* The block the file gives: one row per Reference in the file's order, or one line when it has none,
|
|
4
|
+
* with a blank line inside each marker and each `when` trimmed, as oxfmt leaves it.
|
|
5
|
+
*/
|
|
3
6
|
export declare function renderBlock(references: Reference[]): string;
|
|
4
7
|
/**
|
|
5
8
|
* The Router's text with `block` in place of the one between its markers: as it is when the two say
|
package/dist/router-block.js
CHANGED
|
@@ -6,16 +6,40 @@ const END = "<!-- references:end -->";
|
|
|
6
6
|
const START_LINE = /^<!-- references:start\b.*-->\s*$/;
|
|
7
7
|
const END_LINE = /^<!-- references:end -->\s*$/;
|
|
8
8
|
const NONE = "No Reference is declared yet: `/sdlc-references-add <repository>` adds one.";
|
|
9
|
-
|
|
9
|
+
const GRAPHEMES = new Intl.Segmenter("en", { granularity: "grapheme" });
|
|
10
|
+
/** An emoji drawn as one, or a character of the East Asian wide and fullwidth ranges. */
|
|
11
|
+
const WIDE = /[\p{Emoji_Presentation}\u{FE0F}\u{1100}-\u{115F}\u{2329}\u{232A}\u{2E80}-\u{303E}\u{3040}-\u{A4CF}\u{AC00}-\u{D7A3}\u{F900}-\u{FAFF}\u{FE10}-\u{FE19}\u{FE30}-\u{FE6F}\u{FF00}-\u{FF60}\u{FFE0}-\u{FFE6}\u{20000}-\u{2FFFD}\u{30000}-\u{3FFFD}]/u;
|
|
12
|
+
/**
|
|
13
|
+
* A cell's width in columns, close to oxfmt's: two for a wide grapheme, one for any other. It matches
|
|
14
|
+
* oxfmt on Latin text, CJK, Hangul and emoji; a script with combining signs, such as Devanagari, or a
|
|
15
|
+
* rarer wide character comes out off, and the formatter pads that table again.
|
|
16
|
+
*/
|
|
17
|
+
function width(cell) {
|
|
18
|
+
let columns = 0;
|
|
19
|
+
for (const { segment } of GRAPHEMES.segment(cell)) {
|
|
20
|
+
columns += WIDE.test(segment) ? 2 : 1;
|
|
21
|
+
}
|
|
22
|
+
return columns;
|
|
23
|
+
}
|
|
24
|
+
function tableLine(cells) {
|
|
25
|
+
return `| ${cells.join(" | ")} |`;
|
|
26
|
+
}
|
|
27
|
+
/** A Markdown table, each column padded to its widest cell and three at the least. */
|
|
28
|
+
function table(header, body) {
|
|
29
|
+
const rows = [header, ...body];
|
|
30
|
+
const widths = header.map((_, column) => Math.max(3, ...rows.map((row) => width(row[column] ?? ""))));
|
|
31
|
+
const paddedLine = (row) => tableLine(row.map((cell, column) => cell + " ".repeat((widths[column] ?? 0) - width(cell))));
|
|
32
|
+
return [paddedLine(header), tableLine(widths.map((count) => "-".repeat(count))), ...body.map(paddedLine)];
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The block the file gives: one row per Reference in the file's order, or one line when it has none,
|
|
36
|
+
* with a blank line inside each marker and each `when` trimmed, as oxfmt leaves it.
|
|
37
|
+
*/
|
|
10
38
|
export function renderBlock(references) {
|
|
11
39
|
const body = references.length === 0
|
|
12
40
|
? [NONE]
|
|
13
|
-
: [
|
|
14
|
-
|
|
15
|
-
"| --- | --- |",
|
|
16
|
-
...references.map((reference) => `| \`${reference.dir}\` | ${reference.when.replaceAll("|", "\\|")} |`)
|
|
17
|
-
];
|
|
18
|
-
return `${[START, ...body, END].join("\n")}\n`;
|
|
41
|
+
: table(["dir", "reach for it when"], references.map((reference) => [`\`${reference.dir}\``, reference.when.trim().replaceAll("|", "\\|")]));
|
|
42
|
+
return `${[START, "", ...body, "", END].join("\n")}\n`;
|
|
19
43
|
}
|
|
20
44
|
/** A table's delimiter row, dashes of any count. */
|
|
21
45
|
const DELIMITER = /^\|(?:\s*-+\s*\|)+$/;
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
export type Status = "pass" | "fail" | "error";
|
|
2
|
+
/**
|
|
3
|
+
* Where a command's output goes: `capture` keeps its last lines when it does not pass; `stdout` and
|
|
4
|
+
* `stderr` pass it through as it is written, onto stderr when `--json` keeps stdout for the document.
|
|
5
|
+
*/
|
|
6
|
+
export type Output = "capture" | "stdout" | "stderr";
|
|
7
|
+
export type Ran = {
|
|
8
|
+
status: Status;
|
|
9
|
+
/** Null when it never started or a signal stopped it. */
|
|
10
|
+
exitCode: number | null;
|
|
11
|
+
/** The signal that stopped it: one the Gate got while it ran, or one that ended it. */
|
|
12
|
+
signal: NodeJS.Signals | null;
|
|
13
|
+
seconds: number;
|
|
14
|
+
/** What went wrong, as `exited 3`; absent on a pass. */
|
|
15
|
+
detail?: string;
|
|
16
|
+
/** Its last `TAIL_LINES` lines, both streams in the order written, when captured and not passing. */
|
|
17
|
+
tail: string[] | null;
|
|
18
|
+
};
|
|
19
|
+
export declare const TAIL_LINES = 50;
|
|
20
|
+
/** Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate reports it. */
|
|
21
|
+
export declare function holdingSignals<T>(body: () => Promise<T>): Promise<T>;
|
|
22
|
+
/** The signal that stopped the run, once one has; no command starts after it. */
|
|
23
|
+
export declare function stoppedBy(): NodeJS.Signals | undefined;
|
|
24
|
+
export declare function runCommand(command: string, root: string, output: Output): Promise<Ran>;
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
// How the Gate runs one command, a Step or a `gate.checks.run` script: through `sh -c` from the
|
|
2
|
+
// repository root, stdin closed, its output captured or passed through, and what it answered; and
|
|
3
|
+
// how SIGINT and SIGTERM stop the command running and every command after it.
|
|
4
|
+
import { spawn } from "node:child_process";
|
|
5
|
+
import { closeSync, fstatSync, mkdtempSync, openSync, readSync, rmSync } from "node:fs";
|
|
6
|
+
import { tmpdir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
export const TAIL_LINES = 50;
|
|
9
|
+
/** What the shell answers for a command it cannot find, and for one it cannot execute. */
|
|
10
|
+
const NOT_RUNNABLE = { 127: "was not found", 126: "could not be executed" };
|
|
11
|
+
const STOPPING = ["SIGINT", "SIGTERM"];
|
|
12
|
+
/** The command running, which a signal the Gate gets is passed on to. */
|
|
13
|
+
let running;
|
|
14
|
+
/** The first SIGINT or SIGTERM the Gate got; no command starts after it. */
|
|
15
|
+
let received;
|
|
16
|
+
function release() {
|
|
17
|
+
for (const signal of STOPPING) {
|
|
18
|
+
process.off(signal, stop);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
function stop(signal) {
|
|
22
|
+
if (received !== undefined) {
|
|
23
|
+
// A second signal ends the Gate at once, as it would with no handler: the command ignored the first.
|
|
24
|
+
release();
|
|
25
|
+
process.kill(process.pid, signal);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
received = signal;
|
|
29
|
+
running?.kill(signal);
|
|
30
|
+
}
|
|
31
|
+
/** Runs `body` with SIGINT and SIGTERM held, so a signal stops the command running and the Gate reports it. */
|
|
32
|
+
export async function holdingSignals(body) {
|
|
33
|
+
running = undefined;
|
|
34
|
+
received = undefined;
|
|
35
|
+
for (const signal of STOPPING) {
|
|
36
|
+
process.on(signal, stop);
|
|
37
|
+
}
|
|
38
|
+
try {
|
|
39
|
+
return await body();
|
|
40
|
+
}
|
|
41
|
+
finally {
|
|
42
|
+
// One turn of the event loop hands a signal that landed after the last command to its handler.
|
|
43
|
+
await new Promise((resolve) => setImmediate(resolve));
|
|
44
|
+
release();
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** The signal that stopped the run, once one has; no command starts after it. */
|
|
48
|
+
export function stoppedBy() {
|
|
49
|
+
return received;
|
|
50
|
+
}
|
|
51
|
+
/** Everything written to `fd`, read from its start: the command's writes moved the offset it shares to the end. */
|
|
52
|
+
function written(fd) {
|
|
53
|
+
const buffer = Buffer.alloc(fstatSync(fd).size);
|
|
54
|
+
let filled = 0;
|
|
55
|
+
while (filled < buffer.length) {
|
|
56
|
+
const read = readSync(fd, buffer, filled, buffer.length - filled, filled);
|
|
57
|
+
if (read === 0) {
|
|
58
|
+
break;
|
|
59
|
+
}
|
|
60
|
+
filled += read;
|
|
61
|
+
}
|
|
62
|
+
return buffer.toString("utf8", 0, filled);
|
|
63
|
+
}
|
|
64
|
+
/** The last lines of `text`; a line a lone `\r` rewrote keeps the last text written over it, as a terminal shows it. */
|
|
65
|
+
function lastLines(text) {
|
|
66
|
+
const all = text.split(/\r?\n/);
|
|
67
|
+
if (all.at(-1) === "") {
|
|
68
|
+
all.pop();
|
|
69
|
+
}
|
|
70
|
+
return all.slice(-TAIL_LINES).map((line) => line.split("\r").findLast((part) => part !== "") ?? "");
|
|
71
|
+
}
|
|
72
|
+
/** What the command's exit says; a signal the Gate got while it ran stops it, however it then exited. */
|
|
73
|
+
function answer(exitCode, ended) {
|
|
74
|
+
const signal = received ?? ended;
|
|
75
|
+
if (signal !== null) {
|
|
76
|
+
return { status: "fail", exitCode, signal, detail: `was stopped by ${signal}` };
|
|
77
|
+
}
|
|
78
|
+
if (exitCode === 0) {
|
|
79
|
+
return { status: "pass", exitCode, signal };
|
|
80
|
+
}
|
|
81
|
+
const notRunnable = exitCode === null ? undefined : NOT_RUNNABLE[exitCode];
|
|
82
|
+
if (notRunnable !== undefined) {
|
|
83
|
+
return { status: "error", exitCode, signal, detail: `${notRunnable} (exit ${exitCode})` };
|
|
84
|
+
}
|
|
85
|
+
return { status: "fail", exitCode, signal, detail: `exited ${exitCode}` };
|
|
86
|
+
}
|
|
87
|
+
function run(command, root, stdio) {
|
|
88
|
+
const started = performance.now();
|
|
89
|
+
return new Promise((resolve) => {
|
|
90
|
+
const done = (answered) => {
|
|
91
|
+
running = undefined;
|
|
92
|
+
resolve({ ...answered, seconds: (performance.now() - started) / 1000 });
|
|
93
|
+
};
|
|
94
|
+
const child = spawn(command, { shell: true, cwd: root, stdio });
|
|
95
|
+
running = child;
|
|
96
|
+
// A signal that cannot be delivered is an error too; only a child with no pid never started.
|
|
97
|
+
child.on("error", (error) => {
|
|
98
|
+
if (child.pid === undefined) {
|
|
99
|
+
done({ status: "error", exitCode: null, signal: null, detail: `could not start: ${error.message}` });
|
|
100
|
+
}
|
|
101
|
+
});
|
|
102
|
+
child.once("exit", (exitCode, signal) => done(answer(exitCode, signal)));
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
export async function runCommand(command, root, output) {
|
|
106
|
+
if (output !== "capture") {
|
|
107
|
+
return { ...(await run(command, root, ["ignore", output === "stderr" ? 2 : "inherit", "inherit"])), tail: null };
|
|
108
|
+
}
|
|
109
|
+
// Both streams share one open file, so its lines keep the order the command wrote them in. The file
|
|
110
|
+
// leaves the disk before the command starts, so no run, an interrupted one included, leaves it behind.
|
|
111
|
+
const scratch = mkdtempSync(join(tmpdir(), "dw-sdlc-run-"));
|
|
112
|
+
const fd = openSync(join(scratch, "output"), "w+");
|
|
113
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
114
|
+
try {
|
|
115
|
+
const ran = await run(command, root, ["ignore", fd, fd]);
|
|
116
|
+
return { ...ran, tail: ran.status === "pass" ? null : lastLines(written(fd)) };
|
|
117
|
+
}
|
|
118
|
+
finally {
|
|
119
|
+
closeSync(fd);
|
|
120
|
+
}
|
|
121
|
+
}
|
package/dist/setup-gate.js
CHANGED
|
@@ -1,28 +1,19 @@
|
|
|
1
1
|
// What `dw-sdlc setup` changes to adopt the Gate, in files the repository commits: the `dw-sdlc`
|
|
2
2
|
// devDependency in package.json, pinned to the version that runs the setup, and the `gate` key of the
|
|
3
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
4
|
import { CHECK_NAMES } from "./checks/index.js";
|
|
7
5
|
import { appendKey, isObject, parseObject, render } from "./json-text.js";
|
|
6
|
+
import { packageVersion } from "./package-version.js";
|
|
8
7
|
const PACKAGE = "dw-sdlc";
|
|
9
8
|
/**
|
|
10
9
|
* 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,
|
|
12
|
-
*
|
|
10
|
+
* setup never writes and the first Gate names. The rest read an input a repository adds first, so they
|
|
11
|
+
* start `false`.
|
|
13
12
|
*/
|
|
14
13
|
const ON_BY_DEFAULT = new Set(["agents-map", "markdown-refs", "temporal-language", "comment-budget"]);
|
|
15
14
|
/** Where a committed config the setup creates points `$schema`: the schema the pinned package installs. */
|
|
16
15
|
const SCHEMA = `../node_modules/${PACKAGE}/config/agentic.config.schema.json`;
|
|
17
16
|
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
17
|
/** `a`, `a and b`, `a, b and c`. */
|
|
27
18
|
function list(items) {
|
|
28
19
|
return items.length < 2 ? items.join("") : `${items.slice(0, -1).join(", ")} and ${items.at(-1)}`;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type JsonObject } from "./json-text.ts";
|
|
2
|
+
/** The `worker` block a local file holds, carried through on every run, `--force` included. */
|
|
3
|
+
export declare function keptWorker(existing: unknown): JsonObject | undefined;
|
|
4
|
+
/**
|
|
5
|
+
* The `worker` block to write: the one the file holds, with `command` added when it has none and `cswap`
|
|
6
|
+
* is on PATH. A command already there, whatever it holds, stays; without `cswap` nothing is added.
|
|
7
|
+
*/
|
|
8
|
+
export declare function pinnedWorker(kept: JsonObject | undefined, pathVariable: string | undefined): {
|
|
9
|
+
worker: JsonObject | undefined;
|
|
10
|
+
detail?: string;
|
|
11
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
// The `worker` block of the machine's local layer: the command a Worker starts with, which the setup
|
|
2
|
+
// writes once when `cswap` is on PATH so no prompt ever carries it.
|
|
3
|
+
import { accessSync, constants, statSync } from "node:fs";
|
|
4
|
+
import { delimiter, join } from "node:path";
|
|
5
|
+
import { isObject } from "./json-text.js";
|
|
6
|
+
/** The command that starts a Worker on the account `cswap` maps to the repository, prompts bypassed. */
|
|
7
|
+
const CSWAP_WORKER_COMMAND = "cswap run -- --dangerously-skip-permissions";
|
|
8
|
+
/** Whether an executable file `name` sits in a directory of `pathVariable`, a PATH as the shell reads it. */
|
|
9
|
+
function onPath(name, pathVariable) {
|
|
10
|
+
return (pathVariable ?? "").split(delimiter).some((dir) => {
|
|
11
|
+
if (dir === "") {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const file = join(dir, name);
|
|
16
|
+
accessSync(file, constants.X_OK);
|
|
17
|
+
return statSync(file).isFile();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
/** The `worker` block a local file holds, carried through on every run, `--force` included. */
|
|
25
|
+
export function keptWorker(existing) {
|
|
26
|
+
return isObject(existing) && isObject(existing.worker) ? existing.worker : undefined;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The `worker` block to write: the one the file holds, with `command` added when it has none and `cswap`
|
|
30
|
+
* is on PATH. A command already there, whatever it holds, stays; without `cswap` nothing is added.
|
|
31
|
+
*/
|
|
32
|
+
export function pinnedWorker(kept, pathVariable) {
|
|
33
|
+
if ((kept !== undefined && "command" in kept) || !onPath("cswap", pathVariable)) {
|
|
34
|
+
return { worker: kept };
|
|
35
|
+
}
|
|
36
|
+
return { worker: { ...kept, command: CSWAP_WORKER_COMMAND }, detail: `worker.command: ${CSWAP_WORKER_COMMAND}` };
|
|
37
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/** `git <args>` in the repository at `root`, on `index` when given. */
|
|
2
|
+
export declare function git(root: string, args: string[], index?: string): {
|
|
3
|
+
out: string;
|
|
4
|
+
} | {
|
|
5
|
+
error: string;
|
|
6
|
+
};
|
|
7
|
+
/** The tree of the checkout at `root` and its git dir, or why it has none. */
|
|
8
|
+
export declare function workingTree(root: string): {
|
|
9
|
+
tree: string;
|
|
10
|
+
gitDir: string;
|
|
11
|
+
} | {
|
|
12
|
+
error: string;
|
|
13
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// A checkout's working tree as git would commit it: the tree of every file git tracks or would track,
|
|
2
|
+
// hashed on a copy of the index. The Gate's cache fingerprints it and the watch for Drift records it.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
import { copyFileSync, existsSync, mkdtempSync, rmSync, statSync, utimesSync } from "node:fs";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { join } from "node:path";
|
|
7
|
+
import { repositoryFreeEnv } from "./git-env.js";
|
|
8
|
+
/** `git <args>` in the repository at `root`, on `index` when given. */
|
|
9
|
+
export function git(root, args, index) {
|
|
10
|
+
const env = repositoryFreeEnv();
|
|
11
|
+
const result = spawnSync("git", ["-C", root, ...args], {
|
|
12
|
+
encoding: "utf8",
|
|
13
|
+
env: index === undefined ? env : { ...env, GIT_INDEX_FILE: index },
|
|
14
|
+
// A Step that writes thousands of files makes a diff-tree past the default 1 MiB.
|
|
15
|
+
maxBuffer: 64 * 1024 * 1024
|
|
16
|
+
});
|
|
17
|
+
if (result.status === 0) {
|
|
18
|
+
return { out: result.stdout.trim() };
|
|
19
|
+
}
|
|
20
|
+
const reason = result.error?.message ?? result.stderr.split("\n").find((line) => line.trim() !== "");
|
|
21
|
+
return { error: `git ${args[0] ?? ""}: ${reason?.trim() ?? `exited ${result.status ?? "on a signal"}`}` };
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* The tree `git write-tree` hashes from a copy of the index after `git add -A` on it: the copy keeps the
|
|
25
|
+
* stat cache, so only what changed is read again; identical content survives a commit, an amend or a
|
|
26
|
+
* rebase, and a new untracked file changes it while an ignored one does not.
|
|
27
|
+
*/
|
|
28
|
+
function treeOf(root, gitDir) {
|
|
29
|
+
const scratch = mkdtempSync(join(tmpdir(), "dw-sdlc-gate-"));
|
|
30
|
+
const index = join(scratch, "index");
|
|
31
|
+
const real = join(gitDir, "index");
|
|
32
|
+
try {
|
|
33
|
+
if (existsSync(real)) {
|
|
34
|
+
// git trusts a cached stat only when the index is newer than the file, so the copy takes the mtime
|
|
35
|
+
// the real one had before it was read: a newer one would hide an edit made in that same second.
|
|
36
|
+
const { atime, mtime } = statSync(real);
|
|
37
|
+
copyFileSync(real, index);
|
|
38
|
+
utimesSync(index, atime, mtime);
|
|
39
|
+
}
|
|
40
|
+
const added = git(root, ["add", "-A"], index);
|
|
41
|
+
return "error" in added ? added : git(root, ["write-tree"], index);
|
|
42
|
+
}
|
|
43
|
+
finally {
|
|
44
|
+
rmSync(scratch, { recursive: true, force: true });
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
/** The tree of the checkout at `root` and its git dir, or why it has none. */
|
|
48
|
+
export function workingTree(root) {
|
|
49
|
+
try {
|
|
50
|
+
const gitDir = git(root, ["rev-parse", "--absolute-git-dir"]);
|
|
51
|
+
if ("error" in gitDir) {
|
|
52
|
+
return gitDir;
|
|
53
|
+
}
|
|
54
|
+
const tree = treeOf(root, gitDir.out);
|
|
55
|
+
return "error" in tree ? tree : { tree: tree.out, gitDir: gitDir.out };
|
|
56
|
+
}
|
|
57
|
+
catch (error) {
|
|
58
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
59
|
+
}
|
|
60
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dw-sdlc",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.10.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, the Sync and setup of References, and the Ship script that drives a pull request to merged; the resolver of the config's layers, its JSON Schema and the validator that reads it.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"repository": {
|
package/templates/router.md
CHANGED
|
@@ -16,6 +16,7 @@ skips. Before you research, open the clones whose `when` matches the task.
|
|
|
16
16
|
<!-- references:start — written from .ai/references.json by the setup; edit the file -->
|
|
17
17
|
|
|
18
18
|
No Reference is declared yet: `/sdlc-references-add <repository>` adds one.
|
|
19
|
+
|
|
19
20
|
<!-- references:end -->
|
|
20
21
|
|
|
21
22
|
## Rules
|