@pmelab/gtd 29.1.0 → 29.2.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.
@@ -1,6 +1,6 @@
1
1
  import { afterEach, describe, expect, it } from "vitest"
2
- import { installContext, type Change, type StepRequest } from "../../flows/index.js"
3
- import { declaredTests, packages } from "./packages.js"
2
+ import { installContext, type Change, type Changes, type StepRequest } from "../../flows/index.js"
3
+ import { declaredTests, packages, requireDeclaredTests } from "./packages.js"
4
4
  import { fixtureContext } from "../shared/text.fixture.js"
5
5
 
6
6
  afterEach(() => installContext(undefined))
@@ -16,8 +16,8 @@ describe("declaredTests", () => {
16
16
  "- e2e: `spec/a.feature`",
17
17
  ].join("\n")
18
18
  expect(declaredTests(text)).toEqual([
19
- { level: "unit", path: "lib/a.test.ts" },
20
- { level: "e2e", path: "spec/a.feature" },
19
+ { level: "unit", path: "lib/a.test.ts", unchanged: false },
20
+ { level: "e2e", path: "spec/a.feature", unchanged: false },
21
21
  ])
22
22
  })
23
23
 
@@ -33,7 +33,88 @@ describe("declaredTests", () => {
33
33
  "## Notes",
34
34
  "- e2e: `spec/other.feature`",
35
35
  ].join("\n")
36
- expect(declaredTests(text)).toEqual([{ level: "unit", path: "lib/ok.test.ts" }])
36
+ expect(declaredTests(text)).toEqual([
37
+ { level: "unit", path: "lib/ok.test.ts", unchanged: false },
38
+ ])
39
+ })
40
+
41
+ it("marks a whitespace-led, case-insensitive `(unchanged)` ending at a boundary", () => {
42
+ const text = [
43
+ "## Tests",
44
+ "- unit: `a.test.ts` (unchanged)",
45
+ "- unit: `b.test.ts` extra",
46
+ "- unit: `c.test.ts` (unchanged)x",
47
+ "- unit: `f.test.ts` (unchanged) — kept as is",
48
+ "- unit: `d.test.ts` (Unchanged)",
49
+ "- e2e: `e.feature` (unchanged)",
50
+ "- unit: `g.test.ts`\t(unchanged)",
51
+ "- unit: `h.test.ts` (unchanged)",
52
+ "- unit: `i.test.ts`(unchanged)",
53
+ "- Unit: `j.test.ts` (unchanged)",
54
+ ].join("\n")
55
+ expect(declaredTests(text).map((t) => [t.path, t.unchanged])).toEqual([
56
+ ["a.test.ts", true],
57
+ ["b.test.ts", false],
58
+ ["c.test.ts", false],
59
+ ["f.test.ts", true],
60
+ ["d.test.ts", true],
61
+ ["e.feature", true],
62
+ ["g.test.ts", true],
63
+ ["h.test.ts", true],
64
+ ["i.test.ts", false],
65
+ ])
66
+ })
67
+
68
+ it("never joins a marker across an empty or whitespace-only line", () => {
69
+ for (const gap of ["\n\n", "\n \n", "\n\t\n"]) {
70
+ const text = `## Tests\n- unit: \`a.test.ts\`${gap} (unchanged)\n`
71
+ expect(declaredTests(text).map((t) => t.unchanged)).toEqual([false])
72
+ }
73
+ })
74
+
75
+ it("reads an entry whose path or marker is wrapped, in any line-end style", () => {
76
+ const forms = [
77
+ "- unit: `a/long.test.ts` (unchanged)",
78
+ "- unit:\n `a/long.test.ts` (unchanged)",
79
+ "- unit: `a/long.test.ts`\n (unchanged)",
80
+ "- unit:\n `a/long.test.ts`\n (unchanged)",
81
+ ]
82
+ for (const form of forms) {
83
+ const lf = `## Tests\n${form}\n- e2e: \`b.feature\`\n`
84
+ const want = [
85
+ { level: "unit", path: "a/long.test.ts", unchanged: true },
86
+ { level: "e2e", path: "b.feature", unchanged: false },
87
+ ]
88
+ expect(declaredTests(lf)).toEqual(want)
89
+ expect(declaredTests(lf.replace(/\n/g, "\r\n"))).toEqual(want)
90
+ expect(declaredTests(lf.replace(/\n/g, "\r"))).toEqual(want)
91
+ }
92
+ })
93
+
94
+ it("keeps the heading and its entries when an indented marker follows `## Tests`", () => {
95
+ const text = "## Tests\n (unchanged)\n- unit: `a.test.ts`\n"
96
+ expect(declaredTests(text)).toEqual([{ level: "unit", path: "a.test.ts", unchanged: false }])
97
+ })
98
+
99
+ it("never extends an entry across a new item or a heading", () => {
100
+ const text = [
101
+ "## Tests",
102
+ "- unit: `a.test.ts`",
103
+ " - unit: `b.test.ts` (unchanged)",
104
+ "- unit: `c.test.ts`",
105
+ "## Tests",
106
+ " (unchanged)",
107
+ ].join("\n")
108
+ expect(declaredTests(text).map((t) => [t.path, t.unchanged])).toEqual([
109
+ ["a.test.ts", false],
110
+ ["b.test.ts", true],
111
+ ["c.test.ts", false],
112
+ ])
113
+ })
114
+
115
+ it("closes an entry on an unindented line", () => {
116
+ const text = "## Tests\n- unit: `a.test.ts`\n(unchanged)\n"
117
+ expect(declaredTests(text).map((t) => t.unchanged)).toEqual([false])
37
118
  })
