dw-sdlc 0.6.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.
- package/README.md +38 -5
- package/config/agentic.config.schema.json +67 -18
- package/dist/agentic-config.d.ts +28 -1
- package/dist/agentic-config.js +35 -5
- 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/cli.js +5 -1
- 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.d.ts +2 -0
- package/dist/commands/pr-reviewed.js +92 -0
- package/dist/commands/pr-ship.d.ts +2 -0
- package/dist/commands/pr-ship.js +183 -0
- package/dist/commands/references-sync.js +5 -6
- 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 +59 -0
- package/dist/pr/decide.js +250 -0
- package/dist/pr/read.d.ts +24 -0
- package/dist/pr/read.js +112 -0
- package/dist/pr/state.d.ts +54 -0
- package/dist/pr/state.js +220 -0
- 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/working-tree.d.ts +13 -0
- package/dist/working-tree.js +60 -0
- package/package.json +2 -2
- package/templates/router.md +1 -0
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,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,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dw-sdlc",
|
|
3
|
-
"version": "0.
|
|
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,
|
|
3
|
+
"version": "0.9.0",
|
|
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": {
|
|
7
7
|
"type": "git",
|
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
|