@pmelab/gtd 27.1.0 → 27.3.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 (45) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -9
  3. package/dist/chunks/registry-CP3YuV9j.mjs +101 -0
  4. package/dist/chunks/{reserved-BL9krPao.mjs → reserved-BPdVZkgv.mjs} +66 -73
  5. package/dist/chunks/tail-iLAMoigl.mjs +81 -0
  6. package/dist/gtd.bundle.mjs +5 -2
  7. package/dist/testkit.mjs +17 -2
  8. package/dist/workflows/cleanup.mjs +37 -0
  9. package/dist/workflows/feature.mjs +119 -5
  10. package/dist/workflows/fix.mjs +2 -1
  11. package/dist/workflows/review.mjs +2 -1
  12. package/dist/workflows/rollout.mjs +33 -0
  13. package/dist/workflows.mjs +362 -25
  14. package/package.json +11 -1
  15. package/src/flags/index.test.ts +28 -0
  16. package/src/flags/index.ts +9 -4
  17. package/src/testkit/EmittedScriptRecognizer.ts +21 -0
  18. package/src/testkit/EmittedScripts.test.ts +9 -0
  19. package/src/testkit/steps.ts +26 -0
  20. package/src/testkit/testkit.feature +8 -1
  21. package/src/workflows/cleanup/cleanup.feature +323 -0
  22. package/src/workflows/cleanup/cleanup.test.ts +272 -0
  23. package/src/workflows/cleanup/cleanup.ts +52 -0
  24. package/src/workflows/cleanup/index.ts +6 -0
  25. package/src/workflows/feature/index.ts +1 -0
  26. package/src/workflows/feature/refactor.test.ts +466 -0
  27. package/src/workflows/feature/refactor.ts +178 -0
  28. package/src/workflows/index.ts +4 -1
  29. package/src/workflows/rollout/index.ts +6 -0
  30. package/src/workflows/rollout/rollout.feature +214 -0
  31. package/src/workflows/rollout/rollout.test.ts +167 -0
  32. package/src/workflows/rollout/rollout.ts +44 -0
  33. package/src/workflows/shared/access.ts +2 -1
  34. package/src/workflows/shared/health.test.ts +103 -0
  35. package/src/workflows/shared/health.ts +32 -7
  36. package/src/workflows/shared/options.test.ts +30 -0
  37. package/src/workflows/shared/options.ts +20 -0
  38. package/src/workflows/shared/registry.test.ts +305 -0
  39. package/src/workflows/shared/registry.ts +158 -0
  40. package/src/workflows/shared/skills.test.ts +6 -0
  41. package/src/workflows/shared/skills.ts +5 -1
  42. package/src/workflows/shared/tail.test.ts +36 -2
  43. package/src/workflows/shared/tail.ts +30 -18
  44. package/src/workflows/shared/text.ts +35 -0
  45. package/src/workflows/shared/vars.ts +1 -0
@@ -1,5 +1,6 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { toRequest } from "../../judges/index.js"
3
+ import { testRepo } from "../../testkit/index.js"
3
4
  import { retryQuestion } from "./health.js"
4
5
 
5
6
  describe("retryQuestion", () => {
@@ -11,3 +12,105 @@ describe("retryQuestion", () => {
11
12
  ).toBeGreaterThanOrEqual(2)
12
13
  })
13
14
  })
