@pmelab/gtd 58.1.0 → 59.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (142) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +115 -390
  3. package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-CMBAAyRK.mjs} +13409 -3351
  4. package/dist/gtd.bundle.mjs +76 -59
  5. package/dist/testkit/gherkin.mjs +30 -2
  6. package/dist/testkit.mjs +1 -1
  7. package/package.json +3 -42
  8. package/skills/authoring/SKILL.md +31 -25
  9. package/src/cli/Cli.test.ts +15 -19
  10. package/src/cli/Cli.ts +2 -4
  11. package/src/cli/Program.test.ts +60 -29
  12. package/src/cli/Program.ts +5 -7
  13. package/src/edge/Edge.ts +3 -3
  14. package/src/flags/index.test.ts +0 -13
  15. package/src/flags/index.ts +1 -3
  16. package/src/gherkin/git-control.feature +78 -0
  17. package/src/gherkin/index.ts +35 -1
  18. package/src/install/Install.test.ts +14 -11
  19. package/src/install/Install.ts +38 -15
  20. package/src/install/index.ts +1 -2
  21. package/src/replay/Replay.test.ts +1 -1
  22. package/src/replay/index.ts +1 -1
  23. package/src/step/planStart.ts +6 -1
  24. package/src/testkit/TestRepo.test.ts +118 -2
  25. package/src/testkit/TestRepo.ts +57 -6
  26. package/src/testkit/index.ts +1 -0
  27. package/src/twins/EmittedScriptRecognizer.ts +12 -14
  28. package/src/twins/FlowScripts.ts +10 -9
  29. package/src/twins/InMemRepo.ts +26 -1
  30. package/src/twins/Layers.ts +12 -1
  31. package/src/twins/index.ts +6 -1
  32. package/src/workflow/index.ts +1 -0
  33. package/src/workflow/load.test.ts +138 -113
  34. package/src/workflow/load.ts +1 -1
  35. package/src/workflow/vars.test.ts +30 -3
  36. package/src/workflow/vars.ts +34 -2
  37. package/src/workflow-kit/index.ts +1 -1
  38. package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
  39. package/dist/chunks/review-Bfaror38.mjs +0 -427
  40. package/dist/chunks/tail-Bd7L940p.mjs +0 -355
  41. package/dist/chunks/testkit-D1-auYau.mjs +0 -10362
  42. package/dist/workflows/cleanup.mjs +0 -36
  43. package/dist/workflows/feature.mjs +0 -1018
  44. package/dist/workflows/fix.mjs +0 -41
  45. package/dist/workflows/review.mjs +0 -45
  46. package/dist/workflows/rollout.mjs +0 -48
  47. package/dist/workflows.mjs +0 -40651
  48. package/src/install/ConfigScaffold.ts +0 -22
  49. package/src/workflows/cleanup/cleanup-path.feature +0 -94
  50. package/src/workflows/cleanup/cleanup.feature +0 -315
  51. package/src/workflows/cleanup/cleanup.test.ts +0 -276
  52. package/src/workflows/cleanup/cleanup.ts +0 -52
  53. package/src/workflows/cleanup/index.ts +0 -6
  54. package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
  55. package/src/workflows/feature/acceptance-criteria.feature +0 -389
  56. package/src/workflows/feature/criteria-coverage.feature +0 -102
  57. package/src/workflows/feature/criteria.test.ts +0 -147
  58. package/src/workflows/feature/criteria.ts +0 -267
  59. package/src/workflows/feature/fast-path.feature +0 -93
  60. package/src/workflows/feature/feature.test.ts +0 -338
  61. package/src/workflows/feature/feature.ts +0 -156
  62. package/src/workflows/feature/green.ts +0 -19
  63. package/src/workflows/feature/index.ts +0 -18
  64. package/src/workflows/feature/lens-models.feature +0 -69
  65. package/src/workflows/feature/lens-review.feature +0 -566
  66. package/src/workflows/feature/naive-acceptance.feature +0 -147
  67. package/src/workflows/feature/naive-test.feature +0 -263
  68. package/src/workflows/feature/one-step-land.feature +0 -93
  69. package/src/workflows/feature/planning.ts +0 -41
  70. package/src/workflows/feature/refactor-absorbs.feature +0 -131
  71. package/src/workflows/feature/refactor-first.feature +0 -158
  72. package/src/workflows/feature/refactor-lens-models.feature +0 -79
  73. package/src/workflows/feature/refactor-rebuild.feature +0 -273
  74. package/src/workflows/feature/refactor-review.feature +0 -1528
  75. package/src/workflows/feature/refactor.test.ts +0 -1220
  76. package/src/workflows/feature/refactor.ts +0 -368
  77. package/src/workflows/feature/release-flag.feature +0 -167
  78. package/src/workflows/feature/release-flag.test.ts +0 -49
  79. package/src/workflows/feature/release-flag.ts +0 -46
  80. package/src/workflows/feature/review-gate.feature +0 -241
  81. package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
  82. package/src/workflows/feature/risk-tier.feature +0 -351
  83. package/src/workflows/feature/seam-rejection.feature +0 -146
  84. package/src/workflows/feature/seam.feature +0 -297
  85. package/src/workflows/feature/seam.test.ts +0 -527
  86. package/src/workflows/feature/seam.ts +0 -426
  87. package/src/workflows/feature/standards.feature +0 -354
  88. package/src/workflows/feature/steps.test.ts +0 -65
  89. package/src/workflows/feature/steps.ts +0 -45
  90. package/src/workflows/feature/tier-pin.feature +0 -66
  91. package/src/workflows/feature/tier.test.ts +0 -166
  92. package/src/workflows/feature/tier.ts +0 -56
  93. package/src/workflows/fix/fix-lens-origin.feature +0 -114
  94. package/src/workflows/fix/fix-quality.feature +0 -212
  95. package/src/workflows/fix/fix.feature +0 -79
  96. package/src/workflows/fix/fix.test.ts +0 -74
  97. package/src/workflows/fix/fix.ts +0 -52
  98. package/src/workflows/fix/index.ts +0 -6
  99. package/src/workflows/index.ts +0 -47
  100. package/src/workflows/review/index.ts +0 -6
  101. package/src/workflows/review/review-door.feature +0 -199
  102. package/src/workflows/review/review.test.ts +0 -27
  103. package/src/workflows/review/review.ts +0 -60
  104. package/src/workflows/rollout/index.ts +0 -6
  105. package/src/workflows/rollout/rollout.feature +0 -510
  106. package/src/workflows/rollout/rollout.test.ts +0 -263
  107. package/src/workflows/rollout/rollout.ts +0 -82
  108. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
  109. package/src/workflows/shared/access.test.ts +0 -62
  110. package/src/workflows/shared/access.ts +0 -24
  111. package/src/workflows/shared/config.fixture.ts +0 -12
  112. package/src/workflows/shared/fences.ts +0 -24
  113. package/src/workflows/shared/health.test.ts +0 -148
  114. package/src/workflows/shared/health.ts +0 -191
  115. package/src/workflows/shared/learn.test.ts +0 -140
  116. package/src/workflows/shared/learn.ts +0 -201
  117. package/src/workflows/shared/lenses.test.ts +0 -39
  118. package/src/workflows/shared/lenses.ts +0 -294
  119. package/src/workflows/shared/options.test.ts +0 -157
  120. package/src/workflows/shared/options.ts +0 -92
  121. package/src/workflows/shared/progress.feature +0 -124
  122. package/src/workflows/shared/prose.ts +0 -263
  123. package/src/workflows/shared/recheck.feature +0 -60
  124. package/src/workflows/shared/registry.test.ts +0 -345
  125. package/src/workflows/shared/registry.ts +0 -183
  126. package/src/workflows/shared/reserved.ts +0 -11
  127. package/src/workflows/shared/review.test.ts +0 -1062
  128. package/src/workflows/shared/review.ts +0 -446
  129. package/src/workflows/shared/skills.test.ts +0 -121
  130. package/src/workflows/shared/skills.ts +0 -49
  131. package/src/workflows/shared/standards.test.ts +0 -90
  132. package/src/workflows/shared/standards.ts +0 -156
  133. package/src/workflows/shared/steps.test.ts +0 -219
  134. package/src/workflows/shared/steps.ts +0 -201
  135. package/src/workflows/shared/tail.test.ts +0 -454
  136. package/src/workflows/shared/tail.ts +0 -264
  137. package/src/workflows/shared/text.fixture.ts +0 -128
  138. package/src/workflows/shared/text.test.ts +0 -1430
  139. package/src/workflows/shared/text.ts +0 -1700
  140. package/src/workflows/shared/threads.test.ts +0 -129
  141. package/src/workflows/shared/vars.ts +0 -47
  142. package/src/workflows/shared/workflows.steps.ts +0 -54
