dw-sdlc 0.20.0 → 0.21.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 CHANGED
@@ -65,19 +65,19 @@ line; a `gate.checks.run` entry takes a `hint` beside `name` and `command`.
65
65
  Under `gate.checks`, a Check is `true` for its defaults, an object of its options, or `false`. The
66
66
  schema's description of each one names its options and defaults.
67
67
 
68
- | Check | fails | reads first |
69
- | -------------------- | --------------------------------------------------------------------------- | ------------------------- |
70
- | `markdown-refs` | a relative link or image whose path or heading anchor does not resolve | nothing |
71
- | `temporal-language` | a changelog phrase in a durable document, outside code | nothing |
72
- | `comment-budget` | a comment block over 3 lines of prose in JS, TS or shell | nothing |
73
- | `disable-directives` | a linter or type-checker disable comment with no reason | nothing |
74
- | `agents-map` | an `AGENTS.md` over budget, missing the Gate command or pointing at nothing | `AGENTS.md` |
75
- | `primitives` | a catalogue entry out of step with the tree, or a file no entry owns | `.ai/primitives.json` |
76
- | `routed-docs` | a document under `docs/` no Task Router row or Primitive leads to | the map, the catalogue |
77
- | `markdown-budget` | a markdown file over the byte budget of its glob | the files its globs match |
78
- | `decision-records` | a decision record misnamed, misnumbered or without a valid status | a records directory |
79
- | `file-size-ratchet` | a code file over its line limit that the baseline does not allow | its baseline |
80
- | `skill-sections` | a README section a skill copies that differs from its source | the catalogue |
68
+ | Check | fails | reads first |
69
+ | -------------------- | ------------------------------------------------------------------------------ | ------------------------- |
70
+ | `markdown-refs` | a relative link, image or repository URL whose path or anchor does not resolve | nothing |
71
+ | `temporal-language` | a changelog phrase in a durable document, outside code | nothing |
72
+ | `comment-budget` | a comment block over 3 lines of prose in JS, TS or shell | nothing |
73
+ | `disable-directives` | a linter or type-checker disable comment with no reason | nothing |
74
+ | `agents-map` | an `AGENTS.md` over budget, missing the Gate command or pointing at nothing | `AGENTS.md` |
75
+ | `primitives` | a catalogue entry out of step with the tree, or a file no entry owns | `.ai/primitives.json` |
76
+ | `routed-docs` | a document under `docs/` no Task Router row or Primitive leads to | the map, the catalogue |
77
+ | `markdown-budget` | a markdown file over the byte budget of its glob | the files its globs match |
78
+ | `decision-records` | a decision record misnamed, misnumbered or without a valid status | a records directory |
79
+ | `file-size-ratchet` | a code file over its line limit that the baseline does not allow | its baseline |
80
+ | `skill-sections` | a README section a skill copies that differs from its source | the catalogue |
81
81
 
82
82
  Each Check's options and defaults:
83
83
  [its description in the schema](https://github.com/dominikwozniak/dw-sdlc/blob/main/packages/dw-sdlc/config/agentic.config.schema.json).
@@ -264,7 +264,7 @@
264
264
  "additionalProperties": false
265
265
  },