38
119
 
39
120
  it("returns nothing without a Tests section", () => {
@@ -72,3 +153,216 @@ describe("packages", () => {
72
153
  expect(closings[1]).toContain(".gtd/ARCHITECTURE.md")
73
154
  })
74
155
  })
156
+
157
+ describe("requireDeclaredTests", () => {
158
+ const run = (
159
+ text: string,
160
+ tree: readonly string[],
161
+ changes: readonly Change[],
162
+ satisfied = false,
163
+ turn: "build" | "fix" | "default" = "build",
164
+ ) => {
165
+ const pkg = ".gtd/packages/01-a.md"
166
+ installContext(
167
+ fixtureContext(
168
+ {},
169
+ {
170
+ read: (path: string) =>
171
+ path === pkg
172
+ ? text
173
+ : path === ".gtd/SATISFIED.md"
174
+ ? satisfied
175
+ ? "x"
176
+ : undefined
177
+ : tree.includes(path)
178
+ ? "x"
179
+ : undefined,
180
+ refuse: (message: string): never => {
181
+ throw new Error(message)
182
+ },
183
+ changesSince: (): Changes => {
184
+ const list: Change[] = [
185
+ { path: pkg, status: "modified", before: text, after: text },
186
+ ...changes,
187
+ ]
188
+ return Object.assign(list, {
189
+ paths: list.map((c) => c.path),
190
+ get: (path: string) => list.find((c) => c.path === path),
191
+ })
192
+ },
193
+ },
194
+ ),
195
+ )
196
+ let message = ""
197
+ try {
198
+ if (turn === "default") requireDeclaredTests(pkg, "c")
199
+ else requireDeclaredTests(pkg, "c", turn)
200
+ } catch (error) {
201
+ message = String(error instanceof Error ? error.message : error)
202
+ }
203
+ return { message }
204
+ }
205
+ const deleted = (path: string): Change => ({
206
+ path,
207
+ status: "deleted",
208
+ before: "x",
209
+ after: undefined,
210
+ })
211
+
212
+ it("groups an unwritten unmarked test and a missing marked test in one refusal", () => {
213
+ const text = [
214
+ "## Tests",
215
+ "- unit: `lib/unwritten.test.ts`",
216
+ "- unit: `lib/gone.test.ts` (unchanged)",
217
+ "- unit: `lib/kept.test.ts` (unchanged)",
218
+ ].join("\n")
219
+ const { message } = run(text, ["lib/kept.test.ts"], [])
220
+ expect(message).toContain("gtd land: declared-tests:")
221
+ expect(message).not.toMatch(/satisf/i)
222
+ const diffAt = message.indexOf("not in the package's diff")
223
+ const treeAt = message.indexOf("missing from the tree")
224
+ expect(diffAt).toBeGreaterThanOrEqual(0)
225
+ expect(treeAt).toBeGreaterThan(diffAt)
226
+ expect(message.indexOf(" - lib/unwritten.test.ts")).toBeGreaterThan(diffAt)
227
+ expect(message.indexOf(" - lib/unwritten.test.ts")).toBeLessThan(treeAt)
228
+ expect(message.indexOf(" - lib/gone.test.ts")).toBeGreaterThan(treeAt)
229
+ expect(message).not.toContain("kept.test.ts")
230
+ })
231
+
232
+ it("sends a marked deleted test to restore only", () => {
233
+ const { message } = run("## Tests\n- unit: `a.test.ts` (unchanged)", [], [deleted("a.test.ts")])
234
+ expect(message).toContain("declares tests this turn left unmet:")
235
+ expect(message).toContain("marked (unchanged) but deleted — restore them:\n - a.test.ts")
236
+ expect(message).not.toContain("missing from the tree")
237
+ expect(message).not.toContain("write them")
238
+ })
239
+
240
+ it("keeps a marked absent test with no diff change under missing", () => {
241
+ const { message } = run("## Tests\n- unit: `a.test.ts` (unchanged)", [], [])
242
+ expect(message).toContain("missing from the tree:\n - a.test.ts")
243
+ expect(message).not.toContain("restore")
244
+ })
245
+
246
+ it("keeps an unmarked deleted test under write, or missing once satisfied", () => {
247
+ const text = "## Tests\n- unit: `a.test.ts`"
248
+ expect(run(text, [], [deleted("a.test.ts")]).message).toContain(
249
+ "not in the package's diff — write them:\n - a.test.ts",
250
+ )
251
+ const done = run(text, [], [deleted("a.test.ts")], true).message
252
+ expect(done).toContain("missing from the tree:\n - a.test.ts")
253
+ expect(done).not.toContain("restore")
254
+ })
255
+
256
+ const modified = (path: string): Change => ({
257
+ path,
258
+ status: "modified",
259
+ before: "x",
260
+ after: "y",
261
+ })
262
+
263
+ it("refuses a marked test the build modified, even with SATISFIED.md", () => {
264
+ const text = "## Tests\n- unit: `a.test.ts` (unchanged)"
265
+ for (const satisfied of [false, true]) {
266
+ const { message } = run(text, ["a.test.ts"], [modified("a.test.ts")], satisfied)
267
+ expect(message).toContain("marked (unchanged) but modified — revert them:\n - a.test.ts")
268
+ }
269
+ })
270
+
271
+ it("lets a fix turn modify a marked test but not delete it", () => {
272
+ const text = "## Tests\n- unit: `a.test.ts` (unchanged)\n- unit: `b.test.ts` (unchanged)"
273
+ expect(run(text, ["a.test.ts"], [modified("a.test.ts")], false, "fix").message).toContain(
274
+ "missing from the tree:\n - b.test.ts",
275
+ )
276
+ expect(run(text, ["a.test.ts"], [modified("a.test.ts")], false, "fix").message).not.toContain(
277
+ "revert",
278
+ )
279
+ const gone = run(text, [], [deleted("a.test.ts"), deleted("b.test.ts")], false, "fix").message
280
+ expect(gone).toContain("restore them:\n - a.test.ts\n - b.test.ts")
281
+ const clean = "## Tests\n- unit: `a.test.ts` (unchanged)"
282
+ expect(run(clean, ["a.test.ts"], [modified("a.test.ts")], false, "fix").message).toBe("")
283
+ })
284
+
285
+ it("lets an unmarked entry for the same file win over a marked one", () => {
286
+ const text = "## Tests\n- e2e: `x.feature`\n- e2e: `x.feature` (unchanged)"
287
+ expect(run(text, ["x.feature"], [modified("x.feature")]).message).toBe("")
288
+ })
289
+
290
+ const added = (path: string): Change => ({
291
+ path,
292
+ status: "added",
293
+ before: undefined,
294
+ after: "x",
295
+ })
296
+ const addedGroup =
297
+ "marked (unchanged) but added by this turn — the marker promises an existing test; stop and leave it to a person"
298
+
299
+ it("refuses a marked test the build added, as added and never as modified", () => {
300
+ const { message } = run(
301
+ "## Tests\n- unit: `a.test.ts` (unchanged)",
302
+ ["a.test.ts"],
303
+ [added("a.test.ts")],
304
+ )
305
+ expect(message).toContain(`${addedGroup}:\n - a.test.ts`)
306
+ expect(message).not.toContain("revert")
307
+ expect(message).not.toMatch(/delete|restore/i)
308
+ })
309
+
310
+ it("only the build turn refuses an added marked test (turn split, not a fix-turn flow)", () => {
311
+ const text = "## Tests\n- unit: `a.test.ts` (unchanged)"
312
+ expect(run(text, ["a.test.ts"], [added("a.test.ts")], false, "fix").message).toBe("")
313
+ })
314
+
315
+ it.each([
316
+ ["modified", modified("a.test.ts")],
317
+ ["added", added("a.test.ts")],
318
+ ] as [string, Change][])("defaults to the fix turn with two arguments (%s)", (_label, change) => {
319
+ const text = "## Tests\n- unit: `a.test.ts` (unchanged)"
320
+ expect(run(text, ["a.test.ts"], [change], false, "default").message).toBe("")
321
+ expect(run(text, ["a.test.ts"], [change], false, "build").message).not.toBe("")
322
+ })
323
+
324
+ it("lets an unmarked entry win over a marked one the build added", () => {
325
+ const text = "## Tests\n- unit: `a.test.ts`\n- unit: `a.test.ts` (unchanged)"
326
+ expect(run(text, ["a.test.ts"], [added("a.test.ts")]).message).toBe("")
327
+ })
328
+
329
+ it("lists a duplicated unwritten line once", () => {
330
+ const text = "## Tests\n- unit: `a.test.ts`\n- unit: `a.test.ts`"
331
+ const { message } = run(text, [], [])
332
+ expect(message.split("a.test.ts").length - 1).toBe(1)
333
+ })
334
+
335
+ it("lists a duplicated marked line missing from the tree once", () => {
336
+ const text = "## Tests\n- unit: `a.test.ts` (unchanged)\n- unit: `a.test.ts` (unchanged)"
337
+ const { message } = run(text, [], [])
338
+ expect(message.split("a.test.ts").length - 1).toBe(1)
339
+ })
340
+
341
+ it("orders write, revert, added, restore, missing and never says satisfied", () => {
342
+ const text = [
343
+ "## Tests",
344
+ "- unit: `w.test.ts`",
345
+ "- unit: `r.test.ts` (unchanged)",
346
+ "- unit: `m.test.ts` (unchanged)",
347
+ "- unit: `v.test.ts` (unchanged)",
348
+ "- unit: `a.test.ts` (unchanged)",
349
+ ].join("\n")
350
+ const { message } = run(
351
+ text,
352
+ ["v.test.ts", "a.test.ts"],
353
+ [deleted("r.test.ts"), modified("v.test.ts"), added("a.test.ts")],
354
+ )
355
+ const w = message.indexOf("write them")
356
+ const v = message.indexOf("revert them")
357
+ expect(v).toBeGreaterThan(w)
358
+ const ad = message.indexOf("added by this turn")
359
+ expect(ad).toBeGreaterThan(v)
360
+ const r = message.indexOf("restore them")
361
+ expect(r).toBeGreaterThan(ad)
362
+ const m = message.indexOf("missing from the tree")
363
+ expect(w).toBeGreaterThan(0)
364
+ expect(r).toBeGreaterThan(w)
365
+ expect(m).toBeGreaterThan(r)
366
+ expect(message).not.toMatch(/satisf/i)
367
+ })
368
+ })
@@ -15,46 +15,129 @@ import { build, fixSuite } from "./steps.js"
15
15
  export interface DeclaredTest {
16
16
  readonly level: "unit" | "e2e"
17
17
  readonly path: string
18
+ readonly unchanged: boolean
18
19
  }