15
+
16
+ type W = { write(p: string, c: string): void }
17
+
18
+ describe("gate guard", () => {
19
+ const config = (guard: string) =>
20
+ [
21
+ `import { gate } from "@pmelab/gtd/workflows"`,
22
+ `import { agent, refuse } from "@pmelab/gtd/flows"`,
23
+ `export const held = async () => { await agent("first", "go"); ${guard}; await gate("suite", "fix it", guard) }`,
24
+ `export default held`,
25
+ ].join("\n")
26
+
27
+ const red = {
28
+ first: (_: unknown, r: W) => r.write("f", "1\n"),
29
+ "suite.check": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", "red\n"),
30
+ }
31
+
32
+ it("runs the guard after a blocked landing and lets its refusal stop it", async () => {
33
+ const repo = testRepo({
34
+ workflow: config('const guard = () => { refuse("guarded: nope") }'),
35
+ env: { GTD_TEST_COMMAND: "true" },
36
+ })
37
+ await repo.start("held")
38
+ await repo.drive(red)
39
+ await expect(repo.drive({ "suite.blocked": (_, r) => r.write("x", "1\n") })).rejects.toThrow(
40
+ "guarded: nope",
41
+ )
42
+ })
43
+
44
+ it("never runs the guard after the check step", async () => {
45
+ const repo = testRepo({
46
+ workflow: config('const guard = () => { refuse("guarded: nope") }'),
47
+ env: { GTD_TEST_COMMAND: "true" },
48
+ })
49
+ await repo.start("held")
50
+ const rest = await repo.drive(red)
51
+ expect(rest.state).toBe("suite.blocked")
52
+ })
53
+ })
54
+
55
+ describe("healthy describeGuard", () => {
56
+ const config = (options: string) =>
57
+ [
58
+ `import { healthy } from "@pmelab/gtd/workflows"`,
59
+ `import { agent, read, refuse } from "@pmelab/gtd/flows"`,
60
+ `const describeGuard = () => { refuse("describe-guarded: nope") }`,
61
+ `const guard = () => { refuse("guarded: nope") }`,
62
+ `export const held = async () => { await agent("first", "go"); await healthy(() => agent("fix", "go"), ${options}) }`,
63
+ `export default held`,
64
+ ].join("\n")
65
+
66
+ let runs = 0
67
+ const handlers = (extra: Record<string, unknown>) => ({
68
+ "health.check": (_: unknown, r: W) => r.write(".gtd/FEEDBACK.md", `red ${runs++}\n`),
69
+ "health.judge": () => '[{"id":"verdict","answer":"progress","p":0.9}]',
70
+ fix: (_: unknown, r: W) => r.write("f", `${runs}\n`),
71
+ first: (_: unknown, r: W) => r.write("start", "1\n"),
72
+ ...extra,
73
+ })
74
+ const make = (options: string) =>
75
+ testRepo({ workflow: config(options), env: { GTD_TEST_COMMAND: "true" } })
76
+
77
+ it("runs after a describe landing and its refusal stops the turn", async () => {
78
+ runs = 0
79
+ const repo = make("{ describeGuard }")
80
+ await repo.start("held")
81
+ await expect(repo.drive(handlers({ "health.describe": () => undefined }))).rejects.toThrow(
82
+ "describe-guarded: nope",
83
+ )
84
+ })
85
+
86
+ it("never runs after the human stop rest", async () => {
87
+ runs = 0
88
+ // Refuses only once a person's edit exists, which only a post-stop run can see.
89
+ const repo = make(
90
+ '{ describeGuard: () => { if (read("human.txt") !== undefined) refuse("describe-guarded: late") } }',
91
+ )
92
+ await repo.start("held")
93
+ const rest = await repo.drive(handlers({ "health.describe": () => undefined }))
94
+ expect(rest.state).toBe("health.stop")
95
+ const after = await repo.drive(
96
+ handlers({ "health.stop": (_: unknown, r: W) => r.write("human.txt", "edit\n") }),
97
+ )
98
+ expect(after.state).not.toBe("health.stop")
99
+ })
100
+
101
+ it("keeps guard running after describe", async () => {
102
+ runs = 0
103
+ // Refuses only once the describe turn has written its analysis.
104
+ const repo = make(
105
+ '{ guard: () => { if (read(".gtd/ESCALATION.md") !== undefined) refuse("guarded: nope") } }',
106
+ )
107
+ await repo.start("held")
108
+ await expect(
109
+ repo.drive(
110
+ handlers({
111
+ "health.describe": (_: unknown, r: W) => r.write(".gtd/ESCALATION.md", "why\n"),
112
+ }),
113
+ ),
114
+ ).rejects.toThrow("guarded: nope")
115
+ })
116
+ })
@@ -28,13 +28,30 @@ const escalationSteps: kit.EscalationSteps = {
28
28
  exhausted: escalationExhausted,
29
29
  }
30
30
 
