dw-sdlc 0.21.0 → 0.21.1
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/dist/agentic-config.d.ts +16 -0
- package/dist/agentic-config.js +28 -23
- package/dist/cannot-run.d.ts +27 -0
- package/dist/cannot-run.js +48 -0
- package/dist/catalogue.js +2 -1
- package/dist/checks/agents-map.js +7 -8
- package/dist/checks/check.d.ts +10 -8
- package/dist/checks/check.js +9 -7
- package/dist/checks/decision-records.js +3 -2
- package/dist/checks/file-size-ratchet.js +13 -17
- package/dist/checks/index.d.ts +6 -5
- package/dist/checks/index.js +10 -9
- package/dist/checks/markdown-refs.js +1 -1
- package/dist/checks/primitives.js +5 -5
- package/dist/checks/routed-docs.js +1 -3
- package/dist/checks/skill-sections.js +12 -20
- package/dist/checks/tree.js +7 -7
- package/dist/cli.d.ts +2 -1
- package/dist/cli.js +26 -5
- package/dist/commands/check.js +27 -42
- package/dist/commands/config.js +22 -27
- package/dist/commands/doctor.d.ts +2 -0
- package/dist/commands/doctor.js +11 -18
- package/dist/commands/gate.js +35 -40
- package/dist/commands/lead-start.js +28 -42
- package/dist/commands/pr-reviewed.js +41 -59
- package/dist/commands/pr-ship.js +71 -89
- package/dist/commands/primitives.js +38 -40
- package/dist/commands/recap.js +23 -74
- package/dist/commands/references-setup.js +28 -76
- package/dist/commands/references-sync.js +29 -53
- package/dist/commands/setup.d.ts +2 -7
- package/dist/commands/setup.js +38 -90
- package/dist/commands/sweep.js +46 -157
- package/dist/commands/worker-ls.js +15 -31
- package/dist/commands/worker-report.js +36 -52
- package/dist/commands/worker-start.js +43 -57
- package/dist/drift.js +2 -1
- package/dist/exec.d.ts +20 -0
- package/dist/exec.js +37 -4
- package/dist/gate-cache.js +2 -1
- package/dist/git.d.ts +32 -0
- package/dist/git.js +60 -0
- package/dist/json-schema.js +1 -3
- package/dist/landed.js +11 -22
- package/dist/paused-operation.d.ts +2 -0
- package/dist/paused-operation.js +17 -0
- package/dist/pins.js +2 -1
- package/dist/pr/decide.d.ts +4 -16
- package/dist/pr/decide.js +48 -84
- package/dist/pr/delta.d.ts +3 -4
- package/dist/pr/delta.js +10 -19
- package/dist/pr/read.d.ts +27 -30
- package/dist/pr/read.js +94 -182
- package/dist/pr/reply.js +3 -5
- package/dist/pr/route.d.ts +11 -3
- package/dist/pr/route.js +5 -5
- package/dist/pr/standing.d.ts +138 -0
- package/dist/pr/standing.js +181 -0
- package/dist/pr/state.d.ts +10 -69
- package/dist/pr/state.js +42 -72
- package/dist/pr/upstream.d.ts +1 -6
- package/dist/pr/upstream.js +2 -2
- package/dist/primitives-diff.js +1 -1
- package/dist/recap-next.js +34 -47
- package/dist/recap-primitives.js +1 -1
- package/dist/references-key.d.ts +0 -1
- package/dist/references-key.js +3 -4
- package/dist/run-command.d.ts +4 -1
- package/dist/run-command.js +32 -11
- package/dist/setup-gate.d.ts +1 -7
- package/dist/setup-rules.js +2 -6
- package/dist/setup-writer.d.ts +32 -0
- package/dist/setup-writer.js +60 -0
- package/dist/shell-line.d.ts +2 -0
- package/dist/shell-line.js +5 -0
- package/dist/sweep-removal.d.ts +31 -0
- package/dist/sweep-removal.js +133 -0
- package/dist/sweep-terminals.d.ts +1 -1
- package/dist/sweep-terminals.js +21 -57
- package/dist/sweep-trees.d.ts +19 -3
- package/dist/sweep-trees.js +67 -31
- package/dist/worker/herdr.d.ts +67 -23
- package/dist/worker/herdr.js +126 -77
- package/dist/worker/launch.js +32 -45
- package/dist/worker/origin.d.ts +1 -1
- package/dist/worker/origin.js +9 -11
- package/dist/worker/tabs.js +11 -7
- package/dist/working-tree.d.ts +0 -8
- package/dist/working-tree.js +6 -22
- package/dist/worktree-lock.js +1 -1
- package/package.json +1 -1
- package/dist/git-env.d.ts +0 -2
- package/dist/git-env.js +0 -10
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
4
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
5
|
+
import { CannotRun, messageOf, readArgs, usageError } from "../cannot-run.js";
|
|
6
|
+
import { lead, sendTo, wholeNumber } from "../worker/herdr.js";
|
|
7
7
|
export const summary = "deliver a Recap to the Lead, retrying while it is blocked";
|
|
8
8
|
const USAGE = `Usage: dw-sdlc worker report --to <lead> --recap <file> [--attempts <n>] [--interval <seconds>]
|
|
9
9
|
|
|
@@ -18,47 +18,40 @@ it at once with exit 1.
|
|
|
18
18
|
Needs a terminal herdr started ($HC_TERMINAL_ID) and hc on PATH.
|
|
19
19
|
|
|
20
20
|
Exit 0 delivered, 1 not delivered, 2 when it cannot run.`;
|
|
21
|
-
/** `hc send` exits with this when the target is blocked on a permission or a question. */
|
|
22
|
-
const BLOCKED = 9;
|
|
23
21
|
const DEFAULT_ATTEMPTS = 30;
|
|
24
22
|
const DEFAULT_INTERVAL = 60;
|
|
25
23
|
/** The longest Recap that goes as itself, in characters; a longer one goes as a path. */
|
|
26
24
|
const SHORT = 300;
|
|
27
25
|
function parseCli(argv) {
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
interval: { type: "string" }
|
|
37
|
-
}
|
|
38
|
-
});
|
|
39
|
-
if (values.help === true) {
|
|
40
|
-
return "help";
|
|
41
|
-
}
|
|
42
|
-
const { to, recap } = values;
|
|
43
|
-
if (to === undefined || to === "") {
|
|
44
|
-
return { error: "name the Lead's terminal: --to <lead>" };
|
|
45
|
-
}
|
|
46
|
-
if (recap === undefined) {
|
|
47
|
-
return { error: "name the Recap file: --recap <file>" };
|
|
48
|
-
}
|
|
49
|
-
const attempts = wholeNumber("--attempts", values.attempts, DEFAULT_ATTEMPTS, 1);
|
|
50
|
-
const interval = wholeNumber("--interval", values.interval, DEFAULT_INTERVAL, 0);
|
|
51
|
-
if (typeof attempts === "object") {
|
|
52
|
-
return attempts;
|
|
26
|
+
const { values } = readArgs({
|
|
27
|
+
args: argv,
|
|
28
|
+
options: {
|
|
29
|
+
help: { type: "boolean", short: "h" },
|
|
30
|
+
to: { type: "string" },
|
|
31
|
+
recap: { type: "string" },
|
|
32
|
+
attempts: { type: "string" },
|
|
33
|
+
interval: { type: "string" }
|
|
53
34
|
}
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
}
|
|
57
|
-
return { to, recap, attempts, interval };
|
|
35
|
+
}, USAGE);
|
|
36
|
+
if (values.help === true) {
|
|
37
|
+
return { help: true };
|
|
58
38
|
}
|
|
59
|
-
|
|
60
|
-
|
|
39
|
+
const { to, recap } = values;
|
|
40
|
+
if (to === undefined || to === "") {
|
|
41
|
+
throw usageError("name the Lead's terminal: --to <lead>", USAGE);
|
|
42
|
+
}
|
|
43
|
+
if (recap === undefined) {
|
|
44
|
+
throw usageError("name the Recap file: --recap <file>", USAGE);
|
|
61
45
|
}
|
|
46
|
+
const attempts = wholeNumber("--attempts", values.attempts, DEFAULT_ATTEMPTS, 1);
|
|
47
|
+
const interval = wholeNumber("--interval", values.interval, DEFAULT_INTERVAL, 0);
|
|
48
|
+
if (typeof attempts === "object") {
|
|
49
|
+
throw usageError(attempts.error, USAGE);
|
|
50
|
+
}
|
|
51
|
+
if (typeof interval === "object") {
|
|
52
|
+
throw usageError(interval.error, USAGE);
|
|
53
|
+
}
|
|
54
|
+
return { help: false, to, recap, attempts, interval };
|
|
62
55
|
}
|
|
63
56
|
/** What to send for the Recap in `file`: the Recap itself when it is one short line, else the file's path. */
|
|
64
57
|
function messageFor(file) {
|
|
@@ -68,10 +61,10 @@ function messageFor(file) {
|
|
|
68
61
|
text = readFileSync(path, "utf8").trim();
|
|
69
62
|
}
|
|
70
63
|
catch (error) {
|
|
71
|
-
throw new
|
|
64
|
+
throw new CannotRun(`cannot read the Recap ${file}: ${messageOf(error)}`);
|
|
72
65
|
}
|
|
73
66
|
if (text === "") {
|
|
74
|
-
throw new
|
|
67
|
+
throw new CannotRun(`the Recap ${file} is empty`);
|
|
75
68
|
}
|
|
76
69
|
return { message: text.includes("\n") || text.length > SHORT ? path : text, path };
|
|
77
70
|
}
|
|
@@ -79,13 +72,13 @@ async function report({ to, recap, attempts, interval }) {
|
|
|
79
72
|
lead();
|
|
80
73
|
const { message, path } = messageFor(recap);
|
|
81
74
|
for (let attempt = 1; attempt <= attempts; attempt += 1) {
|
|
82
|
-
const sent =
|
|
83
|
-
if (sent
|
|
75
|
+
const sent = sendTo(to, message);
|
|
76
|
+
if (sent === "sent") {
|
|
84
77
|
process.stdout.write(`reported to ${to}\n`);
|
|
85
78
|
return 0;
|
|
86
79
|
}
|
|
87
|
-
if (sent
|
|
88
|
-
process.stderr.write(`dw-sdlc worker report:
|
|
80
|
+
if (sent !== "blocked") {
|
|
81
|
+
process.stderr.write(`dw-sdlc worker report: ${sent.refused} — the Recap stays at ${path}\n`);
|
|
89
82
|
return 1;
|
|
90
83
|
}
|
|
91
84
|
if (attempt === attempts) {
|
|
@@ -100,18 +93,9 @@ async function report({ to, recap, attempts, interval }) {
|
|
|
100
93
|
}
|
|
101
94
|
export async function run(argv) {
|
|
102
95
|
const cli = parseCli(argv);
|
|
103
|
-
if (cli
|
|
96
|
+
if (cli.help) {
|
|
104
97
|
process.stdout.write(`${USAGE}\n`);
|
|
105
98
|
return 0;
|
|
106
99
|
}
|
|
107
|
-
|
|
108
|
-
process.stderr.write(`dw-sdlc worker report: ${cli.error}\n\n${USAGE}\n`);
|
|
109
|
-
return 2;
|
|
110
|
-
}
|
|
111
|
-
try {
|
|
112
|
-
return await report(cli);
|
|
113
|
-
}
|
|
114
|
-
catch (error) {
|
|
115
|
-
return refuse("worker report", error);
|
|
116
|
-
}
|
|
100
|
+
return report(cli);
|
|
117
101
|
}
|
|
@@ -2,9 +2,9 @@
|
|
|
2
2
|
// and effort the config names, and reads back whether the Worker took it.
|
|
3
3
|
import { readFileSync } from "node:fs";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
|
-
import { parseArgs } from "node:util";
|
|
6
5
|
import { mainCheckout, repositoryRoot, resolveConfig, WORKER_EFFORTS } from "../agentic-config.js";
|
|
7
|
-
import {
|
|
6
|
+
import { CannotRun, messageOf, readArgs, usageError } from "../cannot-run.js";
|
|
7
|
+
import { isLive, lead, wholeNumber, workersOf } from "../worker/herdr.js";
|
|
8
8
|
import { launch, NAME, spaceOf, TAB_WAIT, undelivered } from "../worker/launch.js";
|
|
9
9
|
import { freshenOrigin } from "../worker/origin.js";
|
|
10
10
|
export const summary = "start a Worker on a Brief in a herdr terminal";
|
|
@@ -38,49 +38,44 @@ Exit 0 started and delivered, 1 started but not delivered, 2 when it cannot run,
|
|
|
38
38
|
const DEFAULT_MAX_WORKERS = 3;
|
|
39
39
|
const DEFAULT_WAIT = 30;
|
|
40
40
|
function parseCli(argv) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
wait: { type: "string" }
|
|
53
|
-
}
|
|
54
|
-
});
|
|
55
|
-
if (values.help === true) {
|
|
56
|
-
return "help";
|
|
57
|
-
}
|
|
58
|
-
const { brief, name, model, effort } = values;
|
|
59
|
-
if (brief === undefined) {
|
|
60
|
-
return { error: "name the Brief file: --brief <file>" };
|
|
61
|
-
}
|
|
62
|
-
if (name === undefined || !NAME.test(name)) {
|
|
63
|
-
return { error: `--name takes 1-32 of a-z, 0-9, - and _, not ${JSON.stringify(name ?? "")}` };
|
|
64
|
-
}
|
|
65
|
-
if (model !== undefined && (model.trim() === "" || model.startsWith("-"))) {
|
|
66
|
-
return { error: `--model takes a model name, not ${JSON.stringify(model)}` };
|
|
67
|
-
}
|
|
68
|
-
if (effort !== undefined && !WORKER_EFFORTS.includes(effort)) {
|
|
69
|
-
return { error: `--effort takes one of ${WORKER_EFFORTS.join(", ")}, not ${JSON.stringify(effort)}` };
|
|
41
|
+
const { values } = readArgs({
|
|
42
|
+
args: argv,
|
|
43
|
+
options: {
|
|
44
|
+
help: { type: "boolean", short: "h" },
|
|
45
|
+
json: { type: "boolean" },
|
|
46
|
+
brief: { type: "string" },
|
|
47
|
+
name: { type: "string" },
|
|
48
|
+
model: { type: "string" },
|
|
49
|
+
effort: { type: "string" },
|
|
50
|
+
"max-workers": { type: "string" },
|
|
51
|
+
wait: { type: "string" }
|
|
70
52
|
}
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
return max;
|
|
75
|
-
}
|
|
76
|
-
if (typeof wait === "object") {
|
|
77
|
-
return wait;
|
|
78
|
-
}
|
|
79
|
-
return { brief, name, model, effort, max, wait, json: values.json === true };
|
|
53
|
+
}, USAGE);
|
|
54
|
+
if (values.help === true) {
|
|
55
|
+
return { help: true };
|
|
80
56
|
}
|
|
81
|
-
|
|
82
|
-
|
|
57
|
+
const { brief, name, model, effort } = values;
|
|
58
|
+
if (brief === undefined) {
|
|
59
|
+
throw usageError("name the Brief file: --brief <file>", USAGE);
|
|
60
|
+
}
|
|
61
|
+
if (name === undefined || !NAME.test(name)) {
|
|
62
|
+
throw usageError(`--name takes 1-32 of a-z, 0-9, - and _, not ${JSON.stringify(name ?? "")}`, USAGE);
|
|
83
63
|
}
|
|
64
|
+
if (model !== undefined && (model.trim() === "" || model.startsWith("-"))) {
|
|
65
|
+
throw usageError(`--model takes a model name, not ${JSON.stringify(model)}`, USAGE);
|
|
66
|
+
}
|
|
67
|
+
if (effort !== undefined && !WORKER_EFFORTS.includes(effort)) {
|
|
68
|
+
throw usageError(`--effort takes one of ${WORKER_EFFORTS.join(", ")}, not ${JSON.stringify(effort)}`, USAGE);
|
|
69
|
+
}
|
|
70
|
+
const max = wholeNumber("--max-workers", values["max-workers"], DEFAULT_MAX_WORKERS, 1);
|
|
71
|
+
const wait = wholeNumber("--wait", values.wait, DEFAULT_WAIT, 0);
|
|
72
|
+
if (typeof max === "object") {
|
|
73
|
+
throw usageError(max.error, USAGE);
|
|
74
|
+
}
|
|
75
|
+
if (typeof wait === "object") {
|
|
76
|
+
throw usageError(wait.error, USAGE);
|
|
77
|
+
}
|
|
78
|
+
return { help: false, brief, name, model, effort, max, wait, json: values.json === true };
|
|
84
79
|
}
|
|
85
80
|
/** The Brief's text; a file that is missing or holds only whitespace stops the command. */
|
|
86
81
|
function readBrief(file) {
|
|
@@ -89,10 +84,10 @@ function readBrief(file) {
|
|
|
89
84
|
text = readFileSync(resolve(file), "utf8");
|
|
90
85
|
}
|
|
91
86
|
catch (error) {
|
|
92
|
-
throw new
|
|
87
|
+
throw new CannotRun(`cannot read the Brief ${file}: ${messageOf(error)}`);
|
|
93
88
|
}
|
|
94
89
|
if (text.trim() === "") {
|
|
95
|
-
throw new
|
|
90
|
+
throw new CannotRun(`the Brief ${file} is empty`);
|
|
96
91
|
}
|
|
97
92
|
return text;
|
|
98
93
|
}
|
|
@@ -105,13 +100,13 @@ async function start(cli) {
|
|
|
105
100
|
const { baseBranch, worker } = resolveConfig(checkout).config;
|
|
106
101
|
const command = worker.command.split(/\s+/).filter(Boolean);
|
|
107
102
|
if (command.length === 0) {
|
|
108
|
-
throw new
|
|
103
|
+
throw new CannotRun("worker.command holds no command", "set it in .ai/agentic.config.local.json, or remove the key");
|
|
109
104
|
}
|
|
110
105
|
const model = cli.model ?? worker.model;
|
|
111
106
|
const effort = cli.effort ?? worker.effort;
|
|
112
107
|
const live = workersOf(leadId, false).filter(isLive);
|
|
113
108
|
if (live.length >= cli.max) {
|
|
114
|
-
throw new
|
|
109
|
+
throw new CannotRun(`${leadId} already has ${live.length} live Workers (the limit is ${cli.max})`, `carry one through first, or raise the limit with --max-workers ${live.length + 1}`);
|
|
115
110
|
}
|
|
116
111
|
// Before hc run, so a failed fetch starts no terminal.
|
|
117
112
|
freshenOrigin(root, baseBranch);
|
|
@@ -139,18 +134,9 @@ async function start(cli) {
|
|
|
139
134
|
}
|
|
140
135
|
export async function run(argv) {
|
|
141
136
|
const cli = parseCli(argv);
|
|
142
|
-
if (cli
|
|
137
|
+
if (cli.help) {
|
|
143
138
|
process.stdout.write(`${USAGE}\n`);
|
|
144
139
|
return 0;
|
|
145
140
|
}
|
|
146
|
-
|
|
147
|
-
process.stderr.write(`dw-sdlc worker start: ${cli.error}\n\n${USAGE}\n`);
|
|
148
|
-
return 2;
|
|
149
|
-
}
|
|
150
|
-
try {
|
|
151
|
-
return await start(cli);
|
|
152
|
-
}
|
|
153
|
-
catch (error) {
|
|
154
|
-
return refuse("worker start", error);
|
|
155
|
-
}
|
|
141
|
+
return start(cli);
|
|
156
142
|
}
|
package/dist/drift.js
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
// Drift: a file a Step creates, changes or deletes while the Gate runs. Before and after each Step the
|
|
2
2
|
// Gate records the tree of every file git tracks or would track, never against HEAD, so an ignored
|
|
3
3
|
// file never drifts and an edit made before the run sits in both records.
|
|
4
|
+
import { git } from "./git.js";
|
|
4
5
|
import { stoppedBy } from "./run-command.js";
|
|
5
|
-
import {
|
|
6
|
+
import { workingTree } from "./working-tree.js";
|
|
6
7
|
const REMEDIES = {
|
|
7
8
|
drift: "commit the change, or make the Step only read",
|
|
8
9
|
cannotWatch: 'run the Gate in a git checkout, or set gate.drift to "allow"'
|
package/dist/exec.d.ts
CHANGED
|
@@ -10,5 +10,25 @@ export type ExecOptions = {
|
|
|
10
10
|
/** A tool that outlived its limit and was stopped; the message names it. */
|
|
11
11
|
export declare class TimedOut extends Error {
|
|
12
12
|
}
|
|
13
|
+
/** Sends `signal` to every process of the group `pid` leads; nothing when the group is gone. */
|
|
14
|
+
export declare function signalGroup(pid: number, signal: NodeJS.Signals): void;
|
|
13
15
|
/** `tool args` as a child process; a tool's own exit, or its absence, is the caller's. Throws `TimedOut` at the limit. */
|
|
14
16
|
export declare function exec(tool: string, args: string[], options?: ExecOptions): SpawnSyncReturns<string>;
|
|
17
|
+
/** What a tool the command needs answered: its exit status, 1 for a signal, and both streams. */
|
|
18
|
+
export type Called = {
|
|
19
|
+
status: number;
|
|
20
|
+
stdout: string;
|
|
21
|
+
stderr: string;
|
|
22
|
+
};
|
|
23
|
+
/** The first line of a tool's output that says anything, trimmed; undefined where none does. */
|
|
24
|
+
export declare function firstLine(text: string): string | undefined;
|
|
25
|
+
/** Why a call failed, in the tool's words: its first line, stderr before stdout, else the exit it ended with. */
|
|
26
|
+
export declare function whyFailed({ status, stdout, stderr }: Called): string;
|
|
27
|
+
/**
|
|
28
|
+
* `tool args` for a command that cannot go on without the tool: one not on PATH stops the command with
|
|
29
|
+
* `missing`, one past its limit with `stalled`. Its own exit is the caller's.
|
|
30
|
+
*/
|
|
31
|
+
export declare function need(tool: string, args: string[], remedies: {
|
|
32
|
+
missing: string;
|
|
33
|
+
stalled: string;
|
|
34
|
+
}): Called;
|
package/dist/exec.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// every child it started; no terminal, so no prompt, ssh's included; Ctrl-C stops the package, not the
|
|
3
3
|
// tool. A Step of the Gate runs through `run-command.ts`, as long as it takes.
|
|
4
4
|
import { spawnSync } from "node:child_process";
|
|
5
|
+
import { CannotRun } from "./cannot-run.js";
|
|
5
6
|
const LIMIT_MS = 120_000;
|
|
6
7
|
/** For what the network or a repository's size can stretch: a clone, a fetch, a scan of its history. */
|
|
7
8
|
export const SLOW_LIMIT_MS = 600_000;
|
|
@@ -19,14 +20,14 @@ function limitOf(asked) {
|
|
|
19
20
|
function shown(tool, args) {
|
|
20
21
|
return [tool, ...args].join(" ").replaceAll(/\s+/g, " ").trim().slice(0, SHOWN);
|
|
21
22
|
}
|
|
22
|
-
/**
|
|
23
|
-
function
|
|
23
|
+
/** Sends `signal` to every process of the group `pid` leads; nothing when the group is gone. */
|
|
24
|
+
export function signalGroup(pid, signal) {
|
|
24
25
|
// A pid of 0 would make the kill reach this process's own group.
|
|
25
26
|
if (pid <= 0) {
|
|
26
27
|
return;
|
|
27
28
|
}
|
|
28
29
|
try {
|
|
29
|
-
process.kill(-pid,
|
|
30
|
+
process.kill(-pid, signal);
|
|
30
31
|
}
|
|
31
32
|
catch (error) {
|
|
32
33
|
// ESRCH: no process is left in the group.
|
|
@@ -51,8 +52,40 @@ export function exec(tool, args, options = {}) {
|
|
|
51
52
|
};
|
|
52
53
|
const result = spawnSync(tool, args, spawnOptions);
|
|
53
54
|
if (result.error !== undefined && "code" in result.error && result.error.code === "ETIMEDOUT") {
|
|
54
|
-
|
|
55
|
+
// Every process left in the group the tool led; the tool itself is stopped and reaped by now.
|
|
56
|
+
signalGroup(result.pid, "SIGKILL");
|
|
55
57
|
throw new TimedOut(`${shown(tool, args)} did not answer within ${limitMs / 1000} s`);
|
|
56
58
|
}
|
|
57
59
|
return result;
|
|
58
60
|
}
|
|
61
|
+
/** The first line of a tool's output that says anything, trimmed; undefined where none does. */
|
|
62
|
+
export function firstLine(text) {
|
|
63
|
+
return text
|
|
64
|
+
.split("\n")
|
|
65
|
+
.map((line) => line.trim())
|
|
66
|
+
.find((line) => line !== "");
|
|
67
|
+
}
|
|
68
|
+
/** Why a call failed, in the tool's words: its first line, stderr before stdout, else the exit it ended with. */
|
|
69
|
+
export function whyFailed({ status, stdout, stderr }) {
|
|
70
|
+
return firstLine(`${stderr}\n${stdout}`) ?? `exited ${status}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* `tool args` for a command that cannot go on without the tool: one not on PATH stops the command with
|
|
74
|
+
* `missing`, one past its limit with `stalled`. Its own exit is the caller's.
|
|
75
|
+
*/
|
|
76
|
+
export function need(tool, args, remedies) {
|
|
77
|
+
let result;
|
|
78
|
+
try {
|
|
79
|
+
result = exec(tool, args);
|
|
80
|
+
}
|
|
81
|
+
catch (error) {
|
|
82
|
+
throw error instanceof TimedOut ? new CannotRun(error.message, remedies.stalled) : error;
|
|
83
|
+
}
|
|
84
|
+
if (result.error !== undefined) {
|
|
85
|
+
if ("code" in result.error && result.error.code === "ENOENT") {
|
|
86
|
+
throw new CannotRun(`${tool} is not on PATH`, remedies.missing);
|
|
87
|
+
}
|
|
88
|
+
throw result.error;
|
|
89
|
+
}
|
|
90
|
+
return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
|
|
91
|
+
}
|
package/dist/gate-cache.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { messageOf } from "./cannot-run.js";
|
|
6
7
|
import { packageVersion } from "./package-version.js";
|
|
7
8
|
import { workingTree } from "./working-tree.js";
|
|
8
9
|
const STAMP_FILE = "dw-sdlc-gate.green";
|
|
@@ -17,7 +18,7 @@ export function fingerprint(root, gate) {
|
|
|
17
18
|
return { key: `${found.tree} ${judge.digest("hex")}`, stampFile: join(found.gitDir, STAMP_FILE) };
|
|
18
19
|
}
|
|
19
20
|
catch (error) {
|
|
20
|
-
return { error:
|
|
21
|
+
return { error: messageOf(error) };
|
|
21
22
|
}
|
|
22
23
|
}
|
|
23
24
|
/** Whether the last green run of this checkout was stamped with this fingerprint. */
|
package/dist/git.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
export type GitOptions = {
|
|
2
|
+
/** Text on git's stdin. */
|
|
3
|
+
input?: string;
|
|
4
|
+
/** The time limit, for what a repository's size or the network stretches: a fetch, a scan of history. */
|
|
5
|
+
limitMs?: number;
|
|
6
|
+
/** Variables over the cleared environment, as a scratch GIT_INDEX_FILE. */
|
|
7
|
+
env?: Record<string, string>;
|
|
8
|
+
/** stdout as git wrote it, for NUL-separated or patch output; trimmed otherwise. */
|
|
9
|
+
raw?: boolean;
|
|
10
|
+
/** Every line git wrote on stderr, joined, where one line of a report must hold all it said. */
|
|
11
|
+
whole?: boolean;
|
|
12
|
+
};
|
|
13
|
+
/** What git answered: its stdout on exit 0; else `said`, what went wrong, and `error`, it after `git <first argument>:`. */
|
|
14
|
+
export type GitAnswer = {
|
|
15
|
+
out: string;
|
|
16
|
+
} | {
|
|
17
|
+
error: string;
|
|
18
|
+
said: string;
|
|
19
|
+
};
|
|
20
|
+
/** This process's environment without the variables that tie git to one repository, as git lists them. */
|
|
21
|
+
export declare function repositoryFreeEnv(): NodeJS.ProcessEnv;
|
|
22
|
+
/** `git -C <dir> <args>`; past its limit it throws `TimedOut`, as `exec` does. */
|
|
23
|
+
export declare function git(dir: string, args: string[], options?: GitOptions): GitAnswer;
|
|
24
|
+
/** What `git -C <dir> <args>` prints, or undefined when it exits non-zero, as a question answered no. */
|
|
25
|
+
export declare function asked(dir: string, args: string[], options?: GitOptions): string | undefined;
|
|
26
|
+
/** What `git -C <dir> <args>` prints; a failure stops the command, naming git's problem. */
|
|
27
|
+
export declare function must(dir: string, args: string[]): string;
|
|
28
|
+
/** The git dir of the checkout at `dir` and the common one its worktrees share, absolute; undefined outside one. */
|
|
29
|
+
export declare function gitDirs(dir: string): {
|
|
30
|
+
gitDir: string;
|
|
31
|
+
commonDir: string;
|
|
32
|
+
} | undefined;
|
package/dist/git.js
ADDED
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
// The package's git commands, through `exec.ts`: from the directory each names, without the variables that tie
|
|
2
|
+
// git to one repository, so a hook's GIT_DIR never aims one at the hook's repository; failures in git's words.
|
|
3
|
+
// Two run outside it: the doctor's `git config --global`, which reads no repository, and landed.ts's pipeline.
|
|
4
|
+
import { CannotRun } from "./cannot-run.js";
|
|
5
|
+
import { exec, firstLine } from "./exec.js";
|
|
6
|
+
const REMEDY = "check the repository with git status";
|
|
7
|
+
let repositoryVars;
|
|
8
|
+
/** This process's environment without the variables that tie git to one repository, as git lists them. */
|
|
9
|
+
export function repositoryFreeEnv() {
|
|
10
|
+
repositoryVars ??= new Set((exec("git", ["rev-parse", "--local-env-vars"]).stdout ?? "").split("\n").filter(Boolean));
|
|
11
|
+
const vars = repositoryVars;
|
|
12
|
+
return Object.fromEntries(Object.entries(process.env).filter(([name]) => !vars.has(name)));
|
|
13
|
+
}
|
|
14
|
+
/** What git said went wrong: its first line on stderr, or with `whole` every line; else how it ended. */
|
|
15
|
+
function saidOf(result, whole) {
|
|
16
|
+
if (result.error !== undefined) {
|
|
17
|
+
return result.error.message;
|
|
18
|
+
}
|
|
19
|
+
const words = whole
|
|
20
|
+
? result.stderr
|
|
21
|
+
.split("\n")
|
|
22
|
+
.map((line) => line.trim())
|
|
23
|
+
.filter((line) => line !== "")
|
|
24
|
+
.join(" ")
|
|
25
|
+
: firstLine(result.stderr);
|
|
26
|
+
return words === undefined || words === "" ? `exited ${result.status ?? "on a signal"}` : words;
|
|
27
|
+
}
|
|
28
|
+
/** `git -C <dir> <args>`; past its limit it throws `TimedOut`, as `exec` does. */
|
|
29
|
+
export function git(dir, args, options = {}) {
|
|
30
|
+
const { input, limitMs } = options;
|
|
31
|
+
const result = exec("git", ["-C", dir, ...args], {
|
|
32
|
+
env: { ...repositoryFreeEnv(), ...options.env },
|
|
33
|
+
...(input === undefined ? {} : { input }),
|
|
34
|
+
...(limitMs === undefined ? {} : { limitMs })
|
|
35
|
+
});
|
|
36
|
+
if (result.status === 0) {
|
|
37
|
+
return { out: options.raw === true ? result.stdout : result.stdout.trim() };
|
|
38
|
+
}
|
|
39
|
+
const said = saidOf(result, options.whole === true);
|
|
40
|
+
return { error: `git ${args[0] ?? ""}: ${said}`, said };
|
|
41
|
+
}
|
|
42
|
+
/** What `git -C <dir> <args>` prints, or undefined when it exits non-zero, as a question answered no. */
|
|
43
|
+
export function asked(dir, args, options = {}) {
|
|
44
|
+
const answer = git(dir, args, options);
|
|
45
|
+
return "out" in answer ? answer.out : undefined;
|
|
46
|
+
}
|
|
47
|
+
/** What `git -C <dir> <args>` prints; a failure stops the command, naming git's problem. */
|
|
48
|
+
export function must(dir, args) {
|
|
49
|
+
const answer = git(dir, args);
|
|
50
|
+
if ("error" in answer) {
|
|
51
|
+
throw new CannotRun(answer.error, REMEDY);
|
|
52
|
+
}
|
|
53
|
+
return answer.out;
|
|
54
|
+
}
|
|
55
|
+
/** The git dir of the checkout at `dir` and the common one its worktrees share, absolute; undefined outside one. */
|
|
56
|
+
export function gitDirs(dir) {
|
|
57
|
+
const answer = asked(dir, ["rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir"]);
|
|
58
|
+
const [gitDir, commonDir] = answer === undefined ? [] : answer.split("\n");
|
|
59
|
+
return gitDir === undefined || commonDir === undefined ? undefined : { gitDir, commonDir };
|
|
60
|
+
}
|
package/dist/json-schema.js
CHANGED
|
@@ -1,12 +1,10 @@
|
|
|
1
1
|
// The subset of JSON Schema draft 2020-12 the shipped schemas use, validated without a dependency: Node
|
|
2
2
|
// has no validator built in, and a config file does not justify pulling one in. A keyword outside the
|
|
3
3
|
// subset throws instead of passing silently, so a schema never promises more than this file enforces.
|
|
4
|
+
import { isObject } from "./json-text.js";
|
|
4
5
|
export class SchemaError extends Error {
|
|
5
6
|
}
|
|
6
7
|
const ANNOTATIONS = new Set(["$schema", "$id", "$comment", "title", "description", "default", "examples"]);
|
|
7
|
-
function isObject(value) {
|
|
8
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
9
|
-
}
|
|
10
8
|
function show(value) {
|
|
11
9
|
return value === undefined ? "undefined" : JSON.stringify(value);
|
|
12
10
|
}
|
package/dist/landed.js
CHANGED
|
@@ -2,33 +2,22 @@
|
|
|
2
2
|
// (plugins/sdlc/hooks/lib-git.sh) judges it. The guard keeps its own bash version; docs/adr/0015 says why there
|
|
3
3
|
// are two, and the shared table of scenarios, plugins/sdlc/test/landed-scenarios.ts, holds them to one meaning.
|
|
4
4
|
import { exec, SLOW_LIMIT_MS } from "./exec.js";
|
|
5
|
-
import { repositoryFreeEnv } from "./git
|
|
5
|
+
import { asked, repositoryFreeEnv } from "./git.js";
|
|
6
6
|
/** The base commits read, newest first: a branch that forked further back is kept rather than read for seconds. */
|
|
7
7
|
const SCAN = 5000;
|
|
8
8
|
/** Only local objects: a partial clone's missing blob is no reason to ask its remote. */
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
}
|
|
12
|
-
|
|
13
|
-
const result = exec("git", ["-C", root, ...args], {
|
|
14
|
-
env: localEnv(),
|
|
15
|
-
...(input === undefined ? {} : { input }),
|
|
16
|
-
...(limitMs === undefined ? {} : { limitMs })
|
|
17
|
-
});
|
|
18
|
-
return { status: result.status ?? 1, stdout: result.stdout ?? "" };
|
|
19
|
-
}
|
|
20
|
-
/** What `git <args>` prints, or undefined when it exits non-zero. */
|
|
21
|
-
function answer(root, args, input, limitMs) {
|
|
22
|
-
const ran = git(root, args, input, limitMs);
|
|
23
|
-
return ran.status === 0 ? ran.stdout.trim() : undefined;
|
|
9
|
+
const LOCAL_ONLY = { GIT_NO_LAZY_FETCH: "1" };
|
|
10
|
+
/** What `git <args>` prints over local objects only, or undefined when it exits non-zero. */
|
|
11
|
+
function answer(root, args, options = {}) {
|
|
12
|
+
return asked(root, args, { ...options, env: LOCAL_ONLY });
|
|
24
13
|
}
|
|
25
14
|
/** The patch-id of the whole diff from `from` to `to`; undefined for an empty diff or one git could not finish. */
|
|
26
15
|
function patchIdOf(root, from, to) {
|
|
27
|
-
const diff =
|
|
28
|
-
if (diff
|
|
16
|
+
const diff = answer(root, ["diff-tree", "-p", "--binary", "--no-renames", from, to], { raw: true });
|
|
17
|
+
if (diff === undefined) {
|
|
29
18
|
return undefined;
|
|
30
19
|
}
|
|
31
|
-
const id = answer(root, ["patch-id", "--verbatim"], diff
|
|
20
|
+
const id = answer(root, ["patch-id", "--verbatim"], { input: diff })?.split(" ")[0];
|
|
32
21
|
return id === undefined || id === "" ? undefined : id;
|
|
33
22
|
}
|
|
34
23
|
/** Whether a base commit in `range`, merges aside, has `patch` for the patch-id of its diff. */
|
|
@@ -44,7 +33,7 @@ function baseHoldsPatch(root, range, patch) {
|
|
|
44
33
|
].join(" | ")
|
|
45
34
|
].join("; ");
|
|
46
35
|
const result = exec("bash", ["-c", pipeline, "bash", root, String(SCAN), range], {
|
|
47
|
-
env:
|
|
36
|
+
env: { ...repositoryFreeEnv(), ...LOCAL_ONLY },
|
|
48
37
|
limitMs: SLOW_LIMIT_MS
|
|
49
38
|
});
|
|
50
39
|
if (result.status !== 0) {
|
|
@@ -66,7 +55,7 @@ export function isLanded(root, baseRef, branch) {
|
|
|
66
55
|
* its tree or the verbatim patch-id of its whole diff, as a squash merge leaves them.
|
|
67
56
|
*/
|
|
68
57
|
export function isCommitLanded(root, baseRef, tip) {
|
|
69
|
-
if (
|
|
58
|
+
if (answer(root, ["merge-base", "--is-ancestor", tip, baseRef]) !== undefined) {
|
|
70
59
|
return true;
|
|
71
60
|
}
|
|
72
61
|
const fork = answer(root, ["merge-base", tip, baseRef]);
|
|
@@ -75,7 +64,7 @@ export function isCommitLanded(root, baseRef, tip) {
|
|
|
75
64
|
return false;
|
|
76
65
|
}
|
|
77
66
|
const range = `${fork}..${baseRef}`;
|
|
78
|
-
const trees = answer(root, ["log", "--format=%T", `--max-count=${SCAN}`, range],
|
|
67
|
+
const trees = answer(root, ["log", "--format=%T", `--max-count=${SCAN}`, range], { limitMs: SLOW_LIMIT_MS }) ?? "";
|
|
79
68
|
if (trees.split("\n").includes(tree)) {
|
|
80
69
|
return true;
|
|
81
70
|
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
// The operation git left paused in a checkout, read from the record git resumes it from.
|
|
2
|
+
import { existsSync } from "node:fs";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
/** Git's own record of each paused operation, in the git directory a linked worktree keeps apart. */
|
|
5
|
+
const PAUSES = [
|
|
6
|
+
{ record: "rebase-merge", name: "rebase" },
|
|
7
|
+
{ record: "rebase-apply", name: "rebase or am" },
|
|
8
|
+
{ record: "MERGE_HEAD", name: "merge" },
|
|
9
|
+
{ record: "CHERRY_PICK_HEAD", name: "cherry-pick" },
|
|
10
|
+
{ record: "REVERT_HEAD", name: "revert" },
|
|
11
|
+
{ record: "sequencer", name: "cherry-pick or revert" },
|
|
12
|
+
{ record: "BISECT_LOG", name: "bisect" }
|
|
13
|
+
];
|
|
14
|
+
/** The operation paused in the checkout whose git directory is `gitDir`, if any. */
|
|
15
|
+
export function pausedIn(gitDir) {
|
|
16
|
+
return PAUSES.find(({ record }) => existsSync(join(gitDir, record)))?.name;
|
|
17
|
+
}
|
package/dist/pins.js
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// ref without `{name}` reads neither file, so a broken manifest fails only the References that name one.
|
|
4
4
|
import { existsSync, readFileSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
|
+
import { messageOf } from "./cannot-run.js";
|
|
6
7
|
/** A pin that names one version: `x.y.z`, a pre-release tag allowed. */
|
|
7
8
|
export const EXACT_VERSION = /^\d+\.\d+\.\d+(?:-[\w.]+)?$/;
|
|
8
9
|
/** The two ways a pin names the default catalog: by its name, or by pnpm's shorthand. */
|
|
@@ -65,7 +66,7 @@ export function readPins(root) {
|
|
|
65
66
|
return parsePins(readFileSync(manifest, "utf8"), catalog);
|
|
66
67
|
}
|
|
67
68
|
catch (error) {
|
|
68
|
-
throw new Error(`package.json: ${
|
|
69
|
+
throw new Error(`package.json: ${messageOf(error)}`, { cause: error });
|
|
69
70
|
}
|
|
70
71
|
}
|
|
71
72
|
/** `ref` with each `{name}` replaced by the exact version `pins` holds for package `name`. */
|