19
20
 
20
- const TEST_LINE = /^\s*-\s+(unit|e2e):\s+`([^`]+)`/
21
+ const TEST_LINE = /^-\s+(unit|e2e):\s+`([^`]+)`(.*)$/
22
+ const MARKER = /^[ \t]+\(unchanged\)(?=\s|$)/i
23
+ const ITEM = /^\s*-\s/
21
24
 
22
- /** The `## Tests` entries of a package file; malformed lines are ignored. */
25
+ /** The `## Tests` entries of a package file; malformed entries are ignored. */
23
26
  export const declaredTests = (packageText: string): readonly DeclaredTest[] => {
24
27
  const found: DeclaredTest[] = []
25
28
  let inTests = false
26
- for (const line of packageText.split("\n")) {
27
- if (/^##\s/.test(line)) inTests = /^##\s+Tests\s*$/.test(line)
28
- else if (inTests) {
29
- const match = TEST_LINE.exec(line)
30
- if (match) found.push({ level: match[1] as DeclaredTest["level"], path: match[2]! })
31
- }
29
+ let entry: string | undefined
30
+ const flush = () => {
31
+ const match = entry === undefined ? null : TEST_LINE.exec(entry)
32
+ if (match)
33
+ found.push({
34
+ level: match[1] as DeclaredTest["level"],
35
+ path: match[2]!,
36
+ unchanged: MARKER.test(match[3]!),
37
+ })
38
+ entry = undefined
39
+ }
40
+ // An entry is a list line plus its indented continuation lines (oxfmt wraps
41
+ // long ones); a blank line, a new item or a heading ends it.
42
+ for (const line of packageText.replace(/\r\n?/g, "\n").split("\n")) {
43
+ if (/^##\s/.test(line)) {
44
+ flush()
45
+ inTests = /^##\s+Tests\s*$/.test(line)
46
+ } else if (!inTests) continue
47
+ else if (ITEM.test(line)) {
48
+ flush()
49
+ entry = line.trim()
50
+ } else if (entry !== undefined && /^\s/.test(line) && line.trim() !== "")
51
+ entry = `${entry} ${line.trim()}`
52
+ else flush()
32
53
  }
54
+ flush()
33
55
  return found
34
56
  }
35
57
 
58
+ export type DeclaredTestsTurn = "build" | "fix"
59
+
60
+ type Unmet = "notInDiff" | "modifiedMarked" | "addedMarked" | "deletedMarked" | "notInTree"
61
+
62
+ type Declared = { path: string; unchanged: boolean }
63
+
64
+ const diffBucket = (
65
+ test: Declared,
66
+ status: string | undefined,
67
+ turn: DeclaredTestsTurn,
68
+ satisfied: boolean,
69
+ ): Unmet | undefined => {
70
+ if (test.unchanged) {
71
+ if (turn !== "build") return undefined
72
+ if (status === "modified") return "modifiedMarked"
73
+ return status === "added" ? "addedMarked" : undefined
74
+ }
75
+ return !satisfied && (status === undefined || status === "deleted") ? "notInDiff" : undefined
76
+ }
77
+
78
+ const treeBucket = (test: Declared, status: string | undefined): Unmet | undefined => {
79
+ if (read(test.path) !== undefined) return undefined
80
+ return test.unchanged && status === "deleted" ? "deletedMarked" : "notInTree"
81
+ }
82
+
83
+ const unmetBucket = (
84
+ test: Declared,
85
+ change: { status: string } | undefined,
86
+ turn: DeclaredTestsTurn,
87
+ satisfied: boolean,
88
+ ): Unmet | undefined =>
89
+ diffBucket(test, change?.status, turn, satisfied) ?? treeBucket(test, change?.status)
90
+
36
91
  /**
37
92
  * Refuse a build or fix turn unless every test `pkg` declares exists and is in the
38
93
  * diff since `since`. The package text is read as of `since`, so a builder
39
- * editing its own `## Tests` cannot dodge the guard; with `.gtd/SATISFIED.md`
40
- * written, existing in the tree suffices.
94
+ * editing its own `## Tests` (or adding `(unchanged)`) cannot dodge the guard;
95
+ * with `.gtd/SATISFIED.md` written, or the line marked, existing suffices. The
96
+ * build turn must neither modify nor add a marked test; a fix turn may edit it.
41
97
  */
42
- export const requireDeclaredTests = (pkg: string, since: string): void => {
98
+ export const requireDeclaredTests = (
99
+ pkg: string,
100
+ since: string,
101
+ turn: DeclaredTestsTurn = "fix",
102
+ ): void => {
43
103
  const range = changesSince(since)
44
104
  const text = range.get(pkg)?.before ?? read(pkg) ?? ""
45
105
  const satisfied = read(".gtd/SATISFIED.md") !== undefined
46
- const missing = declaredTests(text)
47
- .map((test) => test.path)
48
- .filter((path) =>
49
- satisfied
50
- ? read(path) === undefined
51
- : range.get(path) === undefined ||
52
- range.get(path)?.status === "deleted" ||
53
- read(path) === undefined,
54
- )
55
- if (missing.length === 0) return
106
+ const buckets = {
107
+ notInDiff: [] as string[],
108
+ modifiedMarked: [] as string[],
109
+ addedMarked: [] as string[],
110
+ deletedMarked: [] as string[],
111
+ notInTree: [] as string[],
112
+ }
113
+ const entries = declaredTests(text)
114
+ const seen = new Set<string>()
115
+ for (const test of entries) {
116
+ // A file listed both ways is written by the package: the unmarked entry wins.
117
+ if (test.unchanged && entries.some((e) => !e.unchanged && e.path === test.path)) continue
118
+ const bucket = unmetBucket(test, range.get(test.path), turn, satisfied)
119
+ if (bucket && !seen.has(test.path)) {
120
+ seen.add(test.path)
121
+ buckets[bucket].push(test.path)
122
+ }
123
+ }
124
+ const { notInDiff, modifiedMarked, addedMarked, deletedMarked, notInTree } = buckets
125
+ const groups = [
126
+ ["not in the package's diff — write them", notInDiff],
127
+ ["marked (unchanged) but modified — revert them", modifiedMarked],
128
+ [
129
+ "marked (unchanged) but added by this turn — the marker promises an existing test; stop and leave it to a person",
130
+ addedMarked,
131
+ ],
132
+ ["marked (unchanged) but deleted — restore them", deletedMarked],
133
+ ["missing from the tree", notInTree],
134
+ ] as const
135
+ const reasons = groups.filter(([, paths]) => paths.length > 0)
136
+ if (reasons.length === 0) return
56
137
  refuse(
57
- `gtd land: declared-tests: ${pkg} declares tests that are ${satisfied ? "missing from the tree" : "not in the package's diff"} — write them first:\n${missing.map((path) => ` - ${path}`).join("\n")}`,
138
+ `gtd land: declared-tests: ${pkg} declares tests this turn left unmet:\n${reasons
139
+ .map(([reason, paths]) => `${reason}:\n${paths.map((path) => ` - ${path}`).join("\n")}`)
140
+ .join("\n")}`,
58
141
  )
59
142
  }
60
143
 
@@ -66,12 +149,12 @@ export const packageItem = async (pkg: string): Promise<void> => {
66
149
  const from = head()
67
150
  // Rechecked after every fix too: a fix deleting a red declared test would
68
151
  // otherwise green the suite.
69
- const declared = (turn: () => Promise<void>) => async () => {
152
+ const declared = (turn: () => Promise<void>, kind: DeclaredTestsTurn) => async () => {
70
153
  await turn()
71
- requireDeclaredTests(pkg, from)
154
+ requireDeclaredTests(pkg, from, kind)
72
155
  }
73
- await declared(() => build(pkg))()
74
- await healthy(declared(fixSuite))
156
+ await declared(() => build(pkg), "build")()
157
+ await healthy(declared(fixSuite, "fix"))
75
158
  const last = glob(".gtd/packages/*.md").length === 1
76
159
  await run("closing", removeScript([pkg, ".gtd/SATISFIED.md", ...(last ? [ARCHITECTURE] : [])]), {
77
160
  label: "Closing out the package",
@@ -216,7 +216,13 @@ const refactorLap = async (p: Plan, guard: Guard, feedback?: string): Promise<vo
216
216
  )
217
217
  await t.agentWithSkills(
218
218
  "building",
219
- t.refactorBuildingPrompt(REFACTOR_PACKAGE, strays, pattern, reference, feedback !== undefined),
219
+ t.refactorBuildingPrompt({
220
+ pkg: REFACTOR_PACKAGE,
221
+ strays,
222
+ pattern,
223
+ reference,
224
+ feedback: feedback !== undefined,
225
+ }),
220
226
  { label: "Refactoring", model: coder(), system: t.builderSystem() },
221
227
  )
222
228
  guard()
@@ -275,7 +281,8 @@ export const reviewedRefactor = async (
275
281
  for (;;) {
276
282
  await refactorLap(p, guard, collected)
277
283
  // A person's edit at an escalation rest is never refused, as in the lap.
278
- const outcome = await buildTail(false, entry, true, {
284
+ const outcome = await buildTail(entry, {
285
+ green: true,
279
286
  guard,
280
287
  guardRests: false,
281
288
  lenses: lensesOf(vars.refactorQualityReviews ?? ""),
@@ -16,6 +16,7 @@ import {
16
16
  } from "./review.js"
17
17
  import type { ReviewNote } from "../../steering/index.js"
18
18
  import * as bundled from "../index.js"
19
+ import type { BuildTailOptions as PublicBuildTailOptions } from "./index.js"
19
20
  import { fixtureContext } from "../shared/text.fixture.js"
20
21
 
21
22
  afterEach(() => installContext(undefined))
@@ -771,7 +772,10 @@ describe("severity routing", () => {
771
772
  })
772
773
 
773
774
  describe("BuildTailOptions.lensBase", () => {
774
- const lensPrompts = async (options: BuildTailOptions): Promise<string[]> => {
775
+ const lensPrompts = async (
776
+ options: BuildTailOptions,
777
+ call: () => Promise<unknown> = () => buildTail("base", { ...options, green: true }),
778
+ ): Promise<string[]> => {
775
779
  const prompts: string[] = []
776
780
  installContext(
777
781
  fixtureContext(
@@ -793,7 +797,7 @@ describe("BuildTailOptions.lensBase", () => {
793
797
  },
794
798
  ),
795
799
  )
796
- await buildTail(false, "base", true, options).catch((error: unknown) => {
800
+ await call().catch((error: unknown) => {
797
801
  if (!(error instanceof Stop)) throw error
798
802
  })
799
803
  return prompts
@@ -810,8 +814,104 @@ describe("BuildTailOptions.lensBase", () => {
810
814
  expect(prompts).toHaveLength(2)
811
815
  for (const prompt of prompts) expect(prompt).toContain("from `START-SHA`")
812
816
  })
817
+
818
+ it("the deprecated positional form runs the same tail as the options form", async () => {
819
+ const options = { lensBase: "SLICE-BASE" }
820
+ const modern = await lensPrompts(options)
821
+ const legacy = await lensPrompts(options, () => buildTail(false, "base", true, options))
822
+ expect(legacy).toEqual(modern)
823
+ expect(legacy).toHaveLength(2)
824
+ })
825
+
826
+ describe("buildTail flags", () => {
827
+ const requestNames = async (
828
+ call: () => Promise<unknown>,
829
+ names: string[] = [],
830
+ ): Promise<string[]> => {
831
+ installContext(
832
+ fixtureContext(
833
+ {},
834
+ {
835
+ step: (request: StepRequest) => {
836
+ if (request.kind === "restart") return Promise.resolve()
837
+ names.push(`${request.kind}:${request.name}`)
838
+ if (names.length >= 4) throw new Stop()
839
+ return Promise.resolve()
840
+ },
841
+ pushScope: () => undefined,
842
+ popScope: () => undefined,
843
+ changesSince: () => {
844
+ throw new Stop()
845
+ },
846
+ start: () => "START-SHA",
847
+ },
848
+ ),
849
+ )
850
+ await call().catch((error: unknown) => {
851
+ if (!(error instanceof Stop)) throw error
852
+ })
853
+ return names
854
+ }
855
+
856
+ for (const [fixFirst, green] of [
857
+ [true, false],
858
+ [true, true],
859
+ [false, true],
860
+ [false, false],
861
+ ] as const) {
862
+ it(`fixFirst ${fixFirst}, green ${green} match the options form`, async () => {
863
+ const modern = await requestNames(() => buildTail("base", { fixFirst, green }))
864
+ const legacy = await requestNames(() => buildTail(fixFirst, "base", green))
865
+ expect(legacy).toEqual(modern)
866
+ })
867
+ }
868
+
869
+ it("fixFirst and green each change the request sequence", async () => {
870
+ const seq = (fixFirst: boolean, green: boolean) =>
871
+ requestNames(() => buildTail(fixFirst, "base", green))
872
+ const base = await seq(false, false)
873
+ expect(await seq(true, false)).not.toEqual(base)
874
+ expect(await seq(false, true)).not.toEqual(base)
875
+ })
876
+
877
+ for (const bad of [undefined, null, 1, {}]) {
878
+ it(`rejects naming both forms for sole first argument ${String(bad)}`, async () => {
879
+ const names: string[] = []
880
+ const rejected = expect(requestNames(() => buildTail(bad as never), names)).rejects
881
+ await rejected.toThrow("buildTail(base, options?)")
882
+ await rejected.toThrow("buildTail(fixFirst, base, green?, options?)")
883
+ expect(names).toEqual([])
884
+ })
885
+ }
886
+
887
+ it("rejects an undefined first argument followed by an options object", async () => {
888
+ const names: string[] = []
889
+ const rejected = expect(
890
+ requestNames(() => buildTail(undefined as never, { green: true } as never), names),
891
+ ).rejects
892
+ await rejected.toThrow("buildTail(base, options?)")
893
+ await rejected.toThrow("buildTail(fixFirst, base, green?, options?)")
894
+ expect(names).toEqual([])
895
+ })
896
+
897
+ it("still runs buildTail(undefined, base) as a legacy call", async () => {
898
+ const names: string[] = []
899
+ await requestNames(() => buildTail(undefined as never, "base"), names)
900
+ expect(names).not.toEqual([])
901
+ })
902
+
903
+ // pins `| undefined` on BuildTailOptions under exactOptionalPropertyTypes (typecheck)
904
+ it("explicitly undefined fixFirst and green default like omitted ones", async () => {
905
+ expect(
906
+ await requestNames(() => buildTail("base", { fixFirst: undefined, green: undefined })),
907
+ ).toEqual(await requestNames(() => buildTail("base")))
908
+ })
909
+ })
813
910
  })
814
911
 
912
+ // pins the public re-exports from the feature entry point and the published workflows entry
913
+ export type _PublicBuildTailOptions = [PublicBuildTailOptions, bundled.BuildTailOptions]
914
+
815
915
  describe("buildTail without rules", () => {
816
916
  const RULES = "Change only these paths"
817
917
 
@@ -850,7 +950,7 @@ describe("buildTail without rules", () => {
850
950
  },
851
951
  ),
852
952
  )
853
- await buildTail(true, "base", false, {}).catch((error: unknown) => {
953
+ await buildTail("base", { fixFirst: true }).catch((error: unknown) => {
854
954
  if (!(error instanceof Stop)) throw error
855
955
  })
856
956
  return prompts