@pmelab/gtd 27.9.0 → 28.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 (37) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +50 -26
  3. package/claude/hooks/doors.ts +12 -0
  4. package/claude/hooks/register.tsx +2 -4
  5. package/claude/hooks/ship.ts +115 -20
  6. package/dist/chunks/{reserved-UdSuHDUJ.mjs → reserved-BIsngMGL.mjs} +168 -27
  7. package/dist/chunks/{tail-fj60rQ3y.mjs → tail-DHEaOHyp.mjs} +1 -1
  8. package/dist/gtd.bundle.mjs +43 -15
  9. package/dist/testkit.mjs +43 -15
  10. package/dist/workflows/cleanup.mjs +3 -4
  11. package/dist/workflows/feature.mjs +2 -2
  12. package/dist/workflows/fix.mjs +2 -2
  13. package/dist/workflows/review.mjs +2 -2
  14. package/dist/workflows/rollout.mjs +19 -4
  15. package/dist/workflows.mjs +186 -127
  16. package/package.json +1 -1
  17. package/skills/authoring/SKILL.md +4 -3
  18. package/src/cli/Cli.ts +4 -4
  19. package/src/install/ConfigScaffold.ts +4 -1
  20. package/src/install/Install.test.ts +10 -0
  21. package/src/install/Install.ts +5 -2
  22. package/src/workflow/load.test.ts +82 -5
  23. package/src/workflow/load.ts +40 -16
  24. package/src/workflows/cleanup/cleanup.feature +21 -0
  25. package/src/workflows/cleanup/cleanup.ts +1 -1
  26. package/src/workflows/index.ts +2 -1
  27. package/src/workflows/rollout/rollout.feature +282 -2
  28. package/src/workflows/rollout/rollout.test.ts +96 -2
  29. package/src/workflows/rollout/rollout.ts +41 -3
  30. package/src/workflows/shared/options.test.ts +25 -2
  31. package/src/workflows/shared/options.ts +7 -4
  32. package/src/workflows/shared/registry.test.ts +41 -1
  33. package/src/workflows/shared/registry.ts +26 -1
  34. package/src/workflows/shared/text.test.ts +252 -2
  35. package/src/workflows/shared/text.ts +44 -1
  36. package/src/workflows/shared/vars.ts +2 -0
  37. package/dist/chunks/registry-CP3YuV9j.mjs +0 -101
@@ -548,31 +548,43 @@ const doorsFrom = (exports: Record<string, unknown>): ReadonlyMap<string, Door>
548
548
 
549
549
  /**
550
550
  * Each exported workflow's own doors, onto the name it is exported under. The
551
- * `doors` export adds to them and settles a name two workflows both declare.
551
+ * `doors` export adds to them and declares a name several workflows clash on.
552
552
  */
553
553
  const derivedDoors = (
554
554
  catalogue: ReadonlyMap<string, LoadedWorkflow>,
555
555
  declared: ReadonlyMap<string, Door>,
556
- ): ReadonlyMap<string, Door> => {
557
- const doors = new Map<string, Door>()
556
+ ): {
557
+ readonly doors: ReadonlyMap<string, Door>
558
+ readonly clashes: ReadonlyMap<string, readonly string[]>
559
+ } => {
560
+ const builders = new Map<string, Door[]>()
558
561
  for (const [workflow, loaded] of catalogue) {
559
562
  for (const [name, spec] of Object.entries(loaded.doors ?? {})) {
560
563
  const door: Door = { ...spec, workflow }
561
564
  const problem = doorProblem(name, door)
562
565
  if (problem !== undefined)
563
566
  throw new Error(`workflow "${workflow}" builds door "${name}", which ${problem}`)
564
- const other = doors.get(name)
565
- if (other !== undefined && !declared.has(name)) {
566
- throw new Error(
567
- `door "${name}" is built by workflows "${other.workflow}" and "${workflow}" — declare it in the "doors" export to choose one (a declared door replaces the derived one, so restate its args and vars)`,
568
- )
569
- }
570
- doors.set(name, door)
567
+ builders.set(name, [...(builders.get(name) ?? []), door])
571
568
  }
572
569
  }
573
- return new Map([...doors, ...declared])
570
+ const doors = new Map<string, Door>()
571
+ const clashes = new Map<string, readonly string[]>()
572
+ for (const [name, built] of builders) {
573
+ const owner = ownerOf(name, built)
574
+ if (owner !== undefined) doors.set(name, owner)
575
+ else if (!declared.has(name))
576
+ clashes.set(
577
+ name,
578
+ built.map((door) => door.workflow),
579
+ )
580
+ }
581
+ return { doors: new Map([...doors, ...declared]), clashes }
574
582
  }