266
266
  "markdown-refs": {
267
- "description": "Every relative link and image in the markdown files resolves: its path to a file or directory git tracks or would track, its heading anchor to a heading of the markdown file it names, and `#anchor` alone to a heading of its own file. A link with a scheme, such as `https:`, and a link inside code are not judged.",
267
+ "description": "Every relative link and image in the markdown files resolves: its path to a file or directory git tracks or would track, its heading anchor to a heading of the markdown file it names, and `#anchor` alone to a heading of its own file. A URL into the GitHub repository `origin` names, at its default branch (the one `origin/HEAD` names, else `main`), as a `blob`, `tree` or raw file URL, resolves the same way, inside code too. Any other link with a scheme, such as `https:`, and a relative link inside code are not judged.",
268
268
  "type": ["boolean", "object"],
269
269
  "properties": {
270
270
  "files": {
@@ -1,9 +1,10 @@
1
- // `markdown-refs`: every relative link and image in the markdown resolves, its path to a file or a
2
- // directory git tracks or would track and its heading anchor to a heading of the file it names, so a
3
- // link to an ignored file or to another case of a file's name fails here as it fails in CI.
1
+ // `markdown-refs`: every relative link and image, and every URL into origin's default branch even in
2
+ // code, resolves to a file or directory git tracks or would track and its anchor to a heading there, so
3
+ // a link to an ignored file or to another case of a file's name fails here as it fails in CI.
4
4
  import { readFileSync } from "node:fs";
5
5
  import { join, posix } from "node:path";
6
6
  import { anchorsOf, links } from "../markdown.js";
7
+ import { asked } from "../working-tree.js";
7
8
  import { directoriesOf, matching, treeFiles } from "./tree.js";
8
9
  const REMEDIES = {
9
10
  path: "fix the path or drop the link",
@@ -12,6 +13,51 @@ const REMEDIES = {
12
13
  };
13
14
  /** A target with a scheme, such as `https:` or `mailto:`, or a host: no file of the tree answers it. */
14
15
  const EXTERNAL = /^([a-z][a-z\d+.-]*:|\/\/)/i;
16
+ /** `owner/name` in the URL of a GitHub remote, over SSH or HTTPS, with a user, a token or a port, `.git` or not. */
17
+ const GITHUB_REMOTE = /^(?:git@github\.com:|(?:ssh|https?):\/\/(?:[^@/]+@)?github\.com(?::\d+)?\/)([^/]+\/[^/]+?)(?:\.git)?\/?$/i;
18
+ const REMOTE_HEAD = "refs/remotes/origin/HEAD";
19
+ /** The branch a URL is judged at when origin/HEAD names none, as in a CI checkout. */
20
+ const FALLBACK_BRANCH = "main";
21
+ /** A URL into a GitHub repository: a file page, a directory page or a raw file, up to a space, a quote or a bracket. */
22
+ const REPOSITORY_URL = /https:\/\/(?:github\.com\/[^/\s]+\/[^/\s]+\/(?:blob|tree)|raw\.githubusercontent\.com\/[^/\s]+\/[^/\s]+)\/[^\s"'`<>()[\]]+/gi;
23
+ /** Closing punctuation and emphasis markers after a URL, which GitHub leaves out of it. */
24
+ const TRAILING = /[.,;:!?*_~]+$/;
25
+ /** A GitHub anchor to a line or a range of lines, which names no heading. */
26
+ const LINE_ANCHOR = /#L\d+(C\d+)?(-L\d+(C\d+)?)?$/;
27
+ /** The prefixes of URLs into origin's default branch; none when origin is not on GitHub. */
28
+ function prefixesOf(root) {
29
+ const slug = GITHUB_REMOTE.exec(asked(root, ["remote", "get-url", "origin"]) ?? "")?.[1]?.toLowerCase();
30
+ if (slug === undefined) {
31
+ return [];
32
+ }
33
+ const head = asked(root, ["symbolic-ref", "-q", REMOTE_HEAD])?.replace(/^refs\/remotes\/origin\//, "");
34
+ const branch = head === undefined || head === "" ? FALLBACK_BRANCH : head;
35
+ return [
36
+ `https://github.com/${slug}/blob/`,
37
+ `https://github.com/${slug}/tree/`,
38
+ `https://raw.githubusercontent.com/${slug}/`
39
+ ].map((repository) => ({ repository, branch }));
40
+ }
41
+ /** The path from the root `url` names under one of `prefixes`, its line anchor dropped; undefined under none. */
42
+ function pathOf(url, prefixes) {
43
+ const prefix = prefixes.find(({ repository, branch }) => url.toLowerCase().startsWith(repository) && url.slice(repository.length).startsWith(`${branch}/`));
44
+ return prefix === undefined
45
+ ? undefined
46
+ : `/${url.slice(prefix.repository.length + prefix.branch.length + 1).replace(LINE_ANCHOR, "")}`;
47
+ }
48
+ /** Every URL of `text` into the repository's default branch, a fenced line's included, once per line. */
49
+ function repositoryLinks(text, prefixes) {
50
+ if (prefixes.length === 0) {
51
+ return [];
52
+ }
53
+ return text.split("\n").flatMap((line, index) => {
54
+ const urls = new Set(Array.from(line.matchAll(REPOSITORY_URL), ([url]) => url.replace(TRAILING, "")));
55
+ return [...urls].flatMap((url) => {
56
+ const target = pathOf(url, prefixes);
57
+ return target === undefined ? [] : [{ target, line: index + 1, shown: url }];
58
+ });
59
+ });
60
+ }
15
61
  function decode(text) {
16
62
  try {
17
63
  return decodeURIComponent(text);
@@ -30,13 +76,13 @@ function resolve(file, path) {
30
76
  return normal.length > 1 ? normal.replace(/\/$/, "") : normal;
31
77
  }
32
78
  /** What is wrong with one link of `file`: a path out of the repository or not in it, or an anchor with no heading. */
33
- function judge(repository, file, { target, line }) {
79
+ function judge(repository, file, { target, line, shown = target }) {
34
80
  if (EXTERNAL.test(target)) {
35
81
  return [];
36
82
  }
37
83
  const [beforeAnchor = "", anchor = ""] = target.split("#", 2);
38
84
  const resolved = resolve(file, decode(beforeAnchor.split("?", 1)[0] ?? ""));
39
- const quoted = JSON.stringify(target);
85
+ const quoted = JSON.stringify(shown);
40
86
  if (resolved === ".." || resolved.startsWith("../")) {
41
87
  return [{ file, line, message: `links to ${quoted}, which is outside the repository`, remedy: REMEDIES.outside }];
42
88
  }
@@ -53,8 +99,14 @@ function judge(repository, file, { target, line }) {
53
99
  function run(root, { files, exempt }) {
54
100
  const tree = treeFiles(root);
55
101
  const known = new Set(tree);
102
+ const prefixes = prefixesOf(root);
56
103
  const repository = { root, files: known, directories: directoriesOf(known), anchors: new Map() };
57
- return matching(root, tree, files, exempt).flatMap((file) => links(readFileSync(join(root, file), "utf8")).flatMap((link) => judge(repository, file, link)));
104
+ return matching(root, tree, files, exempt).flatMap((file) => {
105
+ const text = readFileSync(join(root, file), "utf8");
106
+ return [...links(text), ...repositoryLinks(text, prefixes)]
107
+ .toSorted((left, right) => left.line - right.line)
108
+ .flatMap((link) => judge(repository, file, link));
109
+ });
58
110
  }
59
111
  export const markdownRefs = {
60
112
  name: "markdown-refs",
@@ -28,7 +28,10 @@ file outside lockfiles with no patch, full; the rest light. After a push it name
28
28
  the last commit a Review read under details.since, where the Review reads from and auto routes from:
29
29
  one with a ${REVIEW} success not posted under off, or the read mark pr reviewed --findings
30
30
  posts. It carries a success over a head whose only commits since are merges of the base, unless
31
- --review names a depth for the call; a read mark never carries.
31
+ --review names a depth for the call; a read mark never carries. A read mark no later commit answers,
32
+ on the head or before only merges or a rebase, is named under details.open: the Review then reads
33
+ the whole pull request, and nothing carries over it. After a merge or a rebase, details.paths names
34
+ the files whose own change moved, the only ones the Review reads.
32
35
  Each Action the call runs is listed under performed. Its ok says whether the read after it shows the
33
36
  Action took effect, whatever gh's exit status; gh's exit status and stderr stand beside it, so an ok
34
37
  false with exit 0 is an Action gh accepted that the read does not show yet. An update counts when
package/dist/pr/decide.js CHANGED
@@ -249,7 +249,8 @@ const ROWS = [
249
249
  // The agent may raise a routed depth to full, so auto names the command at any depth.
250
250
  ...(depth === "full" || source === "auto" ? { reviewCommand: policy.reviewCommand } : {}),
251
251
  ...after,
252
- ...(pr.scope === null ? {} : { paths: pr.scope })
252
+ ...(pr.scope === null ? {} : { paths: pr.scope }),
253
+ ...(pr.open === null ? {} : { open: pr.open })
253
254
  }
254
255
  };
255
256
  }
@@ -1,4 +1,4 @@
1
- import { type ChangedFile } from "./state.ts";
1
+ import type { ChangedFile } from "./state.ts";
2
2
  /** The gh arguments of the compare from `from` to `commit`, the files on its one page. */
3
3
  export declare function compareArgs(repo: string, from: string, commit: string): string[];
4
4
  /** How many commits of the compare's base its head lacks: 0 when the head carries all of it. */
package/dist/pr/delta.js CHANGED
@@ -1,7 +1,7 @@
1
1
  // GitHub's compare of two commits, read two ways: whether a head carries the Review of an earlier commit,
2
2
  // the pull request's own changes from the base the same at both, since a merge of the base in adds none
3
3
  // of its own; and the files a Review would read, with their changed lines, for `pr.review: auto`.
4
- import { fieldOf, integer, object, ReplyError, rootOf, shown, string } from "./state.js";
4
+ import { fieldOf, integer, object, ReplyError, rootOf, shown, string } from "./reply.js";
5
5
  /** GitHub's compare lists at most this many files; one that lists as many may hold back the rest. */
6
6
  const FILE_LIMIT = 300;
7
7
  /** The gh arguments of the compare from `from` to `commit`, the files on its one page. */
package/dist/pr/read.js CHANGED
@@ -3,7 +3,8 @@
3
3
  // and that size its changes, and beside them the default branch and the clock the decision reads.
4
4
  import { exec, TimedOut } from "../exec.js";
5
5
  import { behindOf, changedOf, changesOf, compareArgs, sameChanges } from "./delta.js";
6
- import { awaitsReview, parseDefaultBranch, parsePr, parseView, QUERY, ReplyError } from "./state.js";
6
+ import { ReplyError } from "./reply.js";
7
+ import { awaitsReview, parseDefaultBranch, parsePr, parseView, QUERY } from "./state.js";
7
8
  /** What stops a pr command or recap before it answers: exit 2, the problem and its remedy on one line. */
8
9
  export class CannotRun extends Error {
9
10
  remedy;
@@ -192,14 +193,39 @@ function ownChanges(pr, commit) {
192
193
  const reply = compared(pr, pr.baseOid, commit, `the compare with ${commit.slice(0, 7)}`);
193
194
  return parseOrCannotRun(() => changesOf(reply));
194
195
  }
196
+ /** `pr` and, with no read in its history, the newest commit force pushes left behind that a Review read. */
197
+ function withRewritten(pr, rewritten) {
198
+ const [newest] = rewritten;
199
+ if (newest === undefined || pr.lastRead !== null || pr.open !== null) {
200
+ return pr;
201
+ }
202
+ return { ...pr, lastRead: { ...newest, baseOnly: true, baseMoved: true } };
203
+ }
204
+ /**
205
+ * `pr` with its last read mark `open` when a merge of the base or a rebase came after it and no fix
206
+ * shows: the own changes are as it read them, or a compare cannot show them whole (ADR 0023). The
207
+ * compares run only when a Review awaits the head.
208
+ */
209
+ function withOpen(pr) {
210
+ const last = pr.lastRead;
211
+ if (last === null || last.clean || !last.baseMoved || pr.state !== "OPEN" || !awaitsReview(pr)) {
212
+ return pr;
213
+ }
214
+ const atRead = ownChanges(pr, last.commit);
215
+ const atHead = ownChanges(pr, pr.head);
216
+ if (atRead !== null && atHead !== null && !sameChanges(atRead, atHead)) {
217
+ return pr;
218
+ }
219
+ return { ...pr, lastRead: null, open: last.commit };
220
+ }
195
221
  /**
196
222
  * `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.
223
+ * the base's changes since it, and the same own changes at both. A read mark never carries. The
224
+ * compares run only when a Review awaits the head.
199
225
  */
200
226
  function withCarried(pr) {
201
227
  const last = pr.lastRead;
202
- if (last === null || !last.clean || !last.mergesOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
228
+ if (last === null || !last.clean || !last.baseOnly || pr.state !== "OPEN" || !awaitsReview(pr)) {
203
229
  return { ...pr, carried: false };
204
230
  }
205
231
  return { ...pr, carried: sameChanges(ownChanges(pr, last.commit), ownChanges(pr, pr.head)) };
@@ -226,15 +252,15 @@ function ownScope(pr, from) {
226
252
  return [...new Set(names)].toSorted();
227
253
  }
228
254
  /**
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.
255
+ * `pr` and, when a Review awaits a head that carries none, its `scope` after a merge of the base or a
256
+ * rebase, and when `sized`, the files the Review would read: the compare from the last commit a Review
257
+ * read, else from the base, to the head, kept to the scope when there is one.
232
258
  */
233
259
  function withChanged(pr, sized) {
234
260
  if (pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
235
261
  return { ...pr, changed: null, scope: null };
236
262
  }
237
- const scope = pr.lastRead?.merges === true ? ownScope(pr, pr.lastRead.commit) : null;
263
+ const scope = pr.lastRead?.baseMoved === true ? ownScope(pr, pr.lastRead.commit) : null;
238
264
  if (!sized) {
239
265
  return { ...pr, changed: null, scope };
240
266
  }
@@ -267,6 +293,6 @@ function query({ repo, number }, sized) {
267
293
  const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
268
294
  throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
269
295
  }
270
- const parsed = parseOrCannotRun(() => parsePr(reply.stdout, repo));
271
- return withChanged(withCarried(parsed), sized);
296
+ const { pr, rewritten } = parseOrCannotRun(() => parsePr(reply.stdout, repo));
297
+ return withChanged(withCarried(withOpen(withRewritten(pr, rewritten))), sized);
272
298
  }
@@ -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,114 @@
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
+ /** A reply that is not the shape the query asks for; the message names the field. */
4
+ export class ReplyError extends Error {
5
+ }
6
+ function isRecord(value) {
7
+ return typeof value === "object" && value !== null && !Array.isArray(value);
8
+ }
9
+ export function shown(value) {
10
+ return value === undefined ? "missing" : JSON.stringify(value);
11
+ }
12
+ /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
13
+ export function rootOf(reply, what) {
14
+ let json;
15
+ try {
16
+ json = JSON.parse(reply);
17
+ }
18
+ catch {
19
+ throw new ReplyError(`${what} is not JSON`);
20
+ }
21
+ if (!isRecord(json)) {
22
+ throw new ReplyError(`${what} is not a JSON object`);
23
+ }
24
+ return { value: json, at: "" };
25
+ }
26
+ export function fieldOf(node, key) {
27
+ return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
28
+ }
29
+ export function object(value, at) {
30
+ if (!isRecord(value)) {
31
+ throw new ReplyError(`${at} is ${shown(value)}, not an object`);
32
+ }
33
+ return { value, at };
34
+ }
35
+ export function child(node, key) {
36
+ const { value, at } = fieldOf(node, key);
37
+ return object(value, at);
38
+ }
39
+ export function childOrNull(node, key) {
40
+ const { value, at } = fieldOf(node, key);
41
+ return value === null ? null : object(value, at);
42
+ }
43
+ export function string(node, key) {
44
+ const { value, at } = fieldOf(node, key);
45
+ if (typeof value !== "string") {
46
+ throw new ReplyError(`${at} is ${shown(value)}, not a string`);
47
+ }
48
+ return value;
49
+ }
50
+ export function stringOrNull(node, key) {
51
+ return node.value[key] === null ? null : string(node, key);
52
+ }
53
+ export function boolean(node, key) {
54
+ const { value, at } = fieldOf(node, key);
55
+ if (typeof value !== "boolean") {
56
+ throw new ReplyError(`${at} is ${shown(value)}, not a boolean`);
57
+ }
58
+ return value;
59
+ }
60
+ export function integer(node, key) {
61
+ const { value, at } = fieldOf(node, key);
62
+ if (typeof value !== "number" || !Number.isInteger(value)) {
63
+ throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
64
+ }
65
+ return value;
66
+ }
67
+ export function integerOrNull(node, key) {
68
+ const { value, at } = fieldOf(node, key);
69
+ if (value !== null && !Number.isInteger(value)) {
70
+ throw new ReplyError(`${at} is ${shown(value)}, not an integer or null`);
71
+ }
72
+ return typeof value === "number" ? value : null;
73
+ }
74
+ /** A time as GitHub writes it, ISO 8601; the decision reads it as one. */
75
+ export function time(node, key) {
76
+ const value = string(node, key);
77
+ if (Number.isNaN(Date.parse(value))) {
78
+ throw new ReplyError(`${fieldOf(node, key).at} is ${shown(value)}, not a time`);
79
+ }
80
+ return value;
81
+ }
82
+ export function timeOrNull(node, key) {
83
+ return node.value[key] === null ? null : time(node, key);
84
+ }
85
+ export function isOneOf(values, value) {
86
+ return values.some((known) => known === value);
87
+ }
88
+ export function oneOf(node, key, values) {
89
+ const { value, at } = fieldOf(node, key);
90
+ if (!isOneOf(values, value)) {
91
+ throw new ReplyError(`${at} is ${shown(value)}, not one of ${values.join(", ")}`);
92
+ }
93
+ return value;
94
+ }
95
+ /** The objects of a connection's `nodes`. */
96
+ export function nodes(node, key) {
97
+ const { value, at } = fieldOf(child(node, key), "nodes");
98
+ if (!Array.isArray(value)) {
99
+ throw new ReplyError(`${at} is ${shown(value)}, not a list`);
100
+ }
101
+ return value.map((item, index) => object(item, `${at}[${index}]`));
102
+ }
103
+ /** Whether a connection has more nodes after the ones the reply holds. */
104
+ export function hasMore(node, key) {
105
+ return boolean(child(child(node, key), "pageInfo"), "hasNextPage");
106
+ }
107
+ /** The first object of a connection's `nodes`; an empty connection does not parse. */
108
+ export function firstNode(node, key) {
109
+ const [first] = nodes(node, key);
110
+ if (first === undefined) {
111
+ throw new ReplyError(`${node.at}.${key}.nodes is empty`);
112
+ }
113
+ return first;
114
+ }
@@ -10,7 +10,7 @@ export declare const MARK_STATE = "failure";
10
10
  export declare const READ_MARK = "read, findings open";
11
11
  /** How the description of the success the dev's `--accept` posts reads: the head, taken with its Findings open. */
12
12
  export declare const ACCEPTED = "accepted by the dev, findings open";
13
- export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName headRef { name } isCrossRepository baseRefName\n baseRefOid baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n ready: timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) { nodes { ... on ReadyForReviewEvent { createdAt } } }\n latestReviews(first: 100) { nodes { state author { login } commit { oid } } }\n reviewThreads(first: 100) { pageInfo { hasNextPage } nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { pageInfo { hasNextPage } nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n }\n }\n}";
13
+ export declare const QUERY = "query($owner: String!, $name: String!, $number: Int!) {\n repository(owner: $owner, name: $name) {\n pullRequest(number: $number) {\n url number state isDraft mergeable mergeStateStatus reviewDecision headRefOid headRefName headRef { name } isCrossRepository baseRefName\n baseRefOid baseRef { target { oid } } viewerDidAuthor mergeCommit { oid }\n ready: timelineItems(last: 1, itemTypes: [READY_FOR_REVIEW_EVENT]) { nodes { ... on ReadyForReviewEvent { createdAt } } }\n latestReviews(first: 100) { nodes { state author { login } commit { oid } } }\n reviewThreads(first: 100) { pageInfo { hasNextPage } nodes { id isResolved isOutdated path line\n comments(first: 1) { nodes { author { login } body } }\n latest: comments(last: 1) { nodes { viewerDidAuthor } } } }\n commits(last: 1) { nodes { commit { committedDate statusCheckRollup {\n contexts(first: 50) { pageInfo { hasNextPage } nodes {\n ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }\n ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }\n history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: \"dw-sdlc/review\") { state description } } } } }\n rewritten: timelineItems(last: 20, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) { nodes { ... on HeadRefForcePushedEvent {\n beforeCommit { oid history(first: 20) { nodes { oid parents(first: 1) { nodes { oid } }\n status { context(name: \"dw-sdlc/review\") { state description } } } } } } } }\n }\n }\n}";
14
14
  export type Bucket = "pass" | "fail" | "pending" | "skipping";
15
15
  export type CiCheck = {
16
16
  name: string;
@@ -76,10 +76,19 @@ export type PrState = {
76
76
  commits: string[];
77
77
  /**
78
78
  * The last commit before the head a Review read, of the last 100: a review success not posted under
79
- * off, or a read mark.
79
+ * off, or a read mark. With none there, a commit force pushes left behind that a Review read.
80
80
  */
81
81
  lastRead: LastRead | null;
82
- /** The read marks since the last review success of any kind, of the last 100 commits. */
82
+ /**
83
+ * A read mark whose Findings no later commit answers: the head's own, or one a merge of the base or a
84
+ * rebase came after with no fix showing. Then `lastRead` is null, the Review reads the whole pull
85
+ * request, and nothing carries (ADR 0023).
86
+ */
87
+ open: string | null;
88
+ /**
89
+ * The read marks since the last review success of any kind, of the last 100 commits, then, when they
90
+ * hold no success, of the commits force pushes left behind.
91
+ */
83
92
  marks: ReadMarks;
84
93
  /** Whether the head carries the success of `lastRead`: the read decides it from two compares. */
85
94
  carried: boolean;
@@ -103,15 +112,19 @@ export type ChangedFile = {
103
112
  previous: string | null;
104
113
  lines: number | null;
105
114
  };
106
- /**
107
- * A commit a Review read; `clean` when that Review left a success on it, not a read mark; and whether
108
- * every commit after it, the head included, is a merge.
109
- */
110
- export type LastRead = {
115
+ /** A commit a Review read; `clean` when that Review left a success on it, not a read mark. */
116
+ export type Read = {
111
117
  commit: string;
112
118
  clean: boolean;
113
- mergesOnly: boolean;
114
- merges: boolean;
119
+ };
120
+ /**
121
+ * A commit a Review read; `baseOnly` when every change after it is the base's, the head included: only
122
+ * merges of the base, or a rebase past no other read; `baseMoved` when a merge of the base or a rebase
123
+ * came after it.
124
+ */
125
+ export type LastRead = Read & {
126
+ baseOnly: boolean;
127
+ baseMoved: boolean;
115
128
  };
116
129
  /**
117
130
  * The Reviews that left a Finding open since the last review success, a success under off and the dev's
@@ -122,24 +135,6 @@ export type ReadMarks = {
122
135
  count: number;
123
136
  since: string | null;
124
137
  };
125
- /** A reply that is not the shape the query asks for; the message names the field. */
126
- export declare class ReplyError extends Error {
127
- }
128
- /** A JSON object of the reply and the path that reached it, as `data.repository.pullRequest`. */
129
- export type Node = {
130
- value: Record<string, unknown>;
131
- at: string;
132
- };
133
- export declare function shown(value: unknown): string;
134
- /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
135
- export declare function rootOf(reply: string, what: string): Node;
136
- export declare function fieldOf(node: Node, key: string): {
137
- value: unknown;
138
- at: string;
139
- };
140
- export declare function object(value: unknown, at: string): Node;
141
- export declare function string(node: Node, key: string): string;
142
- export declare function integer(node: Node, key: string): number;
143
138
  /** The viewer's own pull request without a review success on its head: the one a Review reads. */
144
139
  export declare function awaitsReview(pr: Pick<PrState, "viewerDidAuthor" | "checks">): boolean;
145
140
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
@@ -149,6 +144,15 @@ export declare function parseView(reply: string): {
149
144
  };
150
145
  /** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
151
146
  export declare function parseDefaultBranch(reply: string): string;
152
- /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
153
- export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed" | "scope">;
147
+ /** The state as the reply holds it, before the compares decide what the head carries and a Review reads. */
148
+ export type Parsed = Omit<PrState, "carried" | "changed" | "scope">;
149
+ /**
150
+ * The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`, and
151
+ * `rewritten`: the commits the last 20 force pushes left behind that a Review read, newest first, none the
152
+ * history still holds, where a Review's read stands once a rebase rewrote every commit it read.
153
+ */
154
+ export declare function parsePr(reply: string, repo: string): {
155
+ pr: Parsed;
156
+ rewritten: Read[];
157
+ };
154
158
  export {};
package/dist/pr/state.js CHANGED
@@ -1,5 +1,6 @@
1
1
  // One pull request as the Ship script reads it: the one GraphQL query, and its reply parsed into the
2
2
  // state the decision reads. Every field the state needs is checked; a reply that fails names the field.
3
+ import { boolean, child, childOrNull, firstNode, hasMore, integer, integerOrNull, nodes, oneOf, ReplyError, rootOf, string, stringOrNull, time, timeOrNull } from "./reply.js";
3
4
  /** The commit status a clean review of the head leaves on it. */
4
5
  export const REVIEW = "dw-sdlc/review";
5
6
  /** How the description of a success the script posts under off begins: no one read that head. */
@@ -15,6 +16,12 @@ export const ACCEPTED = "accepted by the dev, findings open";
15
16
  /** How many checks and review threads the one read holds; each list's `pageInfo` says when more follow. */
16
17
  const CHECKS_READ = 50;
17
18
  const THREADS_READ = 100;
19
+ /**
20
+ * How many of the last force pushes the read holds, each with the head it left behind, and how many
21
+ * commits of that head's history.
22
+ */
23
+ const FORCE_PUSHES_READ = 20;
24
+ const LEFT_BEHIND_READ = 20;
18
25
  export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
19
26
  repository(owner: $owner, name: $name) {
20
27
  pullRequest(number: $number) {
@@ -30,124 +37,15 @@ export const QUERY = `query($owner: String!, $name: String!, $number: Int!) {
30
37
  ... on CheckRun { name status conclusion detailsUrl startedAt isRequired(pullRequestNumber: $number) }
31
38
  ... on StatusContext { context state targetUrl createdAt isRequired(pullRequestNumber: $number) } } } } } } }
32
39
  history: commits(last: 100) { nodes { commit { oid parents { totalCount } status { context(name: "${REVIEW}") { state description } } } } }
40
+ rewritten: timelineItems(last: ${FORCE_PUSHES_READ}, itemTypes: [HEAD_REF_FORCE_PUSHED_EVENT]) { nodes { ... on HeadRefForcePushedEvent {
41
+ beforeCommit { oid history(first: ${LEFT_BEHIND_READ}) { nodes { oid parents(first: 1) { nodes { oid } }
42
+ status { context(name: "${REVIEW}") { state description } } } } } } } }
33
43
  }
34
44
  }
35
45
  }`;
36
46
  const STATES = ["OPEN", "CLOSED", "MERGED"];
37
47
  const MERGEABLE = ["MERGEABLE", "CONFLICTING", "UNKNOWN"];
38
48
  const REVIEW_DECISIONS = ["APPROVED", "CHANGES_REQUESTED", "REVIEW_REQUIRED"];
39
- /** A reply that is not the shape the query asks for; the message names the field. */
40
- export class ReplyError extends Error {
41
- }
42
- function isRecord(value) {
43
- return typeof value === "object" && value !== null && !Array.isArray(value);
44
- }
45
- export function shown(value) {
46
- return value === undefined ? "missing" : JSON.stringify(value);
47
- }
48
- /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
49
- export function rootOf(reply, what) {
50
- let json;
51
- try {
52
- json = JSON.parse(reply);
53
- }
54
- catch {
55
- throw new ReplyError(`${what} is not JSON`);
56
- }
57
- if (!isRecord(json)) {
58
- throw new ReplyError(`${what} is not a JSON object`);
59
- }
60
- return { value: json, at: "" };
61
- }
62
- export function fieldOf(node, key) {
63
- return { value: node.value[key], at: node.at === "" ? key : `${node.at}.${key}` };
64
- }
65
- export function object(value, at) {
66
- if (!isRecord(value)) {
67
- throw new ReplyError(`${at} is ${shown(value)}, not an object`);
68
- }
69
- return { value, at };
70
- }
71
- function child(node, key) {
72
- const { value, at } = fieldOf(node, key);
73
- return object(value, at);
74
- }
75
- function childOrNull(node, key) {
76
- const { value, at } = fieldOf(node, key);
77
- return value === null ? null : object(value, at);
78
- }
79
- export function string(node, key) {
80
- const { value, at } = fieldOf(node, key);
81
- if (typeof value !== "string") {
82
- throw new ReplyError(`${at} is ${shown(value)}, not a string`);
83
- }
84
- return value;
85
- }
86
- function stringOrNull(node, key) {
87
- return node.value[key] === null ? null : string(node, key);
88
- }
89
- function boolean(node, key) {
90
- const { value, at } = fieldOf(node, key);
91
- if (typeof value !== "boolean") {
92
- throw new ReplyError(`${at} is ${shown(value)}, not a boolean`);
93
- }
94
- return value;
95
- }
96
- export function integer(node, key) {
97
- const { value, at } = fieldOf(node, key);
98
- if (typeof value !== "number" || !Number.isInteger(value)) {
99
- throw new ReplyError(`${at} is ${shown(value)}, not an integer`);
100
- }
101
- return value;
102
- }
103
- function integerOrNull(node, key) {
104
- const { value, at } = fieldOf(node, key);
105
- if (value !== null && !Number.isInteger(value)) {
106
- throw new ReplyError(`${at} is ${shown(value)}, not an integer or null`);
107
- }
108
- return typeof value === "number" ? value : null;
109
- }
110
- /** A time as GitHub writes it, ISO 8601; the decision reads it as one. */
111
- function time(node, key) {
112
- const value = string(node, key);
113
- if (Number.isNaN(Date.parse(value))) {
114
- throw new ReplyError(`${fieldOf(node, key).at} is ${shown(value)}, not a time`);
115
- }
116
- return value;
117
- }
118
- function timeOrNull(node, key) {
119
- return node.value[key] === null ? null : time(node, key);
120
- }
121
- function isOneOf(values, value) {
122
- return values.some((known) => known === value);
123
- }
124
- function oneOf(node, key, values) {
125
- const { value, at } = fieldOf(node, key);
126
- if (!isOneOf(values, value)) {
127
- throw new ReplyError(`${at} is ${shown(value)}, not one of ${values.join(", ")}`);
128
- }
129
- return value;
130
- }
131
- /** The objects of a connection's `nodes`. */
132
- function nodes(node, key) {
133
- const { value, at } = fieldOf(child(node, key), "nodes");
134
- if (!Array.isArray(value)) {
135
- throw new ReplyError(`${at} is ${shown(value)}, not a list`);
136
- }
137
- return value.map((item, index) => object(item, `${at}[${index}]`));
138
- }
139
- /** Whether a connection has more nodes after the ones the reply holds. */
140
- function hasMore(node, key) {
141
- return boolean(child(child(node, key), "pageInfo"), "hasNextPage");
142
- }
143
- /** The first object of a connection's `nodes`; an empty connection does not parse. */
144
- function firstNode(node, key) {
145
- const [first] = nodes(node, key);
146
- if (first === undefined) {
147
- throw new ReplyError(`${node.at}.${key}.nodes is empty`);
148
- }
149
- return first;
150
- }
151
49
  const FAIL = new Set(["ERROR", "FAILURE", "TIMED_OUT", "ACTION_REQUIRED", "CANCELLED", "STARTUP_FAILURE"]);
152
50
  const SKIPPING = new Set(["SKIPPED", "NEUTRAL"]);
153
51
  /** A check's bucket as `gh pr checks` sorts it: every word not passed, failed or skipped is pending. */
@@ -237,29 +135,71 @@ function historyOf(pr) {
237
135
  });
238
136
  }
239
137
  /**
240
- * The last commit before `head` a Review read, the history oldest first. A read mark with only merges
241
- * after it is passed over: no fix answers its Findings yet, and a Review from it would read only the
242
- * base's changes, so the Review reads from the read before it and finds them again.
138
+ * The last commit before `head` a Review read, the history oldest first, or `open`: a read mark on the
139
+ * head, or with only merges after it. No fix answers its Findings, so no read stands in (ADR 0023).
243
140
  */
244
141
  function lastReadOf(history, head) {
245
- const commits = history.map(({ oid, posted, merge }) => ({
246
- oid,
247
- read: oid === head || posted === "off" ? null : posted,
248
- merge
249
- }));
250
- const mergesOnlyAfter = (index) => commits.slice(index + 1).every(({ merge }) => merge);
251
- const index = commits.findLastIndex(({ read }, at) => read === "clean" || (read === "findings" && !mergesOnlyAfter(at)));
252
- const last = commits[index];
142
+ if (history.some(({ oid, posted }) => oid === head && posted === "findings")) {
143
+ return { lastRead: null, open: head };
144
+ }
145
+ const index = history.findLastIndex(({ oid, posted }) => oid !== head && (posted === "clean" || posted === "findings"));
146
+ const last = history[index];
253
147
  if (last === undefined) {
254
- return null;
148
+ return { lastRead: null, open: null };
149
+ }
150
+ const after = history.slice(index + 1);
151
+ const baseOnly = after.every(({ merge }) => merge);
152
+ if (last.posted === "findings" && baseOnly) {
153
+ return { lastRead: null, open: last.oid };
255
154
  }
256
155
  return {
257
- commit: last.oid,
258
- clean: last.read === "clean",
259
- mergesOnly: mergesOnlyAfter(index),
260
- merges: commits.slice(index + 1).some(({ merge }) => merge)
156
+ lastRead: {
157
+ commit: last.oid,
158
+ clean: last.posted === "clean",
159
+ baseOnly,
160
+ baseMoved: after.some(({ merge }) => merge)
161
+ },
162
+ open: null
261
163
  };
262
164
  }
165
+ /**
166
+ * The commits a force push left behind: the head, then down its first parents within the history the
167
+ * read holds of it. A merge's first parent is the branch, so the walk stays off the base's side.
168
+ */
169
+ function lineOf(left) {
170
+ const held = new Map(nodes(left, "history").map((commit) => [string(commit, "oid"), commit]));
171
+ const line = [];
172
+ let commit = held.get(string(left, "oid"));
173
+ while (commit !== undefined && line.length < held.size) {
174
+ line.push({ oid: string(commit, "oid"), posted: postedOn(commit) });
175
+ const [parent] = nodes(commit, "parents");
176
+ commit = parent === undefined ? undefined : held.get(string(parent, "oid"));
177
+ }
178
+ return line;
179
+ }
180
+ /**
181
+ * The commits the force pushes left behind, newest push first, each once and none the history still
182
+ * holds: a line stops where it meets the history, as under an amend, or a newer push's line.
183
+ */
184
+ function forcePushedOf(pr, history) {
185
+ const seen = new Set(history.map(({ oid }) => oid));
186
+ const left = [];
187
+ for (const event of nodes(pr, "rewritten").toReversed()) {
188
+ const head = childOrNull(event, "beforeCommit");
189
+ for (const commit of head === null ? [] : lineOf(head)) {
190
+ if (seen.has(commit.oid)) {
191
+ break;
192
+ }
193
+ seen.add(commit.oid);
194
+ left.push(commit);
195
+ }
196
+ }
197
+ return left;
198
+ }
199
+ /** The commits of `forcePushed` a Review read; a success under off says no one read its commit. */
200
+ function rewrittenOf(forcePushed) {
201
+ return forcePushed.flatMap(({ oid, posted }) => posted === "clean" || posted === "findings" ? [{ commit: oid, clean: posted === "clean" }] : []);
202
+ }
263
203
  /**
264
204
  * Whether a non-merge commit follows `commit` in the history, oldest first, as `lastReadOf` judges a read
265
205
  * mark. A commit outside the history, after a force-push or past the last 100, is not answered.
@@ -281,10 +221,22 @@ function changeRequestsOf(pr, history) {
281
221
  };
282
222
  });
283
223
  }
284
- function readMarksOf(history) {
285
- const success = history.findLastIndex(({ posted }) => posted === "clean" || posted === "off");
286
- const count = history.slice(success + 1).filter(({ posted }) => posted === "findings").length;
287
- return { count, since: history[success]?.oid ?? null };
224
+ /**
225
+ * The read marks since the last success, the history's first; with no success there, the marks on the
226
+ * commits force pushes left behind go on the count, newest first, up to the first success among them.
227
+ */
228
+ function readMarksOf(history, forcePushed) {
229
+ const isSuccess = ({ posted }) => posted === "clean" || posted === "off";
230
+ const findings = (commits) => commits.filter(({ posted }) => posted === "findings").length;
231
+ const success = history.findLastIndex(isSuccess);
232
+ const count = findings(history.slice(success + 1));
233
+ const lastSuccess = history[success];
234
+ if (lastSuccess !== undefined) {
235
+ return { count, since: lastSuccess.oid };
236
+ }
237
+ const pushedSuccess = forcePushed.findIndex(isSuccess);
238
+ const sincePushed = pushedSuccess < 0 ? forcePushed : forcePushed.slice(0, pushedSuccess);
239
+ return { count: count + findings(sincePushed), since: forcePushed[pushedSuccess]?.oid ?? null };
288
240
  }
289
241
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
290
242
  export function parseView(reply) {
@@ -299,7 +251,11 @@ export function parseView(reply) {
299
251
  export function parseDefaultBranch(reply) {
300
252
  return string(child(rootOf(reply, "the reply of gh repo view"), "defaultBranchRef"), "name");
301
253
  }
302
- /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
254
+ /**
255
+ * The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`, and
256
+ * `rewritten`: the commits the last 20 force pushes left behind that a Review read, newest first, none the
257
+ * history still holds, where a Review's read stands once a rebase rewrote every commit it read.
258
+ */
303
259
  export function parsePr(reply, repo) {
304
260
  const pr = child(child(child(rootOf(reply, "the reply"), "data"), "repository"), "pullRequest");
305
261
  const commit = child(firstNode(pr, "commits"), "commit");
@@ -309,7 +265,7 @@ export function parsePr(reply, repo) {
309
265
  const baseRef = childOrNull(pr, "baseRef");
310
266
  const history = historyOf(pr);
311
267
  const [ready] = nodes(pr, "ready");
312
- return {
268
+ const state = {
313
269
  url: string(pr, "url"),
314
270
  repo,
315
271
  number: integer(pr, "number"),
@@ -338,7 +294,8 @@ export function parsePr(reply, repo) {
338
294
  ...(hasMore(pr, "reviewThreads") ? [`the first ${THREADS_READ} threads`] : [])
339
295
  ],
340
296
  commits: history.map(({ oid }) => oid),
341
- lastRead: lastReadOf(history, head),
342
- marks: readMarksOf(history)
297
+ ...lastReadOf(history, head)
343
298
  };
299
+ const forcePushed = forcePushedOf(pr, history);
300
+ return { pr: { ...state, marks: readMarksOf(history, forcePushed) }, rewritten: rewrittenOf(forcePushed) };
344
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.20.0",
3
+ "version": "0.21.0",
4
4
  "description": "The Gate and the agentic config behind the sdlc Claude Code plugin: a bin with the Gate and its Checks, the setup, the doctor, the Sync of References, the Ship script that drives a pull request to merged, the Sweep, and Lead and Worker sessions in herdr; the resolver of a repository's .ai/agentic.config.json layers and its JSON Schema.",
5
5
  "license": "MIT",
6
6
  "repository": {