@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +50 -26
- package/claude/hooks/doors.ts +12 -0
- package/claude/hooks/register.tsx +2 -4
- package/claude/hooks/ship.ts +115 -20
- package/dist/chunks/{reserved-UdSuHDUJ.mjs → reserved-BIsngMGL.mjs} +168 -27
- package/dist/chunks/{tail-fj60rQ3y.mjs → tail-DHEaOHyp.mjs} +1 -1
- package/dist/gtd.bundle.mjs +43 -15
- package/dist/testkit.mjs +43 -15
- package/dist/workflows/cleanup.mjs +3 -4
- package/dist/workflows/feature.mjs +2 -2
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +19 -4
- package/dist/workflows.mjs +186 -127
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +4 -3
- package/src/cli/Cli.ts +4 -4
- package/src/install/ConfigScaffold.ts +4 -1
- package/src/install/Install.test.ts +10 -0
- package/src/install/Install.ts +5 -2
- package/src/workflow/load.test.ts +82 -5
- package/src/workflow/load.ts +40 -16
- package/src/workflows/cleanup/cleanup.feature +21 -0
- package/src/workflows/cleanup/cleanup.ts +1 -1
- package/src/workflows/index.ts +2 -1
- package/src/workflows/rollout/rollout.feature +282 -2
- package/src/workflows/rollout/rollout.test.ts +96 -2
- package/src/workflows/rollout/rollout.ts +41 -3
- package/src/workflows/shared/options.test.ts +25 -2
- package/src/workflows/shared/options.ts +7 -4
- package/src/workflows/shared/registry.test.ts +41 -1
- package/src/workflows/shared/registry.ts +26 -1
- package/src/workflows/shared/text.test.ts +252 -2
- package/src/workflows/shared/text.ts +44 -1
- package/src/workflows/shared/vars.ts +2 -0
- package/dist/chunks/registry-CP3YuV9j.mjs +0 -101
package/src/workflow/load.ts
CHANGED
|
@@ -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
|
|
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
|
-
):
|
|
557
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
package/src/workflows/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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().
|
|
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 {
|
|
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")
|