575
583
 
584
+ /** The export named after the door owns it, else the sole builder; a clash of several builds no door. */
585
+ const ownerOf = (name: string, built: readonly Door[]): Door | undefined =>
586
+ built.find((door) => door.workflow === name) ?? (built.length === 1 ? built[0] : undefined)
587
+
576
588
  /** Sort the doors and check each names a startable workflow. */
577
589
  const checkDoors = (
578
590
  declared: ReadonlyMap<string, Door>,
@@ -628,16 +640,28 @@ const loadWorkflows = (module: WorkflowModule, shipped: ShippedModules): Loaded
628
640
  }),
629
641
  )
630
642
  const defaultWorkflow = built.get(value)
643
+ const derived =
644
+ rejections.length > 0
645
+ ? { doors: new Map<string, Door>(), clashes: new Map<string, readonly string[]>() }
646
+ : derivedDoors(catalogue, doorsFrom(exports))
631
647
  return {
632
648
  file,
633
649
  catalogue,
634
- doors:
635
- rejections.length > 0
636
- ? new Map()
637
- : checkDoors(derivedDoors(catalogue, doorsFrom(exports)), catalogue),
650
+ doors: checkDoors(derived.doors, catalogue),
638
651
  default: defaultWorkflow && { ...defaultWorkflow, name: defaultName },
639
652
  config: compileConfig(exports["config"], module.filepath),
640
- diagnostics: nameRejections(rejections),
653
+ diagnostics: [
654
+ ...nameRejections(rejections),
655
+ ...[...derived.clashes].map(([name, builders]): Diagnostic => {
656
+ const names = [...builders].sort().map((b) => JSON.stringify(b))
657
+ return {
658
+ severity: "warning",
659
+ message: `door "${name}" is built by workflows ${names.slice(0, -1).join(", ")} and ${names.at(-1)} and none is exported under that name — declare it in the "doors" export`,
660
+ path: ["doors", name],
661
+ origin: file.origin,
662
+ }
663
+ }),
664
+ ],
641
665
  }
642
666
  }
643
667
 
@@ -75,6 +75,27 @@ Feature: cleanup removes a default-on release flag and keeps its flag-on path
75
75
  And the process is driven
76
76
  Then the process rests at "build.review.reviewing"
77
77
 
78
+ @inmem
79
+ Scenario: gtd door cleanup starts the cleanup of its flag argument and reaches the review
80
+ Given step "cleanup.removing" writes "src/flags/index.ts" with:
81
+ """
82
+ export const FLAGS = [
83
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
84
+ ] as const
85
+ """
86
+ And step "cleanup.removing" writes "src/calc.ts" with:
87
+ """
88
+ export const on = true
89
+ """
90
+ And step "build.health.check" changes nothing
91
+ When gtd opens door "cleanup" with "live"
92
+ And the process is driven
93
+ Then the process rests at "build.review.reviewing"
94
+
95
+ @inmem
96
+ Scenario: gtd door cleanup with no argument is refused naming the flag
97
+ Then opening door "cleanup" is refused with "usage: gtd door cleanup <flag>"
98
+
78
99
  @inmem
79
100
  Scenario: a fix after a red suite that restores the registry entry is refused
80
101
  Given step "cleanup.removing" writes "src/flags/index.ts" with:
@@ -49,4 +49,4 @@ export const cleanupFlow = async (): Promise<void> => {
49
49
  await handOffTail(false, start(), guard)
50
50
  }
51
51
 
52
- export const cleanup = flagShipped(cleanupFlow)
52
+ export const cleanup = flagShipped(cleanupFlow, "cleanup")
@@ -35,6 +35,7 @@ export default feature
35
35
 
