@pmelab/gtd 58.2.0 → 59.0.1

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 (150) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +120 -395
  3. package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-07Z_ENch.mjs} +13503 -3460
  4. package/dist/chunks/{wire-C12dahrH.mjs → wire-BTUhRGXj.mjs} +1 -1
  5. package/dist/flows.mjs +1 -1
  6. package/dist/gtd.bundle.mjs +484 -497
  7. package/dist/testkit/gherkin.mjs +145 -65
  8. package/dist/testkit.mjs +1 -1
  9. package/dist/wire.mjs +1 -1
  10. package/package.json +3 -42
  11. package/skills/authoring/SKILL.md +34 -27
  12. package/src/cli/Cli.test.ts +148 -71
  13. package/src/cli/Cli.ts +384 -463
  14. package/src/cli/Help.test.ts +47 -0
  15. package/src/cli/Help.ts +70 -0
  16. package/src/cli/Program.test.ts +127 -33
  17. package/src/cli/Program.ts +70 -25
  18. package/src/edge/Edge.ts +4 -4
  19. package/src/emit/Emit.test.ts +1 -1
  20. package/src/emit/Emit.ts +1 -1
  21. package/src/flags/index.test.ts +0 -13
  22. package/src/flags/index.ts +1 -3
  23. package/src/flows/factory.ts +3 -1
  24. package/src/gherkin/index.ts +135 -71
  25. package/src/gherkin/messages.test.ts +90 -0
  26. package/src/gherkin/messages.ts +84 -0
  27. package/src/install/Install.test.ts +14 -11
  28. package/src/install/Install.ts +38 -15
  29. package/src/install/index.ts +1 -2
  30. package/src/replay/Replay.test.ts +1 -1
  31. package/src/replay/index.ts +1 -1
  32. package/src/step/planStart.ts +6 -1
  33. package/src/testkit/TestRepo.test.ts +47 -2
  34. package/src/testkit/TestRepo.ts +51 -6
  35. package/src/twins/EmittedScriptRecognizer.ts +5 -8
  36. package/src/twins/FlowScripts.ts +1 -1
  37. package/src/wire/Land.ts +1 -1
  38. package/src/workflow/index.ts +1 -0
  39. package/src/workflow/load.test.ts +138 -113
  40. package/src/workflow/load.ts +7 -7
  41. package/src/workflow/skills.test.ts +5 -5
  42. package/src/workflow/skills.ts +1 -1
  43. package/src/workflow/vars.test.ts +30 -3
  44. package/src/workflow/vars.ts +34 -2
  45. package/src/workflow-kit/index.ts +1 -1
  46. package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
  47. package/dist/chunks/review-Bfaror38.mjs +0 -427
  48. package/dist/chunks/tail-Bd7L940p.mjs +0 -355
  49. package/dist/chunks/testkit-JDtpNKyi.mjs +0 -10410
  50. package/dist/workflows/cleanup.mjs +0 -36
  51. package/dist/workflows/feature.mjs +0 -1018
  52. package/dist/workflows/fix.mjs +0 -41
  53. package/dist/workflows/review.mjs +0 -45
  54. package/dist/workflows/rollout.mjs +0 -48
  55. package/dist/workflows.mjs +0 -40651
  56. package/src/install/ConfigScaffold.ts +0 -22
  57. package/src/workflows/cleanup/cleanup-path.feature +0 -94
  58. package/src/workflows/cleanup/cleanup.feature +0 -315
  59. package/src/workflows/cleanup/cleanup.test.ts +0 -276
  60. package/src/workflows/cleanup/cleanup.ts +0 -52
  61. package/src/workflows/cleanup/index.ts +0 -6
  62. package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
  63. package/src/workflows/feature/acceptance-criteria.feature +0 -389
  64. package/src/workflows/feature/criteria-coverage.feature +0 -102
  65. package/src/workflows/feature/criteria.test.ts +0 -147
  66. package/src/workflows/feature/criteria.ts +0 -267
  67. package/src/workflows/feature/fast-path.feature +0 -93
  68. package/src/workflows/feature/feature.test.ts +0 -338
  69. package/src/workflows/feature/feature.ts +0 -156
  70. package/src/workflows/feature/green.ts +0 -19
  71. package/src/workflows/feature/index.ts +0 -18
  72. package/src/workflows/feature/lens-models.feature +0 -69
  73. package/src/workflows/feature/lens-review.feature +0 -566
  74. package/src/workflows/feature/naive-acceptance.feature +0 -147
  75. package/src/workflows/feature/naive-test.feature +0 -263
  76. package/src/workflows/feature/one-step-land.feature +0 -93
  77. package/src/workflows/feature/planning.ts +0 -41
  78. package/src/workflows/feature/refactor-absorbs.feature +0 -131
  79. package/src/workflows/feature/refactor-first.feature +0 -158
  80. package/src/workflows/feature/refactor-lens-models.feature +0 -79
  81. package/src/workflows/feature/refactor-rebuild.feature +0 -273
  82. package/src/workflows/feature/refactor-review.feature +0 -1528
  83. package/src/workflows/feature/refactor.test.ts +0 -1220
  84. package/src/workflows/feature/refactor.ts +0 -368
  85. package/src/workflows/feature/release-flag.feature +0 -167
  86. package/src/workflows/feature/release-flag.test.ts +0 -49
  87. package/src/workflows/feature/release-flag.ts +0 -46
  88. package/src/workflows/feature/review-gate.feature +0 -241
  89. package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
  90. package/src/workflows/feature/risk-tier.feature +0 -351
  91. package/src/workflows/feature/seam-rejection.feature +0 -146
  92. package/src/workflows/feature/seam.feature +0 -297
  93. package/src/workflows/feature/seam.test.ts +0 -527
  94. package/src/workflows/feature/seam.ts +0 -426
  95. package/src/workflows/feature/standards.feature +0 -354
  96. package/src/workflows/feature/steps.test.ts +0 -65
  97. package/src/workflows/feature/steps.ts +0 -45
  98. package/src/workflows/feature/tier-pin.feature +0 -66
  99. package/src/workflows/feature/tier.test.ts +0 -166
  100. package/src/workflows/feature/tier.ts +0 -56
  101. package/src/workflows/fix/fix-lens-origin.feature +0 -114
  102. package/src/workflows/fix/fix-quality.feature +0 -212
  103. package/src/workflows/fix/fix.feature +0 -79
  104. package/src/workflows/fix/fix.test.ts +0 -74
  105. package/src/workflows/fix/fix.ts +0 -52
  106. package/src/workflows/fix/index.ts +0 -6
  107. package/src/workflows/index.ts +0 -47
  108. package/src/workflows/review/index.ts +0 -6
  109. package/src/workflows/review/review-door.feature +0 -199
  110. package/src/workflows/review/review.test.ts +0 -27
  111. package/src/workflows/review/review.ts +0 -60
  112. package/src/workflows/rollout/index.ts +0 -6
  113. package/src/workflows/rollout/rollout.feature +0 -510
  114. package/src/workflows/rollout/rollout.test.ts +0 -263
  115. package/src/workflows/rollout/rollout.ts +0 -82
  116. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
  117. package/src/workflows/shared/access.test.ts +0 -62
  118. package/src/workflows/shared/access.ts +0 -24
  119. package/src/workflows/shared/config.fixture.ts +0 -12
  120. package/src/workflows/shared/fences.ts +0 -24
  121. package/src/workflows/shared/health.test.ts +0 -148
  122. package/src/workflows/shared/health.ts +0 -191
  123. package/src/workflows/shared/learn.test.ts +0 -140
  124. package/src/workflows/shared/learn.ts +0 -201
  125. package/src/workflows/shared/lenses.test.ts +0 -39
  126. package/src/workflows/shared/lenses.ts +0 -294
  127. package/src/workflows/shared/options.test.ts +0 -157
  128. package/src/workflows/shared/options.ts +0 -92
  129. package/src/workflows/shared/progress.feature +0 -124
  130. package/src/workflows/shared/prose.ts +0 -263
  131. package/src/workflows/shared/recheck.feature +0 -60
  132. package/src/workflows/shared/registry.test.ts +0 -345
  133. package/src/workflows/shared/registry.ts +0 -183
  134. package/src/workflows/shared/reserved.ts +0 -11
  135. package/src/workflows/shared/review.test.ts +0 -1062
  136. package/src/workflows/shared/review.ts +0 -446
  137. package/src/workflows/shared/skills.test.ts +0 -121
  138. package/src/workflows/shared/skills.ts +0 -49
  139. package/src/workflows/shared/standards.test.ts +0 -90
  140. package/src/workflows/shared/standards.ts +0 -156
  141. package/src/workflows/shared/steps.test.ts +0 -219
  142. package/src/workflows/shared/steps.ts +0 -201
  143. package/src/workflows/shared/tail.test.ts +0 -454
  144. package/src/workflows/shared/tail.ts +0 -264
  145. package/src/workflows/shared/text.fixture.ts +0 -128
  146. package/src/workflows/shared/text.test.ts +0 -1430
  147. package/src/workflows/shared/text.ts +0 -1700
  148. package/src/workflows/shared/threads.test.ts +0 -129
  149. package/src/workflows/shared/vars.ts +0 -47
  150. package/src/workflows/shared/workflows.steps.ts +0 -54