31
+ /** Runs after an agent or human turn; refuses it by throwing (`refuse`). */
32
+ export type Guard = () => void
33
+
34
+ export const guarded =
35
+ <A extends unknown[]>(turn: (...a: A) => Promise<void>, guard?: Guard) =>
36
+ async (...a: A): Promise<void> => {
37
+ await turn(...a)
38
+ guard?.()
39
+ }
40
+
41
+ const guardedSteps = (guard?: Guard, describeGuard?: Guard): kit.EscalationSteps => ({
42
+ limit: escalationSteps.limit,
43
+ describe: guarded(guarded(escalationSteps.describe, guard), describeGuard),
44
+ stop: guarded(escalationSteps.stop, guard),
45
+ exhausted: guarded(escalationSteps.exhausted, guard),
46
+ })
47
+
31
48
  /**
32
49
  * Hand a red suite that repeated attempts could not fix to a person: an
33
50
  * agent writes `.gtd/ESCALATION.md`, a human narrows it. From the third round
34
51
  * on, the human is told the budget is spent instead.
35
52
  */
36
- export const escalation = (count: kit.EscalationCount): Promise<void> =>
37
- kit.escalation(count, escalationSteps)
53
+ export const escalation = (count: kit.EscalationCount, guard?: Guard): Promise<void> =>
54
+ kit.escalation(count, guardedSteps(guard))
38
55
 
39
56
  /** "Is this red round the same failure as the last one?" — asked of two reports, or of nothing when one is empty. */
40
57
  export const retryQuestion = (comparable: boolean): JudgeQuestion =>
@@ -79,6 +96,10 @@ export interface HealthOptions {
79
96
  readonly escalations?: kit.EscalationCount
80
97
  /** Paths removed on a green run. */
81
98
  readonly sweepOnGreen?: readonly string[]
99
+ /** Runs after every fix and escalation turn. */
100
+ readonly guard?: Guard | undefined
101
+ /** Runs after the escalation's agent turn only; the human rests stay unguarded. */
102
+ readonly describeGuard?: Guard | undefined
82
103
  }
83
104
 
84
105
  const runSuite = (options: HealthOptions): Promise<boolean> =>
@@ -95,20 +116,24 @@ const runSuite = (options: HealthOptions): Promise<boolean> =>
95
116
  * identical to the previous round's.
96
117
  */
97
118
  export const healthy = (fix: () => Promise<void>, options: HealthOptions = {}): Promise<void> =>