36
36
  /**
37
37
  * @deprecated Empty: each shipped workflow builds its own doors, so a config
38
- * gets `fix` and `review [base]` by exporting `fix` and `review`.
38
+ * gets `fix` and `review [base]` by exporting `fix` and `review`, and
39
+ * `rollout <flag>` and `cleanup <flag>` by exporting `rollout` and `cleanup`.
39
40
  */
40
41
  export const doors: Doors = {}
@@ -47,11 +47,16 @@ Feature: rollout turns one release flag default-on and touches nothing else
47
47
  ] as const
48
48
  """
49
49
  And step "rollout.suite.check" changes nothing
50
+ And step "rollout.mentions" runs its script
50
51
  When gtd starts workflow "rollout" with "--var flag=alpha"
51
52
  And the process is driven
52
53
  Then the process rests at "idle"
53
54
  And "src/flags/index.ts" contains "kind: \"release\", defaultOn: true },"
54
- And the last commit subject is "gtd(check): rollout.suite.check → idle"
55
+ And the commit subjects end with:
56
+ """
57
+ gtd(check): rollout.suite.check → rollout.mentions
58
+ gtd(check): rollout.mentions → idle
59
+ """
55
60
 
56
61
  Scenario: a turn also changing a source file is refused naming that path
57
62
  Given step "rollout.flipping" writes "src/flags/index.ts" with:
@@ -98,11 +103,34 @@ Feature: rollout turns one release flag default-on and touches nothing else
98
103
  ] as const
99
104
  """
100
105
  And step "rollout.suite.check" changes nothing
106
+ And step "rollout.mentions" runs its script
101
107
  When gtd starts workflow "elsewhere" with "--var flag=gamma"
102
108
  And the process is driven
103
109
  Then the process rests at "idle"
104
110
  And "config/flags.ts" contains "defaultOn: true"
105
111
 
112
+ @inmem
113
+ Scenario: gtd door rollout starts the rollout of its flag argument
114
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
115
+ """
116
+ export const FLAGS = [
117
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
118
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
119
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
120
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
121
+ ] as const
122
+ """
123
+ And step "rollout.suite.check" changes nothing
124
+ And step "rollout.mentions" runs its script
125
+ When gtd opens door "rollout" with "alpha"
126
+ And the process is driven
127
+ Then the process rests at "idle"
128
+ And "src/flags/index.ts" contains "kind: \"release\", defaultOn: true },"
129
+
130
+ @inmem
131
+ Scenario: gtd door rollout with no argument is refused naming the flag
132
+ Then opening door "rollout" is refused with "usage: gtd door rollout <flag>"
133
+
106
134
  Scenario: a missing flag var is refused before any turn
107
135
  Then starting workflow "rollout" with "" is refused with "gtd: rollout needs --var flag=<name>"
108
136
 
@@ -209,6 +237,258 @@ Feature: rollout turns one release flag default-on and touches nothing else
209
237
  fixed by hand
