@pmelab/gtd 29.2.2 → 29.2.4

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gtd",
3
- "version": "29.2.2",
3
+ "version": "29.2.4",
4
4
  "description": "Drive the gtd loop inside Claude Code: agent turns run as subagents, gtd decides every beat",
5
5
  "author": {
6
6
  "name": "Philipp Melab"
package/README.md CHANGED
@@ -324,36 +324,39 @@ Then, in any repository:
324
324
  /gtd Add a --json flag to the export command
325
325
  ```
326
326
 
327
- That starts a process from your requirements and drives it until it needs you. A
328
- long, multi-line requirements paste still starts a process; if Claude Code never
329
- expanded the paste, gtd says it could not read it, and you write the
330
- requirements to `.gtd/TODO.md` and run `/gtd`. `/gtd fix`, `/gtd review [base]`,
331
- `/gtd rollout <flag>` and `/gtd cleanup <flag>` (where the config exports them)
332
- take the doors described below; text whose word count does not fit a door's args
333
- (bare `/gtd rollout` included) starts a process from that text as requirements.
334
- At every human rest Claude itself takes over: it walks you through the open
335
- questions one at a time, answers your own questions about the plan or the diff,
336
- records what you decide in the steering file the way the step asks, and moves
337
- the process on only when you say so; a review you leave untouched signs off.
338
- While a step runs you can keep asking; requests for changes wait for the next
339
- rest. When the process finishes, Claude offers to squash it and open its pull
340
- request(s) (or run `/gtd ship`; `GTD_SHIP_MODE` picks one stacked pull request
341
- per slice or one commit per slice, see the [driver doc](docs/driver.md)); a
342
- process started for an issue ships with `Closes #N` on its top pull request;
343
- re-running `/gtd ship` after a lower stacked pull request merged restacks the
344
- rest (it refuses, with nothing changed, when that pull request merged anywhere
345
- but the default branch (unless into a lower slice branch before that one merged
346
- into it), a slice branch is checked out in another worktree, an unrelated local
347
- branch sits inside the rebased commits, or a slice's change is already upstream;
348
- a failed rebase is rolled back). A process can change hands at any gate: ask
349
- Claude to hand it off (or run `/gtd throw @dev`) and it opens a draft pull
350
- request assigned to them, and `/gtd catch <pr>` picks it up exactly where it
351
- waits, so whoever wrote the requirements can hand the architecture to someone
352
- else. Ship, hand-off and throw answer at once and run in the background, with
353
- progress on the status line; they report by waking Claude, a toast, a transcript
354
- line and a push notification, and a failed push or `gh` call reports as a
355
- failure. While a ship or hand-off runs or is queued, `/gtd catch`, the doors,
356
- `/gtd <requirements>` and bare `/gtd` wait for its report. See
327
+ That starts a process from your requirements and drives it until it needs you.
328
+ The requirements go to the top of `.gtd/TODO.md`; deferred review notes already
329
+ there stay. A long, multi-line requirements paste still starts a process; if
330
+ Claude Code never expanded the paste, gtd says it could not read it, and you add
331
+ the requirements to the top of `.gtd/TODO.md` and run `/gtd`. `/gtd fix`,
332
+ `/gtd review [base]`, `/gtd rollout <flag>` and `/gtd cleanup <flag>` (where the
333
+ config exports them) take the doors described below; text whose word count does
334
+ not fit a door's args (bare `/gtd rollout` included) starts a process from that
335
+ text as requirements. At every human rest Claude itself takes over: it walks you
336
+ through the open questions one at a time, answers your own questions about the
337
+ plan or the diff, records what you decide in the steering file the way the step
338
+ asks, and moves the process on only when you say so; a review you leave
339
+ untouched signs off. While a step runs you can keep asking; requests for changes
340
+ wait for the next rest. When the process finishes, Claude offers to squash it
341
+ and open its pull request(s) (or run `/gtd ship`; `GTD_SHIP_MODE` picks one
342
+ stacked pull request per slice or one commit per slice, see the
343
+ [driver doc](docs/driver.md)); a process started for an issue ships with
344
+ `Closes #N` on its top pull request; a closed pull request whose branch was
345
+ deleted from origin is left behind and a new one opens; re-running `/gtd ship`
346
+ after a lower stacked pull request merged restacks the rest (it refuses, with
347
+ nothing changed, when that pull request merged anywhere but the default branch
348
+ (unless into a lower slice branch before that one merged into it), a slice
349
+ branch is checked out in another worktree, an unrelated local branch sits inside
350
+ the rebased commits, or a slice's change is already upstream; a failed rebase is
351
+ rolled back). A process can change hands at any gate: ask Claude to hand it off
352
+ (or run `/gtd throw @dev`) and it opens a draft pull request assigned to them,
353
+ and `/gtd catch <pr>` picks it up exactly where it waits, so whoever wrote the
354
+ requirements can hand the architecture to someone else. Ship, hand-off and throw
355
+ answer at once and run in the background, with progress on the status line; they
356
+ report by waking Claude, a toast, a transcript line and a push notification, and
357
+ a failed push or `gh` call reports as a failure. While a ship or hand-off runs
358
+ or is queued, `/gtd catch`, the doors, `/gtd <requirements>` and bare `/gtd`
359
+ wait for its report. See
357
360
  [Inside Claude Code](https://github.com/pmelab/gtd/blob/main/docs/driver.md#inside-claude-code-the-gtd-mod).
358
361
 
359
362
  ### Then let an agent build your own
@@ -10,7 +10,7 @@ export function pastedCommand(text: string): Pasted | undefined {
10
10
  if (!arg) return undefined
11
11
  if (PLACEHOLDER.test(arg)) {
12
12
  return {
13
- lost: "The pasted requirements did not arrive whole. Write them to .gtd/TODO.md and run /gtd.",
13
+ lost: "The pasted requirements did not arrive whole. Add them to the top of .gtd/TODO.md and run /gtd.",
14
14
  }
15
15
  }
16
16
  return { arg }
@@ -17,6 +17,7 @@ import { catchFrom, throwTo } from "./handoff"
17
17
  import { jobs } from "./jobs"
18
18
  import { documents, readDocument } from "./schema"
19
19
  import { READ_ONLY_GIT, ship } from "./ship"
20
+ import { withRequirements } from "./sketch"
20
21
  import type { Shipped, ShipIo } from "./ship"
21
22
  import { collectAnswer } from "./delegate"
22
23
  import { decideCommand, decideContinue, decideJob, restId, TOOLS } from "./tools"
@@ -331,7 +332,8 @@ async function begin($: $, requirements: string) {
331
332
  const file = b.file ?? ".gtd/TODO.md"
332
333
  const path = `${root}/${file}`
333
334
  await $.process.run(["mkdir", "-p", path.slice(0, path.lastIndexOf("/"))])
334
- await $.fs.write(path, requirements + "\n")
335
+ const existing = (await $.fs.exists(path)) ? await $.fs.read(path) : ""
336
+ await $.fs.write(path, withRequirements(existing, requirements))
335
337
  await start($)
336
338
  return `Started a new gtd process from ${file}.`
337
339
  }
@@ -633,7 +633,13 @@ export const NO_PR_FOUND = "no pull requests found"
633
633
 
634
634
  // `gh pr view <branch>`: only NO_PR_FOUND reads as none; any
635
635
  // other failure (auth, network) must refuse, or ship would open a duplicate PR.
636
- async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | string> {
636
+ // A closed PR whose branch origin lost is an abandoned earlier ship and reads as
637
+ // none; `abandoned` is recorded before the push so a retry, with origin holding
638
+ // the branch again, still reads it as none. `gone`: a tracking ref is stale.
639
+ async function readPr(
640
+ io: ShipIo,
641
+ branch: string,
642
+ ): Promise<{ view?: View; gone?: boolean; abandoned?: number } | string> {
637
643
  const v = await io.run([
638
644
  "gh",
639
645
  "pr",
@@ -643,14 +649,41 @@ async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | st
643
649
  "number,title,body,url,state,baseRefName,mergedAt",
644
650
  ])
645
651
  if (v.code !== 0) {
646
- if (v.err.includes(NO_PR_FOUND)) return undefined
652
+ if (v.err.includes(NO_PR_FOUND)) return {}
647
653
  return `gh pr view ${branch} failed: ${v.err.trim()}`
648
654
  }
655
+ let view: View
649
656
  try {
650
- return JSON.parse(v.out) as View
657
+ view = JSON.parse(v.out) as View
651
658
  } catch {
652
659
  return `gh pr view ${branch} returned something unreadable.`
653
660
  }
661
+ if (view.state !== "CLOSED") return { view }
662
+ const key = await io.run(["git", "config", "--local", "--get", ABANDONED_KEY(branch)])
663
+ const recorded = key.out.trim() === String(view.number)
664
+ const ls = await io.run(["git", "ls-remote", "origin", `refs/heads/${branch}`])
665
+ const gone = ls.code === 0 && !ls.out.trim()
666
+ if (recorded) return { gone }
667
+ return gone ? { gone, abandoned: view.number } : { view }
668
+ }
669
+
670
+ const ABANDONED_KEY = (branch: string) => `branch.${branch}.gtdAbandonedPr`
671
+
672
+ // Once the new PR opens, gh pr view finds it: the record has served its purpose.
673
+ async function settled<T extends Shipped>(io: ShipIo, branch: string, done: T) {
674
+ if (done.ok) await io.run(["git", "config", "--local", "--unset", ABANDONED_KEY(branch)])
675
+ return done
676
+ }
677
+
678
+ async function abandon(io: ShipIo, branch: string, pr: number | undefined) {
679
+ if (pr === undefined) return undefined
680
+ const set = await io.run(["git", "config", "--local", ABANDONED_KEY(branch), String(pr)])
681
+ return set.code === 0 ? undefined : `git config failed.\n${set.err}`.trimEnd()
682
+ }
683
+
684
+ async function viewPr(io: ShipIo, branch: string): Promise<View | undefined | string> {
685
+ const read = await readPr(io, branch)
686
+ return typeof read === "string" ? read : read.view
654
687
  }
655
688
 
656
689
  // `dead` names belong to a retired record gtd built itself: not a collision.
@@ -1512,10 +1545,13 @@ async function shipLink(
1512
1545
  const l = links[i]!
1513
1546
  const lower = i ? links[i - 1]! : undefined
1514
1547
  const base = baseOf(links, i, baseBranch)
1515
- const view = await viewPr(io, l.branch)
1516
- if (typeof view === "string")
1517
- return { step: "pull request", why: `${view} ${l.branch} was not pushed; /gtd ship retries.` }
1518
- const pushed = await pushLink(io, git, l, i < links.length - 1)
1548
+ const read = await readPr(io, l.branch)
1549
+ if (typeof read === "string")
1550
+ return { step: "pull request", why: `${read} ${l.branch} was not pushed; /gtd ship retries.` }
1551
+ const { view, gone } = read
1552
+ const marked = await abandon(io, l.branch, read.abandoned)
1553
+ if (marked) return { step: "push", why: marked }
1554
+ const pushed = await pushLink(io, git, { ...l, fresh: l.fresh || gone }, i < links.length - 1)
1519
1555
  if (pushed) return { step: "push", why: pushed }
1520
1556
  const lowerRef = lower?.branch ?? baseRef
1521
1557
  const ctx: Ctx = {
@@ -1528,6 +1564,7 @@ async function shipLink(
1528
1564
  }
1529
1565
  const done = await sliceStep(io, git, ok, l, view, base, lowerRef, ctx)
1530
1566
  if ("step" in done) return done
1567
+ if (!view) await settled(io, l.branch, done)
1531
1568
  if (done.number === undefined && i < links.length - 1)
1532
1569
  return { step: "pull request", why: "its number could not be read from gh's reply." }
1533
1570
  return done
@@ -1764,17 +1801,24 @@ async function pullRequest(
1764
1801
  if (mergeBase === (await git("rev-parse", "HEAD")))
1765
1802
  return fail(`HEAD is an ancestor of ${baseRef}: nothing to describe.`)
1766
1803
 
1767
- const existing = await viewPr(io, branch)
1768
- if (typeof existing === "string")
1804
+ const read = await readPr(io, branch)
1805
+ if (typeof read === "string")
1769
1806
  return fail(
1770
- `${existing} ${branch} was not pushed; the squash and history ref stay, /gtd ship retries.`,
1807
+ `${read} ${branch} was not pushed; the squash and history ref stay, /gtd ship retries.`,
1771
1808
  )
1809
+ const { view: existing, gone } = read
1772
1810
  if (existing && existing.state !== "OPEN")
1773
1811
  return fail(`The pull request for ${branch} is ${existing.state}, not open.`)
1774
- const pushed = await pushBranch(io, branch)
1812
+ const marked = await abandon(io, branch, read.abandoned)
1813
+ if (marked) return fail(`${marked} ${branch} was not pushed; /gtd ship retries.`)
1814
+ const pushed = await pushBranch(io, branch, "HEAD", undefined, gone)
1775
1815
  if (pushed) return fail(pushed)
1776
1816
  if (!existing)
1777
- return createPr(io, git, branch, baseBranch, baseRef, { from: mergeBase, to: "HEAD" })
1817
+ return settled(
1818
+ io,
1819
+ branch,
1820
+ await createPr(io, git, branch, baseBranch, baseRef, { from: mergeBase, to: "HEAD" }),
1821
+ )
1778
1822
  // A branch once shipped as a stack keeps the PR aimed at a slice branch; any
1779
1823
  // other base is the author's own and stays.
1780
1824
  const onSlice = recorded.some((n) => existing.baseRefName === `${branch}-${n}`)
@@ -0,0 +1,6 @@
1
+ // New requirements go above what the steering file already holds, untouched:
2
+ // a deferred review note the start diff alters reads as the human's edit.
3
+ export function withRequirements(existing: string, requirements: string): string {
4
+ const head = `${requirements.trimEnd()}\n`
5
+ return existing.trim() ? `${head}\n${existing}` : head
6
+ }
@@ -770,9 +770,13 @@ ${fixFeedbackPrompt}
770
770
  - Leave everything uncommitted and finish your turn — do not commit
771
771
  `;
772
772
  const refactorRules = (strays, pattern) => `- Behaviour-neutral: the suite's outcome must not change
773
- - Change only these paths, nothing else, and never a test file even
774
- when it is one of them:
773
+ - Change only these paths, nothing else:
775
774
  ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
775
+ - An existing test file only where a changed call shape reaches it:
776
+ update its call sites, inside an assertion too;
777
+ never add or delete a test file,
778
+ never add, remove or alter a test or an assertion, and never
779
+ change what an assertion checks, an expected value or a snapshot
776
780
  - Introduce no new flag read and add no second read of an existing one:
777
781
  every match of \`${pattern}\` may move, never increase in number`;
778
782
  const withRules = (rules) => rules === void 0 ? "" : `${rules}\n`;
@@ -782,8 +786,13 @@ ${feedback ? `- The package carries a \`## Review feedback\` section: a human's
782
786
  last refactor. Act on it, within the rules below
783
787
  ` : ""}${refactorRules(strays, pattern)}
784
788
  ${reference === void 0 ? "" : `- Reference only: \`git show ${reference} -- ${strays.map(quote).join(" ")}\` shows an
785
- earlier attempt at this change. Read it for ideas; never apply,
786
- cherry-pick or check it out — write a fresh refactor
789
+ earlier attempt at this change, and \`git show --stat ${reference}\`
790
+ lists every file it touched, the test call sites included.
791
+ Read it; never apply, cherry-pick or check it out
792
+ - Its changes to these paths' existing code are the seam: write that
793
+ restructuring afresh here, minus the feature's own behaviour. A
794
+ refactor that touches none of it is wrong, however clean — never
795
+ swap in an unrelated tidy-up
787
796
  `}- Leave everything uncommitted and finish your turn — do not commit
788
797
  `;
789
798
  const refactorFixPrompt = (strays, pattern) => `${stateFileRules}
@@ -1099,11 +1108,11 @@ const closedFold = (round, target) => round === "edits" ? `- Fold every hand-edi
1099
1108
  /** `refactor` retargets the prompt at `.gtd/REFACTOR.md` and fences it to the strays. A concern outside the fence is not folded; if nothing at all is written the landing is refused, but a partly buildable round drops the rest silently. */
1100
1109
  const buildReviewCollectingPrompt = (capture, refactor) => {
1101
1110
  const target = refactor ? ".gtd/REFACTOR.md" : ".gtd/REQUIREMENTS.md";
1102
- const fence = refactor ? `- The next refactor may change only these paths, and never a test file
1103
- or a flag read:
1111
+ const fence = refactor ? `- The next refactor may change only these paths, plus an existing test
1112
+ file's call sites, and never a flag read:
1104
1113
  ${refactor.strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
1105
- A concern needing any other path, a test file, a new or added flag
1106
- read, or a change in behaviour (that belongs to the feature, not this
1114
+ A concern needing any other path, a new or changed test or assertion,
1115
+ a new or added flag read, or a change in behaviour (that belongs to the feature, not this
1107
1116
  refactor) cannot be built: do not fold it
1108
1117
  ` : "";
1109
1118
  const actionable = refactor ? `The round is actionable if the human left a note on
@@ -1207,7 +1216,7 @@ ${removed.map(({ path, lines }) => lines.map((a) => ` ${path} — ${a.scenari
1207
1216
  const lapRange = {
1208
1217
  first: "it covers everything since the base.",
1209
1218
  feedback: "it starts at the previous review's boundary, so it covers only what is new since the previous review.",
1210
- refactor: "it covers the whole refactor since it started, on every round."
1219
+ refactor: "it covers the whole refactor since it started, on every round. A refactor may update an existing test's call sites only: give any test-file hunk its own chunk, and say so if it changes a test, an assertion or an expected value."
1211
1220
  };
1212
1221
  const buildReviewReviewingPrompt = (base, lap, carry, removed = []) => `${styleBlock}
1213
1222
 
@@ -1,6 +1,6 @@
1
1
  import { read, refuse, removeScript, run, scope, showScript, vars } from "../flows.mjs";
2
2
  import { fixFindings, lensesOf, qualityLap as qualityLap$1, reviewToHandOff } from "../workflow-kit.mjs";
3
- import { E as reviewing, S as fixRisks, T as reviewQuality, W as handOffAwaitReviewMessage, _ as REVIEW, b as fixCheck, c as escalation, d as healthy, m as QUALITY, u as guarded, v as awaitReview, w as reviewMissing, x as fixQuality } from "./reserved-vvu5yc3I.mjs";
3
+ import { E as reviewing, S as fixRisks, T as reviewQuality, W as handOffAwaitReviewMessage, _ as REVIEW, b as fixCheck, c as escalation, d as healthy, m as QUALITY, u as guarded, v as awaitReview, w as reviewMissing, x as fixQuality } from "./reserved-BduvUQxQ.mjs";
4
4
  //#region src/workflows/shared/tail.ts
5
5
  /** The reviewer's own `.gtd/REVIEW.md`, kept beside the human's notes so `fix` can tell them apart. */
6
6
  const REVIEW_ORIG = ".gtd/REVIEW.orig.md";
@@ -1,6 +1,6 @@
1
1
  import { agent, changes, glob, read, refuse, scope, start, vars } from "../flows.mjs";
2
- import { _t as registryEntry, a as flagShipped, dt as defaults, ft as envDefaults, ht as flagReferences, i as skills, n as summary, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-vvu5yc3I.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail--WYGA4LA.mjs";
2
+ import { _t as registryEntry, a as flagShipped, dt as defaults, ft as envDefaults, ht as flagReferences, i as skills, n as summary, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-BduvUQxQ.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-pgq1Fbbv.mjs";
4
4
  //#region src/workflows/cleanup/cleanup.ts
5
5
  const removingPrompt = (name, registry) => `Clean up the release flag "${name}", which is default-on: delete its entry in ${registry}, and remove every \`GTD_FLAG_${name.toUpperCase()}\` check from the shipped sources. Keep the flag-on path; delete code only the off path reached, including tests that pin the off path.`;
6
6
  const cleanupFlow = async () => {
@@ -1,7 +1,7 @@
1
1
  import { answered, changes, changesSince, glob, hasThreadFor, head, human, judge, judgeDiffShape, kept, mark, matches, numeric, quote, read, refuse, removeScript, requireAnswers, requireProgress, requireReplies, requireRevert, requireThreadsClosed, restoreScript, revertScript, run, scope, slice, start, vars } from "../flows.mjs";
2
2
  import { lensesOf, review as review$1 } from "../workflow-kit.mjs";
3
- import { $ as refactorFixPrompt, A as architectureDecomposePrompt, B as designSystem, C as planner, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as idleMessage, I as buildReviewTriageMessage, J as naivePassPrompt, K as implementBuildingPrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as refactorDroppedRefusal, R as deferNotesScript, S as fixRisks, V as designTriagePrompt, X as packagesItemFixSuitePrompt, Y as packagesItemBuildingPrompt, Z as refactorBuildingPrompt, _ as REVIEW, at as reviewerSystem, b as fixCheck, ct as startGateBlockedMessage, d as healthy, dt as defaults, et as refactorRules, f as ARCHITECTURE, ft as envDefaults, g as REQUIREMENTS, h as REFACTOR, i as skills, it as reviewNotesCapture, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as gate, lt as unwindFailedMessage, n as summary, nt as reviewEditsCapture, o as shipped, ot as seamByHandMessage, p as FEEDBACK, pt as testFileGlobs, q as implementFixPrompt, r as access, st as seamSteerMessage, t as steering, tt as reviewEditNotesCapture, u as guarded, ut as unwindFailure, v as awaitReview, w as reviewMissing, y as coder, z as designGateAnswerMessage } from "../chunks/reserved-vvu5yc3I.mjs";
4
- import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail--WYGA4LA.mjs";
3
+ import { $ as refactorFixPrompt, A as architectureDecomposePrompt, B as designSystem, C as planner, D as agentWithSkills, E as reviewing, F as buildReviewFixNitsPrompt, G as idleMessage, I as buildReviewTriageMessage, J as naivePassPrompt, K as implementBuildingPrompt, L as builderSystem, M as buildReviewAnswerQuestionsPrompt, N as buildReviewAwaitAfterDeferMessage, O as architectSystem, P as buildReviewCollectingPrompt, Q as refactorDroppedRefusal, R as deferNotesScript, S as fixRisks, V as designTriagePrompt, X as packagesItemFixSuitePrompt, Y as packagesItemBuildingPrompt, Z as refactorBuildingPrompt, _ as REVIEW, at as reviewerSystem, b as fixCheck, ct as startGateBlockedMessage, d as healthy, dt as defaults, et as refactorRules, f as ARCHITECTURE, ft as envDefaults, g as REQUIREMENTS, h as REFACTOR, i as skills, it as reviewNotesCapture, j as architectureGateAnswerMessage, k as architectureAuthorPrompt, l as gate, lt as unwindFailedMessage, n as summary, nt as reviewEditsCapture, o as shipped, ot as seamByHandMessage, p as FEEDBACK, pt as testFileGlobs, q as implementFixPrompt, r as access, st as seamSteerMessage, t as steering, tt as reviewEditNotesCapture, u as guarded, ut as unwindFailure, v as awaitReview, w as reviewMissing, y as coder, z as designGateAnswerMessage } from "../chunks/reserved-BduvUQxQ.mjs";
4
+ import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail-pgq1Fbbv.mjs";
5
5
  //#region src/workflows/shared/fences.ts
6
6
  const markFenced = (text) => {
7
7
  let fence;
@@ -516,6 +516,35 @@ const requireNoTestFiles = () => {
516
516
  if (tests.length === 0) return;
517
517
  refuse(`gtd land: refactor-tests: the refactor may not change a test file — revert these:\n${tests.map((path) => ` - ${path}`).join("\n")}`);
518
518
  };
519
+ const holdsCallSites = (path) => !path.endsWith(".feature") && !path.endsWith(".snap");
520
+ const COMMENT = /(?<![*/])\/\*[\s\S]*?\*\/|(?<!:)\/\/.*$/gm;
521
+ const ASSERTION = /\b(?:expect|assert\w*)\s*[.(]/g;
522
+ const TEST_CASE = /(?<![.\w])x?(?:it|test|describe)\s*[.(]/g;
523
+ const MODIFIER = /(?<![.\w])(?:it|test|describe)\.(skip|only|todo|fails|skipIf|runIf)\b|(?<![.\w])(x)(?:it|test|describe)\b/g;
524
+ const counts = (text) => {
525
+ const live = text.replace(COMMENT, "");
526
+ const modifiers = [...live.matchAll(MODIFIER)].map((m) => m[1] ?? m[2]).sort();
527
+ return JSON.stringify([
528
+ [...live.matchAll(ASSERTION)].length,
529
+ [...live.matchAll(TEST_CASE)].length,
530
+ modifiers
531
+ ]);
532
+ };
533
+ const tested = (text) => {
534
+ const live = text.replace(COMMENT, "");
535
+ return live.search(ASSERTION) >= 0 || live.search(TEST_CASE) >= 0;
536
+ };
537
+ const callSitesOnly = ({ path, status, before, after }) => status === "modified" && holdsCallSites(path) && before !== void 0 && after !== void 0 && tested(before) && counts(before) === counts(after);
538
+ /**
539
+ * The test-file half of the refactor guard: an existing test may follow a changed call shape,
540
+ * nothing more. Returns the test files it admitted, which the scope check then allows.
541
+ */
542
+ const requireTestCallSitesOnly = () => {
543
+ const tests = changes().filter((c) => isCode(c.path) && isTestFile(c.path));
544
+ const refused = tests.filter((c) => !callSitesOnly(c)).map((c) => c.path);
545
+ if (refused.length > 0) refuse(`gtd land: refactor-tests: the refactor may update an existing test file's call sites only — never add, delete, skip or comment out a test or an assertion, or touch a snapshot, a .feature file or test data; revert these:\n${refused.map((path) => ` - ${path}`).join("\n")}`);
546
+ return tests.map((c) => c.path);
547
+ };
519
548
  const flagExpression = (pattern) => {
520
549
  if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank");
521
550
  let expression;
@@ -571,12 +600,16 @@ const packageLines = (strays, pattern, reference) => [
571
600
  "## Rules",
572
601
  "",
573
602
  "- The suite's outcome must not change",
574
- "- Change only the paths above, and never a test file",
603
+ "- Change only the paths above, plus an existing test file where a changed call shape reaches it: never add or delete a test file, and never add, remove or alter a test or an assertion",
575
604
  `- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
576
605
  "",
577
606
  "## Reference",
578
607
  "",
579
- reference === void 0 ? "None." : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
608
+ ...reference === void 0 ? ["None."] : [
609
+ `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
610
+ "",
611
+ "Its changes to the existing code above are the seam this refactor exists to land, minus the feature's own behaviour."
612
+ ],
580
613
  ""
581
614
  ];
582
615
  const demoteHeadings = (text) => {
@@ -609,8 +642,7 @@ const plan = (shape, options) => {
609
642
  };
610
643
  };
611
644
  const guardOf = ({ pattern, strays }, entry) => () => {
612
- requireNoTestFiles();
613
- requireWithin(strays);
645
+ requireWithin([...strays, ...requireTestCallSitesOnly()]);
614
646
  requireNoNewFlags(pattern, entry);
615
647
  };
616
648
  /** Derive, build, check and close one refactor lap; `feedback` is the collected review text of the lap before. */
@@ -651,7 +683,11 @@ const refactorLap = async (p, guard, feedback) => {
651
683
  });
652
684
  await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" });
653
685
  };
654
- const strayAccess = (strays) => ({ write: [...strays, ".gtd/**"] });
686
+ const strayAccess = (strays) => ({ write: [
687
+ ...strays,
688
+ ...testFileGlobs,
689
+ ".gtd/**"
690
+ ] });
655
691
  const refactorFirst = async (shape, options = {}) => {
656
692
  const p = plan(shape, options);
657
693
  if (p === void 0) return false;
@@ -886,4 +922,4 @@ const feature = shipped(ordinaryStart);
886
922
  //#region src/workflows/feature/index.ts
887
923
  var feature_default = feature;
888
924
  //#endregion
889
- export { REFACTOR_PACKAGE, access, afterTail, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, build, buildTail, collecting, declaredTests, decompose, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, fixSuite, implement, judgeSeam, naivePass, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireDeclaredTests, requireNoNewFlags, requireNoTestFiles, requireWithin, reviewedRefactor, seamBuild, skills, steering, summary, triage, unwind };
925
+ export { REFACTOR_PACKAGE, access, afterTail, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, build, buildTail, collecting, declaredTests, decompose, feature_default as default, defaults, deferNotes, design, envDefaults, feature, fixNits, fixSuite, implement, judgeSeam, naivePass, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireDeclaredTests, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, reviewedRefactor, seamBuild, skills, steering, summary, triage, unwind };
@@ -1,7 +1,7 @@
1
1
  import { read, refuse, removeScript, run, scope, start } from "../flows.mjs";
2
2
  import { d as reviewNotes, f as reviewRisks } from "../chunks/steering-DVILanVf.mjs";
3
- import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, _ as REVIEW, dt as defaults, ft as envDefaults, i as skills, n as summary, o as shipped, r as access, s as baseline, t as steering, y as coder } from "../chunks/reserved-vvu5yc3I.mjs";
4
- import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail--WYGA4LA.mjs";
3
+ import { D as agentWithSkills, H as finisherSystem, U as fixReviewNotesPrompt, _ as REVIEW, dt as defaults, ft as envDefaults, i as skills, n as summary, o as shipped, r as access, s as baseline, t as steering, y as coder } from "../chunks/reserved-BduvUQxQ.mjs";
4
+ import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail-pgq1Fbbv.mjs";
5
5
  //#region src/workflows/fix/fix.ts
6
6
  /**
7
7
  * Fix every note a human left in a handed-off `.gtd/REVIEW.md` and every
@@ -1,6 +1,6 @@
1
1
  import { start } from "../flows.mjs";
2
- import { dt as defaults, ft as envDefaults, i as skills, l as gate, n as summary, o as shipped, r as access, rt as reviewGateBlockedMessage, t as steering } from "../chunks/reserved-vvu5yc3I.mjs";
3
- import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail--WYGA4LA.mjs";
2
+ import { dt as defaults, ft as envDefaults, i as skills, l as gate, n as summary, o as shipped, r as access, rt as reviewGateBlockedMessage, t as steering } from "../chunks/reserved-BduvUQxQ.mjs";
3
+ import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail-pgq1Fbbv.mjs";
4
4
  //#region src/workflows/review/review.ts
5
5
  /**
6
6
  * Pure review of everything since `reviewBase`: a green gate, the quality
@@ -1,5 +1,5 @@
1
1
  import { agent, changesSince, glob, head, mark, read, refuse, removeScript, run, scope, vars } from "../flows.mjs";
2
- import { a as flagShipped, dt as defaults, ft as envDefaults, gt as flipProblems, i as skills, l as gate, mt as flagMentions, n as summary, p as FEEDBACK, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-vvu5yc3I.mjs";
2
+ import { a as flagShipped, dt as defaults, ft as envDefaults, gt as flipProblems, i as skills, l as gate, mt as flagMentions, n as summary, p as FEEDBACK, r as access, t as steering, vt as requireFlag, y as coder } from "../chunks/reserved-BduvUQxQ.mjs";
3
3
  //#region src/workflows/rollout/rollout.ts
4
4
  const flippingPrompt = (name, registry) => `Roll out the release flag "${name}": in ${registry}, set \`defaultOn: true\` on its entry. Change nothing else — no other entry, no other field, no other file, no comments.`;
5
5
  const suiteBlockedMessage = () => "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land.";
@@ -17554,9 +17554,13 @@ ${fixFeedbackPrompt}
17554
17554
  - Leave everything uncommitted and finish your turn — do not commit
17555
17555
  `;
17556
17556
  const refactorRules = (strays, pattern) => `- Behaviour-neutral: the suite's outcome must not change
17557
- - Change only these paths, nothing else, and never a test file even
17558
- when it is one of them:
17557
+ - Change only these paths, nothing else:
17559
17558
  ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
17559
+ - An existing test file only where a changed call shape reaches it:
17560
+ update its call sites, inside an assertion too;
17561
+ never add or delete a test file,
17562
+ never add, remove or alter a test or an assertion, and never
17563
+ change what an assertion checks, an expected value or a snapshot
17560
17564
  - Introduce no new flag read and add no second read of an existing one:
17561
17565
  every match of \`${pattern}\` may move, never increase in number`;
17562
17566
  const withRules = (rules) => rules === void 0 ? "" : `${rules}\n`;
@@ -17566,8 +17570,13 @@ ${feedback ? `- The package carries a \`## Review feedback\` section: a human's
17566
17570
  last refactor. Act on it, within the rules below
17567
17571
  ` : ""}${refactorRules(strays, pattern)}
17568
17572
  ${reference === void 0 ? "" : `- Reference only: \`git show ${reference} -- ${strays.map(quote).join(" ")}\` shows an
17569
- earlier attempt at this change. Read it for ideas; never apply,
17570
- cherry-pick or check it out — write a fresh refactor
17573
+ earlier attempt at this change, and \`git show --stat ${reference}\`
17574
+ lists every file it touched, the test call sites included.
17575
+ Read it; never apply, cherry-pick or check it out
17576
+ - Its changes to these paths' existing code are the seam: write that
17577
+ restructuring afresh here, minus the feature's own behaviour. A
17578
+ refactor that touches none of it is wrong, however clean — never
17579
+ swap in an unrelated tidy-up
17571
17580
  `}- Leave everything uncommitted and finish your turn — do not commit
17572
17581
  `;
17573
17582
  const refactorFixPrompt = (strays, pattern) => `${stateFileRules}
@@ -17883,11 +17892,11 @@ const closedFold = (round, target) => round === "edits" ? `- Fold every hand-edi
17883
17892
  /** `refactor` retargets the prompt at `.gtd/REFACTOR.md` and fences it to the strays. A concern outside the fence is not folded; if nothing at all is written the landing is refused, but a partly buildable round drops the rest silently. */
17884
17893
  const buildReviewCollectingPrompt = (capture, refactor) => {
17885
17894
  const target = refactor ? ".gtd/REFACTOR.md" : ".gtd/REQUIREMENTS.md";
17886
- const fence = refactor ? `- The next refactor may change only these paths, and never a test file
17887
- or a flag read:
17895
+ const fence = refactor ? `- The next refactor may change only these paths, plus an existing test
17896
+ file's call sites, and never a flag read:
17888
17897
  ${refactor.strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
17889
- A concern needing any other path, a test file, a new or added flag
17890
- read, or a change in behaviour (that belongs to the feature, not this
17898
+ A concern needing any other path, a new or changed test or assertion,
17899
+ a new or added flag read, or a change in behaviour (that belongs to the feature, not this
17891
17900
  refactor) cannot be built: do not fold it
17892
17901
  ` : "";
17893
17902
  const actionable = refactor ? `The round is actionable if the human left a note on
@@ -17991,7 +18000,7 @@ ${removed.map(({ path, lines }) => lines.map((a) => ` ${path} — ${a.scenari
17991
18000
  const lapRange = {
17992
18001
  first: "it covers everything since the base.",
17993
18002
  feedback: "it starts at the previous review's boundary, so it covers only what is new since the previous review.",
17994
- refactor: "it covers the whole refactor since it started, on every round."
18003
+ refactor: "it covers the whole refactor since it started, on every round. A refactor may update an existing test's call sites only: give any test-file hunk its own chunk, and say so if it changes a test, an assertion or an expected value."
17995
18004
  };
17996
18005
  const buildReviewReviewingPrompt = (base, lap, carry, removed = []) => `${styleBlock}
17997
18006
 
@@ -38344,6 +38353,35 @@ const requireNoTestFiles = () => {
38344
38353
  if (tests.length === 0) return;
38345
38354
  refuse(`gtd land: refactor-tests: the refactor may not change a test file — revert these:\n${tests.map((path) => ` - ${path}`).join("\n")}`);
38346
38355
  };
38356
+ const holdsCallSites = (path) => !path.endsWith(".feature") && !path.endsWith(".snap");
38357
+ const COMMENT = /(?<![*/])\/\*[\s\S]*?\*\/|(?<!:)\/\/.*$/gm;
38358
+ const ASSERTION = /\b(?:expect|assert\w*)\s*[.(]/g;
38359
+ const TEST_CASE = /(?<![.\w])x?(?:it|test|describe)\s*[.(]/g;
38360
+ const MODIFIER = /(?<![.\w])(?:it|test|describe)\.(skip|only|todo|fails|skipIf|runIf)\b|(?<![.\w])(x)(?:it|test|describe)\b/g;
38361
+ const counts = (text) => {
38362
+ const live = text.replace(COMMENT, "");
38363
+ const modifiers = [...live.matchAll(MODIFIER)].map((m) => m[1] ?? m[2]).sort();
38364
+ return JSON.stringify([
38365
+ [...live.matchAll(ASSERTION)].length,
38366
+ [...live.matchAll(TEST_CASE)].length,
38367
+ modifiers
38368
+ ]);
38369
+ };
38370
+ const tested = (text) => {
38371
+ const live = text.replace(COMMENT, "");
38372
+ return live.search(ASSERTION) >= 0 || live.search(TEST_CASE) >= 0;
38373
+ };
38374
+ const callSitesOnly = ({ path, status, before, after }) => status === "modified" && holdsCallSites(path) && before !== void 0 && after !== void 0 && tested(before) && counts(before) === counts(after);
38375
+ /**
38376
+ * The test-file half of the refactor guard: an existing test may follow a changed call shape,
38377
+ * nothing more. Returns the test files it admitted, which the scope check then allows.
38378
+ */
38379
+ const requireTestCallSitesOnly = () => {
38380
+ const tests = changes().filter((c) => isCode(c.path) && isTestFile(c.path));
38381
+ const refused = tests.filter((c) => !callSitesOnly(c)).map((c) => c.path);
38382
+ if (refused.length > 0) refuse(`gtd land: refactor-tests: the refactor may update an existing test file's call sites only — never add, delete, skip or comment out a test or an assertion, or touch a snapshot, a .feature file or test data; revert these:\n${refused.map((path) => ` - ${path}`).join("\n")}`);
38383
+ return tests.map((c) => c.path);
38384
+ };
38347
38385
  const flagExpression = (pattern) => {
38348
38386
  if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank");
38349
38387
  let expression;
@@ -38399,12 +38437,16 @@ const packageLines = (strays, pattern, reference) => [
38399
38437
  "## Rules",
38400
38438
  "",
38401
38439
  "- The suite's outcome must not change",
38402
- "- Change only the paths above, and never a test file",
38440
+ "- Change only the paths above, plus an existing test file where a changed call shape reaches it: never add or delete a test file, and never add, remove or alter a test or an assertion",
38403
38441
  `- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
38404
38442
  "",
38405
38443
  "## Reference",
38406
38444
  "",
38407
- reference === void 0 ? "None." : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
38445
+ ...reference === void 0 ? ["None."] : [
38446
+ `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
38447
+ "",
38448
+ "Its changes to the existing code above are the seam this refactor exists to land, minus the feature's own behaviour."
38449
+ ],
38408
38450
  ""
38409
38451
  ];
38410
38452
  const demoteHeadings = (text) => {
@@ -38437,8 +38479,7 @@ const plan = (shape, options) => {
38437
38479
  };
38438
38480
  };
38439
38481
  const guardOf = ({ pattern, strays }, entry) => () => {
38440
- requireNoTestFiles();
38441
- requireWithin(strays);
38482
+ requireWithin([...strays, ...requireTestCallSitesOnly()]);
38442
38483
  requireNoNewFlags(pattern, entry);
38443
38484
  };
38444
38485
  /** Derive, build, check and close one refactor lap; `feedback` is the collected review text of the lap before. */
@@ -38479,7 +38520,11 @@ const refactorLap = async (p, guard, feedback) => {
38479
38520
  });
38480
38521
  await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" });
38481
38522
  };
38482
- const strayAccess = (strays) => ({ write: [...strays, ".gtd/**"] });
38523
+ const strayAccess = (strays) => ({ write: [
38524
+ ...strays,
38525
+ ...testFileGlobs,
38526
+ ".gtd/**"
38527
+ ] });
38483
38528
  const refactorFirst = async (shape, options = {}) => {
38484
38529
  const p = plan(shape, options);
38485
38530
  if (p === void 0) return false;
@@ -38905,4 +38950,4 @@ var workflows_default = feature;
38905
38950
  */
38906
38951
  const doors = {};
38907
38952
  //#endregion
38908
- export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, cleanup, cleanupFlow, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, gate, guarded, handOffReview, handOffTail, healthy, implement, judgeSeam, lensesOf, naivePass, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireDeclaredTests, requireNoNewFlags, requireNoTestFiles, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, steering, summary, tail, threads, triage, unwind };
38953
+ export { ARCHITECTURE, ESCALATION, FEEDBACK, FIX_CAP, FlagOptions, QUALITY, REFACTOR, REFACTOR_PACKAGE, REQUIREMENTS, REVIEW, REVIEW_ORIG, ShippedOptions, access, afterTail, agentWithSkills, answerProductQuestions, answerReviewQuestions, answerTechnicalQuestions, architecture, author, awaitReview, base, baseline, build, buildTail, builtInLenses, cleanup, cleanupFlow, closeReview, coder, collecting, declaredTests, decompose, workflows_default as default, defaults, deferNotes, describeEscalation, design, doors, envDefaults, escalate, escalation, escalationExhausted, feature, fix, fixCheck, fixFlow, fixNits, fixQuality, fixQualityFindings, fixReviewNotes, fixRisks, fixSuite, gate, guarded, handOffReview, handOffTail, healthy, implement, judgeSeam, lensesOf, naivePass, nextPackage, ordinaryStart, packageItem, packages, planAndBuild, planner, qualityLap, reUnwind, refactorCollecting, refactorFirst, refactorStrays, requireDeclaredTests, requireNoNewFlags, requireNoTestFiles, requireTestCallSitesOnly, requireWithin, retryQuestion, review, reviewFlow, reviewMissing, reviewQuality, reviewedRefactor, reviewing, rollout, rolloutFlow, seamBuild, skills, steering, summary, tail, threads, triage, unwind };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pmelab/gtd",
3
- "version": "29.2.2",
3
+ "version": "29.2.4",
4
4
  "private": false,
5
5
  "description": "Git-aware CLI that emits the next prompt for an autonomous coding agent based on the current repository state",
6
6
  "bin": {
@@ -13,7 +13,7 @@ Feature: the refactor slice is reviewed on its own before the feature is impleme
13
13
  And an environment variable "GTD_FLAG_NAIVE_PASS" set to "1"
14
14
  And an environment variable "GTD_FLAG_RISK_TIER" set to "0"
15
15
 
16
- Scenario: with a narrowed seamAllow a test-named file written by the refactor is refused
16
+ Scenario: with a narrowed seamAllow a test file added by the refactor is refused
17
17
  Given an environment variable "GTD_REFACTORQUALITYREVIEWS" set to ""
18
18
  And an environment variable "GTD_SEAMALLOW" set to "docs/**"
19
19
  And a file "src/calc.ts" with:
@@ -85,12 +85,12 @@ Feature: the refactor slice is reviewed on its own before the feature is impleme
85
85
  import { expect, it } from "vitest"
86
86
  it("adds", () => expect(2 + 2).toBe(4))
87
87
  """
88
- And step "refactor.building" writes "src/calc.test.ts" with:
88
+ And step "refactor.building" writes "src/calc.spec.ts" with:
89
89
  """
90
90
  import { expect, it } from "vitest"
91
91
  it("adds", () => expect(3 + 3).toBe(6))
92
92
  """
93
- Then driving the process is refused with "outside its write access"
93
+ Then driving the process is refused with "refactor-tests"
94
94
 
95
95
 
96
96
  Scenario: refactorQualityReviews picks the refactor's lenses and qualityReviews does not reach them
@@ -1607,6 +1607,40 @@ Feature: the refactor slice is reviewed on its own before the feature is impleme
1607
1607
  And ".gtd/REFACTOR.md" contains "src/calc.ts"
1608
1608
  And ".gtd/REFACTOR.md" does not contain "src/calc.test.ts"
1609
1609
 
1610
+ Scenario: the refactor updates an existing test's call sites to the seam's new shape
1611
+ Given step "seam.naive" writes "src/calc.ts" with:
1612
+ """
1613
+ export const add = ({ a, b }: { a: number; b: number }) => a + b
1614
+ export const subtract = ({ a, b }: { a: number; b: number }) => a - b
1615
+ """
1616
+ And step "refactor.deriving" runs its script
1617
+ And step "refactor.building" writes "src/calc.ts" with:
1618
+ """
1619
+ export const add = ({ a, b }: { a: number; b: number }) => a + b
1620
+ """
1621
+ And step "refactor.building" writes "src/calc.test.ts" with:
1622
+ """
1623
+ import { expect, it } from "vitest"
1624
+ import { add } from "./calc"
1625
+ it("adds", () => expect(add({ a: 1, b: 1 })).toBe(2))
1626
+ """
1627
+ When the process is driven
1628
+ Then the process rests at "refactor.health.check"
1629
+
1630
+ Scenario: the refactor may not drop an assertion from an existing test
1631
+ Given step "seam.naive" writes "src/calc.ts" with:
1632
+ """
1633
+ export const add = (a: number, b: number) => a + b
1634
+ export const subtract = (a: number, b: number) => a - b
1635
+ """
1636
+ And step "refactor.deriving" runs its script
1637
+ And step "refactor.building" writes "src/calc.test.ts" with:
1638
+ """
1639
+ import { it } from "vitest"
1640
+ it("adds", () => {})
1641
+ """
1642
+ Then driving the process is refused with "refactor-tests"
1643
+
1610
1644
  Scenario: a refactor-first whose strays are all test files goes straight to implement
1611
1645
  Given step "seam.naive" writes "src/calc.test.ts" with:
1612
1646
  """
@@ -13,6 +13,7 @@ import {
13
13
  refactorStrays,
14
14
  requireNoNewFlags,
15
15
  requireNoTestFiles,
16
+ requireTestCallSitesOnly,
16
17
  requireWithin,
17
18
  reviewedRefactor,
18
19
  REFACTOR_PACKAGE,
@@ -20,6 +21,15 @@ import {
20
21
 
21
22
  afterEach(() => installContext(undefined))
22
23
 
24
+ const testFileGlobs = [
25
+ "**/*.test.*",
26
+ "**/*.spec.*",
27
+ "**/*.feature",
28
+ "**/tests/**",
29
+ "**/test/**",
30
+ "**/__tests__/**",
31
+ ]
32
+
23
33
  const change = (
24
34
  path: string,
25
35
  before: string | undefined,
@@ -131,6 +141,137 @@ describe("requireNoTestFiles", () => {
131
141
  })
132
142
  })
133
143
 
144
+ describe("requireTestCallSitesOnly", () => {
145
+ it("refuses an added or deleted test path, naming it", () => {
146
+ const list = [
147
+ change("tests/unit/b.ts", undefined, "x", "added"),
148
+ change("src/__tests__/c.ts", "a", undefined, "deleted"),
149
+ change("src/a.test.ts", "expect(a()).toBe(1)", "expect(a(1)).toBe(1)"),
150
+ ]
151
+ const message = withChanges(list, () => requireTestCallSitesOnly())
152
+ expect(message).toMatch(/^gtd land: refactor-tests: /)
153
+ expect(message).toContain(" - tests/unit/b.ts")
154
+ expect(message).toContain(" - src/__tests__/c.ts")
155
+ expect(message).not.toContain("src/a.test.ts")
156
+ })
157
+
158
+ it("passes a modified test file's call site, non-test paths and .gtd/ paths", () => {
159
+ const list = [
160
+ change("src/a.test.ts", "expect(a()).toBe(1)", "expect(a(1)).toBe(1)"),
161
+ change("src/new.ts", undefined, "x", "added"),
162
+ change(".gtd/tests/x.md", "", "x", "added"),
163
+ ]
164
+ expect(withChanges(list, () => requireTestCallSitesOnly())).toBeUndefined()
165
+ })
166
+
167
+ it("refuses a snapshot change and a dropped test or assertion, naming each", () => {
168
+ const list = [
169
+ change("src/__snapshots__/a.test.ts.snap", "a", "b"),
170
+ change("src/b.test.ts", 'it("a", () => {})\nit("b", () => {})', 'it("a", () => {})'),
171
+ change("src/c.test.ts", "expect(c()).toBe(1)\nexpect(c()).toBe(1)", "expect(c()).toBe(1)"),
172
+ change(
173
+ "d.feature",
174
+ "Feature: d\n Scenario: d\n Then it is\n",
175
+ "Feature: d\n Scenario: d\n",
176
+ ),
177
+ ]
178
+ const message = withChanges(list, () => requireTestCallSitesOnly())
179
+ for (const { path } of list) expect(message).toContain(` - ${path}`)
180
+ })
181
+
182
+ it.each([
183
+ [
184
+ "a skipped test",
185
+ 'it("a", () => expect(a()).toBe(1))',
186
+ 'it.skip("a", () => expect(a()).toBe(1))',
187
+ ],
188
+ [
189
+ "an x-prefixed test",
190
+ 'it("a", () => expect(a()).toBe(1))',
191
+ 'xit("a", () => expect(a()).toBe(1))',
192
+ ],
193
+ [
194
+ "a line-commented assertion",
195
+ 'it("a", () => {\n expect(a()).toBe(1)\n})',
196
+ 'it("a", () => {\n // expect(a()).toBe(1)\n})',
197
+ ],
198
+ [
199
+ "a block-commented test",
200
+ 'it("a", () => expect(a()).toBe(1))',
201
+ '/* it("a", () => expect(a()).toBe(1)) */',
202
+ ],
203
+ [
204
+ "a .skip swapped for an .only",
205
+ 'it.skip("a", () => expect(a()).toBe(1))\nit("b", () => expect(b()).toBe(1))',
206
+ 'it.only("a", () => expect(a()).toBe(1))\nit("b", () => expect(b()).toBe(1))',
207
+ ],
208
+ [
209
+ "an assertion dropped after a URL in a test name",
210
+ 'it("GET http://x", () => expect(get()).toBe(1))',
211
+ 'it("GET http://x", () => {})',
212
+ ],
213
+ ["fixture data with no test", '{"sum": 2}', '{"sum": 3}'],
214
+ ])("refuses %s", (_label, before, after) => {
215
+ const message = withChanges([change("tests/a.test.ts", before, after)], () =>
216
+ requireTestCallSitesOnly(),
217
+ )
218
+ expect(message).toContain(" - tests/a.test.ts")
219
+ })
220
+
221
+ it("refuses a modified test file whose content cannot be read", () => {
222
+ const message = withChanges([change("tests/a.test.ts", undefined, "expect(a()).toBe(1)")], () =>
223
+ requireTestCallSitesOnly(),
224
+ )
225
+ expect(message).toContain(" - tests/a.test.ts")
226
+ })
227
+
228
+ it("passes a regex .test( call rewritten at a call site", () => {
229
+ const list = [
230
+ change(
231
+ "src/a.test.ts",
232
+ 'it("a", () => expect(/x/.test(s)).toBe(true))',
233
+ 'it("a", () => expect(has(s)).toBe(true))',
234
+ ),
235
+ ]
236
+ expect(withChanges(list, () => requireTestCallSitesOnly())).toBeUndefined()
237
+ })
238
+
239
+ it("passes a call site rewritten inside an assertion", () => {
240
+ const list = [
241
+ change(
242
+ "src/a.test.ts",
243
+ 'it("adds", () => expect(add(1, 1)).toBe(2))',
244
+ 'it("adds", async () => expect(await add({ a: 1, b: 1 })).toBe(2))',
245
+ ),
246
+ ]
247
+ expect(withChanges(list, () => requireTestCallSitesOnly())).toBeUndefined()
248
+ })
249
+
250
+ it("is not narrowed by a narrowed seamAllow", () => {
251
+ installContext(
252
+ fixtureContext(
253
+ { vars: { seamAllow: "docs/**" } },
254
+ {
255
+ changes: () => [change("src/a.test.ts", undefined, "b", "added")],
256
+ matches: globMatches,
257
+ refuse: (message) => {
258
+ throw new Error(message)
259
+ },
260
+ },
261
+ ),
262
+ )
263
+ expect(refusal(() => requireTestCallSitesOnly())).toContain("src/a.test.ts")
264
+ })
265
+
266
+ it("does not refuse other naming such as _test.go or test_*.py", () => {
267
+ const list = [
268
+ change("pkg/a_test.go", undefined, "b", "added"),
269
+ change("pkg/test_a.py", undefined, "b", "added"),
270
+ ]
271
+ expect(withChanges(list, () => requireTestCallSitesOnly())).toBeUndefined()
272
+ })
273
+ })
274
+
134
275
  describe("refactorStrays", () => {
135
276
  it("refactorStrays drops test files, keeps the rest and ignores a narrowed seamAllow", () => {
136
277
  installContext(fixtureContext({ vars: { seamAllow: "docs/**" } }, { matches: globMatches }))
@@ -359,7 +500,7 @@ describe("refactorFirst", () => {
359
500
  const body = derive?.kind === "run" ? derive.body : ""
360
501
  expect(body).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
361
502
  expect(body).not.toContain("a.test.ts")
362
- expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ".gtd/**"] })
503
+ expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ...testFileGlobs, ".gtd/**"] })
363
504
  })
364
505
 
365
506
  it("throws before any step when a stray holds a flag read the pattern matches zero-length", async () => {
@@ -403,10 +544,13 @@ describe("refactorFirst", () => {
403
544
  expect(lines).toEqual(["- `src/z.ts`", "- `src/a.ts`", "- `it's.ts`"]) // gtd-path-exempt: fixture paths
404
545
  })
405
546
 
406
- it("runs inside a refactor scope whose write access is the strays plus .gtd/**", async () => {
547
+ it("runs inside a refactor scope whose write access is the strays, test files and .gtd/**", async () => {
407
548
  const h = await drive(shape())
408
549
  expect(h.scopes).toEqual([
409
- { name: "refactor", access: { write: ["src/a.ts", "src/b.ts", ".gtd/**"] } },
550
+ {
551
+ name: "refactor",
552
+ access: { write: ["src/a.ts", "src/b.ts", ...testFileGlobs, ".gtd/**"] },
553
+ },
410
554
  ])
411
555
  })
412
556
 
@@ -439,19 +583,38 @@ describe("refactorFirst", () => {
439
583
  expect(names(h)).not.toContain("health.check")
440
584
  })
441
585
 
442
- it("refuses a build turn changing a test file even when it is a stray", async () => {
443
- const h = await drive(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
444
- build: [change("src/a.test.ts", "a", "b")],
586
+ it("lets a build turn update an existing test file's call sites", async () => {
587
+ const h = await drive(shape(), {
588
+ build: [
589
+ change("src/a.ts", "a", "b"),
590
+ change("tests/a.test.ts", "expect(a()).toBe(1)", "expect(await a()).toBe(1)"),
591
+ ],
592
+ })
593
+ expect(h.result).toBe(true)
594
+ expect(names(h)).toContain("health.check")
595
+ })
596
+
597
+ it("refuses a build turn adding a test file", async () => {
598
+ const h = await drive(shape(), {
599
+ build: [change("src/a.test.ts", undefined, "b", "added")],
445
600
  })
446
601
  expect((h.result as Error).message).toContain("refactor-tests")
447
602
  expect((h.result as Error).message).toContain("src/a.test.ts")
448
603
  expect(names(h)).not.toContain("health.check")
449
604
  })
450
605
 
451
- it("says never a test file in the package rules", async () => {
606
+ it("lets the package rules admit only an existing test file's call sites", async () => {
452
607
  const h = await drive(shape())
453
- expect(bodyOf(h, 0)).toContain("never a test file")
454
- expect(bodyOf(h, 0)).not.toContain("only when it is one of them")
608
+ expect(bodyOf(h, 0)).toContain("never add or delete a test file")
609
+ expect(bodyOf(h, 0)).toContain("never add, remove or alter a test or an assertion")
610
+ })
611
+
612
+ it("names the reference's seam as the change to carry, when there is a reference", async () => {
613
+ const withRef = await drive(shape(), { reference: "abc1234" })
614
+ expect(bodyOf(withRef, 0)).toMatch(/seam/)
615
+ expect(buildingPrompt(withRef)).toMatch(/touches none of it/)
616
+ const bare = await drive(shape())
617
+ expect(buildingPrompt(bare)).not.toMatch(/touches none of it/)
455
618
  })
456
619
 
457
620
  it("refuses a build turn adding a flag read", async () => {
@@ -734,7 +897,7 @@ describe("reviewedRefactor", () => {
734
897
  const body = derive?.kind === "run" ? derive.body : ""
735
898
  expect(body).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
736
899
  expect(body).not.toContain("a.test.ts")
737
- expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ".gtd/**"] })
900
+ expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ...testFileGlobs, ".gtd/**"] })
738
901
  const collecting = h.steps.find((s) => s.kind === "agent" && s.name === "review.collecting")
739
902
  const prompt = collecting?.kind === "agent" ? collecting.prompt : ""
740
903
  expect(prompt).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
@@ -958,14 +1121,18 @@ describe("reviewedRefactor", () => {
958
1121
  const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
959
1122
  notes: ["add — typo"],
960
1123
  answers: { "note-1": { answer: "nit", p: 0.9 } },
961
- turns: { "refactor.build.review.fix.nits.fixing": [change("src/a.test.ts", "a", "b")] },
1124
+ turns: {
1125
+ "refactor.build.review.fix.nits.fixing": [
1126
+ change("src/a.test.ts", undefined, "b", "added"),
1127
+ ],
1128
+ },
962
1129
  })
963
1130
  expect((h.result as Error).message).toContain("refactor-tests")
964
1131
  expect(h.names.at(-1)).toBe("refactor.build.review.fix.nits.fixing")
965
1132
  })
966
1133
 
967
- it("runs the nit-fix turn under the strays plus .gtd/** as its write access", async () => {
968
- const write = ["src/a.ts", ".gtd/**"]
1134
+ it("runs the nit-fix turn under the strays, test files and .gtd/** as its write access", async () => {
1135
+ const write = ["src/a.ts", ...testFileGlobs, ".gtd/**"]
969
1136
  const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
970
1137
  notes: ["add — typo"],
971
1138
  answers: { "note-1": { answer: "nit", p: 0.9 } },
@@ -979,7 +1146,7 @@ describe("reviewedRefactor", () => {
979
1146
  notes: ["add — typo"],
980
1147
  answers: { "note-1": { answer: "nit", p: 0.9 } },
981
1148
  reds: 2,
982
- turns: { "refactor.build.fix": [change("src/a.test.ts", "x", "y")] },
1149
+ turns: { "refactor.build.fix": [change("src/a.test.ts", "x", undefined, "deleted")] },
983
1150
  })
984
1151
  expect(h.names).toContain("refactor.build.fix")
985
1152
  expect((h.result as Error).message).toContain("refactor-tests")
@@ -1031,11 +1198,11 @@ describe("reviewedRefactor's tail prompts", () => {
1031
1198
  ],
1032
1199
  ["refactor.build.health.describe", { findings: true, reds: 6 }],
1033
1200
  ] as const)(
1034
- "%s names every stray and bans test files and new flag reads",
1201
+ "%s names every stray and bans new test files and new flag reads",
1035
1202
  async (name, script) => {
1036
1203
  const prompt = await promptAt(name, script as ReviewedScript)
1037
1204
  for (const stray of strays) expect(prompt).toContain(`\`${stray}\``)
1038
- expect(prompt).toContain("never a test file")
1205
+ expect(prompt).toContain("never add or delete a test file")
1039
1206
  expect(prompt).toContain("no new flag read")
1040
1207
  },
1041
1208
  )
@@ -10,6 +10,7 @@ import {
10
10
  run,
11
11
  scope,
12
12
  vars,
13
+ type Change,
13
14
  type DiffShapeResult,
14
15
  } from "../../flows/index.js"
15
16
  import { lensesOf } from "../../workflow-kit/index.js"
@@ -43,6 +44,7 @@ export const requireWithin = (allowed: readonly string[]): void => {
43
44
  // A fixed list, not `seamAllow`: narrowing that setting must not let a test file through.
44
45
  const isTestFile = (path: string): boolean => testFileGlobs.some((glob) => matches(path, glob))
45
46
 
47
+ // The bundled refactor uses `requireTestCallSitesOnly`; this stays a public export.
46
48
  export const requireNoTestFiles = (): void => {
47
49
  const tests = changes()
48
50
  .map((c) => c.path)
@@ -53,6 +55,58 @@ export const requireNoTestFiles = (): void => {
53
55
  )
54
56
  }
55
57
 
58
+ // Call sites live in code: a `.feature` file or a snapshot holds only expectations.
59
+ const holdsCallSites = (path: string): boolean =>
60
+ !path.endsWith(".feature") && !path.endsWith(".snap")
61
+
62
+ // `//` after a `:` (a URL) and `/*` after `*` or `/` (a glob) sit in strings, not comments.
63
+ const COMMENT = /(?<![*/])\/\*[\s\S]*?\*\/|(?<!:)\/\/.*$/gm
64
+ const ASSERTION = /\b(?:expect|assert\w*)\s*[.(]/g
65
+ const TEST_CASE = /(?<![.\w])x?(?:it|test|describe)\s*[.(]/g
66
+ const MODIFIER =
67
+ /(?<![.\w])(?:it|test|describe)\.(skip|only|todo|fails|skipIf|runIf)\b|(?<![.\w])(x)(?:it|test|describe)\b/g
68
+
69
+ // Modifiers count per kind, so swapping a `.skip` for an `.only` shows.
70
+ const counts = (text: string): string => {
71
+ const live = text.replace(COMMENT, "")
72
+ const modifiers = [...live.matchAll(MODIFIER)].map((m) => m[1] ?? m[2]).sort()
73
+ return JSON.stringify([
74
+ [...live.matchAll(ASSERTION)].length,
75
+ [...live.matchAll(TEST_CASE)].length,
76
+ modifiers,
77
+ ])
78
+ }
79
+
80
+ const tested = (text: string): boolean => {
81
+ const live = text.replace(COMMENT, "")
82
+ return live.search(ASSERTION) >= 0 || live.search(TEST_CASE) >= 0
83
+ }
84
+
85
+ // A call-shape update keeps every live test and assertion, so the counts hold. A changed
86
+ // expected value keeps every count — that is left to the refactor review.
87
+ const callSitesOnly = ({ path, status, before, after }: Change): boolean =>
88
+ status === "modified" &&
89
+ holdsCallSites(path) &&
90
+ before !== undefined &&
91
+ after !== undefined &&
92
+ // A file with no test or assertion is fixture or helper data, never a call site to follow.
93
+ tested(before) &&
94
+ counts(before) === counts(after)
95
+
96
+ /**
97
+ * The test-file half of the refactor guard: an existing test may follow a changed call shape,
98
+ * nothing more. Returns the test files it admitted, which the scope check then allows.
99
+ */
100
+ export const requireTestCallSitesOnly = (): readonly string[] => {
101
+ const tests = changes().filter((c) => isCode(c.path) && isTestFile(c.path))
102
+ const refused = tests.filter((c) => !callSitesOnly(c)).map((c) => c.path)
103
+ if (refused.length > 0)
104
+ refuse(
105
+ `gtd land: refactor-tests: the refactor may update an existing test file's call sites only — never add, delete, skip or comment out a test or an assertion, or touch a snapshot, a .feature file or test data; revert these:\n${refused.map((path) => ` - ${path}`).join("\n")}`,
106
+ )
107
+ return tests.map((c) => c.path)
108
+ }
109
+
56
110
  const flagExpression = (pattern: string): RegExp => {
57
111
  if (pattern.trim() === "") throw new Error("gtd: refactorFlagPattern must not be blank")
58
112
  let expression: RegExp
@@ -133,14 +187,18 @@ const packageLines = (
133
187
  "## Rules",
134
188
  "",
135
189
  "- The suite's outcome must not change",
136
- "- Change only the paths above, and never a test file",
190
+ "- Change only the paths above, plus an existing test file where a changed call shape reaches it: never add or delete a test file, and never add, remove or alter a test or an assertion",
137
191
  `- Introduce no flag read and add no second read of an existing one (\`${pattern}\`)`,
138
192
  "",
139
193
  "## Reference",
140
194
  "",
141
- reference === undefined
142
- ? "None."
143
- : `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
195
+ ...(reference === undefined
196
+ ? ["None."]
197
+ : [
198
+ `Reading material only, never applied: \`git show ${reference} -- ${strays.map(quote).join(" ")}\``,
199
+ "",
200
+ "Its changes to the existing code above are the seam this refactor exists to land, minus the feature's own behaviour.",
201
+ ]),
144
202
  "",
145
203
  ]
146
204
 
@@ -195,9 +253,7 @@ const plan = (shape: DiffShapeResult, options: RefactorOptions): Plan | undefine
195
253
  const guardOf =
196
254
  ({ pattern, strays }: Plan, entry: string): Guard =>
197
255
  () => {
198
- // Tests first: a test file is never a stray here, so the scope refusal would mask it.
199
- requireNoTestFiles()
200
- requireWithin(strays)
256
+ requireWithin([...strays, ...requireTestCallSitesOnly()])
201
257
  requireNoNewFlags(pattern, entry)
202
258
  }
203
259
 
@@ -241,7 +297,9 @@ const refactorLap = async (p: Plan, guard: Guard, feedback?: string): Promise<vo
241
297
  await run("closing", removeScript([REFACTOR_PACKAGE]), { label: "Closing out the refactor" })
242
298
  }
243
299
 
244
- const strayAccess = (strays: readonly string[]) => ({ write: [...strays, ".gtd/**"] })
300
+ const strayAccess = (strays: readonly string[]) => ({
301
+ write: [...strays, ...testFileGlobs, ".gtd/**"],
302
+ })
245
303
 
246
304
  export const refactorFirst = async (
247
305
  shape: DiffShapeResult,
@@ -1066,16 +1066,25 @@ describe("the refactor prompts", () => {
1066
1066
  const strays = ["src/a.ts", "src/b.ts"]
1067
1067
  const pattern = "\\bGTD_FLAG_\\w+"
1068
1068
 
1069
- it("building and fix rules forbid every test file, with no stray exception", () => {
1069
+ it("building and fix rules admit only call-site updates in existing test files", () => {
1070
1070
  for (const prompt of renderText(() => [
1071
1071
  refactorBuildingPrompt({ pkg: ".gtd/REFACTOR.md", strays, pattern }),
1072
1072
  refactorFixPrompt(strays, pattern),
1073
1073
  ])) {
1074
- expect(prompt).toContain("never a test file")
1075
- expect(prompt).not.toContain("only when it is")
1074
+ expect(prompt).toContain("never add or delete a test file")
1075
+ expect(prompt).toContain("never add, remove or alter a test or an assertion")
1076
1076
  }
1077
1077
  })
1078
1078
 
1079
+ it("building names the reference's seam only when there is a reference", () => {
1080
+ const [bare, ref] = renderText(() => [
1081
+ refactorBuildingPrompt({ pkg: ".gtd/REFACTOR.md", strays, pattern }),
1082
+ refactorBuildingPrompt({ pkg: ".gtd/REFACTOR.md", strays, pattern, reference: "abc1234" }),
1083
+ ])
1084
+ expect(bare).not.toContain("touches none of it")
1085
+ expect(ref).toContain("touches none of it")
1086
+ })
1087
+
1079
1088
  it("building adds the act-on-review-feedback line only when asked", () => {
1080
1089
  const [plain, feedback] = renderText(() => [
1081
1090
  refactorBuildingPrompt({ pkg: ".gtd/REFACTOR.md", strays, pattern }),
@@ -1096,11 +1105,11 @@ describe("the refactor prompts", () => {
1096
1105
  expect(prompt).toContain("capture-text")
1097
1106
  })
1098
1107
 
1099
- it("lists the strays and says a non-stray, test-file or flag concern cannot be built", () => {
1108
+ it("lists the strays and says a non-stray, test or flag concern cannot be built", () => {
1100
1109
  expect(prompt).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
1101
1110
  expect(prompt).toContain("`src/b.ts`") // gtd-path-exempt: fixture paths
1102
1111
  expect(prompt).toMatch(/cannot be built/)
1103
- expect(prompt).toMatch(/test file/)
1112
+ expect(prompt).toMatch(/test or assertion/)
1104
1113
  expect(prompt).toMatch(/flag/)
1105
1114
  expect(prompt).toMatch(/behaviour/)
1106
1115
  expect(prompt).not.toMatch(/say in the reply/i)
@@ -1231,7 +1240,7 @@ describe("the rules-taking fix prompts", () => {
1231
1240
  (k) => {
1232
1241
  const [bare] = renderText(() => [prompts[k]!()])
1233
1242
  expect(bare).not.toContain("Change only these paths")
1234
- expect(bare).not.toContain("never a test file")
1243
+ expect(bare).not.toContain("never add or delete a test file")
1235
1244
  },
1236
1245
  )
1237
1246
 
@@ -1242,7 +1251,7 @@ describe("the rules-taking fix prompts", () => {
1242
1251
  })
1243
1252
  expect(prompt).toContain(rules)
1244
1253
  for (const stray of strays) expect(prompt).toContain(`\`${stray}\``)
1245
- expect(prompt).toContain("never a test file")
1254
+ expect(prompt).toContain("never add or delete a test file")
1246
1255
  expect(prompt).toContain("no new flag read")
1247
1256
  })
1248
1257
 
@@ -1274,6 +1283,15 @@ describe("buildReviewReviewingPrompt lap wording", () => {
1274
1283
  )
1275
1284
  })
1276
1285
 
1286
+ it("asks the refactor review, and only it, to flag a test-file hunk that is more than a call site", () => {
1287
+ const [refactor, first] = renderText(() => [
1288
+ buildReviewReviewingPrompt("base", "refactor"),
1289
+ buildReviewReviewingPrompt("base", "first"),
1290
+ ])
1291
+ expect(refactor).toContain("give any test-file hunk its own chunk")
1292
+ expect(first).not.toContain("give any test-file hunk its own chunk")
1293
+ })
1294
+
1277
1295
  describe("architectureDecomposePrompt tests rule", () => {
1278
1296
  it("states one rule: write-or-change, relied-on marked (unchanged), no other untouched", () => {
1279
1297
  const prompt = renderText(() => architectureDecomposePrompt()).replace(/\s+/g, " ")
@@ -465,9 +465,13 @@ ${fixFeedbackPrompt}
465
465
 
466
466
  export const refactorRules = (strays: readonly string[], pattern: string): string =>
467
467
  `- Behaviour-neutral: the suite's outcome must not change
468
- - Change only these paths, nothing else, and never a test file even
469
- when it is one of them:
468
+ - Change only these paths, nothing else:
470
469
  ${strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
470
+ - An existing test file only where a changed call shape reaches it:
471
+ update its call sites, inside an assertion too;
472
+ never add or delete a test file,
473
+ never add, remove or alter a test or an assertion, and never
474
+ change what an assertion checks, an expected value or a snapshot
471
475
  - Introduce no new flag read and add no second read of an existing one:
472
476
  every match of \`${pattern}\` may move, never increase in number`
473
477
 
@@ -499,8 +503,13 @@ ${
499
503
  reference === undefined
500
504
  ? ""
501
505
  : `- Reference only: \`git show ${reference} -- ${strays.map(quote).join(" ")}\` shows an
502
- earlier attempt at this change. Read it for ideas; never apply,
503
- cherry-pick or check it out — write a fresh refactor
506
+ earlier attempt at this change, and \`git show --stat ${reference}\`
507
+ lists every file it touched, the test call sites included.
508
+ Read it; never apply, cherry-pick or check it out
509
+ - Its changes to these paths' existing code are the seam: write that
510
+ restructuring afresh here, minus the feature's own behaviour. A
511
+ refactor that touches none of it is wrong, however clean — never
512
+ swap in an unrelated tidy-up
504
513
  `
505
514
  }- Leave everything uncommitted and finish your turn — do not commit
506
515
  `
@@ -920,11 +929,11 @@ export const buildReviewCollectingPrompt = (
920
929
  ): string => {
921
930
  const target = refactor ? ".gtd/REFACTOR.md" : ".gtd/REQUIREMENTS.md"
922
931
  const fence = refactor
923
- ? `- The next refactor may change only these paths, and never a test file
924
- or a flag read:
932
+ ? `- The next refactor may change only these paths, plus an existing test
933
+ file's call sites, and never a flag read:
925
934
  ${refactor.strays.map((path) => ` - \`${displayPath(path)}\``).join("\n")}
926
- A concern needing any other path, a test file, a new or added flag
927
- read, or a change in behaviour (that belongs to the feature, not this
935
+ A concern needing any other path, a new or changed test or assertion,
936
+ a new or added flag read, or a change in behaviour (that belongs to the feature, not this
928
937
  refactor) cannot be built: do not fold it
929
938
  `
930
939
  : ""
@@ -1048,7 +1057,8 @@ const lapRange: Record<ReviewRange, string> = {
1048
1057
  first: "it covers everything since the base.",
1049
1058
  feedback:
1050
1059
  "it starts at the previous review's boundary, so it covers only what is new since the previous review.",
1051
- refactor: "it covers the whole refactor since it started, on every round.",
1060
+ refactor:
1061
+ "it covers the whole refactor since it started, on every round. A refactor may update an existing test's call sites only: give any test-file hunk its own chunk, and say so if it changes a test, an assertion or an expected value.",
1052
1062
  }
1053
1063
 
1054
1064
  export const buildReviewReviewingPrompt = (