dw-sdlc 0.19.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).
@@ -2,7 +2,7 @@
2
2
  "$schema": "https://json-schema.org/draft/2020-12/schema",
3
3
  "$id": "https://raw.githubusercontent.com/dominikwozniak/dw-sdlc/main/packages/dw-sdlc/config/agentic.config.schema.json",
4
4
  "title": "agentic.config",
5
- "description": "How a repository works for an agent. `.ai/agentic.config.json` is committed; `.ai/agentic.config.local.json` overrides it on one machine and may tighten `guardrails`, `pr.merge` and `pr.review` but never loosen them. A key neither file sets is inferred: the Node package manager from the lockfile, the `conventional` commit preset, and `gate.command` from the manifest's `check` script. Unknown top-level keys are tolerated so other tool families can share the file; the keys named here refuse unknown children.",
5
+ "description": "How a repository works for an agent. `.ai/agentic.config.json` is committed; `.ai/agentic.config.local.json` overrides it on one machine and may tighten `guardrails`, `pr.merge` and `pr.review` but never loosen them. A key neither file sets is inferred: the Node package manager from the lockfile and `gate.command` from the manifest's `check` script. Unknown top-level keys are tolerated so other tool families can share the file; the keys named here refuse unknown children.",
6
6
  "type": "object",
