@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +33 -30
- package/claude/hooks/paste.ts +1 -1
- package/claude/hooks/register.tsx +3 -1
- package/claude/hooks/ship.ts +56 -12
- package/claude/hooks/sketch.ts +6 -0
- package/dist/chunks/{reserved-vvu5yc3I.mjs → reserved-BduvUQxQ.mjs} +18 -9
- package/dist/chunks/{tail--WYGA4LA.mjs → tail-pgq1Fbbv.mjs} +1 -1
- package/dist/workflows/cleanup.mjs +2 -2
- package/dist/workflows/feature.mjs +44 -8
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +1 -1
- package/dist/workflows.mjs +60 -15
- package/package.json +1 -1
- package/src/workflows/feature/refactor-review.feature +37 -3
- package/src/workflows/feature/refactor.test.ts +183 -16
- package/src/workflows/feature/refactor.ts +66 -8
- package/src/workflows/shared/text.test.ts +25 -7
- package/src/workflows/shared/text.ts +19 -9
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.
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
the
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
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
|
package/claude/hooks/paste.ts
CHANGED
|
@@ -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.
|
|
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.
|
|
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
|
}
|
package/claude/hooks/ship.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
1516
|
-
if (typeof
|
|
1517
|
-
return { step: "pull request", why: `${
|
|
1518
|
-
const
|
|
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
|
|
1768
|
-
if (typeof
|
|
1804
|
+
const read = await readPr(io, branch)
|
|
1805
|
+
if (typeof read === "string")
|
|
1769
1806
|
return fail(
|
|
1770
|
-
`${
|
|
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
|
|
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
|
|
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
|
|
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
|
|
786
|
-
|
|
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,
|
|
1103
|
-
|
|
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
|
|
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-
|
|
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-
|
|
3
|
-
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail
|
|
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-
|
|
4
|
-
import { a as tail, i as refusePendingHandOff, n as closeReview } from "../chunks/tail
|
|
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
|
|
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." :
|
|
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
|
-
|
|
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: [
|
|
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 };
|
package/dist/workflows/fix.mjs
CHANGED
|
@@ -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-
|
|
4
|
-
import { r as handOffTail, t as REVIEW_ORIG } from "../chunks/tail
|
|
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-
|
|
3
|
-
import { i as refusePendingHandOff, r as handOffTail } from "../chunks/tail
|
|
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-
|
|
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.";
|
package/dist/workflows.mjs
CHANGED
|
@@ -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
|
|
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
|
|
17570
|
-
|
|
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,
|
|
17887
|
-
|
|
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
|
|
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
|
|
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." :
|
|
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
|
-
|
|
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: [
|
|
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
|
@@ -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
|
|
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.
|
|
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 "
|
|
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
|
|
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
|
-
{
|
|
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("
|
|
443
|
-
const h = await drive(shape(
|
|
444
|
-
build: [
|
|
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("
|
|
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)).
|
|
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: {
|
|
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
|
|
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", "
|
|
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
|
|
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
|
-
:
|
|
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
|
-
|
|
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[]) => ({
|
|
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
|
|
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).
|
|
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
|
|
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
|
|
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
|
|
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
|
|
503
|
-
|
|
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,
|
|
924
|
-
|
|
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
|
|
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:
|
|
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 = (
|