dw-sdlc 0.20.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.
Files changed (97) hide show
  1. package/README.md +13 -13
  2. package/config/agentic.config.schema.json +1 -1
  3. package/dist/agentic-config.d.ts +16 -0
  4. package/dist/agentic-config.js +28 -23
  5. package/dist/cannot-run.d.ts +27 -0
  6. package/dist/cannot-run.js +48 -0
  7. package/dist/catalogue.js +2 -1
  8. package/dist/checks/agents-map.js +7 -8
  9. package/dist/checks/check.d.ts +10 -8
  10. package/dist/checks/check.js +9 -7
  11. package/dist/checks/decision-records.js +3 -2
  12. package/dist/checks/file-size-ratchet.js +13 -17
  13. package/dist/checks/index.d.ts +6 -5
  14. package/dist/checks/index.js +10 -9
  15. package/dist/checks/markdown-refs.js +58 -6
  16. package/dist/checks/primitives.js +5 -5
  17. package/dist/checks/routed-docs.js +1 -3
  18. package/dist/checks/skill-sections.js +12 -20
  19. package/dist/checks/tree.js +7 -7
  20. package/dist/cli.d.ts +2 -1
  21. package/dist/cli.js +26 -5
  22. package/dist/commands/check.js +27 -42
  23. package/dist/commands/config.js +22 -27
  24. package/dist/commands/doctor.d.ts +2 -0
  25. package/dist/commands/doctor.js +11 -18
  26. package/dist/commands/gate.js +35 -40
  27. package/dist/commands/lead-start.js +28 -42
  28. package/dist/commands/pr-reviewed.js +41 -59
  29. package/dist/commands/pr-ship.js +72 -87
  30. package/dist/commands/primitives.js +38 -40
  31. package/dist/commands/recap.js +23 -74
  32. package/dist/commands/references-setup.js +28 -76
  33. package/dist/commands/references-sync.js +29 -53
  34. package/dist/commands/setup.d.ts +2 -7
  35. package/dist/commands/setup.js +38 -90
  36. package/dist/commands/sweep.js +46 -157
  37. package/dist/commands/worker-ls.js +15 -31
  38. package/dist/commands/worker-report.js +36 -52
  39. package/dist/commands/worker-start.js +43 -57
  40. package/dist/drift.js +2 -1
  41. package/dist/exec.d.ts +20 -0
  42. package/dist/exec.js +37 -4
  43. package/dist/gate-cache.js +2 -1
  44. package/dist/git.d.ts +32 -0
  45. package/dist/git.js +60 -0
  46. package/dist/json-schema.js +1 -3
  47. package/dist/landed.js +11 -22
  48. package/dist/paused-operation.d.ts +2 -0
  49. package/dist/paused-operation.js +17 -0
  50. package/dist/pins.js +2 -1
  51. package/dist/pr/decide.d.ts +4 -16
  52. package/dist/pr/decide.js +48 -83
  53. package/dist/pr/delta.d.ts +3 -4
  54. package/dist/pr/delta.js +11 -20
  55. package/dist/pr/read.d.ts +27 -30
  56. package/dist/pr/read.js +96 -158
  57. package/dist/pr/reply.d.ts +34 -0
  58. package/dist/pr/reply.js +112 -0
  59. package/dist/pr/route.d.ts +11 -3
  60. package/dist/pr/route.js +5 -5
  61. package/dist/pr/standing.d.ts +138 -0
  62. package/dist/pr/standing.js +181 -0
  63. package/dist/pr/state.d.ts +14 -69
  64. package/dist/pr/state.js +75 -148
  65. package/dist/pr/upstream.d.ts +1 -6
  66. package/dist/pr/upstream.js +2 -2
  67. package/dist/primitives-diff.js +1 -1
  68. package/dist/recap-next.js +34 -47
  69. package/dist/recap-primitives.js +1 -1
  70. package/dist/references-key.d.ts +0 -1
  71. package/dist/references-key.js +3 -4
  72. package/dist/run-command.d.ts +4 -1
  73. package/dist/run-command.js +32 -11
  74. package/dist/setup-gate.d.ts +1 -7
  75. package/dist/setup-rules.js +2 -6
  76. package/dist/setup-writer.d.ts +32 -0
  77. package/dist/setup-writer.js +60 -0
  78. package/dist/shell-line.d.ts +2 -0
  79. package/dist/shell-line.js +5 -0
  80. package/dist/sweep-removal.d.ts +31 -0
  81. package/dist/sweep-removal.js +133 -0
  82. package/dist/sweep-terminals.d.ts +1 -1
  83. package/dist/sweep-terminals.js +21 -57
  84. package/dist/sweep-trees.d.ts +19 -3
  85. package/dist/sweep-trees.js +67 -31
  86. package/dist/worker/herdr.d.ts +67 -23
  87. package/dist/worker/herdr.js +126 -77
  88. package/dist/worker/launch.js +32 -45
  89. package/dist/worker/origin.d.ts +1 -1
  90. package/dist/worker/origin.js +9 -11
  91. package/dist/worker/tabs.js +11 -7
  92. package/dist/working-tree.d.ts +0 -8
  93. package/dist/working-tree.js +6 -22
  94. package/dist/worktree-lock.js +1 -1
  95. package/package.json +1 -1
  96. package/dist/git-env.d.ts +0 -2
  97. package/dist/git-env.js +0 -10