@@ -1,1220 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest"
2
- import {
3
- installContext,
4
- type Change,
5
- type DiffShapeResult,
6
- type ScopeOptions,
7
- type StepRequest,
8
- } from "../../flows/index.js"
9
- import { fixtureContext, type TextContext } from "../shared/text.fixture.js"
10
- import { globMatches } from "../../wire/index.js"
11
- import {
12
- refactorFirst,
13
- refactorStrays,
14
- requireNoNewFlags,
15
- requireNoTestFiles,
16
- requireTestCallSitesOnly,
17
- requireWithin,
18
- reviewedRefactor,
19
- REFACTOR_PACKAGE,
20
- } from "./refactor.js"
21
-
22
- afterEach(() => installContext(undefined))
23
-
24
- const testFileGlobs = [
25
- "**/*.test.*",
26
- "**/*.spec.*",
27
- "**/*.feature",
28
- "**/tests/**",
29
- "**/test/**",
30
- "**/__tests__/**",
31
- ]
32
-
33
- const change = (
34
- path: string,
35
- before: string | undefined,
36
- after: string | undefined,
37
- status: Change["status"] = "modified",
38
- ): Change => ({ path, status, before, after })
39
-
40
- const refusal = (fn: () => void): string | undefined => {
41
- try {
42
- fn()
43
- } catch (error) {
44
- return (error as Error).message
45
- }
46
- return undefined
47
- }
48
-
49
- const withChanges = (list: readonly Change[], fn: () => void): string | undefined => {
50
- installContext(
51
- fixtureContext(
52
- {},
53
- {
54
- changes: () => list,
55
- changesSince: () => list,
56
- matches: globMatches,
57
- refuse: (message) => {
58
- throw new Error(message)
59
- },
60
- },
61
- ),
62
- )
63
- return refusal(fn)
64
- }
65
-
66
- describe("requireWithin", () => {
67
- it("passes for strays and .gtd/ paths", () => {
68
- const list = [change("src/a.ts", "a", "b"), change(".gtd/FEEDBACK.md", "", "x", "added")]
69
- expect(withChanges(list, () => requireWithin(["src/a.ts"]))).toBeUndefined()
70
- })
71
-
72
- it("refuses naming each other path", () => {
73
- const list = [
74
- change("src/a.ts", "a", "b"),
75
- change("lib/x.ts", "a", "b"),
76
- change("lib/y.ts", "a", "b"),
77
- ]
78
- const message = withChanges(list, () => requireWithin(["src/a.ts"]))
79
- expect(message).toContain("lib/x.ts")
80
- expect(message).toContain("lib/y.ts")
81
- expect(message).not.toContain("src/a.ts")
82
- })
83
- })
84
-
85
- describe("requireNoTestFiles", () => {
86
- it("refuses a changed test path, naming it, even when it is a stray", () => {
87
- const list = [
88
- change("src/a.test.ts", "a", "b"),
89
- change("tests/unit/b.ts", undefined, "x", "added"),
90
- change("src/__tests__/c.ts", "a", undefined, "deleted"),
91
- change("features/d.feature", "a", "b"),
92
- ]
93
- const message = withChanges(list, () => requireNoTestFiles())
94
- expect(message).toMatch(/^gtd land: refactor-tests: /)
95
- for (const path of [
96
- "src/a.test.ts",
97
- "tests/unit/b.ts",
98
- "src/__tests__/c.ts",
99
- "features/d.feature",
100
- ])
101
- expect(message).toContain(` - ${path}`)
102
- })
103
-
104
- it("passes non-test paths and .gtd/ paths", () => {
105
- const list = [
106
- change("src/a.ts", "a", "b"),
107
- change(".gtd/tests/x.md", "", "x", "added"),
108
- change(".gtd/REFACTOR.md", "", "x", "added"),
109
- ]
110
- expect(withChanges(list, () => requireNoTestFiles())).toBeUndefined()
111
- })
112
-
113
- it("names only the test paths", () => {
114
- const message = withChanges(
115
- [change("src/a.ts", "a", "b"), change("src/a.spec.ts", "a", "b")],
116
- () => requireNoTestFiles(),
117
- )
118
- expect(message).toContain("src/a.spec.ts")
119
- expect(message).not.toContain("src/a.ts")
120
- })
121
-
122
- it("is not narrowed by a narrowed seamAllow", () => {
123
- installContext(
124
- fixtureContext(
125
- { vars: { seamAllow: "docs/**" } },
126
- {
127
- changes: () => [change("src/a.test.ts", "a", "b")],
128
- matches: globMatches,
129
- refuse: (message) => {
130
- throw new Error(message)
131
- },
132
- },
133
- ),
134
- )
135
- expect(refusal(() => requireNoTestFiles())).toContain("src/a.test.ts")
136
- })
137
-
138
- it("does not refuse other naming such as _test.go or test_*.py", () => {
139
- const list = [change("pkg/a_test.go", "a", "b"), change("pkg/test_a.py", "a", "b")]
140
- expect(withChanges(list, () => requireNoTestFiles())).toBeUndefined()
141
- })
142
- })
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
-
275
- describe("refactorStrays", () => {
276
- it("refactorStrays drops test files, keeps the rest and ignores a narrowed seamAllow", () => {
277
- installContext(fixtureContext({ vars: { seamAllow: "docs/**" } }, { matches: globMatches }))
278
- expect(
279
- refactorStrays({
280
- choice: "refactor-first",
281
- strays: ["src/a.ts", "src/a.test.ts"],
282
- unreadable: [],
283
- judgment: undefined,
284
- }),
285
- ).toEqual(["src/a.ts"])
286
- })
287
- })
288
-
289
- describe("requireNoNewFlags", () => {
290
- const pattern = "\\bGTD_FLAG_\\w+"
291
-
292
- it("refuses a flag name absent before and present after", () => {
293
- const message = withChanges([change("src/a.ts", "x", "x GTD_FLAG_NEW")], () =>
294
- requireNoNewFlags(pattern, "h"),
295
- )
296
- expect(message).toContain("GTD_FLAG_NEW")
297
- })
298
-
299
- it("refuses a second read of an existing flag", () => {
300
- const message = withChanges([change("src/a.ts", "GTD_FLAG_A", "GTD_FLAG_A GTD_FLAG_A")], () =>
301
- requireNoNewFlags(pattern, "h"),
302
- )
303
- expect(message).toContain("GTD_FLAG_A")
304
- })
305
-
306
- it("counts an added file's flags against zero", () => {
307
- const message = withChanges([change("src/n.ts", undefined, "GTD_FLAG_Z", "added")], () =>
308
- requireNoNewFlags(pattern, "h"),
309
- )
310
- expect(message).toContain("GTD_FLAG_Z")
311
- })
312
-
313
- it("passes a read moved between files", () => {
314
- const list = [change("src/a.ts", "GTD_FLAG_A", ""), change("src/b.ts", "", "GTD_FLAG_A")]
315
- expect(withChanges(list, () => requireNoNewFlags(pattern, "h"))).toBeUndefined()
316
- })
317
-
318
- it("passes a deleted file's flags", () => {
319
- const list = [change("src/gone.ts", "GTD_FLAG_A", undefined, "deleted")]
320
- expect(withChanges(list, () => requireNoNewFlags(pattern, "h"))).toBeUndefined()
321
- })
322
-
323
- it("passes a swap that keeps every per-name count flat", () => {
324
- const list = [
325
- change("src/a.ts", "GTD_FLAG_A", "GTD_FLAG_B"),
326
- change("src/b.ts", "GTD_FLAG_B", "GTD_FLAG_A"),
327
- ]
328
- expect(withChanges(list, () => requireNoNewFlags(pattern, "h"))).toBeUndefined()
329
- })
330
-
331
- it("ignores flag names in .gtd/ files", () => {
332
- const list = [change(".gtd/FEEDBACK.md", "", "GTD_FLAG_X failed", "added")]
333
- expect(withChanges(list, () => requireNoNewFlags(pattern, "h"))).toBeUndefined()
334
- })
335
-
336
- describe("a pattern with a zero-length match", () => {
337
- const lookahead = "(?=GTD_FLAG_\\w+)"
338
-
339
- it("refuses a changed file holding a flag read, naming the setting and the path", () => {
340
- const message = withChanges([change("src/a.ts", "x", "x GTD_FLAG_A")], () =>
341
- requireNoNewFlags(lookahead, "h"),
342
- )
343
- expect(message).toContain("refactorFlagPattern")
344
- expect(message).toContain("zero-length match")
345
- expect(message).toContain(" - src/a.ts")
346
- })
347
-
348
- it("refuses a flag swap across two files that the default pattern passes", () => {
349
- const list = [
350
- change("src/a.ts", "GTD_FLAG_A", "GTD_FLAG_B"),
351
- change("src/b.ts", "GTD_FLAG_B", "GTD_FLAG_A"),
352
- ]
353
- expect(withChanges(list, () => requireNoNewFlags(pattern, "h"))).toBeUndefined()
354
- const message = withChanges(list, () => requireNoNewFlags(lookahead, "h"))
355
- expect(message).toContain(" - src/a.ts")
356
- expect(message).toContain(" - src/b.ts")
357
- })
358
-
359
- it("lists a path once when both sides match", () => {
360
- const message = withChanges([change("src/a.ts", "GTD_FLAG_A", "GTD_FLAG_B")], () =>
361
- requireNoNewFlags(lookahead, "h"),
362
- )
363
- expect(message?.match(/src\/a\.ts/g)).toHaveLength(1)
364
- })
365
-
366
- it("passes a file with no match on either side", () => {
367
- expect(
368
- withChanges([change("src/a.ts", "x", "y")], () => requireNoNewFlags(lookahead, "h")),
369
- ).toBeUndefined()
370
- })
371
- })
372
-
373
- it("refuses an unreadable side of a modified file", () => {
374
- expect(
375
- withChanges([change("src/a.ts", undefined, "x")], () => requireNoNewFlags(pattern, "h")),
376
- ).toContain("src/a.ts")
377
- expect(
378
- withChanges([change("src/a.ts", "x", undefined)], () => requireNoNewFlags(pattern, "h")),
379
- ).toContain("src/a.ts")
380
- })
381
- })
382
-
383
- const shape = (overrides: Partial<DiffShapeResult> = {}): DiffShapeResult => ({
384
- choice: "refactor-first",
385
- strays: ["src/a.ts", "src/b.ts"],
386
- unreadable: [],
387
- judgment: undefined,
388
- ...overrides,
389
- })
390
-
391
- interface Harness {
392
- readonly steps: StepRequest[]
393
- readonly scopes: ScopeOptions[]
394
- }
395
-
396
- /** The phase a step name enters; `fix.suite.fixing` is an agent step. */
397
- const PHASE_AT: Record<string, string | undefined> = {
398
- "fix.suite.fixing": "fix",
399
- "health.describe": "describe",
400
- "health.stop": "stop",
401
- "health.exhausted": "stop",
402
- }
403
-
404
- /** Runs `refactorFirst` against a recording context; `reds` red checks come first. */
405
- const drive = async (
406
- given: DiffShapeResult,
407
- options: {
408
- readonly text?: TextContext
409
- readonly reference?: string
410
- readonly build?: readonly Change[]
411
- readonly fix?: readonly Change[]
412
- /** The fix turn's own changes, when they differ from the cumulative `fix`. */
413
- readonly fixTurn?: readonly Change[]
414
- /** Red checks before the first green one. */
415
- readonly reds?: number
416
- /** The escalation describe turn's own changes. */
417
- readonly describe?: readonly Change[]
418
- /** A person's own changes at the `health.stop` rest. */
419
- readonly stop?: readonly Change[]
420
- } = {},
421
- ): Promise<Harness & { result: boolean | Error }> => {
422
- const steps: StepRequest[] = []
423
- const scopes: ScopeOptions[] = []
424
- let phase = "build"
425
- let checks = 0
426
- const reached = new Set<string>()
427
- const reds = options.reds ?? 0
428
- installContext(
429
- fixtureContext(options.text ?? { scope: "refactor" }, {
430
- step: (request: StepRequest) => {
431
- steps.push(request)
432
- const name = "name" in request ? request.name : ""
433
- phase = PHASE_AT[name] ?? phase
434
- reached.add(phase)
435
- if (request.kind === "run" && name === "health.check") {
436
- checks++
437
- phase = "check"
438
- }
439
- // Re-runs the red check it follows, so it reads red as well.
440
- if (request.kind === "run" && name === "health.recheck") phase = "check"
441
- return Promise.resolve(
442
- request.kind === "judge" ? { answers: {}, truncated: [] } : undefined,
443
- )
444
- },
445
- refuse: (message) => {
446
- throw new Error(message)
447
- },
448
- pushScope: (scope) => void scopes.push(scope),
449
- popScope: () => undefined,
450
- // Cumulative like the live range: every turn reached so far, later phases included.
451
- changesSince: () => [
452
- ...(options.build ?? []),
453
- ...(reached.has("fix") ? (options.fix ?? []) : []),
454
- ...(reached.has("describe") ? (options.describe ?? []) : []),
455
- ...(reached.has("stop") ? (options.stop ?? []) : []),
456
- ],
457
- changes: () => {
458
- const turn: Record<string, readonly Change[] | undefined> = {
459
- build: options.build,
460
- fix: options.fixTurn ?? options.fix,
461
- describe: options.describe,
462
- stop: options.stop,
463
- }
464
- if (phase in turn) return turn[phase] ?? []
465
- return checks <= reds ? [change(".gtd/FEEDBACK.md", undefined, "red", "added")] : []
466
- },
467
- matches: globMatches,
468
- head: () => "c0ffee0",
469
- }),
470
- )
471
- try {
472
- const result = await refactorFirst(given, { reference: options.reference })
473
- return { steps, scopes, result }
474
- } catch (error) {
475
- return { steps, scopes, result: error as Error }
476
- }
477
- }
478
-
479
- const names = (h: Harness): string[] => h.steps.map((s) => ("name" in s ? s.name : s.kind))
480
-
481
- describe("refactorFirst", () => {
482
- it.each([
483
- ["land", shape({ choice: "land" })],
484
- ["reject", shape({ choice: "reject" })],
485
- ["refactor-first with unreadable strays", shape({ unreadable: ["src/a.ts"] })],
486
- ])("resolves false with no step for %s", async (_label, given) => {
487
- const h = await drive(given)
488
- expect(h.result).toBe(false)
489
- expect(h.steps).toEqual([])
490
- })
491
-
492
- it("resolves false with no step and no scope when every stray is a test file", async () => {
493
- const h = await drive(shape({ strays: ["src/a.test.ts"] }))
494
- expect(h.result).toBe(false)
495
- expect(h.steps).toEqual([])
496
- expect(h.scopes).toEqual([])
497
- })
498
-
499
- it("lists only the non-test strays in the package scope and write access", async () => {
500
- const h = await drive(shape({ strays: ["src/a.ts", "src/a.test.ts"] }))
501
- const derive = h.steps.find((s) => s.kind === "run" && s.name === "deriving")
502
- const body = derive?.kind === "run" ? derive.body : ""
503
- expect(body).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
504
- expect(body).not.toContain("a.test.ts")
505
- expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ...testFileGlobs, ".gtd/**"] })
506
- })
507
-
508
- it("throws before any step when a stray holds a flag read the pattern matches zero-length", async () => {
509
- const h = await drive(shape(), {
510
- text: {
511
- scope: "refactor",
512
- read: (path) => (path === "src/a.ts" ? "GTD_FLAG_X" : ""),
513
- vars: { refactorFlagPattern: "(?=GTD_FLAG_\\w+)" },
514
- },
515
- })
516
- expect(h.result).toBeInstanceOf(Error)
517
- expect((h.result as Error).message).toContain("refactorFlagPattern")
518
- expect((h.result as Error).message).toContain(" - src/a.ts")
519
- expect(h.steps).toEqual([])
520
- })
521
-
522
- it("derives, builds, checks and closes on a green run, resolving true", async () => {
523
- const h = await drive(shape())
524
- expect(h.result).toBe(true)
525
- expect(names(h)).toEqual(["deriving", "building", "health.check", "closing"])
526
- const closing = h.steps.at(-1)
527
- expect(closing).toMatchObject({ kind: "run" })
528
- const body = closing?.kind === "run" ? closing.body : ""
529
- expect(body).toContain(REFACTOR_PACKAGE)
530
- })
531
-
532
- it("writes a package whose scope lines equal the strays, in order, and marks them read-only", async () => {
533
- const h = await drive(shape({ strays: ["src/z.ts", "src/a.ts", "it's.ts"] }))
534
- const deriving = h.steps[0]
535
- const body = deriving?.kind === "run" ? deriving.body : ""
536
- expect(body).toContain(REFACTOR_PACKAGE)
537
- expect(body).not.toContain("<<")
538
- expect(body).toContain("## Scope (read-only)")
539
- expect(body).toContain("## Rules")
540
- expect(body).toContain("## Reference")
541
- // shell-unquote enough to read the printf arguments back
542
- const words = [...body.matchAll(/'((?:[^']|'\\'')*)'/g)].map((m) =>
543
- m[1]!.replaceAll("'\\''", "'"),
544
- )
545
- const lines = words.filter((w) => w.startsWith("- `"))
546
- expect(lines).toEqual(["- `src/z.ts`", "- `src/a.ts`", "- `it's.ts`"]) // gtd-path-exempt: fixture paths
547
- })
548
-
549
- it("runs inside a refactor scope whose write access is the strays, test files and .gtd/**", async () => {
550
- const h = await drive(shape())
551
- expect(h.scopes).toEqual([
552
- {
553
- name: "refactor",
554
- access: { write: ["src/a.ts", "src/b.ts", ...testFileGlobs, ".gtd/**"] },
555
- },
556
- ])
557
- })
558
-
559
- const bodyOf = (h: Harness, index: number): string => {
560
- const step = h.steps[index]
561
- return step?.kind === "run" ? step.body : ""
562
- }
563
- const buildingPrompt = (h: Harness): string => {
564
- const step = h.steps.find((s) => s.kind === "agent")
565
- return step?.kind === "agent" ? step.prompt : ""
566
- }
567
-
568
- it("names the reference hash as reference only, in prompt and package file", async () => {
569
- const h = await drive(shape(), { reference: "abc1234" })
570
- expect(buildingPrompt(h)).toContain("git show abc1234 -- ")
571
- expect(buildingPrompt(h)).toMatch(/never apply/i)
572
- expect(bodyOf(h, 0)).toContain("git show abc1234 -- '\\''src/a.ts'\\'' '\\''src/b.ts'\\''")
573
- })
574
-
575
- it.each([undefined, "", " "])("omits the reference for %j", async (reference) => {
576
- const h = await drive(shape(), reference === undefined ? {} : { reference })
577
- expect(buildingPrompt(h)).not.toContain("git show")
578
- expect(bodyOf(h, 0)).toContain("None.")
579
- })
580
-
581
- it("refuses a build turn that touches a path outside the strays", async () => {
582
- const h = await drive(shape(), { build: [change("lib/other.ts", "a", "b")] })
583
- expect(h.result).toBeInstanceOf(Error)
584
- expect((h.result as Error).message).toContain("lib/other.ts")
585
- expect(names(h)).not.toContain("health.check")
586
- })
587
-
588
- it("lets a build turn update an existing test file's call sites", async () => {
589
- const h = await drive(shape(), {
590
- build: [
591
- change("src/a.ts", "a", "b"),
592
- change("tests/a.test.ts", "expect(a()).toBe(1)", "expect(await a()).toBe(1)"),
593
- ],
594
- })
595
- expect(h.result).toBe(true)
596
- expect(names(h)).toContain("health.check")
597
- })
598
-
599
- it("refuses a build turn adding a test file", async () => {
600
- const h = await drive(shape(), {
601
- build: [change("src/a.test.ts", undefined, "b", "added")],
602
- })
603
- expect((h.result as Error).message).toContain("refactor-tests")
604
- expect((h.result as Error).message).toContain("src/a.test.ts")
605
- expect(names(h)).not.toContain("health.check")
606
- })
607
-
608
- it("lets the package rules admit only an existing test file's call sites", async () => {
609
- const h = await drive(shape())
610
- expect(bodyOf(h, 0)).toContain("never add or delete a test file")
611
- expect(bodyOf(h, 0)).toContain("never add, remove or alter a test or an assertion")
612
- })
613
-
614
- it("names the reference's seam as the change to carry, when there is a reference", async () => {
615
- const withRef = await drive(shape(), { reference: "abc1234" })
616
- expect(bodyOf(withRef, 0)).toMatch(/seam/)
617
- expect(buildingPrompt(withRef)).toMatch(/touches none of it/)
618
- const bare = await drive(shape())
619
- expect(buildingPrompt(bare)).not.toMatch(/touches none of it/)
620
- })
621
-
622
- it("refuses a build turn adding a flag read", async () => {
623
- const h = await drive(shape(), { build: [change("src/a.ts", "x", "GTD_FLAG_NEW")] })
624
- expect((h.result as Error).message).toContain("GTD_FLAG_NEW")
625
- })
626
-
627
- it("refuses a fix turn outside the strays", async () => {
628
- const h = await drive(shape(), { reds: 1, fix: [change("lib/other.ts", "a", "b")] })
629
- expect(names(h)).toContain("fix.suite.fixing")
630
- expect((h.result as Error).message).toContain("lib/other.ts")
631
- })
632
-
633
- it("refuses a fix turn adding a flag read", async () => {
634
- const h = await drive(shape(), {
635
- reds: 1,
636
- fix: [change("src/a.ts", "x", "GTD_FLAG_NEW")],
637
- })
638
- expect(names(h)).toContain("fix.suite.fixing")
639
- expect((h.result as Error).message).toContain("GTD_FLAG_NEW")
640
- })
641
-
642
- it("judges a fix turn on its own changes: an earlier non-stray edit does not wedge it", async () => {
643
- const h = await drive(shape(), {
644
- reds: 1,
645
- fix: [change("lib/human.ts", "a", "b")],
646
- fixTurn: [change("src/a.ts", "a", "b")],
647
- })
648
- expect(h.result).toBe(true)
649
- })
650
-
651
- it("loads ponytail on the building and fix turns, and a config.skills entry replaces it", async () => {
652
- const h = await drive(shape(), { reds: 1 })
653
- const agents = h.steps.filter((s) => s.kind === "agent")
654
- expect(agents.map((s) => s.name)).toEqual(["building", "fix.suite.fixing"])
655
- for (const a of agents) expect(a.kind === "agent" ? a.prompt : "").toContain("ponytail")
656
-
657
- const custom = await drive(shape(), {
658
- reds: 1,
659
- text: {
660
- scope: "refactor",
661
- skills: { refactor: ["my-skill"], "refactor.fix.suite": ["my-fix"] },
662
- },
663
- })
664
- const prompts = custom.steps.flatMap((s) => (s.kind === "agent" ? [s.prompt] : []))
665
- expect(prompts[0]).toContain("my-skill")
666
- expect(prompts[0]).not.toContain("ponytail")
667
- expect(prompts[1]).toContain("my-fix")
668
- })
669
-
670
- it.each(["", " ", "(", "[a-", "x?", "(GTD_FLAG_\\w+)?"])(
671
- "throws naming refactorFlagPattern for %j, before any step",
672
- async (bad) => {
673
- const h = await drive(shape(), {
674
- text: { scope: "refactor", vars: { refactorFlagPattern: bad } },
675
- })
676
- expect(h.result).toBeInstanceOf(Error)
677
- expect((h.result as Error).message).toContain("refactorFlagPattern")
678
- expect(h.steps).toEqual([])
679
- },
680
- )
681
-
682
- it("falls back to the default pattern without the setting", async () => {
683
- const h = await drive(shape(), {
684
- text: { scope: "refactor", vars: {} },
685
- build: [change("src/a.ts", "", "GTD_FLAG_N")],
686
- })
687
- expect((h.result as Error).message).toContain("GTD_FLAG_N")
688
- })
689
-
690
- it("lets a custom pattern replace the default", async () => {
691
- const text = { scope: "refactor", vars: { refactorFlagPattern: "\\bFEATURE_\\w+" } }
692
- const ignored = await drive(shape(), { text, build: [change("src/a.ts", "", "GTD_FLAG_N")] })
693
- expect(ignored.result).toBe(true)
694
- const caught = await drive(shape(), { text, build: [change("src/a.ts", "", "FEATURE_N")] })
695
- expect((caught.result as Error).message).toContain("FEATURE_N")
696
- })
697
-
698
- describe("escalation", () => {
699
- // Four reds put the run at `health.describe` (the kit owns the count).
700
- const escalated = { reds: 4 }
701
- // Live, the write access refuses a non-stray first; this pins the guard itself.
702
- const exhausted = { reds: 12 }
703
-
704
- it("reaches the describe turn", async () => {
705
- const h = await drive(shape(), escalated)
706
- expect(names(h)).toContain("health.describe")
707
- expect(h.result).toBe(true)
708
- })
709
-
710
- it("refuses a describe turn outside the strays, naming the path", async () => {
711
- const h = await drive(shape(), { ...escalated, describe: [change("lib/other.ts", "a", "b")] })
712
- expect((h.result as Error).message).toContain("lib/other.ts")
713
- expect(names(h)).not.toContain("health.stop")
714
- })
715
-
716
- it("refuses a describe turn adding a flag read, naming the flag", async () => {
717
- const h = await drive(shape(), {
718
- ...escalated,
719
- describe: [change("src/a.ts", "x", "GTD_FLAG_NEW")],
720
- })
721
- expect((h.result as Error).message).toContain("GTD_FLAG_NEW")
722
- expect(names(h)).not.toContain("health.stop")
723
- })
724
-
725
- it("never refuses a human edit at health.stop", async () => {
726
- const h = await drive(shape(), { ...escalated, stop: [change("lib/human.ts", "a", "b")] })
727
- expect(names(h)).toContain("health.stop")
728
- expect(h.result).toBe(true)
729
- })
730
-
731
- it("never refuses a human edit at health.exhausted", async () => {
732
- const h = await drive(shape(), { ...exhausted, stop: [change("lib/human.ts", "a", "b")] })
733
- expect(names(h)).toContain("health.exhausted")
734
- expect(h.result).toBe(true)
735
- })
736
-
737
- it("refuses the next fix turn for a flag read a person added at the rest", async () => {
738
- const h = await drive(shape(), {
739
- ...escalated,
740
- stop: [change("lib/human.ts", "a", "a GTD_FLAG_NEW")],
741
- })
742
- expect((h.result as Error).message).toContain("GTD_FLAG_NEW")
743
- })
744
- })
745
- })
746
-
747
- const REVIEW = ".gtd/REVIEW.md"
748
- const QUALITY = ".gtd/QUALITY.md"
749
-
750
- const reviewDoc = (note: string): string =>
751
- [
752
- "# Review: abc1234",
753
- "",
754
- "<!-- base: 0000000000000000000000000000000000000000 -->",
755
- "",
756
- "## calc",
757
- "- [ ] ./src/a.ts#1-1",
758
- note,
759
- "",
760
- ].join("\n")
761
-
762
- interface Reviewed {
763
- /** Full step names, scope-qualified like the live replay. */
764
- readonly names: string[]
765
- readonly steps: StepRequest[]
766
- readonly scopes: ScopeOptions[]
767
- readonly result: boolean | Error
768
- }
769
-
770
- interface ReviewedScript {
771
- /** The note the human leaves at each `review.await-review`, in order; `undefined` signs off. */
772
- readonly notes?: readonly (string | undefined)[]
773
- readonly answers?: Readonly<Record<string, { answer: string; p: number }>>
774
- /** Findings every quality lens leaves behind, until the fix turn removes them. */
775
- readonly findings?: boolean
776
- /** Red checks before green. */
777
- readonly reds?: number
778
- /** The turn's own changes by full step name. */
779
- readonly turns?: Readonly<Record<string, readonly Change[]>>
780
- readonly vars?: Readonly<Record<string, string>>
781
- /** The collecting turn writes no package. */
782
- readonly silentCollect?: boolean
783
- readonly collected?: string
784
- }
785
-
786
- /** Runs `reviewedRefactor` against a scripted context; a third await-review stops the run. */
787
- const driveReviewed = async (
788
- given: DiffShapeResult,
789
- script: ReviewedScript = {},
790
- ): Promise<Reviewed> => {
791
- const names: string[] = []
792
- const steps: StepRequest[] = []
793
- const stack: string[] = []
794
- const scopes: ScopeOptions[] = []
795
- const files = new Map<string, string>()
796
- let last = ""
797
- let awaits = 0
798
- let reds = script.reds ?? 0
799
- let checking = false
800
- let pending: boolean | Error = false
801
- const record = (request: Exclude<StepRequest, { kind: "restart" }>): void => {
802
- last = [...stack, request.name].join(".")
803
- names.push(last)
804
- steps.push(request)
805
- // Red checks apply to the tail's own runs, never the refactor lap's.
806
- checking = request.name === "health.check" && stack.includes("build")
807
- if (checking && reds > 0) reds--
808
- // Re-runs the red check it follows, so it reads red as well.
809
- if (request.name === "health.recheck" && stack.includes("build")) checking = true
810
- }
811
- const lensEffects = ["correctness", "ponytail-review", "conventions"].map(
812
- (lens): [string, () => void] => [
813
- `quality.${lens}.reviewing`,
814
- () => {
815
- if (script.findings) files.set(QUALITY, "- finding")
816
- },
817
- ],
818
- )
819
- const effects: Record<string, () => void> = {
820
- ...Object.fromEntries(lensEffects),
821
- deriving: () => void files.set(REFACTOR_PACKAGE, "package"),
822
- closing: () => void files.delete(REFACTOR_PACKAGE),
823
- "review.reviewing": () => void files.set(REVIEW, reviewDoc("add")),
824
- "review.closing": () => void files.delete(REVIEW),
825
- "review.collecting": () => {
826
- if (!script.silentCollect)
827
- files.set(REFACTOR_PACKAGE, script.collected ?? "collected concern")
828
- },
829
- "fix.quality.fixing": () => void files.delete(QUALITY),
830
- "review.await-review": () => {
831
- const note = script.notes?.[awaits]
832
- if (++awaits > (script.notes?.length ?? 1)) throw new Error("stop")
833
- files.set(REVIEW, reviewDoc(note ?? "add"))
834
- },
835
- }
836
- installContext(
837
- fixtureContext(
838
- { scope: "refactor", vars: script.vars ?? {} },
839
- {
840
- step: (request: StepRequest) => {
841
- if (request.kind === "restart") return Promise.resolve()
842
- record(request)
843
- effects[request.name]?.()
844
- // Every lens finding is mechanical, so the fix turn runs.
845
- if (request.kind === "judge" && request.name === "quality.triage")
846
- return Promise.resolve({
847
- answers: Object.fromEntries(
848
- request.questions.map((q) => [q.id, { answer: "mechanical", p: 0.9 }]),
849
- ),
850
- truncated: [],
851
- })
852
- return Promise.resolve(
853
- request.kind === "judge" ? { answers: script.answers ?? {}, truncated: [] } : undefined,
854
- )
855
- },
856
- refuse: (message) => {
857
- throw new Error(message)
858
- },
859
- pushScope: (scope) => {
860
- scopes.push(scope)
861
- stack.push(scope.name ?? "")
862
- },
863
- popScope: () => void stack.pop(),
864
- read: (path) => files.get(path),
865
- changes: () =>
866
- checking && reds > 0
867
- ? [change(".gtd/FEEDBACK.md", undefined, "red", "added")]
868
- : [...(script.turns?.[last] ?? [])],
869
- changesSince: () => {
870
- const after = files.get(REVIEW)
871
- return after === reviewDoc("add")
872
- ? []
873
- : [{ path: REVIEW, status: "modified", before: reviewDoc("add"), after }]
874
- },
875
- matches: globMatches,
876
- head: () => `c${names.length}`,
877
- },
878
- ),
879
- )
880
- try {
881
- pending = await reviewedRefactor(given)
882
- } catch (error) {
883
- pending = error as Error
884
- }
885
- return { names, steps, scopes, result: pending }
886
- }
887
-
888
- describe("reviewedRefactor", () => {
889
- it.each([
890
- ["land", shape({ choice: "land" })],
891
- ["reject", shape({ choice: "reject" })],
892
- ["refactor-first with unreadable strays", shape({ unreadable: ["src/a.ts"] })],
893
- ])("resolves false with no step for %s", async (_label, given) => {
894
- const h = await driveReviewed(given)
895
- expect(h.result).toBe(false)
896
- expect(h.steps).toEqual([])
897
- })
898
-
899
- it("drops test-file strays from scope, access and the collecting fence", async () => {
900
- const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
901
- notes: ["add — rename it", undefined],
902
- })
903
- const derive = h.steps.find((s) => s.kind === "run" && s.name === "deriving")
904
- const body = derive?.kind === "run" ? derive.body : ""
905
- expect(body).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
906
- expect(body).not.toContain("a.test.ts")
907
- expect(h.scopes[0]?.access).toEqual({ write: ["src/a.ts", ...testFileGlobs, ".gtd/**"] })
908
- const collecting = h.steps.find((s) => s.kind === "agent" && s.name === "review.collecting")
909
- const prompt = collecting?.kind === "agent" ? collecting.prompt : ""
910
- expect(prompt).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
911
- expect(prompt).not.toContain("a.test.ts")
912
- })
913
-
914
- it("resolves false with no step and no refactor scope when every stray is a test file", async () => {
915
- const h = await driveReviewed(shape({ strays: ["src/a.test.ts"] }))
916
- expect(h.result).toBe(false)
917
- expect(h.steps).toEqual([])
918
- expect(h.scopes).toEqual([])
919
- })
920
-
921
- it("closes the package, then runs the tail as refactor.build.* and signs off with true", async () => {
922
- const h = await driveReviewed(shape())
923
- expect(h.result).toBe(true)
924
- expect(h.names).toEqual([
925
- "refactor.deriving",
926
- "refactor.building",
927
- "refactor.health.check",
928
- "refactor.closing",
929
- "refactor.build.quality.correctness.reviewing",
930
- "refactor.build.quality.ponytail-review.reviewing",
931
- "refactor.build.quality.conventions.reviewing",
932
- "refactor.build.review.reviewing",
933
- "refactor.build.review.await-review",
934
- "refactor.build.review.closing",
935
- ])
936
- })
937
-
938
- it("reviews from HEAD at scope entry", async () => {
939
- const h = await driveReviewed(shape())
940
- const reviewing = h.steps.find((s) => s.kind === "agent" && s.name === "review.reviewing")
941
- expect(reviewing?.kind === "agent" ? reviewing.prompt : "").toContain("<!-- base: c0 -->")
942
- })
943
-
944
- it("has the reviewer walk the whole refactor on every round", async () => {
945
- const h = await driveReviewed(shape())
946
- const reviewing = h.steps.find((s) => s.kind === "agent" && s.name === "review.reviewing")
947
- expect(reviewing?.kind === "agent" ? reviewing.prompt : "").toContain(
948
- "it covers the whole refactor since it started, on every round",
949
- )
950
- })
951
-
952
- it("names the entry commit in every lens prompt, on the first lap and after a feedback lap", async () => {
953
- const lensPrompts = (h: Reviewed) =>
954
- h.steps.flatMap((s) =>
955
- s.kind === "agent" && s.name === "quality.correctness.reviewing" ? [s.prompt] : [],
956
- )
957
- const first = lensPrompts(await driveReviewed(shape()))
958
- expect(first).toHaveLength(1)
959
- expect(first[0]).toContain("from `c0`")
960
- const after = lensPrompts(
961
- await driveReviewed(shape(), { notes: ["add — rename it", undefined] }),
962
- )
963
- expect(after).toHaveLength(2)
964
- for (const prompt of after) expect(prompt).toContain("from `c0`")
965
- })
966
-
967
- it("takes its quality lenses from refactorQualityReviews, none when blank", async () => {
968
- const one = await driveReviewed(shape(), { vars: { refactorQualityReviews: "correctness" } })
969
- expect(one.names.filter((n) => n.includes(".quality."))).toEqual([
970
- "refactor.build.quality.correctness.reviewing",
971
- ])
972
- const none = await driveReviewed(shape(), {
973
- vars: { refactorQualityReviews: "", qualityReviews: "correctness" },
974
- })
975
- expect(none.names.filter((n) => n.includes(".quality."))).toEqual([])
976
- })
977
-
978
- describe("on review feedback", () => {
979
- const run = () =>
980
- driveReviewed(shape(), {
981
- notes: ["add — rename it", undefined],
982
- })
983
-
984
- it("collects into the refactor package, re-unwinds, then derives and builds again, never a re-plan", async () => {
985
- const h = await run()
986
- const i = h.names.indexOf("refactor.build.review.collecting")
987
- expect(i).toBeGreaterThan(-1)
988
- expect(h.names.slice(i - 2, i + 6)).toEqual([
989
- "refactor.build.review.triage",
990
- "refactor.build.review.closing",
991
- "refactor.build.review.collecting",
992
- "refactor.re-unwind",
993
- "refactor.deriving",
994
- "refactor.building",
995
- "refactor.health.check",
996
- "refactor.closing",
997
- ])
998
- expect(h.names.some((n) => n.startsWith("design"))).toBe(false)
999
- })
1000
-
1001
- it("re-derives a package with the collected text under ## Review feedback and the strays fixed", async () => {
1002
- const h = await run()
1003
- const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
1004
- expect(derivings).toHaveLength(2)
1005
- const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
1006
- expect(body).toContain("Review feedback")
1007
- expect(body).toContain("collected concern")
1008
- expect(body).toContain("- `src/a.ts`") // gtd-path-exempt: fixture paths
1009
- expect(body).toContain("- `src/b.ts`") // gtd-path-exempt: fixture paths
1010
- const first = derivings[0]?.kind === "run" ? derivings[0].body : ""
1011
- expect(first).not.toContain("Review feedback")
1012
- })
1013
-
1014
- it("builds with the feedback line on the second lap only", async () => {
1015
- const h = await run()
1016
- const builds = h.steps.filter((s) => s.kind === "agent" && s.name === "building")
1017
- const prompts = builds.map((s) => (s.kind === "agent" ? s.prompt : ""))
1018
- expect(prompts[0]).not.toContain("## Review feedback")
1019
- expect(prompts[1]).toContain("## Review feedback")
1020
- })
1021
-
1022
- it("nests a collected ## Answered Questions as ### under ## Review feedback", async () => {
1023
- const h = await driveReviewed(shape(), {
1024
- notes: ["add — rename it", undefined],
1025
- collected: "## Rename\n\nrename it\n\n## Answered Questions\n\n### why?\n\nbecause",
1026
- })
1027
- const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
1028
- const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
1029
- expect(body).toContain("## Review feedback")
1030
- expect(body).toContain("### Rename")
1031
- expect(body).toContain("### Answered Questions")
1032
- expect(body).toContain("#### why?")
1033
- expect(body).not.toMatch(/^## Answered Questions/m)
1034
- })
1035
-
1036
- it("nests a collected h1 under ## Review feedback and tracks the opening fence marker", async () => {
1037
- const h = await driveReviewed(shape(), {
1038
- notes: ["add — rename it", undefined],
1039
- collected: "# Top\n\n```\n~~~\n# code\n```\n\n## Sub",
1040
- })
1041
- const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
1042
- const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
1043
- expect(body).toContain("### Top")
1044
- expect(body).toContain("'# code'")
1045
- expect(body).toContain("#### Sub")
1046
- })
1047
-
1048
- it("caps demoted headings at ###### and shifts the shallowest to ###", async () => {
1049
- const h = await driveReviewed(shape(), {
1050
- notes: ["add — rename it", undefined],
1051
- collected: "# Top\n\n##### Deep\n\n```\n# code\n```\n\nprose # not a heading",
1052
- })
1053
- const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
1054
- const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
1055
- expect(body).toContain("'### Top'")
1056
- expect(body).toContain("'###### Deep'")
1057
- expect(body).not.toContain("#######")
1058
- expect(body).toContain("'# code'")
1059
- expect(body).toContain("'prose # not a heading'")
1060
- })
1061
-
1062
- it("keeps a heading fenced inside a four-backtick fence after an inner three-backtick line", async () => {
1063
- const h = await driveReviewed(shape(), {
1064
- notes: ["add — rename it", undefined],
1065
- collected: "## Top\n\n````\n```\n## code\n````\n\n## Sub",
1066
- })
1067
- const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
1068
- const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
1069
- expect(body).toContain("'## code'")
1070
- expect(body).not.toContain("'### code'")
1071
- expect(body).toContain("### Sub")
1072
- })
1073
-
1074
- it("treats a three-backtick line indented four spaces as no fence, so the next heading is demoted", async () => {
1075
- const h = await driveReviewed(shape(), {
1076
- notes: ["add — rename it", undefined],
1077
- collected: "## Top\n\n ```\n\n## Sub",
1078
- })
1079
- const derivings = h.steps.filter((s) => s.kind === "run" && s.name === "deriving")
1080
- const body = derivings[1]?.kind === "run" ? derivings[1].body : ""
1081
- expect(body).toContain("### Sub")
1082
- })
1083
-
1084
- it("collects with the shared prompt's refactor block, naming .gtd/REFACTOR.md and the strays", async () => {
1085
- const h = await run()
1086
- const collecting = h.steps.find((s) => s.kind === "agent" && s.name === "review.collecting")
1087
- const prompt = collecting?.kind === "agent" ? collecting.prompt : ""
1088
- expect(prompt).toContain(".gtd/REFACTOR.md")
1089
- expect(prompt).not.toContain(".gtd/REQUIREMENTS.md")
1090
- expect(prompt).toContain("`src/a.ts`") // gtd-path-exempt: fixture paths
1091
- expect(prompt).toContain("`.gtd/REFACTOR.md`'s `## Answered Questions`")
1092
- // End of round: the review file is already closed, so no thread reply.
1093
- expect(prompt).toContain("already closed")
1094
- expect(prompt).not.toContain("exactly one `- A:` reply")
1095
- })
1096
- })
1097
-
1098
- describe("the collecting prompt variant", () => {
1099
- it("is decided by the round, never by .gtd/REVIEW.md's presence", async () => {
1100
- // The review file is in the tree at the closed round (it is closed only
1101
- // after the collecting capture), yet the closed variant renders.
1102
- const h = await driveReviewed(shape(), { notes: ["add — rename it"], collected: "x" })
1103
- const collecting = h.steps.find((s) => s.kind === "agent" && s.name === "review.collecting")
1104
- const prompt = collecting?.kind === "agent" ? collecting.prompt : ""
1105
- expect(prompt).toContain("already closed")
1106
- expect(prompt).not.toContain("exactly one `- A:` reply")
1107
- expect(prompt).not.toContain("A one-shot footnote needs no reply")
1108
- })
1109
- })
1110
-
1111
- describe("a note the collecting turn leaves out", () => {
1112
- it("refuses the landing naming the note and gtd abandon, never signing off", async () => {
1113
- const h = await driveReviewed(shape(), { notes: ["add — rename it"], silentCollect: true })
1114
- const message = (h.result as Error).message
1115
- expect(h.result).toBeInstanceOf(Error)
1116
- expect(message).toContain("refactor-feedback")
1117
- expect(message).toContain("note-1")
1118
- expect(message).toContain("rename it")
1119
- expect(message).toContain("gtd abandon")
1120
- })
1121
- })
1122
-
1123
- describe("the guard", () => {
1124
- const outside = [change("lib/other.ts", "a", "b")]
1125
-
1126
- it("runs after a quality-fix turn", async () => {
1127
- const h = await driveReviewed(shape(), {
1128
- findings: true,
1129
- turns: { "refactor.build.fix.quality.fixing": outside },
1130
- })
1131
- expect((h.result as Error).message).toContain("lib/other.ts")
1132
- expect(h.names.at(-1)).toBe("refactor.build.fix.quality.fixing")
1133
- })
1134
-
1135
- it("runs after a nit-fix turn", async () => {
1136
- const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
1137
- notes: ["add — typo"],
1138
- answers: { "note-1": { answer: "nit", p: 0.9 } },
1139
- turns: {
1140
- "refactor.build.review.fix.nits.fixing": [
1141
- change("src/a.test.ts", undefined, "b", "added"),
1142
- ],
1143
- },
1144
- })
1145
- expect((h.result as Error).message).toContain("refactor-tests")
1146
- expect(h.names.at(-1)).toBe("refactor.build.review.fix.nits.fixing")
1147
- })
1148
-
1149
- it("runs the nit-fix turn under the strays, test files and .gtd/** as its write access", async () => {
1150
- const write = ["src/a.ts", ...testFileGlobs, ".gtd/**"]
1151
- const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
1152
- notes: ["add — typo"],
1153
- answers: { "note-1": { answer: "nit", p: 0.9 } },
1154
- })
1155
- expect(h.scopes).toContainEqual({ name: "review.fix.nits", access: { write } })
1156
- expect(h.names).toContain("refactor.build.review.fix.nits.fixing")
1157
- })
1158
-
1159
- it("runs after a fix-check turn", async () => {
1160
- const h = await driveReviewed(shape({ strays: ["src/a.ts", "src/a.test.ts"] }), {
1161
- notes: ["add — typo"],
1162
- answers: { "note-1": { answer: "nit", p: 0.9 } },
1163
- reds: 2,
1164
- turns: { "refactor.build.fix": [change("src/a.test.ts", "x", undefined, "deleted")] },
1165
- })
1166
- expect(h.names).toContain("refactor.build.fix")
1167
- expect((h.result as Error).message).toContain("refactor-tests")
1168
- expect(h.names.at(-1)).toBe("refactor.build.fix")
1169
- })
1170
-
1171
- it("never refuses a person's edit at the tail's health.stop", async () => {
1172
- const h = await driveReviewed(shape(), {
1173
- findings: true,
1174
- reds: 5,
1175
- turns: { "refactor.build.health.stop": [change("lib/human.test.ts", "a", "b")] },
1176
- })
1177
- expect(h.names).toContain("refactor.build.health.stop")
1178
- expect(h.result).toBe(true)
1179
- })
1180
-
1181
- it("still refuses the tail's escalation describe turn outside the strays", async () => {
1182
- const h = await driveReviewed(shape(), {
1183
- findings: true,
1184
- reds: 5,
1185
- turns: { "refactor.build.health.describe": outside },
1186
- })
1187
- expect((h.result as Error).message).toContain("lib/other.ts")
1188
- expect(h.names.at(-1)).toBe("refactor.build.health.describe")
1189
- })
1190
- })
1191
- })
1192
-
1193
- describe("reviewedRefactor's tail prompts", () => {
1194
- const strays = ["src/a.ts", "src/b.ts"]
1195
-
1196
- const promptAt = async (name: string, script: ReviewedScript): Promise<string> => {
1197
- const h = await driveReviewed(shape({ strays }), script)
1198
- const step = h.steps.find((s, i) => s.kind === "agent" && h.names[i] === name)
1199
- if (step?.kind !== "agent") throw new Error(`no agent step ${name} in ${h.names.join(", ")}`)
1200
- return step.prompt
1201
- }
1202
-
1203
- it.each([
1204
- ["refactor.build.fix", { findings: true, reds: 2 }],
1205
- ["refactor.build.fix.quality.fixing", { findings: true }],
1206
- [
1207
- "refactor.build.review.fix.nits.fixing",
1208
- { notes: ["nit"], answers: { "note-1": { answer: "nit", p: 0.9 } } },
1209
- ],
1210
- ["refactor.build.health.describe", { findings: true, reds: 6 }],
1211
- ] as const)(
1212
- "%s names every stray and bans new test files and new flag reads",
1213
- async (name, script) => {
1214
- const prompt = await promptAt(name, script as ReviewedScript)
1215
- for (const stray of strays) expect(prompt).toContain(`\`${stray}\``)
1216
- expect(prompt).toContain("never add or delete a test file")
1217
- expect(prompt).toContain("no new flag read")
1218
- },
1219
- )
1220
- })