7
7
  "properties": {
8
8
  "$schema": {
@@ -92,7 +92,7 @@
92
92
  "type": "object",
93
93
  "properties": {
94
94
  "convention": {
95
- "description": "The subject preset `pattern` falls back to. `ticket-key`: `[KEY-123] type: subject`, the tracker's key in brackets before a type from a fixed list. `conventional`: Conventional Commits; inferred when no layer sets either.",
95
+ "description": "The subject preset `pattern` falls back to. `ticket-key`: `[KEY-123] type: subject`, the tracker's key in brackets before a type from a fixed list. `conventional`: Conventional Commits. Never inferred: with no layer setting this or `pattern`, the subject goes unchecked.",
96
96
  "enum": ["ticket-key", "conventional"]
97
97
  },
98
98
  "pattern": {
@@ -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": {
@@ -125,8 +125,8 @@ export type ResolvedConfig = {
125
125
  };
126
126
  guardrails: Guardrails;
127
127
  commit: {
128
- convention: CommitConvention;
129
- pattern: string;
128
+ convention?: CommitConvention;
129
+ pattern?: string;
130
130
  trailer?: string;
131
131
  };
132
132
  gate: Gate;
@@ -136,14 +136,6 @@ export type ResolvedConfig = {
136
136
  setup: string[];
137
137
  };
138
138
  };
139
- /** The layers merged, before `commit.pattern` falls back to the preset its convention names. */
140
- type Merged = Omit<ResolvedConfig, "commit"> & {
141
- commit: {
142
- convention: CommitConvention;
143
- pattern?: string;
144
- trailer?: string;
145
- };
146
- };
147
139
  export type Problem = {
148
140
  file: string;
149
141
  path: string;
@@ -210,13 +202,12 @@ export declare function repositoryRoot(dir: string): string;
210
202
  export declare function mainCheckout(root: string): string | undefined;
211
203
  /**
212
204
  * The bottom layer: the package manager the lockfile implies, the Gate command the manifest's `check`
213
- * script implies, plus the defaults every key falls back to. `commit.pattern` is left to
214
- * `resolveConfig`, which fills it from the convention when no layer sets one.
205
+ * script implies, plus the defaults every key falls back to. No commit convention: only a file sets
206
+ * one, and `resolveConfig` fills `commit.pattern` from it when no layer sets a pattern.
215
207
  */
216
- export declare function inferConfig(root: string): Merged;
208
+ export declare function inferConfig(root: string): ResolvedConfig;
217
209
  /**
218
210
  * Local over committed over inferred, for the repository at `root`. A linked worktree reads a layer it
219
211
  * lacks from its main checkout, where a repository that keeps `.ai/` out of git has the only copy.
220
212
  */
221
213
  export declare function resolveConfig(root: string): Resolution;
222
- export {};
@@ -207,8 +207,8 @@ function inferGate(root) {
207
207
  }
208
208
  /**
209
209
  * The bottom layer: the package manager the lockfile implies, the Gate command the manifest's `check`
210
- * script implies, plus the defaults every key falls back to. `commit.pattern` is left to
211
- * `resolveConfig`, which fills it from the convention when no layer sets one.
210
+ * script implies, plus the defaults every key falls back to. No commit convention: only a file sets
211
+ * one, and `resolveConfig` fills `commit.pattern` from it when no layer sets a pattern.
212
212
  */
213
213
  export function inferConfig(root) {
214
214
  return {
@@ -216,7 +216,7 @@ export function inferConfig(root) {
216
216
  baseBranch: "auto",
217
217
  packageManagers: inferPackageManagers(root),
218
218
  guardrails: cloneGuardrails(DEFAULT_GUARDRAILS),
219
- commit: { convention: "conventional" },
219
+ commit: {},
220
220
  gate: inferGate(root),
221
221
  pr: { ...DEFAULT_PR },
222
222
  worker: { ...DEFAULT_WORKER },
@@ -307,10 +307,16 @@ function prLoosenings(base, local) {
307
307
  return [{ file: LOCAL_FILE, path: `pr.${key}`, message }];
308
308
  });
309
309
  }
310
- /** `commit.pattern` from the convention's preset when no layer set one; a set pattern wins. */
310
+ /**
311
+ * `commit.pattern` from the convention's preset when no layer set one; a set pattern wins. Neither
312
+ * set, no pattern: the subject goes unchecked.
313
+ */
311
314
  function finalize(merged) {
312
- const pattern = merged.commit.pattern ?? COMMIT_PATTERNS[merged.commit.convention];
313
- return { ...merged, commit: { ...merged.commit, pattern } };
315
+ const { convention, pattern } = merged.commit;
316
+ if (pattern !== undefined || convention === undefined) {
317
+ return merged;
318
+ }
319
+ return { ...merged, commit: { ...merged.commit, pattern: COMMIT_PATTERNS[convention] } };
314
320
  }
315
321
  /**
316
322
  * Local over committed over inferred, for the repository at `root`. A linked worktree reads a layer it
@@ -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",
@@ -19,7 +19,7 @@ export type Probe = {
19
19
  export type RepositoryConfig = {
20
20
  config: {
21
21
  commit: {
22
- convention: string;
22
+ convention?: string;
23
23
  };
24
24
  };
25
25
  problems: string[];
@@ -223,7 +223,7 @@ function checkConfig(config, root) {
223
223
  return {
224
224
  name: "config",
225
225
  level: "PASS",
226
- detail: `commit ${config.config.commit.convention}${borrowed.join("")}`
226
+ detail: `commit ${config.config.commit.convention ?? "unchecked"}${borrowed.join("")}`
227
227
  };
228
228
  }
229
229
  export function diagnose(inputs) {
@@ -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
@@ -168,8 +168,8 @@ function localConfig(root, claudeDir, convention, guardrails, worker) {
168
168
  if (worker !== undefined) {
169
169
  config.worker = worker;
170
170
  }
171
- // Only one asked for, by `--convention` or the file's pin: inference always gives `conventional`, and
172
- // pinning it here would override a convention the repository commits later.
171
+ // Only one asked for, by `--convention` or the file's pin: pinning one here would override a
172
+ // convention the repository commits later.
173
173
  if (convention !== undefined) {
174
174
  config.commit = { convention };
175
175
  }
@@ -146,7 +146,7 @@ export function checkSessions(probe, claude) {
146
146
  if (entry === undefined) {
147
147
  const detail = list === undefined
148
148
  ? "claude plugin list did not answer"
149
- : `no ${SESSIONS_ID}, so no /sdlc-recap, /sdlc-brief or /sdlc-delegate. claude plugin install ${SESSIONS_ID} --scope user`;
149
+ : `no ${SESSIONS_ID}, so no /sdlc-recap, /sdlc-kickoff or /sdlc-delegate. claude plugin install ${SESSIONS_ID} --scope user`;
150
150
  return { name, level: "INFO", detail };
151
151
  }
152
152
  const version = typeof entry.version === "string" ? entry.version : "?";
package/dist/pr/decide.js CHANGED
@@ -232,8 +232,10 @@ const ROWS = [
232
232
  return { ...head, action: "mark-reviewed", actor: "script", details: after };
233
233
  }
234
234
  const { depth, source } = depthOf(pr, policy);
235
- // The dev's own depth buys one Review past the limit; a Review under off needs none.
236
- if (depth !== "off" && source !== "flag" && pr.marks.count >= REVIEW_LIMIT) {
235
+ // The dev's own depth buys one Review past the limit, and only one: the flag rides on every
236
+ // later call. A Review under off needs none.
237
+ const limit = source === "flag" ? REVIEW_LIMIT + 1 : REVIEW_LIMIT;
238
+ if (depth !== "off" && pr.marks.count >= limit) {
237
239
  return reviewLimit(pr);
238
240
  }
239
241
  const byScript = depth === "off";
@@ -246,7 +248,9 @@ const ROWS = [
246
248
  source,
247
249
  // The agent may raise a routed depth to full, so auto names the command at any depth.
248
250
  ...(depth === "full" || source === "auto" ? { reviewCommand: policy.reviewCommand } : {}),
249
- ...after
251
+ ...after,
252
+ ...(pr.scope === null ? {} : { paths: pr.scope }),
253
+ ...(pr.open === null ? {} : { open: pr.open })
250
254
  }
251
255
  };
252
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,29 +193,85 @@ 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)) };
206
232
  }
207
233
  /**
208
- * `pr` and, when `sized` and a Review awaits a head that carries none, the files the Review would read:
209
- * the compare from the last commit a Review read, else from the base, to the head.
234
+ * The files whose own change differs between `from` and the head of `pr`, a rename under both names:
235
+ * what a fix touched, without the files a merge of the base brought in. Null when a compare cannot show
236
+ * the own changes whole.
237
+ */
238
+ function ownScope(pr, from) {
239
+ const before = ownChanges(pr, from);
240
+ const after = ownChanges(pr, pr.head);
241
+ if (before === null || after === null) {
242
+ return null;
243
+ }
244
+ const moved = [
245
+ ...after.filter((change) => !before.includes(change)),
246
+ ...before.filter((change) => !after.includes(change))
247
+ ];
248
+ const names = moved.flatMap((change) => {
249
+ const [path, , previous] = JSON.parse(change);
250
+ return previous === null ? [path] : [path, previous];
251
+ });
252
+ return [...new Set(names)].toSorted();
253
+ }
254
+ /**
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.
210
258
  */
211
259
  function withChanged(pr, sized) {
212
- if (!sized || pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
213
- return { ...pr, changed: null };
260
+ if (pr.carried || pr.state !== "OPEN" || !awaitsReview(pr)) {
261
+ return { ...pr, changed: null, scope: null };
262
+ }
263
+ const scope = pr.lastRead?.baseMoved === true ? ownScope(pr, pr.lastRead.commit) : null;
264
+ if (!sized) {
265
+ return { ...pr, changed: null, scope };
214
266
  }
215
267
  const from = pr.lastRead?.commit ?? pr.baseOid;
216
268
  const reply = compared(pr, from, pr.head, `the compare of ${from.slice(0, 7)}...${pr.head.slice(0, 7)}`);
217
- return { ...pr, changed: parseOrCannotRun(() => changedOf(reply)) };
269
+ const changed = parseOrCannotRun(() => changedOf(reply));
270
+ if (scope === null || changed === "unsized") {
271
+ return { ...pr, changed, scope };
272
+ }
273
+ const within = changed.filter(({ path, previous }) => scope.includes(path) || (previous !== null && scope.includes(previous)));
274
+ return { ...pr, changed: within, scope };
218
275
  }
219
276
  /** The state of pull request `number` of `repo`, from one GraphQL request and the compares a Review needs. */
220
277
  function query({ repo, number }, sized) {
@@ -236,6 +293,6 @@ function query({ repo, number }, sized) {
236
293
  const remedy = problem.startsWith("Could not resolve to a") ? REMEDIES.target : REMEDIES.read;
237
294
  throw new CannotRun(`${repo}#${number}: ${problem}`, remedy);
238
295
  }
239
- const parsed = parseOrCannotRun(() => parsePr(reply.stdout, repo));
240
- return withChanged(withCarried(parsed), sized);
296
+ const { pr, rewritten } = parseOrCannotRun(() => parsePr(reply.stdout, repo));
297
+ return withChanged(withCarried(withOpen(withRewritten(pr, rewritten))), sized);
241
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;
@@ -88,6 +97,11 @@ export type PrState = {
88
97
  * auto` to route on: `unsized` when the compare cannot list them all, null when not read.
89
98
  */
90
99
  changed: ChangedFile[] | "unsized" | null;
100
+ /**
101
+ * The files whose own change moved since `lastRead`, when a merge of the base came after it: the files
102
+ * a fix touched, the base's own left out. Null with no such merge, or when a compare cannot show them.
103
+ */
104
+ scope: string[] | null;
91
105
  };
92
106
  /**
93
107
  * A file a Review would read: its name, its name before a rename, and the lines it adds and removes,
@@ -98,14 +112,19 @@ export type ChangedFile = {
98
112
  previous: string | null;
99
113
  lines: number | null;
100
114
  };
101
- /**
102
- * A commit a Review read; `clean` when that Review left a success on it, not a read mark; and whether
103
- * every commit after it, the head included, is a merge.
104
- */
105
- 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 = {
106
117
  commit: string;
107
118
  clean: boolean;
108
- mergesOnly: 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;
109
128
  };
110
129
  /**
111
130
  * The Reviews that left a Finding open since the last review success, a success under off and the dev's
@@ -116,24 +135,6 @@ export type ReadMarks = {
116
135
  count: number;
117
136
  since: string | null;
118
137
  };
119
- /** A reply that is not the shape the query asks for; the message names the field. */
120
- export declare class ReplyError extends Error {
121
- }
122
- /** A JSON object of the reply and the path that reached it, as `data.repository.pullRequest`. */
123
- export type Node = {
124
- value: Record<string, unknown>;
125
- at: string;
126
- };
127
- export declare function shown(value: unknown): string;
128
- /** The JSON object `reply` holds, the root its fields' paths start from; `what` names it in a problem. */
129
- export declare function rootOf(reply: string, what: string): Node;
130
- export declare function fieldOf(node: Node, key: string): {
131
- value: unknown;
132
- at: string;
133
- };
134
- export declare function object(value: unknown, at: string): Node;
135
- export declare function string(node: Node, key: string): string;
136
- export declare function integer(node: Node, key: string): number;
137
138
  /** The viewer's own pull request without a review success on its head: the one a Review reads. */
138
139
  export declare function awaitsReview(pr: Pick<PrState, "viewerDidAuthor" | "checks">): boolean;
139
140
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
@@ -143,6 +144,15 @@ export declare function parseView(reply: string): {
143
144
  };
144
145
  /** The default branch of the repository whose `gh repo view --json defaultBranchRef` printed `reply`. */
145
146
  export declare function parseDefaultBranch(reply: string): string;
146
- /** The state of the pull request in `reply`, the text `gh api graphql` printed for QUERY on `repo`. */
147
- export declare function parsePr(reply: string, repo: string): Omit<PrState, "carried" | "changed">;
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
+ };
148
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,23 +135,70 @@ 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 };
255
149
  }
256
- return { commit: last.oid, clean: last.read === "clean", mergesOnly: mergesOnlyAfter(index) };
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 };
154
+ }
155
+ return {
156
+ lastRead: {
157
+ commit: last.oid,
158
+ clean: last.posted === "clean",
159
+ baseOnly,
160
+ baseMoved: after.some(({ merge }) => merge)
161
+ },
162
+ open: null
163
+ };
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" }] : []);
257
202
  }
258
203
  /**
259
204
  * Whether a non-merge commit follows `commit` in the history, oldest first, as `lastReadOf` judges a read
@@ -276,10 +221,22 @@ function changeRequestsOf(pr, history) {
276
221
  };
277
222
  });
278
223
  }
279
- function readMarksOf(history) {
280
- const success = history.findLastIndex(({ posted }) => posted === "clean" || posted === "off");
281
- const count = history.slice(success + 1).filter(({ posted }) => posted === "findings").length;
282
- 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 };
283
240
  }
284
241
  /** The repository and number of the pull request whose URL `gh pr view --json url` printed. */
285
242
  export function parseView(reply) {
@@ -294,7 +251,11 @@ export function parseView(reply) {
294
251
  export function parseDefaultBranch(reply) {
295
252
  return string(child(rootOf(reply, "the reply of gh repo view"), "defaultBranchRef"), "name");
296
253
  }
297
- /** 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
+ */
298
259
  export function parsePr(reply, repo) {
299
260
  const pr = child(child(child(rootOf(reply, "the reply"), "data"), "repository"), "pullRequest");
300
261
  const commit = child(firstNode(pr, "commits"), "commit");
@@ -304,7 +265,7 @@ export function parsePr(reply, repo) {
304
265
  const baseRef = childOrNull(pr, "baseRef");
305
266
  const history = historyOf(pr);
306
267
  const [ready] = nodes(pr, "ready");
307
- return {
268
+ const state = {
308
269
  url: string(pr, "url"),
309
270
  repo,
310
271
  number: integer(pr, "number"),
@@ -333,7 +294,8 @@ export function parsePr(reply, repo) {
333
294
  ...(hasMore(pr, "reviewThreads") ? [`the first ${THREADS_READ} threads`] : [])
334
295
  ],
335
296
  commits: history.map(({ oid }) => oid),
336
- lastRead: lastReadOf(history, head),
337
- marks: readMarksOf(history)
297
+ ...lastReadOf(history, head)
338
298
  };
299
+ const forcePushed = forcePushedOf(pr, history);
300
+ return { pr: { ...state, marks: readMarksOf(history, forcePushed) }, rewritten: rewrittenOf(forcePushed) };
339
301
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dw-sdlc",
3
- "version": "0.19.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": {