package/dist/pr/read.js CHANGED
@@ -1,17 +1,13 @@
1
1
  // The read the pr commands and recap make: gh as a child process, its auth, the pull request a command names,
2
- // its state from one GraphQL request with the compares that tell whether its head carries an earlier Review
3
- // and that size its changes, and beside them the default branch and the clock the decision reads.
4
- import { exec, TimedOut } from "../exec.js";
5
- import { behindOf, changedOf, changesOf, compareArgs, sameChanges } from "./delta.js";
6
- import { awaitsReview, parseDefaultBranch, parsePr, parseView, QUERY, ReplyError } from "./state.js";
7
- /** What stops a pr command or recap before it answers: exit 2, the problem and its remedy on one line. */
8
- export class CannotRun extends Error {
9
- remedy;
10
- constructor(message, remedy) {
11
- super(message);
12
- this.remedy = remedy;
13
- }
14
- }
2
+ // its state from one GraphQL request and the Review's standing at its head from GitHub's compares, through
3
+ // gh, and beside them the default branch and the clock the decision reads.
4
+ import { CannotRun } from "../cannot-run.js";
5
+ import { firstLine, need } from "../exec.js";
6
+ import { behindOf, changedOf, changesOf, compareArgs } from "./delta.js";
7
+ import { ReplyError } from "./reply.js";
8
+ import { standingOf } from "./standing.js";
9
+ import { awaitsReview, parseDefaultBranch, parsePr, parseView, QUERY } from "./state.js";
10
+ import { holds, renamedUpstream } from "./upstream.js";
15
11
  const REMEDIES = {
16
12
  missing: "install the GitHub CLI from https://cli.github.com",
17
13
  auth: "run gh auth login, or set GH_TOKEN",
@@ -30,39 +26,18 @@ export function namedProblem(number, repo) {
30
26
  }
31
27
  return undefined;
32
28
  }
33
- function firstLine(text) {
34
- return text.trim().split("\n")[0] ?? "";
35
- }
36
29
  /** What gh said went wrong: the first line of its stderr, without its `gh: ` prefix or its `X` icon. */
37
30
  export function said(stderr) {
38
- return firstLine(stderr).replace(/^(?:gh: |X )/, "");
31
+ return (firstLine(stderr) ?? "").replace(/^(?:gh: |X )/, "");
39
32
  }
40
33
  /** The problem `gh auth status` reports: its line marked `X` under the host, else its first line. */
41
34
  function authProblem(stderr) {
42
35
  const marked = stderr.split("\n").find((line) => line.trimStart().startsWith("X "));
43
- return marked === undefined ? firstLine(stderr) : marked.trim().slice(2);
44
- }
45
- /** `gh args` as a child process; past its limit it stops the command as any other failed read does. */
46
- function reachGh(args) {
47
- try {
48
- return exec("gh", args);
49
- }
50
- catch (error) {
51
- if (error instanceof TimedOut) {
52
- throw new CannotRun(error.message, REMEDIES.read);
53
- }
54
- throw error;
55
- }
36
+ return marked === undefined ? (firstLine(stderr) ?? "") : marked.trim().slice(2);
56
37
  }
38
+ /** `gh args` as a child process; not on PATH, or past its limit, it stops the command as any failed read does. */
57
39
  export function gh(args) {
58
- const result = reachGh(args);
59
- if (result.error !== undefined) {
60
- if ("code" in result.error && result.error.code === "ENOENT") {
61
- throw new CannotRun("gh is not on PATH", REMEDIES.missing);
62
- }
63
- throw result.error;
64
- }
65
- return { status: result.status ?? 1, stdout: result.stdout, stderr: result.stderr };
40
+ return need("gh", args, { missing: REMEDIES.missing, stalled: REMEDIES.read });
66
41
  }
67
42
  /** What `parse` returns; a reply it cannot parse stops the command, naming the field. */