210
238
  """
211
239
  And step "rollout.suite.check" from now on changes nothing
240
+ And step "rollout.mentions" runs its script
212
241
  When the process is driven
213
242
  Then the process rests at "idle"
214
- And the last commit subject is "gtd(check): rollout.suite.check → idle"
243
+ And the commit subjects end with:
244
+ """
245
+ gtd(check): rollout.suite.check → rollout.mentions
246
+ gtd(check): rollout.mentions → idle
247
+ """
248
+
249
+ @inmem
250
+ Scenario: a stray file written at the mentions step is refused naming that path
251
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
252
+ """
253
+ export const FLAGS = [
254
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
255
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
256
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
257
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
258
+ ] as const
259
+ """
260
+ And step "rollout.suite.check" changes nothing
261
+ And step "rollout.mentions" runs its script
262
+ And step "rollout.mentions" writes "src/stray.ts" with:
263
+ """
264
+ export const x = 1
265
+ """
266
+ When gtd starts workflow "rollout" with "--var flag=alpha"
267
+ Then driving the process is refused with "rollout-only: src/stray.ts changed"
268
+
269
+ @inmem
270
+ Scenario: a second registry entry edited at the mentions step is refused
271
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
272
+ """
273
+ export const FLAGS = [
274
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
275
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
276
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
277
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
278
+ ] as const
279
+ """
280
+ And step "rollout.suite.check" changes nothing
281
+ And step "rollout.mentions" runs its script
282
+ And step "rollout.mentions" writes "src/flags/index.ts" with:
283
+ """
284
+ export const FLAGS = [
285
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
286
+ { name: "beta", owner: "z", expiry: "2027-02-01", kind: "release" },
287
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
288
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
289
+ ] as const
290
+ """
291
+ When gtd starts workflow "rollout" with "--var flag=alpha"
292
+ Then driving the process is refused with "rollout-only: other edits in src/flags/index.ts"
293
+
294
+ @inmem
295
+ Scenario: every line naming the flag is listed, no file is edited, and gtd summary repeats the list
296
+ Given a file "README.md" with:
297
+ """
298
+ # calc
299
+
300
+ The alpha formatter is on under GTD_FLAG_ALPHA=1.
301
+ """
302
+ And a file "src/calc.ts" with:
303
+ """
304
+ export const help = "pass --flag_alpha to enable the alpha formatter"
305
+ """
306
+ And the working tree is committed as "docs: mention the flag"
307
+ And step "rollout.flipping" writes "src/flags/index.ts" with:
308
+ """
309
+ export const FLAGS = [
310
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
311
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
312
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
313
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
314
+ ] as const
315
+ """
316
+ And step "rollout.suite.check" changes nothing
317
+ And step "rollout.mentions" runs its script
318
+ When gtd starts workflow "rollout" with "--var flag=alpha"
319
+ And the process is driven
320
+ Then the process rests at "idle"
321
+ And ".gtd/FEEDBACK.md" does not exist
322
+ And "README.md" contains "The alpha formatter is on under GTD_FLAG_ALPHA=1."
323
+ And "src/calc.ts" contains "pass --flag_alpha to enable the alpha formatter"
324
+ And the output of gtd "summary" contains "`README.md:3`"
325
+ And the output of gtd "summary" contains "src/calc.ts:1"
326
+
327
+ @inmem
328
+ Scenario: gtd summary's grep hint excludes a factory flagRegistry
329
+ Given a file "config/flags.ts" with:
330
+ """
331
+ export const FLAGS = [
332
+ { name: "alpha", owner: "g", expiry: "2027-05-01", kind: "release" },
333
+ ] as const
334
+ """
335
+ And a file "NOTES.md" with:
336
+ """
337
+ alpha 1
338
+ alpha 2
339
+ alpha 3
340
+ alpha 4
341
+ alpha 5
342
+ alpha 6
343
+ alpha 7
344
+ alpha 8
345
+ alpha 9
346
+ alpha 10
347
+ alpha 11
348
+ alpha 12
349
+ alpha 13
350
+ alpha 14
351
+ alpha 15
352
+ alpha 16
353
+ alpha 17
354
+ alpha 18
355
+ alpha 19
356
+ alpha 20
357
+ alpha 21
358
+ alpha 22
359
+ alpha 23
360
+ alpha 24
361
+ alpha 25
362
+ alpha 26
363
+ alpha 27
364
+ alpha 28
365
+ alpha 29
366
+ alpha 30
367
+ alpha 31
368
+ alpha 32
369
+ alpha 33
370
+ alpha 34
371
+ alpha 35
372
+ alpha 36
373
+ alpha 37
374
+ alpha 38
375
+ alpha 39
376
+ alpha 40
377
+ alpha 41
378
+ alpha 42
379
+ alpha 43
380
+ alpha 44
381
+ alpha 45
382
+ alpha 46
383
+ alpha 47
384
+ alpha 48
385
+ alpha 49
386
+ alpha 50
387
+ alpha 51
388
+ alpha 52
389
+ alpha 53
390
+ alpha 54
391
+ alpha 55
392
+ """
393
+ And the working tree is committed as "docs: mention the flag"
394
+ And step "rollout.flipping" writes "config/flags.ts" with:
395
+ """
396
+ export const FLAGS = [
397
+ { name: "alpha", owner: "g", expiry: "2027-05-01", kind: "release", defaultOn: true },
398
+ ] as const
399
+ """
400
+ And step "rollout.suite.check" changes nothing
401
+ And step "rollout.mentions" runs its script
402
+ When gtd starts workflow "elsewhere" with "--var flag=alpha"
403
+ And the process is driven
404
+ Then the process rests at "idle"
405
+ And the output of gtd "summary" contains "':(top,exclude,literal)config/flags.ts'"
406
+
407
+ @inmem
408
+ Scenario: a flag named nowhere outside the registry says so in gtd summary
409
+ Given step "rollout.flipping" writes "src/flags/index.ts" with:
410
+ """
411
+ export const FLAGS = [
412
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
413
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
414
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
415
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
416
+ ] as const
417
+ """
418
+ And step "rollout.suite.check" changes nothing
419
+ And step "rollout.mentions" runs its script
420
+ When gtd starts workflow "rollout" with "--var flag=alpha"
421
+ And the process is driven
422
+ Then the process rests at "idle"
423
+ And ".gtd/FEEDBACK.md" does not exist
424
+ And the output of gtd "summary" contains "no line outside the registry names it"
425
+
426
+ @inmem
427
+ Scenario: the closing commit marks the total and each listed line
428
+ Given a file "README.md" with:
429
+ """
430
+ # calc
431
+
432
+ The alpha formatter is on under GTD_FLAG_ALPHA=1.
433
+ """
434
+ And a file "src/calc.ts" with:
435
+ """
436
+ export const help = "pass --flag_alpha to enable the alpha formatter"
437
+ """
438
+ And the working tree is committed as "docs: mention the flag"
439
+ And step "rollout.flipping" writes "src/flags/index.ts" with:
440
+ """
441
+ export const FLAGS = [
442
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
443
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
444
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
445
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
446
+ ] as const
447
+ """
448
+ And step "rollout.suite.check" changes nothing
449
+ And step "rollout.mentions" runs its script
450
+ When gtd starts workflow "rollout" with "--var flag=alpha"
451
+ And the process is driven
452
+ Then the last commit subject is "gtd(check): rollout.mentions → idle"
453
+ And the last commit body contains "Gtd-Mark: flag-mentions=2"
454
+ And the last commit body contains "Gtd-Mark: flag-mention=README.md:3"
455
+ And the last commit body contains "Gtd-Mark: flag-mention=src/calc.ts:1"
456
+
457
+ @inmem
458
+ Scenario: a finished rollout leaves no state file for the next feature start
459
+ Given a file "README.md" with:
460
+ """
461
+ # calc
462
+
463
+ The alpha formatter is on under GTD_FLAG_ALPHA=1.
464
+ """
465
+ And a file "src/calc.ts" with:
466
+ """
467
+ export const help = "pass --flag_alpha to enable the alpha formatter"
468
+ """
469
+ And the working tree is committed as "docs: mention the flag"
470
+ And step "rollout.flipping" writes "src/flags/index.ts" with:
471
+ """
472
+ export const FLAGS = [
473
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release", defaultOn: true },
474
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release" },
475
+ { name: "ops1", owner: "c", expiry: "2027-03-01", kind: "ops" },
476
+ { name: "live", owner: "d", expiry: "2027-04-01", kind: "release", defaultOn: true },
477
+ ] as const
478
+ """
479
+ And step "rollout.suite.check" changes nothing
480
+ And step "rollout.mentions" runs its script
481
+ And step "unwind" changes nothing
482
+ And step "start-gate.check" changes nothing
483
+ When gtd starts workflow "rollout" with "--var flag=alpha"
484
+ And the process is driven
485
+ Then the process rests at "idle"
486
+ Given a file "TODO.md" with:
487
+ """
488
+ # Add subtract
489
+
490
+ subtract(a, b) returns the difference.
491
+ """
492
+ When the process is driven
493
+ Then the process rests at "design.triage"
494
+ And ".gtd/FEEDBACK.md" does not exist
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { CONFIG_SCAFFOLD } from "../../install/index.js"
3
- import { testRepo } from "../../testkit/index.js"
3
+ import { runsItsScript, testRepo } from "../../testkit/index.js"
4
4
 
5
5
  const CONFIG = `${CONFIG_SCAFFOLD}export { rollout } from "@pmelab/gtd/workflows"\n`
6
6
 
@@ -30,9 +30,13 @@ describe("the rollout workflow", () => {
30
30
  r.write("src/flags/index.ts", FLIPPED)
31
31
  },
32
32
  "rollout.suite.check": () => undefined,
33
+ "rollout.mentions": runsItsScript,
33
34
  })
34
35
  expect(rest.state).toBe("idle")
35
- expect(repo.subjects().at(-1)).toBe("gtd(check): rollout.suite.check → idle")
36
+ expect(repo.subjects().slice(-2)).toEqual([
37
+ "gtd(check): rollout.suite.check → rollout.mentions",
38
+ "gtd(check): rollout.mentions → idle",
39
+ ])
36
40
  })
37
41
 
38
42
  it("lands a flip when the tree was already dirty at the start", async () => {
@@ -42,6 +46,7 @@ describe("the rollout workflow", () => {
42
46
  const rest = await repo.drive({
43
47
  "rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
44
48
  "rollout.suite.check": () => undefined,
49
+ "rollout.mentions": runsItsScript,
45
50
  })
46
51
  expect(rest.state).toBe("idle")
47
52
  })
@@ -160,8 +165,97 @@ describe("the rollout workflow", () => {
160
165
  ),
161
166
  ),
162
167
  "rollout.suite.check": () => undefined,
168
+ "rollout.mentions": runsItsScript,
163
169
  })
164
170
  expect(rest.state).toBe("idle")
165
171
  })
166
172
  })
167
173
  })
174
+
175
+ describe("rollout.mentions", () => {
176
+ const flip = {
177
+ "rollout.flipping": (_: unknown, r: { write: (p: string, c: string) => void }) =>
178
+ r.write("src/flags/index.ts", FLIPPED),
179
+ }
180
+ const finish = async (
181
+ files: Record<string, string>,
182
+ mentions: typeof runsItsScript = runsItsScript,
183
+ ) => {
184
+ const repo = testRepo({
185
+ workflow: CONFIG,
186
+ files: { "src/flags/index.ts": REGISTRY, "src/a.ts": "a\n", ...files },
187
+ })
188
+ await repo.start("rollout", "--var", "flag=alpha")
189
+ const rest = await repo.drive({
190
+ ...flip,
191
+ "rollout.suite.check": () => undefined,
192
+ "rollout.mentions": mentions,
193
+ })
194
+ expect(rest.state).toBe("idle")
195
+ const marks = (repo.messages().at(-1) ?? "")
196
+ .split("\n")
197
+ .filter((l) => l.startsWith("Gtd-Mark: "))
198
+ .map((l) => l.slice("Gtd-Mark: ".length))
199
+ return { repo, marks }
200
+ }
201
+
202
+ it("lists mentions outside the registry and under no .gtd/ path", async () => {
203
+ const { marks } = await finish({
204
+ "README.md": "intro\non under GTD_FLAG_ALPHA=1\n",
205
+ "src/help.ts": 'export const h = "alpha help"\n',
206
+ ".gtd/NOTES.md": "alpha\n",
207
+ })
208
+ expect(marks).toEqual([
209
+ "flag-mentions=2",
210
+ "flag-mention=README.md:2",
211
+ "flag-mention=src/help.ts:1",
212
+ ])
213
+ })
214
+
215
+ it("the mentions script removes a feedback file present at the step; marks list the tree", async () => {
216
+ const { repo, marks } = await finish({ "README.md": "alpha\n" }, (beat, r) => {
217
+ r.write(".gtd/FEEDBACK.md", "alpha\n")
218
+ runsItsScript(beat, r)
219
+ })
220
+ expect(repo.read(".gtd/FEEDBACK.md")).toBeUndefined()
221
+ expect(marks).toEqual(["flag-mentions=1", "flag-mention=README.md:1"])
222
+ })
223
+
224
+ it("refuses a stray file written by the mentions script", async () => {
225
+ await expect(
226
+ finish({}, (beat, r) => {
227
+ runsItsScript(beat, r)
228
+ r.write("src/stray.ts", "x\n")
229
+ }),
230
+ ).rejects.toThrow("gtd land: rollout-only: src/stray.ts changed")
231
+ })
232
+
233
+ it("refuses a registry edit beyond the flip made by the mentions script", async () => {
234
+ await expect(
235
+ finish({}, (beat, r) => {
236
+ runsItsScript(beat, r)
237
+ r.write("src/flags/index.ts", FLIPPED.replace('owner: "b"', 'owner: "z"'))
238
+ }),
239
+ ).rejects.toThrow("rollout-only: other edits in src/flags/index.ts")
240
+ })
241
+
242
+ it("marks 0 when nothing names the flag", async () => {
243
+ const { marks } = await finish({})
244
+ expect(marks).toEqual(["flag-mentions=0"])
245
+ })
246
+
247
+ it("marks the total for 60 mentions but only the first 50 lines", async () => {
248
+ const text = Array.from({ length: 60 }, (_, i) => `alpha ${i}`).join("\n")
249
+ const { marks } = await finish({ "docs/many.md": `${text}\n` })
250
+ expect(marks[0]).toBe("flag-mentions=60")
251
+ expect(marks.filter((m) => m.startsWith("flag-mention="))).toHaveLength(50)
252
+ })
253
+
254
+ it("counts a path the mark grammar rejects without marking it, and never throws", async () => {
255
+ const { marks } = await finish({
256
+ "docs/bad\u0001name.md": "alpha\n",
257
+ "README.md": "alpha\n",
258
+ })
259
+ expect(marks).toEqual(["flag-mentions=2", "flag-mention=README.md:1"])
260
+ })
261
+ })
@@ -1,7 +1,19 @@
1
- import { agent, changesSince, head, read, refuse, scope, vars } from "../../flows/index.js"
1
+ import {
2
+ agent,
3
+ changesSince,
4
+ glob,
5
+ head,
6
+ mark,
7
+ read,
8
+ refuse,
9
+ removeScript,
10
+ run,
11
+ scope,
12
+ vars,
13
+ } from "../../flows/index.js"
2
14
  import { gate } from "../shared/health.js"
3
15
  import { flagShipped } from "../shared/options.js"
4
- import { flipProblems, requireFlag } from "../shared/registry.js"
16
+ import { flagMentions, flipProblems, requireFlag } from "../shared/registry.js"
5
17
  import { coder, FEEDBACK } from "../shared/steps.js"
6
18
 
7
19
  const flippingPrompt = (name: string, registry: string): string =>
@@ -10,6 +22,31 @@ const flippingPrompt = (name: string, registry: string): string =>
10
22
  const suiteBlockedMessage = (): string =>
11
23
  "The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land."
12
24
 
25
+ // Marks past this many are dropped; the summary points at grep for the rest.
26
+ const MARKED_MENTIONS = 50
27
+
28
+ // Lists, never edits: the human fixes the now-stale prose, or cleanup does. The
29
+ // list rides in marks only: a state file left at idle would be published by
30
+ // ship and read as a failed unwind by the next process.
31
+ const listMentions = async (registry: string, name: string, guard: () => void): Promise<void> => {
32
+ await run("mentions", removeScript([FEEDBACK]), { label: "Listing flag mentions" })
33
+ guard()
34
+ const files = glob("**")
35
+ .filter((path) => path !== registry && path !== ".gtd" && !path.startsWith(".gtd/"))
36
+ .sort()
37
+ .map((path) => ({ path, text: read(path) ?? "" }))
38
+ const mentions = flagMentions(files, name)
39
+ mark("flag-mentions", String(mentions.length))
40
+ for (const m of mentions.slice(0, MARKED_MENTIONS)) {
41
+ // A path the mark grammar rejects stays counted above, just unlisted here.
42
+ try {
43
+ mark("flag-mention", `${m.path}:${m.line}`)
44
+ } catch {
45
+ continue
46
+ }
47
+ }
48
+ }
49
+
13
50
  // No review gate — a one-field change needs no review lap.
14
51
  export const rolloutFlow = async (): Promise<void> => {
15
52
  const registry = vars.flagRegistry ?? ""
@@ -38,7 +75,8 @@ export const rolloutFlow = async (): Promise<void> => {
38
75
  })
39
76
  guard()
40
77
  await gate("suite", suiteBlockedMessage(), guard)
78
+ await listMentions(registry, name, guard)
41
79
  })
42
80
  }
43
81
 
44
- export const rollout = flagShipped(rolloutFlow)
82
+ export const rollout = flagShipped(rolloutFlow, "rollout")