@@ -1,345 +0,0 @@
1
- import { afterEach, describe, expect, it } from "vitest"
2
- import { installContext } from "../../flows/index.js"
3
- import {
4
- flagMentions,
5
- flagReferences,
6
- flipProblems,
7
- registryEntry,
8
- requireFlag,
9
- } from "./registry.js"
10
-
11
- const ONE_LINE = `export const FLAGS = [
12
- { name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
13
- { name: "beta", owner: "b", expiry: "2027-02-01", kind: "release", defaultOn: true },
14
- { name: "gamma", owner: "c", expiry: "2027-03-01", kind: "ops" },
15
- ] as const
16
- `
17
-
18
- const WRAPPED = `export const FLAGS = [
19
- {
20
- name: "alpha",
21
- owner: "a",
22
- expiry: "2027-01-01",
23
- kind: "release",
24
- },
25
- {
26
- name: "beta",
27
- owner: "b",
28
- expiry: "2027-02-01",
29
- kind: "release",
30
- defaultOn: true,
31
- },
32
- ] as const
33
- `
34
-
35
- describe("registryEntry", () => {
36
- it("finds name, kind and defaultOn in a one-line entry", () => {
37
- expect(registryEntry(ONE_LINE, "alpha")).toEqual({
38
- kind: "release",
39
- defaultOn: false,
40
- })
41
- expect(registryEntry(ONE_LINE, "beta")).toEqual({
42
- kind: "release",
43
- defaultOn: true,
44
- })
45
- expect(registryEntry(ONE_LINE, "gamma")?.kind).toBe("ops")
46
- })
47
-
48
- it("finds them in an oxfmt-wrapped entry", () => {
49
- expect(registryEntry(WRAPPED, "alpha")).toEqual({
50
- kind: "release",
51
- defaultOn: false,
52
- })
53
- expect(registryEntry(WRAPPED, "beta")?.defaultOn).toBe(true)
54
- })
55
-
56
- it("is undefined for an unknown name", () => {
57
- expect(registryEntry(ONE_LINE, "delta")).toBeUndefined()
58
- })
59
-
60
- it("ignores a kind inside a block comment ahead of the real one", () => {
61
- const text = 'export const FLAGS = [{ name: "alpha", /* kind: "ops" */ kind: "release" }]'
62
- expect(registryEntry(text, "alpha")?.kind).toBe("release")
63
- })
64
-
65
- it("reads no kind from one with no closing quote", () => {
66
- const text = 'export const FLAGS = [{ name: "alpha", kind: "release-candidate" }]'
67
- expect(registryEntry(text, "alpha")?.kind).toBe("")
68
- })
69
-
70
- it("reads the kind right after a comment with no space between", () => {
71
- const text = 'export const FLAGS = [{ name: "alpha", /*x*/kind: "release" }]'
72
- expect(registryEntry(text, "alpha")?.kind).toBe("release")
73
- })
74
-
75
- it("ignores a kind inside a line comment on a prior line", () => {
76
- const text =
77
- 'export const FLAGS = [{\n name: "alpha",\n // kind: "ops"\n kind: "release",\n}]'
78
- expect(registryEntry(text, "alpha")?.kind).toBe("release")
79
- })
80
-
81
- it("ignores a kind inside a string ahead of the real one", () => {
82
- const text = `export const FLAGS = [{ name: "alpha", owner: "kind: 'ops'", kind: "release" }]`
83
- expect(registryEntry(text, "alpha")?.kind).toBe("release")
84
- })
85
- })
86
-
87
- describe("flipProblems", () => {
88
- it("is empty when defaultOn is added to the named entry", () => {
89
- const after = ONE_LINE.replace(
90
- 'kind: "release" },\n { name: "beta"',
91
- 'kind: "release", defaultOn: true },\n { name: "beta"',
92
- )
93
- expect(after).not.toBe(ONE_LINE)
94
- expect(flipProblems(ONE_LINE, after, "alpha")).toEqual([])
95
- })
96
-
97
- it("is empty when false flips to true", () => {
98
- const before = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: false },')
99
- const after = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: true },')
100
- expect(flipProblems(before, after, "alpha")).toEqual([])
101
- })
102
-
103
- it("ignores re-wrapping and a trailing comma inside the entry", () => {
104
- const after = WRAPPED.replace(
105
- 'kind: "release",\n },',
106
- 'kind: "release",\n defaultOn: true,\n },',
107
- )
108
- expect(flipProblems(WRAPPED, after, "alpha")).toEqual([])
109
- const oneLiner = ONE_LINE.replace(
110
- 'kind: "release" },\n { name: "beta"',
111
- 'kind: "release", defaultOn: true, },\n { name: "beta"',
112
- )
113
- expect(flipProblems(ONE_LINE, oneLiner, "alpha")).toEqual([])
114
- })
115
-
116
- describe("with a non-BMP character in the entry", () => {
117
- const EMOJI =
118
- 'export const FLAGS = [\n { name: "alpha", owner: "Team 🚀", kind: "release" },\n]'
119
-
120
- it("is empty for an oxfmt re-wrap with a trailing comma", () => {
121
- const after =
122
- 'export const FLAGS = [\n {\n name: "alpha",\n owner: "Team 🚀",\n kind: "release",\n defaultOn: true,\n },\n]'
123
- expect(flipProblems(EMOJI, after, "alpha")).toEqual([])
124
- })
125
-
126
- it("faults an out-of-scope edit to a field after the emoji", () => {
127
- const before =
128
- 'export const FLAGS = [\n { name: "alpha", owner: "Team 🚀", kind: " release", defaultOn: false },\n]'
129
- const after =
130
- 'export const FLAGS = [\n { name: "alpha", owner: "Team 🚀", kind: "release", defaultOn: true },\n]'
131
- expect(flipProblems(before, after, "alpha")).toContain(
132
- 'entry "alpha" changed beyond defaultOn',
133
- )
134
- })
135
- })
136
-
137
- it("faults a change to a second entry", () => {
138
- const after = ONE_LINE.replace(
139
- 'kind: "release" },',
140
- 'kind: "release", defaultOn: true },',
141
- ).replace('owner: "c"', 'owner: "z"')
142
- expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
143
- })
144
-
145
- it("faults an owner or expiry change inside the entry", () => {
146
- const owner = ONE_LINE.replace(
147
- 'owner: "a", expiry: "2027-01-01", kind: "release"',
148
- 'owner: "q", expiry: "2027-01-01", kind: "release", defaultOn: true',
149
- )
150
- const expiry = ONE_LINE.replace(
151
- 'owner: "a", expiry: "2027-01-01", kind: "release"',
152
- 'owner: "a", expiry: "2030-01-01", kind: "release", defaultOn: true',
153
- )
154
- expect(flipProblems(ONE_LINE, owner, "alpha")).not.toEqual([])
155
- expect(flipProblems(ONE_LINE, expiry, "alpha")).not.toEqual([])
156
- })
157
-
158
- it("faults a comment outside the entry", () => {
159
- const after = `// note\n${ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: true },')}`
160
- expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
161
- })
162
-
163
- it("faults defaultOn: false", () => {
164
- const after = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: false },')
165
- expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
166
- })
167
-
168
- it("faults an unchanged registry", () => {
169
- expect(flipProblems(ONE_LINE, ONE_LINE, "alpha")).toEqual([
170
- '"alpha" is not set to defaultOn: true',
171
- ])
172
- })
173
-
174
- it("faults a removed entry", () => {
175
- expect(flipProblems(ONE_LINE, "export const FLAGS = []\n", "alpha")).toEqual([
176
- 'entry "alpha" is gone',
177
- ])
178
- })
179
-
180
- it("faults defaultOn smuggled into a string instead of set as a property", () => {
181
- const after = ONE_LINE.replace('owner: "a"', 'owner: "adefaultOn: true"')
182
- expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
183
- })
184
-
185
- it("faults whitespace edited inside a string", () => {
186
- const before = ONE_LINE.replace('owner: "a"', 'owner: "a b"')
187
- const after = before
188
- .replace('owner: "a b"', 'owner: "ab"')
189
- .replace('kind: "release" },', 'kind: "release", defaultOn: true },')
190
- expect(flipProblems(before, after, "alpha")).not.toEqual([])
191
- })
192
-
193
- it("is empty for a flip when a JSDoc example of the entry precedes the array", () => {
194
- const doc = '/** e.g. { name: "alpha", kind: "ops" } */\n'
195
- const after = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: true },')
196
- expect(flipProblems(doc + ONE_LINE, doc + after, "alpha")).toEqual([])
197
- })
198
- })
199
-
200
- describe("flipProblems trailing commas", () => {
201
- const flip = (s: string) => s.replace('kind: "release" }', 'kind: "release", defaultOn: true }')
202
-
203
- it("faults a comma-brace dropped inside a string", () => {
204
- const before = ONE_LINE.replace('owner: "a"', 'owner: "team,}blue"')
205
- const after = flip(before).replace('"team,}blue"', '"team}blue"')
206
- expect(flipProblems(before, after, "alpha")).toContain('entry "alpha" changed beyond defaultOn')
207
- })
208
-
209
- it("faults a comma-brace dropped inside a comment", () => {
210
- const before = ONE_LINE.replace('kind: "release" }', 'kind: "release", /* a,} */ }')
211
- const after = flip(before).replace("/* a,} */", "/* a} */")
212
- expect(flipProblems(before, after, "alpha")).toContain('entry "alpha" changed beyond defaultOn')
213
- })
214
-
215
- it("passes a real trailing comma added or removed", () => {
216
- const comma = flip(ONE_LINE).replace("defaultOn: true }", "defaultOn: true, }")
217
- expect(flipProblems(ONE_LINE, comma, "alpha")).toEqual([])
218
- const before = ONE_LINE.replace('kind: "release" }', 'kind: "release", }')
219
- expect(flipProblems(before, flip(ONE_LINE), "alpha")).toEqual([])
220
- })
221
- })
222
-
223
- describe("requireFlag", () => {
224
- afterEach(() => installContext(undefined))
225
-
226
- const asFile = (text: string | undefined) =>
227
- installContext({
228
- read: (path: string) => (path === "reg.ts" ? text : undefined),
229
- refuse: (message: string): never => {
230
- throw new Error(message)
231
- },
232
- } as never)
233
-
234
- it("accepts a flag that matches", () => {
235
- asFile(ONE_LINE)
236
- expect(() => requireFlag("rollout", "reg.ts", "alpha", false)).not.toThrow()
237
- expect(() => requireFlag("cleanup", "reg.ts", "beta", true)).not.toThrow()
238
- })
239
-
240
- it("refuses an empty name", () => {
241
- asFile(ONE_LINE)
242
- expect(() => requireFlag("rollout", "reg.ts", "", false)).toThrow(
243
- "gtd: rollout needs --var flag=<name>",
244
- )
245
- })
246
-
247
- it("refuses an unknown flag, and a missing registry", () => {
248
- asFile(ONE_LINE)
249
- expect(() => requireFlag("cleanup", "reg.ts", "nope", true)).toThrow(
250
- 'gtd: cleanup: no flag "nope" in reg.ts',
251
- )
252
- asFile(undefined)
253
- expect(() => requireFlag("rollout", "reg.ts", "alpha", false)).toThrow(
254
- 'gtd: rollout: no flag "alpha" in reg.ts',
255
- )
256
- })
257
-
258
- it("refuses a non-release flag", () => {
259
- asFile(ONE_LINE)
260
- expect(() => requireFlag("rollout", "reg.ts", "gamma", false)).toThrow(
261
- 'gtd: rollout: flag "gamma" is ops, not release',
262
- )
263
- })
264
-
265
- it("refuses the wrong default", () => {
266
- asFile(ONE_LINE)
267
- expect(() => requireFlag("rollout", "reg.ts", "beta", false)).toThrow(
268
- 'gtd: rollout: flag "beta" is already default-on',
269
- )
270
- expect(() => requireFlag("cleanup", "reg.ts", "alpha", true)).toThrow(
271
- 'gtd: cleanup: flag "alpha" is not default-on — roll it out first',
272
- )
273
- })
274
- })
275
-
276
- describe("flagReferences", () => {
277
- it("reports path:line for the flag's env var in shipped sources", () => {
278
- const files = [
279
- { path: "src/a.ts", text: "x\nconst on = env.GTD_FLAG_ALPHA\n" },
280
- { path: "src/web/b.tsx", text: "GTD_FLAG_ALPHA\n\nGTD_FLAG_ALPHA" },
281
- { path: "src/c.ts", text: "nothing\n" },
282
- ]
283
- expect(flagReferences(files, "alpha")).toEqual([
284
- "src/a.ts:2",
285
- "src/web/b.tsx:1",
286
- "src/web/b.tsx:3",
287
- ])
288
- })
289
-
290
- it("ignores test, fixture and stories files and longer names", () => {
291
- const text = "GTD_FLAG_ALPHA\n"
292
- const files = [
293
- { path: "src/a.test.ts", text },
294
- { path: "src/a.fixture.ts", text },
295
- { path: "src/a.stories.tsx", text },
296
- { path: "src/b.ts", text: "GTD_FLAG_ALPHA_X\nXGTD_FLAG_ALPHA\n" },
297
- ]
298
- expect(flagReferences(files, "alpha")).toEqual([])
299
- })
300
-
301
- it("matches regex metacharacters in the name literally", () => {
302
- const files = [
303
- { path: "src/a.ts", text: "GTD_FLAG_AXB\nGTD_FLAG_A.B\n" },
304
- { path: "src/b.ts", text: "GTD_FLAG_AAB\nGTD_FLAG_A+B\n" },
305
- ]
306
- expect(flagReferences(files, "a.b")).toEqual(["src/a.ts:2"])
307
- expect(flagReferences(files, "a+b")).toEqual(["src/b.ts:2"])
308
- expect(() => flagReferences(files, "a(b")).not.toThrow()
309
- expect(flagReferences(files, "a(b")).toEqual([])
310
- })
311
- })
312
-
313
- describe("flagMentions", () => {
314
- it("matches the env var, the flag_ form and the bare name, any case, 1-based", () => {
315
- const files = [
316
- {
317
- path: "README.md",
318
- text: "intro\non under GTD_FLAG_ALPHA=1\nflag_alpha helper\nThe Alpha flag\nnone\n",
319
- },
320
- ]
321
- expect(flagMentions(files, "alpha")).toEqual([
322
- { path: "README.md", line: 2 },
323
- { path: "README.md", line: 3 },
324
- { path: "README.md", line: 4 },
325
- ])
326
- })
327
-
328
- it("never matches inside a longer identifier", () => {
329
- const text = "alphabet\nalpha_two\nGTD_FLAG_ALPHA_TWO\nxalpha\nflag_alphabet\n"
330
- expect(flagMentions([{ path: "a.md", text }], "alpha")).toEqual([])
331
- })
332
-
333
- it("keeps test files, and skips a file holding a NUL byte", () => {
334
- const files = [
335
- { path: "src/a.test.ts", text: "GTD_FLAG_ALPHA\n" },
336
- { path: "bin.dat", text: "alpha\u0000alpha\n" },
337
- ]
338
- expect(flagMentions(files, "alpha").map((m) => m.path)).toEqual(["src/a.test.ts"])
339
- })
340
-
341
- it("matches regex metacharacters in the name literally", () => {
342
- const files = [{ path: "a.md", text: "a.b\naxb\n" }]
343
- expect(flagMentions(files, "a.b").map((m) => m.line)).toEqual([1])
344
- })
345
- })
@@ -1,183 +0,0 @@
1
- import { read, refuse } from "../../flows/index.js"
2
-
3
- // The registry is read as TEXT: workflows may not import `src/flags/`, and this
4
- // also lets a repository point at its own registry file.
5
-
6
- export interface RegistryEntry {
7
- readonly kind: string
8
- readonly defaultOn: boolean
9
- }
10
-
11
- // Filler for masked text: a non-word char, so `/*x*/kind` still has a `\b` before `kind`.
12
- const FILL = "\u0001"
13
-
14
- // Same length as `text`, with string contents and comments filled with FILL, so
15
- // a hit inside either (a JSDoc example, `owner: "defaultOn: true"`) is rejected.
16
- const commentLength = (rest: string): number => {
17
- const end = rest.startsWith("//") ? rest.indexOf("\n") : rest.indexOf("*/") + 2
18
- return end <= 0 ? rest.length : end
19
- }
20
-
21
- // Index of the closing quote of the string opened at `i` (or text end).
22
- const stringEnd = (text: string, i: number, quote: string): number => {
23
- let j = i + 1
24
- while (j < text.length && text[j] !== quote) j += text[j] === "\\" ? 2 : 1
25
- return Math.min(j, text.length)
26
- }
27
-
28
- const mask = (text: string): string => {
29
- let out = ""
30
- let i = 0
31
- while (i < text.length) {
32
- const c = text[i] as string
33
- const rest = text.slice(i)
34
- if (rest.startsWith("//") || rest.startsWith("/*")) {
35
- const len = commentLength(rest)
36
- out += FILL.repeat(len)
37
- i += len
38
- } else if (c === '"' || c === "'" || c === "`") {
39
- const j = stringEnd(text, i, c)
40
- out += c + FILL.repeat(j - i - 1) + (j < text.length ? c : "")
41
- i = j + 1
42
- } else {
43
- out += c
44
- i += 1
45
- }
46
- }
47
- return out
48
- }
49
-
50
- export const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
51
-
52
- // Entries are flat object literals, so the nearest braces around the name delimit one.
53
- const entrySpan = (text: string, name: string) => {
54
- const quoted = escapeRegExp(name)
55
- const re = new RegExp(`\\bname\\s*:\\s*(["'\`])${quoted}\\1`, "g")
56
- const masked = mask(text)
57
- for (const hit of text.matchAll(re)) {
58
- if (masked[hit.index] === FILL) continue
59
- const start = masked.lastIndexOf("{", hit.index)
60
- const close = masked.indexOf("}", hit.index)
61
- return start < 0 || close < 0 ? undefined : { start, end: close + 1 }
62
- }
63
- return undefined
64
- }
65
-
66
- const DEFAULT_ON = /\bdefaultOn\s*:\s*(true|false)\s*,?/
67
-
68
- // `defaultOn` as a property of `body`, never one inside a string or comment.
69
- const defaultOnHit = (body: string) => DEFAULT_ON.exec(mask(body))
70
-
71
- const defaultOnValue = (body: string): string | undefined => defaultOnHit(body)?.[1]
72
-
73
- const KIND = /\bkind\s*:\s*["'`]/
74
-
75
- export const registryEntry = (text: string, name: string): RegistryEntry | undefined => {
76
- const span = entrySpan(text, name)
77
- if (span === undefined) return undefined
78
- const body = text.slice(span.start, span.end)
79
- const hit = KIND.exec(mask(body))
80
- const kind = hit === null ? null : /^(\w+)["'`]/.exec(body.slice(hit.index + hit[0].length))
81
- return {
82
- kind: kind?.[1] ?? "",
83
- defaultOn: defaultOnValue(body) === "true",
84
- }
85
- }
86
-
87
- // Drops the `defaultOn` property, then whitespace and trailing commas outside strings.
88
- const canonical = (body: string): string => {
89
- const hit = defaultOnHit(body)
90
- const kept =
91
- hit === null ? body : body.slice(0, hit.index) + body.slice(hit.index + hit[0].length)
92
- const masked = mask(kept)
93
- // UTF-16 units, to match `masked` indexing; code points would drift after a surrogate pair
94
- return kept
95
- .split("")
96
- .filter(
97
- (c, i) =>
98
- masked[i] === FILL || !(/\s/.test(c) || (c === "," && /^\s*}/.test(masked.slice(i + 1)))),
99
- )
100
- .join("")
101
- }
102
-
103
- /**
104
- * [] when `after` differs from `before` only by the named entry's `defaultOn`
105
- * going absent/false → true (whitespace and trailing commas inside that entry
106
- * ignored, so oxfmt re-wrapping it passes); else one line per fault.
107
- */
108
- export const flipProblems = (before: string, after: string, name: string): readonly string[] => {
109
- const was = entrySpan(before, name)
110
- const now = entrySpan(after, name)
111
- if (was === undefined) return [`no entry "${name}" before the turn`]
112
- if (now === undefined) return [`entry "${name}" is gone`]
113
- const faults: string[] = []
114
- if (before.slice(0, was.start) !== after.slice(0, now.start)) {
115
- faults.push("text before the entry changed")
116
- }
117
- if (before.slice(was.end) !== after.slice(now.end)) faults.push("text after the entry changed")
118
- const oldBody = before.slice(was.start, was.end)
119
- const newBody = after.slice(now.start, now.end)
120
- if (canonical(oldBody) !== canonical(newBody))
121
- faults.push(`entry "${name}" changed beyond defaultOn`)
122
- if (defaultOnValue(newBody) !== "true") faults.push(`"${name}" is not set to defaultOn: true`)
123
- return faults
124
- }
125
-
126
- /** `path:line` for every `GTD_FLAG_<NAME>` in shipped sources; re-states scripts/lint-flags.ts' rule by hand. */
127
- export const flagReferences = (
128
- files: readonly { readonly path: string; readonly text: string }[],
129
- name: string,
130
- ): readonly string[] => {
131
- const pattern = new RegExp(`\\bGTD_FLAG_${escapeRegExp(name.toUpperCase())}\\b`)
132
- return files
133
- .filter(({ path }) => !/\.(test|fixture|stories)\.tsx?$/.test(path))
134
- .flatMap(({ path, text }) =>
135
- text.split("\n").flatMap((line, i) => (pattern.test(line) ? [`${path}:${i + 1}`] : [])),
136
- )
137
- }
138
-
139
- export interface FlagMention {
140
- readonly path: string
141
- readonly line: number
142
- }
143
-
144
- /**
145
- * Every line naming flag `name` as `GTD_FLAG_<NAME>`, `flag_<name>` or bare
146
- * `<name>` — case-insensitive, never inside a longer identifier. Unlike
147
- * `flagReferences`, test files count; a file holding a NUL byte is skipped.
148
- */
149
- export const flagMentions = (
150
- files: readonly { readonly path: string; readonly text: string }[],
151
- name: string,
152
- ): readonly FlagMention[] => {
153
- const pattern = new RegExp(
154
- `(?<![A-Za-z0-9_])(?:GTD_FLAG_|flag_)?${escapeRegExp(name)}(?![A-Za-z0-9_])`,
155
- "i",
156
- )
157
- return files
158
- .filter(({ text }) => !text.includes("\u0000"))
159
- .flatMap(({ path, text }) =>
160
- text.split("\n").flatMap((line, i) => (pattern.test(line) ? [{ path, line: i + 1 }] : [])),
161
- )
162
- }
163
-
164
- export const requireFlag = (
165
- workflow: "rollout" | "cleanup",
166
- registryPath: string,
167
- name: string,
168
- defaultOn: boolean,
169
- ): void => {
170
- if (name === "") return refuse(`gtd: ${workflow} needs --var flag=<name>`)
171
- const entry = registryEntry(read(registryPath) ?? "", name)
172
- if (entry === undefined) return refuse(`gtd: ${workflow}: no flag "${name}" in ${registryPath}`)
173
- if (entry.kind !== "release") {
174
- return refuse(`gtd: ${workflow}: flag "${name}" is ${entry.kind}, not release`)
175
- }
176
- if (entry.defaultOn !== defaultOn) {
177
- return refuse(
178
- defaultOn
179
- ? `gtd: ${workflow}: flag "${name}" is not default-on — roll it out first`
180
- : `gtd: ${workflow}: flag "${name}" is already default-on`,
181
- )
182
- }
183
- }
@@ -1,11 +0,0 @@
1
- import type { Summary } from "../../flows/index.js"
2
- import * as t from "./text.js"
3
- import { REQUIREMENTS, REVIEW } from "./steps.js"
4
-
5
- export { defaults, envDefaults } from "./vars.js"
6
- export { skills } from "./skills.js"
7
- export { access } from "./access.js"
8
-
9
- export const summary: Summary = t.summaryPrompt
10
-
11
- export const steering = { [REQUIREMENTS]: "qa", [REVIEW]: "review" }