68
43
  function parseOrCannotRun(parse) {
@@ -88,7 +63,7 @@ function targetOf({ number, repo }, command) {
88
63
  const named = [...(number === undefined ? [] : [String(number)]), ...(repo === undefined ? [] : ["-R", repo])];
89
64
  const view = gh(["pr", "view", ...named, "--json", "url"]);
90
65
  if (view.status !== 0) {
91
- throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr)}`, nameItRemedy(command));
66
+ throw new CannotRun(`gh pr view found no pull request: ${firstLine(view.stderr) ?? ""}`, nameItRemedy(command));
92
67
  }
93
68
  return parseOrCannotRun(() => parseView(view.stdout));
94
69
  }
@@ -100,41 +75,17 @@ function requireAuth() {
100
75
  throw new CannotRun(`gh auth status failed: ${authProblem(auth.stderr)}`, REMEDIES.auth);
101
76
  }
102
77
  }
103
- /**
104
- * The state of the pull request `named`; `command`, as `pr ship`, names the call in a remedy. With
105
- * `sized`, as `pr.review: auto` needs, the read sizes the changes a Review would read.
106
- */
107
- export function read(named, command, sized) {
108
- requireAuth();
109
- return query(targetOf(named, command), sized);
110
- }
111
- /** The pull request `gh pr view` resolves for the head branch `branch`, else the current one; null when none. */
78
+ /** The pull request `gh pr view` resolves for the head branch `branch`, else the current one, or what gh said of none. */
112
79
  function viewed(branch) {
113
80
  const view = gh(["pr", "view", ...(branch === undefined ? [] : [branch]), "--json", "url"]);
114
81
  if (view.status === 0) {
115
82
  return parseOrCannotRun(() => parseView(view.stdout));
116
83
  }
117
84
  if (said(view.stderr).startsWith("no pull requests found")) {
118
- return null;
85
+ return { none: said(view.stderr) };
119
86
  }
120
87
  throw new CannotRun(`gh pr view failed: ${said(view.stderr)}`, REMEDIES.read);
121
88
  }
122
- /**
123
- * The current branch's pull request, open or not, or null. gh tries `@{push}`, else the local name, which
124
- * misses a branch pushed under another name; then `upstreamName` is asked, and `byUpstream` says so.
125
- */
126
- export function readBranch(sized, upstreamName) {
127
- requireAuth();
128
- const own = viewed(undefined);
129
- if (own !== null) {
130
- return { pr: query(own, sized), byUpstream: false };
131
- }
132
- if (upstreamName === undefined) {
133
- return null;
134
- }
135
- const found = viewed(upstreamName);
136
- return found === null ? null : { pr: query(found, sized), byUpstream: true };
137
- }
138
89
  /** The default branch of the repository gh resolves from the current directory. */
139
90
  export function defaultBranch() {
140
91
  const view = gh(["repo", "view", "--json", "defaultBranchRef"]);
@@ -155,100 +106,88 @@ export function now() {
155
106
  }
156
107
  return time;
157
108
  }
158
- /** The state of `pr` once more, after an action the script performed on it: the same pull request. */
159
- export function readAgain(pr, sized) {
160
- return query(pr, sized);
161
- }
162
- /**
163
- * The compares this process has read, by repository and the two commits, so reads that share one ask gh
164
- * once: a compare of two commits never changes.
165
- */
166
- const COMPARES = new Map();
167
- /** For a test that runs the command in its own process: each case starts with no compare read. */
168
- export function forgetCompares() {
169
- COMPARES.clear();
170
- }
171
- /** The reply of the compare from `from` to `commit`, read once per process; `what` names it when gh refuses it. */
172
- function compared(pr, from, commit, what) {
173
- const key = `${pr.repo} ${from}...${commit}`;
174
- const known = COMPARES.get(key);
175
- if (known !== undefined) {
176
- return known;
177
- }
178
- const reply = gh(compareArgs(pr.repo, from, commit));
179
- if (reply.status !== 0) {
180
- throw new CannotRun(`${pr.repo}#${pr.number}: ${what}: ${said(reply.stderr)}`, REMEDIES.read);
181
- }
182
- COMPARES.set(key, reply.stdout);
183
- return reply.stdout;
184
- }
185
- /** Whether the head of `after` carries the base `before` read: the compare shows it behind by none. */
186
- export function carriesBase(before, after) {
187
- const reply = compared(after, before.baseOid, after.head, `the compare with ${before.baseOid.slice(0, 7)}`);
188
- return parseOrCannotRun(() => behindOf(reply)) === 0;
189
- }
190
- /** The own changes of `pr` at `commit`, from the compare with its base; null when it cannot show them whole. */
191
- function ownChanges(pr, commit) {
192
- const reply = compared(pr, pr.baseOid, commit, `the compare with ${commit.slice(0, 7)}`);
193
- return parseOrCannotRun(() => changesOf(reply));
194
- }
195
- /**
196
- * `pr` and whether its head carries the success of the last commit a Review read: a clean read, only
197
- * merges since it, and the same own changes at both. A read mark never carries. The compares run only
198
- * when a Review awaits the head.
199
- */
200
- function withCarried(pr) {
201
- const last = pr.lastRead;
202
- if (last === null || !last.clean || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
203
- return { ...pr, carried: false };
204
- }
205
- return { ...pr, carried: sameChanges(ownChanges(pr, last.commit), ownChanges(pr, pr.head)) };
206
- }
207
- /**
208
- * The files whose own change differs between `from` and the head of `pr`, a rename under both names:
209
- * what a fix touched, without the files a merge of the base brought in. Null when a compare cannot show
210
- * the own changes whole.
211
- */
212
- function ownScope(pr, from) {
213
- const before = ownChanges(pr, from);
214
- const after = ownChanges(pr, pr.head);
215
- if (before === null || after === null) {
216
- return null;
217
- }
218
- const moved = [
219
- ...after.filter((change) => !before.includes(change)),
220
- ...before.filter((change) => !after.includes(change))
221
- ];
222
- const names = moved.flatMap((change) => {
223
- const [path, , previous] = JSON.parse(change);
224
- return previous === null ? [path] : [path, previous];
225
- });
226
- return [...new Set(names)].toSorted();
109
+ /** A commit as a reason names it: its first seven characters. */
110
+ function short(commit) {
111
+ return commit.slice(0, 7);
227
112
  }
228
113
  /**
229
- * `pr` and, when a Review awaits a head that carries none, its `scope` after a merge of the base, and
230
- * when `sized`, the files the Review would read: the compare from the last commit a Review read, else
231
- * from the base, to the head, kept to the scope when there is one.
114
+ * The gh adapter on `target`. A compare of two commits never changes, so it asks gh for each once, for its
115
+ * own life; a compare gh refuses stops the command, naming it.
232
116
  */
233
- function withChanged(pr, sized) {
234
- if (pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
235
- return { ...pr, changed: null, scope: null };
236
- }
237
- const scope = pr.lastRead?.merges === true ? ownScope(pr, pr.lastRead.commit) : null;
238
- if (!sized) {
239
- return { ...pr, changed: null, scope };
240
- }
241
- const from = pr.lastRead?.commit ?? pr.baseOid;
242
- const reply = compared(pr, from, pr.head, `the compare of ${from.slice(0, 7)}...${pr.head.slice(0, 7)}`);
243
- const changed = parseOrCannotRun(() => changedOf(reply));
244
- if (scope === null || changed === "unsized") {
245
- return { ...pr, changed, scope };
246
- }
247
- const within = changed.filter(({ path, previous }) => scope.includes(path) || (previous !== null && scope.includes(previous)));
248
- return { ...pr, changed: within, scope };
249
- }
250
- /** The state of pull request `number` of `repo`, from one GraphQL request and the compares a Review needs. */
251
- function query({ repo, number }, sized) {
117
+ function ghCompares(target) {
118
+ const replies = new Map();
119
+ const replyOf = (from, to, what) => {
120
+ const key = `${from}...${to}`;
121
+ const known = replies.get(key);
122
+ if (known !== undefined) {
123
+ return known;
124
+ }
125
+ const reply = gh(compareArgs(target.repo, from, to));
126
+ if (reply.status !== 0) {
127
+ throw new CannotRun(`${target.repo}#${target.number}: ${what}: ${said(reply.stderr)}`, REMEDIES.read);
128
+ }
129
+ replies.set(key, reply.stdout);
130
+ return reply.stdout;
131
+ };
132
+ return {
133
+ ownChanges: (base, commit) => parseOrCannotRun(() => changesOf(replyOf(base, commit, `the compare with ${short(commit)}`))),
134
+ changed: (from, head) => parseOrCannotRun(() => changedOf(replyOf(from, head, `the compare of ${short(from)}...${short(head)}`))),
135
+ behind: (base, head) => parseOrCannotRun(() => behindOf(replyOf(base, head, `the compare with ${short(base)}`)))
136
+ };
137
+ }
138
+ /** The pull request `named` from the one GraphQL request alone, without the compares the Review's standing reads. */
139
+ export function pullRequestNamed(named, command) {
140
+ requireAuth();
141
+ const target = targetOf(named, command);
142
+ return parseOrCannotRun(() => parsePr(graphql(target), target.repo)).pr;
143
+ }
144
+ /** The reads of one command; with `sized`, as `pr.review: auto` needs, each sizes the changes a Review would read. */
145
+ export function ghReads(sized) {
146
+ const adapters = new Map();
147
+ const comparesOn = (target) => {
148
+ const key = `${target.repo}#${target.number}`;
149
+ const adapter = adapters.get(key) ?? ghCompares(target);
150
+ adapters.set(key, adapter);
151
+ return adapter;
152
+ };
153
+ const query = (target) => {
154
+ const { pr, history } = parseOrCannotRun(() => parsePr(graphql(target), target.repo));
155
+ const awaits = pr.state === "OPEN" && awaitsReview(pr);
156
+ return { ...pr, ...standingOf(history, comparesOn(target), { awaits, sized }) };
157
+ };
158
+ return {
159
+ named: (named, command) => {
160
+ requireAuth();
161
+ return query(targetOf(named, command));
162
+ },
163
+ checkout: (root) => {
164
+ requireAuth();
165
+ const own = viewed(undefined);
166
+ if (!("none" in own)) {
167
+ return query(own);
168
+ }
169
+ const renamed = renamedUpstream(root);
170
+ if (renamed === undefined) {
171
+ return { problem: `gh pr view found no pull request: ${own.none}`, lacksHead: false };
172
+ }
173
+ const { branch, head, upstream } = renamed;
174
+ const named = `${upstream}, the name its upstream gives`;
175
+ const found = viewed(upstream);
176
+ if ("none" in found) {
177
+ return { problem: `gh pr view found no pull request for ${branch}, nor for ${named}`, lacksHead: false };
178
+ }
179
+ // A branch started from another tracks that branch until its first push: its pull request is not this one.
180
+ const pr = query(found);
181
+ return holds(pr, head)
182
+ ? pr
183
+ : { problem: `${pr.url}, found by ${named}, lacks HEAD ${short(head)}`, lacksHead: true };
184
+ },
185
+ again: query,
186
+ carriesBase: (before, after) => comparesOn(after).behind(before.baseOid, after.head) === 0
187
+ };
188
+ }
189
+ /** The reply of the one GraphQL request for pull request `number` of `repo`. */
190
+ function graphql({ repo, number }) {
252
191
  const [owner = "", name = ""] = repo.split("/");
253
192
  const reply = gh([
254
193
  "api",
@@ -267,6 +206,5 @@ function query({ repo, number }, sized) {
267
206
  const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
268
207
  throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
269
208
  }
270
- const parsed = parseOrCannotRun(() => parsePr(reply.stdout, repo));
271
- return withChanged(withCarried(parsed), sized);
209
+ return reply.stdout;
272
210
  }
@@ -0,0 +1,34 @@
1
+ /** A reply that is not the shape the query asks for; the message names the field. */
2
+ export declare class ReplyError extends Error {
3
+ }
4
+ /** A JSON object of the reply and the path that reached it, as `data.repository.pullRequest`. */
5
+ export type Node = {
6
+ value: Record<string, unknown>;
7
+ at: string;
8
+ };
9
+ export declare function shown(value: unknown): string;
10
+ /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
11
+ export declare function rootOf(reply: string, what: string): Node;
12
+ export declare function fieldOf(node: Node, key: string): {
13
+ value: unknown;
14
+ at: string;
15
+ };
16
+ export declare function object(value: unknown, at: string): Node;
17
+ export declare function child(node: Node, key: string): Node;
18
+ export declare function childOrNull(node: Node, key: string): Node | null;
19
+ export declare function string(node: Node, key: string): string;
20
+ export declare function stringOrNull(node: Node, key: string): string | null;
21
+ export declare function boolean(node: Node, key: string): boolean;
22
+ export declare function integer(node: Node, key: string): number;
23
+ export declare function integerOrNull(node: Node, key: string): number | null;
24
+ /** A time as GitHub writes it, ISO 8601; the decision reads it as one. */
25
+ export declare function time(node: Node, key: string): string;
26
+ export declare function timeOrNull(node: Node, key: string): string | null;
27
+ export declare function isOneOf<T extends string>(values: readonly T[], value: unknown): value is T;
28
+ export declare function oneOf<T extends string>(node: Node, key: string, values: readonly T[]): T;
29
+ /** The objects of a connection's `nodes`. */
30
+ export declare function nodes(node: Node, key: string): Node[];
31
+ /** Whether a connection has more nodes after the ones the reply holds. */
32
+ export declare function hasMore(node: Node, key: string): boolean;
33
+ /** The first object of a connection's `nodes`; an empty connection does not parse. */
34
+ export declare function firstNode(node: Node, key: string): Node;
@@ -0,0 +1,112 @@
1
+ // The reply of a gh call read as JSON: each field checked as the type the reader asks for, and a
2
+ // reply that fails names the field by its path.
3
+ import { isObject } from "../json-text.js";
4
+ /** A reply that is not the shape the query asks for; the message names the field. */
5
+ export class ReplyError extends Error {
6
+ }
7
+ export function shown(value) {
8
+ return value === undefined ? "missing" : JSON.stringify(value);
9
+ }
10
+ /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
11
+ export function rootOf(reply, what) {
12
+ let json;
13
+ try {
14
+ json = JSON.parse(reply);
15
+ }
16
+ catch {
17
+ throw new ReplyError(`${what} is not JSON`);
18
+ }
19
+ if (!isObject(json)) {
20
+ throw new ReplyError(`${what} is not a JSON object`);
21
+ }
22
+ return { value: json, at: "" };
23
+ }
24
+ export function fieldOf(node, key) {
25
+ return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
26
+ }
27
+ export function object(value, at) {
28
+ if (!isObject(value)) {
29
+ throw new ReplyError(`${at} is ${shown(value)}, not an object`);
30
+ }
31
+ return { value, at };
32
+ }
33
+ export function child(node, key) {
34
+ const { value, at } = fieldOf(node, key);
35
+ return object(value, at);
36
+ }
37
+ export function childOrNull(node, key) {
38
+ const { value, at } = fieldOf(node, key);
39
+ return value === null ? null : object(value, at);
40
+ }
41
+ export function string(node, key) {
42
+ const { value, at } = fieldOf(node, key);
43
+ if (typeof value !== "string") {
44
+ throw new ReplyError(`${at} is ${shown(value)}, not a string`);
45
+ }
46
+ return value;
47
+ }
48
+ export function stringOrNull(node, key) {
49
+ return node.value[key] === null ? null : string(node, key);
50
+ }
51
+ export function boolean(node, key) {
52
+ const { value, at } = fieldOf(node, key);
53
+ if (typeof value !== "boolean") {
54
+ throw new ReplyError(`${at} is ${shown(value)}, not a boolean`);
55
+ }
56
+ return value;
57
+ }
58
+ export function integer(node, key) {
59
+ const { value, at } = fieldOf(node, key);
60
+ if (typeof value !== "number" || !Number.isInteger(value)) {
61
+ throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
62
+ }
63
+ return value;
64
+ }
65
+ export function integerOrNull(node, key) {
66
+ const { value, at } = fieldOf(node, key);
67
+ if (value !== null && !Number.isInteger(value)) {
68
+ throw new ReplyError(`${at} is ${shown(value)}, not an integer or null`);
69
+ }
70
+ return typeof value === "number" ? value : null;
71
+ }
72
+ /** A time as GitHub writes it, ISO 8601; the decision reads it as one. */
73
+ export function time(node, key) {
74
+ const value = string(node, key);
75
+ if (Number.isNaN(Date.parse(value))) {
76
+ throw new ReplyError(`${fieldOf(node, key).at} is ${shown(value)}, not a time`);
77
+ }
78
+ return value;
79
+ }
80
+ export function timeOrNull(node, key) {
81
+ return node.value[key] === null ? null : time(node, key);
82
+ }
83
+ export function isOneOf(values, value) {
84
+ return values.some((known) => known === value);
85
+ }
86
+ export function oneOf(node, key, values) {
87
+ const { value, at } = fieldOf(node, key);
88
+ if (!isOneOf(values, value)) {
89
+ throw new ReplyError(`${at} is ${shown(value)}, not one of ${values.join(", ")}`);
90
+ }
91
+ return value;
92
+ }
93
+ /** The objects of a connection's `nodes`. */
94
+ export function nodes(node, key) {
95
+ const { value, at } = fieldOf(child(node, key), "nodes");
96
+ if (!Array.isArray(value)) {
97
+ throw new ReplyError(`${at} is ${shown(value)}, not a list`);
98
+ }
99
+ return value.map((item, index) => object(item, `${at}[${index}]`));
100
+ }
101
+ /** Whether a connection has more nodes after the ones the reply holds. */
102
+ export function hasMore(node, key) {
103
+ return boolean(child(child(node, key), "pageInfo"), "hasNextPage");
104
+ }
105
+ /** The first object of a connection's `nodes`; an empty connection does not parse. */
106
+ export function firstNode(node, key) {
107
+ const [first] = nodes(node, key);
108
+ if (first === undefined) {
109
+ throw new ReplyError(`${node.at}.${key}.nodes is empty`);
110
+ }
111
+ return first;
112
+ }
@@ -1,5 +1,13 @@
1
1
  import type { ReviewDepth } from "../agentic-config.ts";
2
- import type { ChangedFile } from "./state.ts";
2
+ /**
3
+ * A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
4
+ * null when the compare shows no patch for it, as for a binary file.
5
+ */
6
+ export type ChangedFile = {
7
+ path: string;
8
+ previous: string | null;
9
+ lines: number | null;
10
+ };
3
11
  /** A routed depth, and under off why no one reads the changes, as the status the script posts says. */
4
12
  export type Routed = {
5
13
  depth: Exclude<ReviewDepth, "off">;
@@ -9,6 +17,6 @@ export type Routed = {
9
17
  };
10
18
  /**
11
19
  * The depth the rules route `changed` to. A change not read or not sized reads in full, as does a file
12
- * outside lockfiles the compare shows no patch for.
20
+ * outside lockfiles the compare shows no patch for. With `findingsOpen` someone reads even lockfiles alone.
13
21
  */
14
- export declare function route(changed: ChangedFile[] | "unsized" | null): Routed;
22
+ export declare function route(changed: ChangedFile[] | "unsized" | null, findingsOpen: boolean): Routed;
package/dist/pr/route.js CHANGED
@@ -1,6 +1,6 @@
1
1
  // The built-in rules `pr.review: auto` routes a Review depth by, from the files the Review would read:
2
- // lockfiles alone are off; a workflow file, more than 100 changed lines outside lockfiles, or a change
3
- // the compare cannot size is full; the rest is light. No config key moves them.
2
+ // lockfiles alone are off while no Finding is open; a workflow file, more than 100 changed lines outside
3
+ // lockfiles, or a change the compare cannot size is full; the rest is light. No config key moves them.
4
4
  /** The lockfiles a package manager writes, by file name in any directory. */
5
5
  const LOCKFILES = new Set([
6
6
  "pnpm-lock.yaml",
@@ -33,14 +33,14 @@ function isWorkflow({ path, previous }) {
33
33
  }
34
34
  /**
35
35
  * The depth the rules route `changed` to. A change not read or not sized reads in full, as does a file
36
- * outside lockfiles the compare shows no patch for.
36
+ * outside lockfiles the compare shows no patch for. With `findingsOpen` someone reads even lockfiles alone.
37
37
  */
38
- export function route(changed) {
38
+ export function route(changed, findingsOpen) {
39
39
  if (changed === null || changed === "unsized") {
40
40
  return { depth: "full" };
41
41
  }
42
42
  const code = changed.filter((file) => !isLockfile(file.path));
43
- if (changed.length > 0 && code.length === 0) {
43
+ if (!findingsOpen && changed.length > 0 && code.length === 0) {
44
44
  return { depth: "off", why: "lockfiles only" };
45
45
  }
46
46
  const lines = code.map((file) => file.lines);
@@ -0,0 +1,138 @@
1
+ import type { PrReview, ReviewDepth } from "../agentic-config.ts";
2
+ import { type ChangedFile } from "./route.ts";
3
+ /** What a commit's review status says: a success, the read mark, a success posted under off, or none. */
4
+ export type Posted = "clean" | "findings" | "off" | null;
5
+ /** A commit of the history and the review status it carries; `merge` when it has more than one parent. */
6
+ export type Commit = {
7
+ oid: string;
8
+ posted: Posted;
9
+ merge: boolean;
10
+ };
11
+ /**
12
+ * A commit a force push left behind and its review status; `forksAt` the index in the history of the
13
+ * commit its line was pushed on, as under an amend, -1 when its line holds none, as after a rebase.
14
+ */
15
+ export type LeftBehind = {
16
+ oid: string;
17
+ posted: Posted;
18
+ forksAt: number;
19
+ };
20
+ /** What the standing is read from. */
21
+ export type History = {
22
+ /** The pull request's last 100 commits, oldest first, the head last. */
23
+ commits: Commit[];
24
+ /** The commits the last force pushes left behind, newest push first, none of them among `commits`. */
25
+ leftBehind: LeftBehind[];
26
+ head: string;
27
+ /** The commit the base branch points at, the one the own changes are compared with. */
28
+ base: string;
29
+ };
30
+ /**
31
+ * A file's own change: its name, its status, its name before a rename, and the lines its patch adds and
32
+ * removes, in order, without the hunk headers and context a moved base shifts.
33
+ */
34
+ export type OwnChange = {
35
+ path: string;
36
+ status: string;
37
+ previous: string | null;
38
+ lines: string[];
39
+ };
40
+ /** GitHub's compares as the standing asks them. A compare of two commits never changes: an adapter may keep it. */
41
+ export type Compares = {
42
+ /** The pull request's own changes at `commit`, from `base`; null when the compare cannot show them whole. */
43
+ ownChanges: (base: string, commit: string) => OwnChange[] | null;
44
+ /** The files from `from` to `head` and their changed lines; `unsized` when the compare may hold some back. */
45
+ changed: (from: string, head: string) => ChangedFile[] | "unsized";
46
+ };
47
+ /**
48
+ * A commit a Review read, `clean` when it left a success there and not a read mark; `baseOnly` when every
49
+ * change after it is the base's, the head included: only merges of the base, or a rebase past no other
50
+ * read; `baseMoved` when a merge of the base or a rebase came after it.
51
+ */
52
+ export type LastRead = {
53
+ commit: string;
54
+ clean: boolean;
55
+ baseOnly: boolean;
56
+ baseMoved: boolean;
57
+ };
58
+ /**
59
+ * The Reviews that left a Finding open since the last review success, a success under off and the dev's
60
+ * acceptance included: `count` their read marks, the head's own among them, and `since` that success's
61
+ * commit, null when no commit read holds one.
62
+ */
63
+ export type ReadMarks = {
64
+ count: number;
65
+ since: string | null;
66
+ };
67
+ /** The Review's standing at the head, as the decision reads it and `pr ship --verbose` shows it. */
68
+ export type Standing = {
69
+ /**
70
+ * A read mark force pushes left behind after every read of the history, else the last commit before the
71
+ * head a Review read, else a commit force pushes left behind that one read.
72
+ */
73
+ lastRead: LastRead | null;
74
+ /**
75
+ * A read mark whose Findings no later commit answers: the head's own, or one the commits since leave with
76
+ * no fix showing, merges of the base and rebases among them. Then `lastRead` is null and nothing carries
77
+ * (ADR 0023).
78
+ */
79
+ open: string | null;
80
+ /** The read marks posted since the last success, on the history and on the commits force pushes left behind. */
81
+ marks: ReadMarks;
82
+ /** Whether the head carries the success of `lastRead`, which two compares decide. */
83
+ carried: boolean;
84
+ /**
85
+ * The files a Review would read, from `lastRead` or the base to the head, read only for `pr.review:
86
+ * auto` to route on: `unsized` when the compare cannot list them all, null when not read.
87
+ */
88
+ changed: ChangedFile[] | "unsized" | null;
89
+ /**
90
+ * The files whose own change moved since `lastRead`, when a merge of the base or a rebase came after it:
91
+ * the files a fix touched, the base's own left out. Null with neither, or when a compare cannot show them.
92
+ */
93
+ scope: string[] | null;
94
+ };
95
+ /**
96
+ * The standing at the head of `history`. The compares run only when a Review `awaits` the head, and the
97
+ * files that Review would read are read only when `sized`, as `pr.review: auto` routes on them.
98
+ */
99
+ export declare function standingOf(history: History, compares: Compares, { awaits, sized }: {
100
+ awaits: boolean;
101
+ sized: boolean;
102
+ }): Standing;
103
+ /** Who set the depth: the dev's `--review` for this call, or `pr.review`, a depth or `auto`. */
104
+ export type ReviewPolicy = {
105
+ depth: ReviewDepth;
106
+ source: "flag";
107
+ } | {
108
+ depth: PrReview;
109
+ source: "config";
110
+ };
111
+ /** Where a Review depth comes from: the dev's word, `pr.review`, or the rules `pr.review: auto` routes by. */
112
+ export type DepthSource = "flag" | "config" | "auto";
113
+ /** A depth as the answer names it, and under off why no one reads the head. */
114
+ export type Depth = {
115
+ depth: Exclude<ReviewDepth, "off">;
116
+ source: DepthSource;
117
+ } | {
118
+ depth: "off";
119
+ source: DepthSource;
120
+ why: string;
121
+ };
122
+ /**
123
+ * The Review the head gets: the success of the read it carries; a Review at a depth that reads from `since`,
124
+ * kept to `paths`, over the read mark left `open`, where under off the script posts the success; or the park.
125
+ */
126
+ export type ReviewCall = {
127
+ kind: "carry";
128
+ from: string;
129
+ } | (Depth & {
130
+ kind: "review";
131
+ since: string | null;
132
+ paths: string[] | null;
133
+ open: string | null;
134
+ }) | (ReadMarks & {
135
+ kind: "limit";
136
+ });
137
+ /** The Review the head gets at `standing` under `review`; the dev's word goes over a carry. */
138
+ export declare function reviewCall(standing: Standing, review: ReviewPolicy): ReviewCall;