98
- kit.healthy(fix, {
119
+ kit.healthy(guarded(fix, options.guard), {
99
120
  check: () => runSuite(options),
100
121
  report: FEEDBACK,
101
122
  fixCap: FIX_CAP,
102
123
  fixesSoFar: options.fixesSoFar ?? 0,
103
124
  sameFailure,
104
125
  escalations: options.escalations ?? { rounds: 0 },
105
- escalation: escalationSteps,
126
+ escalation: guardedSteps(options.guard, options.describeGuard),
106
127
  })
107
128
 
108
- /** Hold the process at `<name>.blocked` until the suite is green. */
109
- export const gate = (name: string, message: string): Promise<void> =>
129
+ /**
130
+ * Hold the process at `<name>.blocked` until the suite is green. `guard` runs
131
+ * after each `blocked` landing only — never after a check, whose refusal would
132
+ * re-run the same script forever.
133
+ */
134
+ export const gate = (name: string, message: string, guard?: Guard): Promise<void> =>
110
135
  kit.gate(
111
136
  name,
112
137
  () => baseline("check"),
113
- () => human("blocked", { message, label: "Baseline is red", file: FEEDBACK }),
138
+ guarded(() => human("blocked", { message, label: "Baseline is red", file: FEEDBACK }), guard),
114
139
  )
@@ -1,8 +1,10 @@
1
1
  import { describe, expect, it } from "vitest"
2
2
  import { configure } from "../../flows/index.js"
3
3
  import * as workflows from "../index.js"
4
+ import * as options from "./options.js"
4
5
 
5
6
  const { defaults, envDefaults, feature, fix, review, ShippedOptions } = workflows
7
+ const { flagShipped } = options
6
8
 
7
9
  describe("the shipped workflow factories", () => {
8
10
  it("take exactly the settings the shipped workflows declare as options", () => {
@@ -30,6 +32,14 @@ describe("the shipped workflow factories", () => {
30
32
  })
31
33
  })
32
34
 
35
+ it("default refactorFlagPattern to GTD_FLAG_ matches and let a factory override win", () => {
36
+ expect(defaults.refactorFlagPattern).toBe("\\bGTD_FLAG_\\w+")
37
+ expect(configure(feature({ refactorFlagPattern: "\\bFF_\\w+" }))).toMatchObject({
38
+ ok: true,
39
+ build: { defaults: { refactorFlagPattern: "\\bFF_\\w+" } },
40
+ })
41
+ })
42
+
33
43
  it("build the shipped settings unchanged with no options", () => {
34
44
  for (const factory of [feature, fix, review]) {
35
45
  expect(configure(factory)).toMatchObject({ ok: true, build: { defaults, envDefaults } })
@@ -57,4 +67,24 @@ describe("the shipped workflow factories", () => {
57
67
  const result = configure(fix({ judgeBudgetBytes: 65536 }))
58
68
  expect(result).toMatchObject({ ok: false, issues: [{ path: ["judgeBudgetBytes"] }] })
59
69
  })
70
+
71
+ describe("flagShipped", () => {
72
+ const factory = flagShipped(async () => {})
73
+
74
+ it("declare flag and flagRegistry as process settings, so --var flag= is accepted", () => {
75
+ expect(configure(factory)).toMatchObject({
76
+ ok: true,
77
+ build: {
78
+ defaults: { ...defaults, flag: "", flagRegistry: "src/flags/index.ts" },
79
+ envDefaults,
80
+ },
81
+ })
82
+ })
83
+
84
+ it("fail the load on an unknown option", () => {
85
+ // @ts-expect-error — `flag` is a --var, not an option
86
+ const result = configure(factory({ flag: "x" }))
87
+ expect(result).toMatchObject({ ok: false, issues: [{ path: ["flag"] }] })
88
+ })
89
+ })
60
90
  })
@@ -16,6 +16,7 @@ export const ShippedOptions = Schema.Struct({
16
16
  judgeIdenticalMinP: setting,
17
17
  reviewNoteActionable: setting,
18
18
  reviewSeverityMinP: setting,
19
+ refactorFlagPattern: setting,
19
20
  qualityReviews: setting,
20
21
  testCommand: setting,
21
22
  plannerModel: setting,
@@ -48,3 +49,22 @@ export const shipped = (
48
49
  base,
49
50
  }),
50
51
  )
52
+
53
+ export const FlagOptions = Schema.Struct({ ...ShippedOptions.fields, flagRegistry: setting })
54
+
55
+ export type FlagOptions = Schema.Schema.Encoded<typeof FlagOptions>
56
+
57
+ export const flagShipped = (flow: Flow): WorkflowFactory<FlagOptions> =>
58
+ defineWorkflow(
59
+ Schema.standardSchemaV1(FlagOptions, { onExcessProperty: "error", errors: "all" }),
60
+ (options) => {
61
+ const flagDefaults = { ...defaults, flag: "", flagRegistry: "src/flags/index.ts" }
62
+ return {
63
+ flow,
64
+ defaults: over(flagDefaults, options),
65
+ envDefaults: over(envDefaults, options),
66
+ doors: {},
67
+ base: undefined,
68
+ }
69
+ },
70
+ )
@@ -0,0 +1,305 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import { installContext } from "../../flows/index.js"
3
+ import { flagReferences, flipProblems, registryEntry, requireFlag } from "./registry.js"
4
+
5
+ const ONE_LINE = `export const FLAGS = [
6
+ { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
7
+ { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release", defaultOn: true },
8
+ { name: "gamma", owner: "c", expiry: "2027-03-01", kind: "ops" },
9
+ ] as const
10
+ `
11
+
12
+ const WRAPPED = `export const FLAGS = [
13
+ {
14
+ name: "alpha",
15
+ owner: "a",
16
+ expiry: "2027-01-01",
17
+ kind: "release",
18
+ },
19
+ {
20
+ name: "beta",
21
+ owner: "b",
22
+ expiry: "2027-02-01",
23
+ kind: "release",
24
+ defaultOn: true,
25
+ },
26
+ ] as const
27
+ `
28
+
29
+ describe("registryEntry", () => {
30
+ it("finds name, kind and defaultOn in a one-line entry", () => {
31
+ expect(registryEntry(ONE_LINE, "alpha")).toEqual({
32
+ kind: "release",
33
+ defaultOn: false,
34
+ })
35
+ expect(registryEntry(ONE_LINE, "beta")).toEqual({
36
+ kind: "release",
37
+ defaultOn: true,
38
+ })
39
+ expect(registryEntry(ONE_LINE, "gamma")?.kind).toBe("ops")
40
+ })
41
+
42
+ it("finds them in an oxfmt-wrapped entry", () => {
43
+ expect(registryEntry(WRAPPED, "alpha")).toEqual({
44
+ kind: "release",
45
+ defaultOn: false,
46
+ })
47
+ expect(registryEntry(WRAPPED, "beta")?.defaultOn).toBe(true)
48
+ })
49
+
50
+ it("is undefined for an unknown name", () => {
51
+ expect(registryEntry(ONE_LINE, "delta")).toBeUndefined()
52
+ })
53
+
54
+ it("ignores a kind inside a block comment ahead of the real one", () => {
55
+ const text = 'export const FLAGS = [{ name: "alpha", /* kind: "ops" */ kind: "release" }]'
56
+ expect(registryEntry(text, "alpha")?.kind).toBe("release")
57
+ })
58
+
59
+ it("reads no kind from one with no closing quote", () => {
60
+ const text = 'export const FLAGS = [{ name: "alpha", kind: "release-candidate" }]'
61
+ expect(registryEntry(text, "alpha")?.kind).toBe("")
62
+ })
63
+
64
+ it("reads the kind right after a comment with no space between", () => {
65
+ const text = 'export const FLAGS = [{ name: "alpha", /*x*/kind: "release" }]'
66
+ expect(registryEntry(text, "alpha")?.kind).toBe("release")
67
+ })
68
+
69
+ it("ignores a kind inside a line comment on a prior line", () => {
70
+ const text =
71
+ 'export const FLAGS = [{\n name: "alpha",\n // kind: "ops"\n kind: "release",\n}]'
72
+ expect(registryEntry(text, "alpha")?.kind).toBe("release")
73
+ })
74
+
75
+ it("ignores a kind inside a string ahead of the real one", () => {
76
+ const text = `export const FLAGS = [{ name: "alpha", owner: "kind: 'ops'", kind: "release" }]`
77
+ expect(registryEntry(text, "alpha")?.kind).toBe("release")
78
+ })
79
+ })
80
+
81
+ describe("flipProblems", () => {
82
+ it("is empty when defaultOn is added to the named entry", () => {
83
+ const after = ONE_LINE.replace(
84
+ 'kind: "release" },\n { name: "beta"',
85
+ 'kind: "release", defaultOn: true },\n { name: "beta"',
86
+ )
87
+ expect(after).not.toBe(ONE_LINE)
88
+ expect(flipProblems(ONE_LINE, after, "alpha")).toEqual([])
89
+ })
90
+
91
+ it("is empty when false flips to true", () => {
92
+ const before = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: false },')
93
+ const after = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: true },')
94
+ expect(flipProblems(before, after, "alpha")).toEqual([])
95
+ })
96
+
97
+ it("ignores re-wrapping and a trailing comma inside the entry", () => {
98
+ const after = WRAPPED.replace(
99
+ 'kind: "release",\n },',
100
+ 'kind: "release",\n defaultOn: true,\n },',
101
+ )
102
+ expect(flipProblems(WRAPPED, after, "alpha")).toEqual([])
103
+ const oneLiner = ONE_LINE.replace(
104
+ 'kind: "release" },\n { name: "beta"',
105
+ 'kind: "release", defaultOn: true, },\n { name: "beta"',
106
+ )
107
+ expect(flipProblems(ONE_LINE, oneLiner, "alpha")).toEqual([])
108
+ })
109
+
110
+ describe("with a non-BMP character in the entry", () => {
111
+ const EMOJI =
112
+ 'export const FLAGS = [\n { name: "alpha", owner: "Team 🚀", kind: "release" },\n]'
113
+
114
+ it("is empty for an oxfmt re-wrap with a trailing comma", () => {
115
+ const after =
116
+ 'export const FLAGS = [\n {\n name: "alpha",\n owner: "Team 🚀",\n kind: "release",\n defaultOn: true,\n },\n]'
117
+ expect(flipProblems(EMOJI, after, "alpha")).toEqual([])
118
+ })
119
+
120
+ it("faults an out-of-scope edit to a field after the emoji", () => {
121
+ const before =
122
+ 'export const FLAGS = [\n { name: "alpha", owner: "Team 🚀", kind: " release", defaultOn: false },\n]'
123
+ const after =
124
+ 'export const FLAGS = [\n { name: "alpha", owner: "Team 🚀", kind: "release", defaultOn: true },\n]'
125
+ expect(flipProblems(before, after, "alpha")).toContain(
126
+ 'entry "alpha" changed beyond defaultOn',
127
+ )
128
+ })
129
+ })
130
+
131
+ it("faults a change to a second entry", () => {
132
+ const after = ONE_LINE.replace(
133
+ 'kind: "release" },',
134
+ 'kind: "release", defaultOn: true },',
135
+ ).replace('owner: "c"', 'owner: "z"')
136
+ expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
137
+ })
138
+
139
+ it("faults an owner or expiry change inside the entry", () => {
140
+ const owner = ONE_LINE.replace(
141
+ 'owner: "a", expiry: "2027-01-01", kind: "release"',
142
+ 'owner: "q", expiry: "2027-01-01", kind: "release", defaultOn: true',
143
+ )
144
+ const expiry = ONE_LINE.replace(
145
+ 'owner: "a", expiry: "2027-01-01", kind: "release"',
146
+ 'owner: "a", expiry: "2030-01-01", kind: "release", defaultOn: true',
147
+ )
148
+ expect(flipProblems(ONE_LINE, owner, "alpha")).not.toEqual([])
149
+ expect(flipProblems(ONE_LINE, expiry, "alpha")).not.toEqual([])
150
+ })
151
+
152
+ it("faults a comment outside the entry", () => {
153
+ const after = `// note\n${ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: true },')}`
154
+ expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
155
+ })
156
+
157
+ it("faults defaultOn: false", () => {
158
+ const after = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: false },')
159
+ expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
160
+ })
161
+
162
+ it("faults an unchanged registry", () => {
163
+ expect(flipProblems(ONE_LINE, ONE_LINE, "alpha")).toEqual([
164
+ '"alpha" is not set to defaultOn: true',
165
+ ])
166
+ })
167
+
168
+ it("faults a removed entry", () => {
169
+ expect(flipProblems(ONE_LINE, "export const FLAGS = []\n", "alpha")).toEqual([
170
+ 'entry "alpha" is gone',
171
+ ])
172
+ })
173
+
174
+ it("faults defaultOn smuggled into a string instead of set as a property", () => {
175
+ const after = ONE_LINE.replace('owner: "a"', 'owner: "adefaultOn: true"')
176
+ expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
177
+ })
178
+
179
+ it("faults whitespace edited inside a string", () => {
180
+ const before = ONE_LINE.replace('owner: "a"', 'owner: "a b"')
181
+ const after = before
182
+ .replace('owner: "a b"', 'owner: "ab"')
183
+ .replace('kind: "release" },', 'kind: "release", defaultOn: true },')
184
+ expect(flipProblems(before, after, "alpha")).not.toEqual([])
185
+ })
186
+
187
+ it("is empty for a flip when a JSDoc example of the entry precedes the array", () => {
188
+ const doc = '/** e.g. { name: "alpha", kind: "ops" } */\n'
189
+ const after = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: true },')
190
+ expect(flipProblems(doc + ONE_LINE, doc + after, "alpha")).toEqual([])
191
+ })
192
+ })
193
+
194
+ describe("flipProblems trailing commas", () => {
195
+ const flip = (s: string) => s.replace('kind: "release" }', 'kind: "release", defaultOn: true }')
196
+
197
+ it("faults a comma-brace dropped inside a string", () => {
198
+ const before = ONE_LINE.replace('owner: "a"', 'owner: "team,}blue"')
199
+ const after = flip(before).replace('"team,}blue"', '"team}blue"')
200
+ expect(flipProblems(before, after, "alpha")).toContain('entry "alpha" changed beyond defaultOn')
201
+ })
202
+
203
+ it("faults a comma-brace dropped inside a comment", () => {
204
+ const before = ONE_LINE.replace('kind: "release" }', 'kind: "release", /* a,} */ }')
205
+ const after = flip(before).replace("/* a,} */", "/* a} */")
206
+ expect(flipProblems(before, after, "alpha")).toContain('entry "alpha" changed beyond defaultOn')
207
+ })
208
+
209
+ it("passes a real trailing comma added or removed", () => {
210
+ const comma = flip(ONE_LINE).replace("defaultOn: true }", "defaultOn: true, }")
211
+ expect(flipProblems(ONE_LINE, comma, "alpha")).toEqual([])
212
+ const before = ONE_LINE.replace('kind: "release" }', 'kind: "release", }')
213
+ expect(flipProblems(before, flip(ONE_LINE), "alpha")).toEqual([])
214
+ })
215
+ })
216
+
217
+ describe("requireFlag", () => {
218
+ afterEach(() => installContext(undefined))
219
+
220
+ const asFile = (text: string | undefined) =>
221
+ installContext({
222
+ read: (path: string) => (path === "reg.ts" ? text : undefined),
223
+ refuse: (message: string): never => {
224
+ throw new Error(message)
225
+ },
226
+ } as never)
227
+
228
+ it("accepts a flag that matches", () => {
229
+ asFile(ONE_LINE)
230
+ expect(() => requireFlag("rollout", "reg.ts", "alpha", false)).not.toThrow()
231
+ expect(() => requireFlag("cleanup", "reg.ts", "beta", true)).not.toThrow()
232
+ })
233
+
234
+ it("refuses an empty name", () => {
235
+ asFile(ONE_LINE)
236
+ expect(() => requireFlag("rollout", "reg.ts", "", false)).toThrow(
237
+ "gtd: rollout needs --var flag=<name>",
238
+ )
239
+ })
240
+
241
+ it("refuses an unknown flag, and a missing registry", () => {
242
+ asFile(ONE_LINE)
243
+ expect(() => requireFlag("cleanup", "reg.ts", "nope", true)).toThrow(
244
+ 'gtd: cleanup: no flag "nope" in reg.ts',
245
+ )
246
+ asFile(undefined)
247
+ expect(() => requireFlag("rollout", "reg.ts", "alpha", false)).toThrow(
248
+ 'gtd: rollout: no flag "alpha" in reg.ts',
249
+ )
250
+ })
251
+
252
+ it("refuses a non-release flag", () => {
253
+ asFile(ONE_LINE)
254
+ expect(() => requireFlag("rollout", "reg.ts", "gamma", false)).toThrow(
255
+ 'gtd: rollout: flag "gamma" is ops, not release',
256
+ )
257
+ })
258
+
259
+ it("refuses the wrong default", () => {
260
+ asFile(ONE_LINE)
261
+ expect(() => requireFlag("rollout", "reg.ts", "beta", false)).toThrow(
262
+ 'gtd: rollout: flag "beta" is already default-on',
263
+ )
264
+ expect(() => requireFlag("cleanup", "reg.ts", "alpha", true)).toThrow(
265
+ 'gtd: cleanup: flag "alpha" is not default-on — roll it out first',
266
+ )
267
+ })
268
+ })
269
+
270
+ describe("flagReferences", () => {
271
+ it("reports path:line for the flag's env var in shipped sources", () => {
272
+ const files = [
273
+ { path: "src/a.ts", text: "x\nconst on = env.GTD_FLAG_ALPHA\n" },
274
+ { path: "src/web/b.tsx", text: "GTD_FLAG_ALPHA\n\nGTD_FLAG_ALPHA" },
275
+ { path: "src/c.ts", text: "nothing\n" },
276
+ ]
277
+ expect(flagReferences(files, "alpha")).toEqual([
278
+ "src/a.ts:2",
279
+ "src/web/b.tsx:1",
280
+ "src/web/b.tsx:3",
281
+ ])
282
+ })
283
+
284
+ it("ignores test, fixture and stories files and longer names", () => {
285
+ const text = "GTD_FLAG_ALPHA\n"
286
+ const files = [
287
+ { path: "src/a.test.ts", text },
288
+ { path: "src/a.fixture.ts", text },
289
+ { path: "src/a.stories.tsx", text },
290
+ { path: "src/b.ts", text: "GTD_FLAG_ALPHA_X\nXGTD_FLAG_ALPHA\n" },
291
+ ]
292
+ expect(flagReferences(files, "alpha")).toEqual([])
293
+ })
294
+
295
+ it("matches regex metacharacters in the name literally", () => {
296
+ const files = [
297
+ { path: "src/a.ts", text: "GTD_FLAG_AXB\nGTD_FLAG_A.B\n" },
298
+ { path: "src/b.ts", text: "GTD_FLAG_AAB\nGTD_FLAG_A+B\n" },
299
+ ]
300
+ expect(flagReferences(files, "a.b")).toEqual(["src/a.ts:2"])
301
+ expect(flagReferences(files, "a+b")).toEqual(["src/b.ts:2"])
302
+ expect(() => flagReferences(files, "a(b")).not.toThrow()
303
+ expect(flagReferences(files, "a(b")).toEqual([])
304
+ })
305
+ })