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/commands/pr-ship.js
CHANGED
|
@@ -2,24 +2,51 @@
|
|
|
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";
|
|
7
|
-
import { CannotRun, gh, namedProblem, read, readAgain, said } from "../pr/read.js";
|
|
5
|
+
import { repositoryRoot, resolveConfig, REVIEW_DEPTHS } from "../agentic-config.js";
|
|
6
|
+
import { decide, devCommand, hostArgs, isScriptAction, readyToMerge, REVIEW, shellLine } from "../pr/decide.js";
|
|
7
|
+
import { CannotRun, carriesBase, 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|light|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.
|
|
17
|
-
|
|
18
|
-
|
|
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 agent reviews it
|
|
18
|
+
with the command pr.reviewCommand names, /code-review by default; under light one sdlc-reviewer
|
|
19
|
+
subagent reads the diff; under off the script posts the success itself, its description saying
|
|
20
|
+
why. Under auto, the default, the script routes the depth from the changes the Review would read:
|
|
21
|
+
lockfiles alone off; a file under .github/workflows/, more than 100 changed lines outside
|
|
22
|
+
lockfiles, or a change it cannot size, more files than GitHub's compare lists or a file outside
|
|
23
|
+
lockfiles with no patch, full; the rest light. After a push it names
|
|
24
|
+
the last reviewed commit under details.since, where the Review reads from and auto routes from. It
|
|
25
|
+
carries that commit's success over a head whose only commits since are merges of the base, unless
|
|
26
|
+
--review names a depth for the call.
|
|
27
|
+
Each Action the call runs is listed under performed. Its ok says whether the read after it shows the
|
|
28
|
+
Action took effect, whatever gh's exit status; gh's exit status and stderr stand beside it, so an ok
|
|
29
|
+
false with exit 0 is an Action gh accepted that the read does not show yet. An update counts when
|
|
30
|
+
the head carries the base it read. When the read after an Action fails, the call exits 2 and names
|
|
31
|
+
each Action that ran with gh's exit, under performed too with --json: it may have landed, so do not
|
|
32
|
+
run it blind again. --dry-run performs nothing: it parks at the first Action the script would
|
|
33
|
+
perform, under performed with ok null.
|
|
34
|
+
One line, or one document with --json; --verbose adds the state as read to the document under
|
|
35
|
+
facts. Needs an authenticated gh.
|
|
19
36
|
|
|
20
37
|
Exit 0 done, 10 the agent's turn, 20 wait, 30 the dev's turn, 2 when it cannot run.`;
|
|
38
|
+
/** The depths --review takes, strictest first, as a sentence lists them. */
|
|
39
|
+
const DEPTHS_SENTENCE = `${REVIEW_DEPTHS.slice(0, -1).join(", ")} or ${REVIEW_DEPTHS.at(-1)}`;
|
|
21
40
|
const EXIT = { done: 0, act: 10, wait: 20, park: 30 };
|
|
22
41
|
const REMEDIES = { clock: "unset it: it stands in for the clock in tests" };
|
|
42
|
+
/** A call that stopped after it ran Actions: what it ran stands in `performed`, `ok` null where no read followed. */
|
|
43
|
+
class Interrupted extends CannotRun {
|
|
44
|
+
performed;
|
|
45
|
+
constructor({ message, remedy }, performed) {
|
|
46
|
+
super(message, remedy);
|
|
47
|
+
this.performed = performed;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
23
50
|
function parseCli(argv) {
|
|
24
51
|
try {
|
|
25
52
|
const { values, positionals } = parseArgs({
|
|
@@ -30,12 +57,14 @@ function parseCli(argv) {
|
|
|
30
57
|
json: { type: "boolean" },
|
|
31
58
|
verbose: { type: "boolean" },
|
|
32
59
|
merge: { type: "boolean" },
|
|
60
|
+
review: { type: "string" },
|
|
33
61
|
"dry-run": { type: "boolean" },
|
|
34
62
|
repo: { type: "string", short: "R" }
|
|
35
63
|
}
|
|
36
64
|
});
|
|
37
65
|
const [number, ...more] = positionals;
|
|
38
66
|
const { repo } = values;
|
|
67
|
+
const review = REVIEW_DEPTHS.find((depth) => depth === values.review);
|
|
39
68
|
if (values.help !== true) {
|
|
40
69
|
if (more.length > 0) {
|
|
41
70
|
return { error: `name one pull request, not ${positionals.length}` };
|
|
@@ -44,12 +73,16 @@ function parseCli(argv) {
|
|
|
44
73
|
if (problem !== undefined) {
|
|
45
74
|
return { error: problem };
|
|
46
75
|
}
|
|
76
|
+
if (values.review !== undefined && review === undefined) {
|
|
77
|
+
return { error: `--review takes ${DEPTHS_SENTENCE}, not ${JSON.stringify(values.review)}` };
|
|
78
|
+
}
|
|
47
79
|
}
|
|
48
80
|
return {
|
|
49
81
|
help: values.help === true,
|
|
50
82
|
json: values.json === true,
|
|
51
83
|
verbose: values.verbose === true,
|
|
52
84
|
merge: values.merge === true,
|
|
85
|
+
review,
|
|
53
86
|
dryRun: values["dry-run"] === true,
|
|
54
87
|
number: number === undefined ? undefined : Number(number),
|
|
55
88
|
repo
|
|
@@ -75,52 +108,98 @@ function now() {
|
|
|
75
108
|
const NAMED = {
|
|
76
109
|
"update-branch": { would: "update the branch", act: "the update", still: "the branch still reads behind its base" },
|
|
77
110
|
"mark-ready": { would: "mark it ready", act: "the mark-ready", still: "the pull request still reads as a draft" },
|
|
111
|
+
"mark-reviewed": {
|
|
112
|
+
would: "mark it reviewed",
|
|
113
|
+
act: "the mark-reviewed",
|
|
114
|
+
still: `the head still carries no ${REVIEW} success`
|
|
115
|
+
},
|
|
78
116
|
merge: { would: "merge it", act: "the merge", still: "the pull request still reads open" }
|
|
79
117
|
};
|
|
118
|
+
/**
|
|
119
|
+
* Whether the read after a script Action shows it took effect. gh's exit status cannot tell: a reply
|
|
120
|
+
* lost on the way back exits non-zero after GitHub applied the Action.
|
|
121
|
+
*/
|
|
122
|
+
const TOOK = {
|
|
123
|
+
// A moved head alone may be a push: the update took effect when the head carries the base it read.
|
|
124
|
+
"update-branch": (before, after) => after.head !== before.head && carriesBase(before, after),
|
|
125
|
+
"mark-ready": (_, after) => !after.draft,
|
|
126
|
+
// The post names the head it read: a head moved since carries none of it.
|
|
127
|
+
"mark-reviewed": (before, after) => after.head === before.head && after.checks.some((check) => check.name === REVIEW && check.bucket === "pass"),
|
|
128
|
+
merge: (_, after) => after.state === "MERGED"
|
|
129
|
+
};
|
|
130
|
+
/**
|
|
131
|
+
* Why an Action that ran comes round again. A head that moved after a mark-reviewed names both heads;
|
|
132
|
+
* otherwise gh refused it, or accepted it and the read shows nothing yet, or the effect is gone again.
|
|
133
|
+
*/
|
|
134
|
+
function reasonOf({ action, ok, exit, stderr = "" }, pr, ranOn, { act, still }) {
|
|
135
|
+
if (action === "mark-reviewed" && exit === 0 && ranOn !== undefined && ranOn !== pr.head) {
|
|
136
|
+
return `${act} posted on ${ranOn.slice(0, 7)}, and the head moved to ${pr.head.slice(0, 7)}`;
|
|
137
|
+
}
|
|
138
|
+
if (ok) {
|
|
139
|
+
return `${act} ran, and ${still}`;
|
|
140
|
+
}
|
|
141
|
+
return exit === 0
|
|
142
|
+
? `gh accepted ${act}; the read does not show it yet`
|
|
143
|
+
: `gh refused ${act}: ${said(stderr) || `gh exited ${exit}`}`;
|
|
144
|
+
}
|
|
80
145
|
/** 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) {
|
|
146
|
+
function handedOver(pr, policy, answer, action, reason) {
|
|
82
147
|
if (action === "merge") {
|
|
83
|
-
return { ...readyToMerge(pr, reason), blockers: answer.blockers };
|
|
148
|
+
return { ...readyToMerge(pr, policy, reason), blockers: answer.blockers };
|
|
84
149
|
}
|
|
85
|
-
return { ...answer, status: "park", actor: "dev", reason, command: devCommand(pr, action) };
|
|
150
|
+
return { ...answer, status: "park", actor: "dev", reason, command: devCommand(pr, action, policy) };
|
|
86
151
|
}
|
|
87
152
|
/**
|
|
88
153
|
* Reads and decides until the Action is not the script's. Each script Action runs at most once, then the
|
|
89
154
|
* pull request is read again; under --dry-run, or when an Action comes round again, it is the dev's.
|
|
90
155
|
*/
|
|
91
|
-
function ship(cli,
|
|
92
|
-
|
|
156
|
+
function ship(cli, policy) {
|
|
157
|
+
// Only pr.review auto routes the depth from the changes, so only it reads them.
|
|
158
|
+
const sized = policy.review.depth === "auto";
|
|
159
|
+
let pr = read(cli, "pr ship", sized);
|
|
93
160
|
const performed = [];
|
|
94
|
-
|
|
161
|
+
// The head each Action ran on.
|
|
162
|
+
const postedOn = new Map();
|
|
95
163
|
for (;;) {
|
|
96
|
-
const answer = decide(pr, now(),
|
|
164
|
+
const answer = decide(pr, now(), policy);
|
|
97
165
|
const { action } = answer;
|
|
98
166
|
if (!isScriptAction(action)) {
|
|
99
167
|
return { pr, answer, performed };
|
|
100
168
|
}
|
|
101
169
|
// -R names the repository, so the checkout the script runs in plays no part.
|
|
102
|
-
const args = hostArgs(pr, action, true);
|
|
170
|
+
const args = hostArgs(pr, action, policy, true);
|
|
103
171
|
const command = shellLine(["gh", ...args]);
|
|
104
|
-
const { would
|
|
172
|
+
const { would } = NAMED[action];
|
|
105
173
|
if (cli.dryRun) {
|
|
106
174
|
performed.push({ action, command, ok: null });
|
|
107
175
|
return {
|
|
108
176
|
pr,
|
|
109
|
-
answer: handedOver(pr, answer, action, `the script would ${would}; --dry-run performs nothing`),
|
|
177
|
+
answer: handedOver(pr, policy, answer, action, `the script would ${would}; --dry-run performs nothing`),
|
|
110
178
|
performed
|
|
111
179
|
};
|
|
112
180
|
}
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
181
|
+
const done = performed.find((earlier) => earlier.action === action);
|
|
182
|
+
// --dry-run returns before any Action runs, so an earlier one always carries gh's exit.
|
|
183
|
+
if (done !== undefined && done.ok !== null) {
|
|
184
|
+
const reason = reasonOf(done, pr, postedOn.get(action), NAMED[action]);
|
|
185
|
+
return { pr, answer: handedOver(pr, policy, answer, action, reason), performed };
|
|
117
186
|
}
|
|
118
|
-
const
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
187
|
+
const { status, stderr } = gh(args);
|
|
188
|
+
const before = pr;
|
|
189
|
+
const message = stderr.trim();
|
|
190
|
+
const record = { action, command, exit: status, ...(message === "" ? {} : { stderr: message }) };
|
|
191
|
+
postedOn.set(action, before.head);
|
|
192
|
+
try {
|
|
193
|
+
pr = readAgain(pr, sized);
|
|
194
|
+
performed.push({ ...record, ok: TOOK[action](before, pr) });
|
|
195
|
+
}
|
|
196
|
+
catch (error) {
|
|
197
|
+
if (error instanceof CannotRun) {
|
|
198
|
+
// The Action may have landed: the caller must not run it blind again.
|
|
199
|
+
throw new Interrupted(error, [...performed, { ...record, ok: null }]);
|
|
200
|
+
}
|
|
201
|
+
throw error;
|
|
122
202
|
}
|
|
123
|
-
pr = readAgain(pr);
|
|
124
203
|
}
|
|
125
204
|
}
|
|
126
205
|
/** `<status> <action>: <reason>`, the action dropped when it repeats the status, then the recheck and the command. */
|
|
@@ -143,15 +222,25 @@ export function run(argv) {
|
|
|
143
222
|
process.stdout.write(`${USAGE}\n`);
|
|
144
223
|
return 0;
|
|
145
224
|
}
|
|
146
|
-
// An invalid config layer resolves to ask, like every other strictest setting.
|
|
147
|
-
const
|
|
225
|
+
// An invalid config layer resolves to ask and full, like every other strictest setting.
|
|
226
|
+
const declared = resolveConfig(repositoryRoot(process.cwd())).config.pr;
|
|
227
|
+
const policy = {
|
|
228
|
+
merge: cli.merge ? "auto" : declared.merge,
|
|
229
|
+
review: cli.review === undefined ? { depth: declared.review, source: "config" } : { depth: cli.review, source: "flag" },
|
|
230
|
+
reviewCommand: declared.reviewCommand
|
|
231
|
+
};
|
|
148
232
|
let shipped;
|
|
149
233
|
try {
|
|
150
|
-
shipped = ship(cli,
|
|
234
|
+
shipped = ship(cli, policy);
|
|
151
235
|
}
|
|
152
236
|
catch (error) {
|
|
153
237
|
if (error instanceof CannotRun) {
|
|
154
|
-
|
|
238
|
+
const ran = error instanceof Interrupted ? error.performed : [];
|
|
239
|
+
const named = ran.map(({ action, ...rest }) => `${action} ran (gh exit ${rest.exit ?? "unknown"})\n`);
|
|
240
|
+
process.stderr.write(`dw-sdlc pr ship: ${error.message} — ${error.remedy}\n${named.join("")}`);
|
|
241
|
+
if (cli.json && ran.length > 0) {
|
|
242
|
+
process.stdout.write(`${JSON.stringify({ error: error.message, remedy: error.remedy, performed: ran }, null, 2)}\n`);
|
|
243
|
+
}
|
|
155
244
|
return 2;
|
|
156
245
|
}
|
|
157
246
|
throw error;
|
|
@@ -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
|
|
140
|
-
//
|
|
141
|
-
const env =
|
|
142
|
-
const result = spawnSync("git", args, { cwd, encoding: "utf8", env
|
|
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")
|
package/dist/commands/setup.d.ts
CHANGED
|
@@ -21,6 +21,8 @@ export type SetupOptions = {
|
|
|
21
21
|
commitOnProtected?: CommitOnProtected | undefined;
|
|
22
22
|
/** The owner's yes to pinning dw-sdlc as a devDependency where package.json lacks it. */
|
|
23
23
|
addPackage?: boolean;
|
|
24
|
+
/** The PATH the setup looks for `cswap` on; `$PATH` when absent. */
|
|
25
|
+
path?: string | undefined;
|
|
24
26
|
};
|
|
25
27
|
export declare const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
|
|
26
28
|
/**
|
package/dist/commands/setup.js
CHANGED
|
@@ -11,6 +11,7 @@ import { COMMIT_CONVENTIONS, COMMITTED_FILE, DEFAULT_GUARDRAILS, LOCAL_FILE, SCH
|
|
|
11
11
|
import { isObject, parseObject, render } from "../json-text.js";
|
|
12
12
|
import { withGate, withPackage } from "../setup-gate.js";
|
|
13
13
|
import { GLOBAL_DENY, LEASE_RULE, RETIRED_DENY, repositoryDeny } from "../setup-rules.js";
|
|
14
|
+
import { keptWorker, pinnedWorker } from "../setup-worker.js";
|
|
14
15
|
export const summary = "adopt the Gate where asked, and write this machine's local config layer, the git excludes and the permission rules";
|
|
15
16
|
const USAGE = `Usage: dw-sdlc setup [--root <dir>] [--home <dir>]
|
|
16
17
|
[--convention <preset>] [--force-push deny|ask|lease] [--commit-on-protected deny|allow]
|
|
@@ -25,7 +26,9 @@ written only when it would change; --convention ticket-key | conventional pins c
|
|
|
25
26
|
the local config, and a run without it keeps the pin as it is; a guardrails block in the local config
|
|
26
27
|
is kept, and --force-push and --commit-on-protected pin those two keys in it, refused where they would
|
|
27
28
|
loosen what the committed config sets; --force overwrites a local config that differs in anything
|
|
28
|
-
else; --dry-run reports without writing.
|
|
29
|
+
else; --dry-run reports without writing. Where cswap is on PATH, the local config also gets
|
|
30
|
+
worker.command, the command a Worker starts with: cswap run -- --dangerously-skip-permissions. A
|
|
31
|
+
worker block the file holds is kept as it is, and without cswap nothing is written.
|
|
29
32
|
|
|
30
33
|
Where the repository has a package.json, the setup adopts the Gate. --add-package pins dw-sdlc
|
|
31
34
|
there as a devDependency, at the version running the setup; a new dependency needs the owner's yes,
|
|
@@ -99,12 +102,13 @@ function keptGuardrails(existing) {
|
|
|
99
102
|
}
|
|
100
103
|
/**
|
|
101
104
|
* A layer without the keys the setup owns: `$schema`, which moves with the clone, a pinned
|
|
102
|
-
* convention, which `--convention` may replace, and a `guardrails` object, which the
|
|
103
|
-
*
|
|
105
|
+
* convention, which `--convention` may replace, and a `guardrails` and a `worker` object, which the
|
|
106
|
+
* setup carries and adds keys to. Everything left is someone's content.
|
|
104
107
|
*/
|
|
105
108
|
function unowned(layer) {
|
|
106
|
-
const { $schema: _schema, commit,
|
|
107
|
-
const
|
|
109
|
+
const { $schema: _schema, commit, ...other } = layer;
|
|
110
|
+
const blocks = ["guardrails", "worker"];
|
|
111
|
+
const rest = Object.fromEntries(Object.entries(other).filter(([key, value]) => !blocks.includes(key) || !isObject(value)));
|
|
108
112
|
if (!isObject(commit) || pinnedConvention(layer) === undefined) {
|
|
109
113
|
return commit === undefined ? rest : { ...rest, commit };
|
|
110
114
|
}
|
|
@@ -144,9 +148,9 @@ function sameContent(existing, config) {
|
|
|
144
148
|
}
|
|
145
149
|
/**
|
|
146
150
|
* The machine's layer: what inference finds, minus every key the committed config already sets, which an
|
|
147
|
-
* inferred key here would override, plus the `guardrails`
|
|
151
|
+
* inferred key here would override, plus the `guardrails` and `worker` blocks and the convention asked for.
|
|
148
152
|
*/
|
|
149
|
-
function localConfig(root, claudeDir, convention, guardrails) {
|
|
153
|
+
function localConfig(root, claudeDir, convention, guardrails, worker) {
|
|
150
154
|
const inferred = inferConfig(root);
|
|
151
155
|
const committed = committedConfig(root);
|
|
152
156
|
const config = { $schema: schemaReference(claudeDir) };
|
|
@@ -160,6 +164,9 @@ function localConfig(root, claudeDir, convention, guardrails) {
|
|
|
160
164
|
if (guardrails !== undefined) {
|
|
161
165
|
config.guardrails = guardrails;
|
|
162
166
|
}
|
|
167
|
+
if (worker !== undefined) {
|
|
168
|
+
config.worker = worker;
|
|
169
|
+
}
|
|
163
170
|
// Only one asked for, by `--convention` or the file's pin: inference always gives `conventional`, and
|
|
164
171
|
// pinning it here would override a convention the repository commits later.
|
|
165
172
|
if (convention !== undefined) {
|
|
@@ -255,12 +262,13 @@ export function setup(options) {
|
|
|
255
262
|
writer.refuse(LOCAL_FILE, pinned.refusal);
|
|
256
263
|
}
|
|
257
264
|
else {
|
|
258
|
-
const
|
|
265
|
+
const { worker, detail } = pinnedWorker(keptWorker(existing), options.path ?? process.env.PATH);
|
|
266
|
+
const local = localConfig(checkout, claudeDir, convention, pinned.guardrails, worker);
|
|
259
267
|
if (localCurrent !== undefined && !sameContent(existing, local) && !options.force) {
|
|
260
268
|
writer.refuse(LOCAL_FILE, "exists with other content; pass --force to overwrite it");
|
|
261
269
|
}
|
|
262
270
|
else {
|
|
263
|
-
writer.put(LOCAL_FILE, localPath, render(local));
|
|
271
|
+
writer.put(LOCAL_FILE, localPath, render(local), detail);
|
|
264
272
|
}
|
|
265
273
|
}
|
|
266
274
|
// 2. The exclude entries, appended to whatever the file holds: they keep both local files out of git
|
package/dist/drift.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { Gate } from "./agentic-config.ts";
|
|
2
|
+
import type { Failure } from "./report.ts";
|
|
3
|
+
import { type Status } from "./run-command.ts";
|
|
4
|
+
/** What a Step answered, and one failure per file it created, changed or deleted. */
|
|
5
|
+
export type Watched<T> = {
|
|
6
|
+
result: T;
|
|
7
|
+
failures: Failure[];
|
|
8
|
+
};
|
|
9
|
+
/** Steps run under watch, and what the watch found. */
|
|
10
|
+
export type DriftWatch = {
|
|
11
|
+
/** Runs one Step by `run` between two records of the tree. */
|
|
12
|
+
step: <T>(command: string, run: () => Promise<T>) => Promise<Watched<T>>;
|
|
13
|
+
/** `fail` once a Step drifted, `error` once the tree could not be recorded, `pass` otherwise. */
|
|
14
|
+
status: () => Status;
|
|
15
|
+
};
|
|
16
|
+
/** A watch over the Steps of `gate` at `root`; under `gate.drift: "allow"` it only runs them. */
|
|
17
|
+
export declare function watchDrift(root: string, gate: Gate): DriftWatch;
|
package/dist/drift.js
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
// Drift: a file a Step creates, changes or deletes while the Gate runs. Before and after each Step the
|
|
2
|
+
// Gate records the tree of every file git tracks or would track, never against HEAD, so an ignored
|
|
3
|
+
// file never drifts and an edit made before the run sits in both records.
|
|
4
|
+
import { stoppedBy } from "./run-command.js";
|
|
5
|
+
import { git, workingTree } from "./working-tree.js";
|
|
6
|
+
const REMEDIES = {
|
|
7
|
+
drift: "commit the change, or make the Step only read",
|
|
8
|
+
cannotWatch: 'run the Gate in a git checkout, or set gate.drift to "allow"'
|
|
9
|
+
};
|
|
10
|
+
/** What a letter of `git diff-tree --name-status` did to the file; T is a change of type, as a link to a file. */
|
|
11
|
+
const CHANGES = { A: "created", D: "deleted", M: "changed", T: "changed" };
|
|
12
|
+
/** One failure per file that differs between the trees `before` and `after`, naming `command`. */
|
|
13
|
+
function drifted(root, command, before, after) {
|
|
14
|
+
if (before === after) {
|
|
15
|
+
return [];
|
|
16
|
+
}
|
|
17
|
+
const diff = git(root, ["diff-tree", "-r", "-z", "--no-renames", "--name-status", before, after]);
|
|
18
|
+
if ("error" in diff) {
|
|
19
|
+
return diff;
|
|
20
|
+
}
|
|
21
|
+
// NUL-terminated pairs: the letter, then the path, which may hold any byte but NUL.
|
|
22
|
+
return [...diff.out.matchAll(/([A-Z])\0([^\0]+)\0/g)].map(([, letter = "", file = ""]) => {
|
|
23
|
+
const message = `${JSON.stringify(command)} ${CHANGES[letter] ?? "changed"} it`;
|
|
24
|
+
return { file, rule: "drift", message, remedy: REMEDIES.drift };
|
|
25
|
+
});
|
|
26
|
+
}
|
|
27
|
+
/** A watch over the Steps of `gate` at `root`; under `gate.drift: "allow"` it only runs them. */
|
|
28
|
+
export function watchDrift(root, gate) {
|
|
29
|
+
let status = "pass";
|
|
30
|
+
let watching = gate.drift !== "allow";
|
|
31
|
+
/** The tree after the last Step, the record before the next: nothing runs between two Steps. */
|
|
32
|
+
let last;
|
|
33
|
+
/** The one failure of a tree that cannot be recorded; the watch stops there. */
|
|
34
|
+
const cannotWatch = (command, error) => {
|
|
35
|
+
watching = false;
|
|
36
|
+
status = "error";
|
|
37
|
+
const message = `cannot watch ${JSON.stringify(command)}: ${error}`;
|
|
38
|
+
return [{ rule: "drift", message, remedy: REMEDIES.cannotWatch }];
|
|
39
|
+
};
|
|
40
|
+
const step = async (command, run) => {
|
|
41
|
+
if (!watching) {
|
|
42
|
+
return { result: await run(), failures: [] };
|
|
43
|
+
}
|
|
44
|
+
const before = last === undefined ? workingTree(root) : { tree: last };
|
|
45
|
+
const result = await run();
|
|
46
|
+
if ("error" in before) {
|
|
47
|
+
return { result, failures: cannotWatch(command, before.error) };
|
|
48
|
+
}
|
|
49
|
+
if (stoppedBy() !== undefined) {
|
|
50
|
+
// A signal cut the Step short and ends the Gate, so nothing is compared after it.
|
|
51
|
+
return { result, failures: [] };
|
|
52
|
+
}
|
|
53
|
+
const after = workingTree(root);
|
|
54
|
+
if ("error" in after) {
|
|
55
|
+
return { result, failures: cannotWatch(command, after.error) };
|
|
56
|
+
}
|
|
57
|
+
last = after.tree;
|
|
58
|
+
const found = drifted(root, command, before.tree, after.tree);
|
|
59
|
+
if ("error" in found) {
|
|
60
|
+
return { result, failures: cannotWatch(command, found.error) };
|
|
61
|
+
}
|
|
62
|
+
if (found.length > 0) {
|
|
63
|
+
status = "fail";
|
|
64
|
+
}
|
|
65
|
+
return { result, failures: found };
|
|
66
|
+
};
|
|
67
|
+
return { step, status: () => status };
|
|
68
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import type { Gate } from "./agentic-config.ts";
|
|
2
|
+
/** A checkout's fingerprint and the file its last green run is stamped in. */
|
|
3
|
+
export type Fingerprint = {
|
|
4
|
+
key: string;
|
|
5
|
+
stampFile: string;
|
|
6
|
+
};
|
|
7
|
+
/** The fingerprint of the checkout at `root` judged by `gate`, or why it has none. */
|
|
8
|
+
export declare function fingerprint(root: string, gate: Gate): Fingerprint | {
|
|
9
|
+
error: string;
|
|
10
|
+
};
|
|
11
|
+
/** Whether the last green run of this checkout was stamped with this fingerprint. */
|
|
12
|
+
export declare function isGreen({ key, stampFile }: Fingerprint): boolean;
|
|
13
|
+
export declare function stampGreen({ key, stampFile }: Fingerprint): void;
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// The Gate's cache. A checkout's fingerprint is its working tree's content and the Gate that judges
|
|
2
|
+
// it. A green run stamps it in the checkout's own git dir, so each worktree keeps its own stamp.
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { readFileSync, writeFileSync } from "node:fs";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { packageVersion } from "./package-version.js";
|
|
7
|
+
import { workingTree } from "./working-tree.js";
|
|
8
|
+
const STAMP_FILE = "dw-sdlc-gate.green";
|
|
9
|
+
/** The fingerprint of the checkout at `root` judged by `gate`, or why it has none. */
|
|
10
|
+
export function fingerprint(root, gate) {
|
|
11
|
+
const found = workingTree(root);
|
|
12
|
+
if ("error" in found) {
|
|
13
|
+
return found;
|
|
14
|
+
}
|
|
15
|
+
try {
|
|
16
|
+
const judge = createHash("sha256").update(JSON.stringify({ version: packageVersion(), gate }));
|
|
17
|
+
return { key: `${found.tree} ${judge.digest("hex")}`, stampFile: join(found.gitDir, STAMP_FILE) };
|
|
18
|
+
}
|
|
19
|
+
catch (error) {
|
|
20
|
+
return { error: error instanceof Error ? error.message : String(error) };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** Whether the last green run of this checkout was stamped with this fingerprint. */
|
|
24
|
+
export function isGreen({ key, stampFile }) {
|
|
25
|
+
try {
|
|
26
|
+
return readFileSync(stampFile, "utf8").trim() === key;
|
|
27
|
+
}
|
|
28
|
+
catch {
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
export function stampGreen({ key, stampFile }) {
|
|
33
|
+
try {
|
|
34
|
+
writeFileSync(stampFile, `${key}\n`);
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
// A stamp that cannot be written only means the next run does not skip.
|
|
38
|
+
}
|
|
39
|
+
}
|
package/dist/git-env.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
// The environment a git command runs under to find its repository from its own directory, as a git
|
|
2
|
+
// hook's GIT_DIR or GIT_INDEX_FILE would otherwise aim it at the hook's repository.
|
|
3
|
+
import { spawnSync } from "node:child_process";
|
|
4
|
+
let repositoryVars;
|
|
5
|
+
/** This process's environment without the variables that tie git to one repository, as git lists them. */
|
|
6
|
+
export function repositoryFreeEnv() {
|
|
7
|
+
repositoryVars ??= new Set((spawnSync("git", ["rev-parse", "--local-env-vars"], { encoding: "utf8" }).stdout ?? "").split("\n").filter(Boolean));
|
|
8
|
+
const vars = repositoryVars;
|
|
9
|
+
return Object.fromEntries(Object.entries(process.env).filter(([name]) => !vars.has(name)));
|
|
10
|
+
}
|
package/dist/markdown.d.ts
CHANGED
|
@@ -25,6 +25,25 @@ export declare function codeSpans(text: string): string[];
|
|
|
25
25
|
export declare function prose(text: string): string;
|
|
26
26
|
/** Every link target in the prose of `text`, its angle brackets dropped, with its 1-based line. */
|
|
27
27
|
export declare function links(text: string): Link[];
|
|
28
|
+
/** A value of a frontmatter, with its 1-based line. */
|
|
29
|
+
export type FrontmatterValue = {
|
|
30
|
+
value: string;
|
|
31
|
+
line: number;
|
|
32
|
+
};
|
|
33
|
+
/** A top-level key of a frontmatter: the value after its colon, and the items of the block list under it. */
|
|
34
|
+
export type FrontmatterEntry = FrontmatterValue & {
|
|
35
|
+
items: FrontmatterValue[];
|
|
36
|
+
};
|
|
37
|
+
/**
|
|
38
|
+
* The top-level keys of the frontmatter `text` opens with, none when it opens with none. A key written
|
|
39
|
+
* twice is read where it first appears.
|
|
40
|
+
*/
|
|
41
|
+
export declare function frontmatter(text: string): Map<string, FrontmatterEntry>;
|
|
42
|
+
/**
|
|
43
|
+
* The values a list key names: comma-separated after its colon, in `[…]` or not, or else its items. A comma
|
|
44
|
+
* inside a glob's `{…}` separates none, and an empty value is dropped.
|
|
45
|
+
*/
|
|
46
|
+
export declare function frontmatterList(key: FrontmatterEntry | undefined): FrontmatterValue[];
|
|
28
47
|
/** The anchors of each markdown file read so far, by path. */
|
|
29
48
|
export type Anchors = Map<string, Set<string>>;
|
|
30
49
|
/** The anchors of the headings of `path` under `root`; none when it is not a file. */
|
package/dist/markdown.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// The parts of a markdown file the Checks read: its ATX headings with the anchors GitHub gives them,
|
|
2
|
-
// the rows of a table under a heading, its links,
|
|
3
|
-
// of a file on disk. A line inside a fenced code block is never any of them.
|
|
2
|
+
// the rows of a table under a heading, its links, its prose with the code masked and the keys of its
|
|
3
|
+
// frontmatter; and the anchors of a file on disk. A line inside a fenced code block is never any of them.
|
|
4
4
|
import { readFileSync, statSync } from "node:fs";
|
|
5
5
|
import { join } from "node:path";
|
|
6
6
|
/** A code fence at any indent, so one inside a list item counts. */
|
|
@@ -103,6 +103,50 @@ export function links(text) {
|
|
|
103
103
|
return targets.map((target) => ({ target: target.replace(/^<(.*)>$/, "$1"), line: index + 1 }));
|
|
104
104
|
});
|
|
105
105
|
}
|
|
106
|
+
/** A scalar as YAML reads it: the text between its quotes, or else the text before a ` #` comment. */
|
|
107
|
+
function scalar(text) {
|
|
108
|
+
const quoted = /^\s*(["'])(.*?)\1\s*(#.*)?$/.exec(text);
|
|
109
|
+
return quoted?.[2] ?? text.replace(/(^|\s)#.*$/, "").trim();
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* The top-level keys of the frontmatter `text` opens with, none when it opens with none. A key written
|
|
113
|
+
* twice is read where it first appears.
|
|
114
|
+
*/
|
|
115
|
+
export function frontmatter(text) {
|
|
116
|
+
const keys = new Map();
|
|
117
|
+
const lines = text.split("\n");
|
|
118
|
+
if (lines[0]?.trim() !== "---") {
|
|
119
|
+
return keys;
|
|
120
|
+
}
|
|
121
|
+
let current;
|
|
122
|
+
for (let index = 1; index < lines.length && lines[index]?.trim() !== "---"; index++) {
|
|
123
|
+
const line = lines[index] ?? "";
|
|
124
|
+
const [, name, rest = ""] = /^([\w-]+):(.*)$/.exec(line) ?? [];
|
|
125
|
+
const item = /^\s*-(?:\s+(.*))?$/.exec(line);
|
|
126
|
+
if (name !== undefined) {
|
|
127
|
+
current = keys.has(name) ? undefined : { value: scalar(rest), line: index + 1, items: [] };
|
|
128
|
+
if (current !== undefined) {
|
|
129
|
+
keys.set(name, current);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
else if (item !== null) {
|
|
133
|
+
current?.items.push({ value: scalar(item[1] ?? ""), line: index + 1 });
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return keys;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* The values a list key names: comma-separated after its colon, in `[…]` or not, or else its items. A comma
|
|
140
|
+
* inside a glob's `{…}` separates none, and an empty value is dropped.
|
|
141
|
+
*/
|
|
142
|
+
export function frontmatterList(key) {
|
|
143
|
+
if (key === undefined) {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
146
|
+
const inline = key.value.replace(/^\[(.*)\]$/, "$1");
|
|
147
|
+
const values = inline === "" ? key.items : inline.split(/,(?![^{]*\})/).map((value) => ({ value: scalar(value), line: key.line }));
|
|
148
|
+
return values.filter(({ value }) => value !== "");
|
|
149
|
+
}
|
|
106
150
|
/** The anchors of the headings of `path` under `root`; none when it is not a file. */
|
|
107
151
|
export function anchorsOf(root, path, cache) {
|
|
108
152
|
let anchors = cache.get(path);
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// The version of this package, read from its own manifest: one directory above `src/` in the source
|
|
2
|
+
// and above `dist/` in what npm installs.
|
|
3
|
+
import { readFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { parseObject } from "./json-text.js";
|
|
6
|
+
/** This package's version; throws when its manifest names none. */
|
|
7
|
+
export function packageVersion() {
|
|
8
|
+
const manifest = parseObject(readFileSync(join(import.meta.dirname, "..", "package.json"), "utf8"));
|
|
9
|
+
if (typeof manifest?.version !== "string") {
|
|
10
|
+
throw new Error("dw-sdlc's package.json names no version");
|
|
11
|
+
}
|
|
12
|
+
return manifest.version;
|
|
13
|
+
}
|