@pmelab/gtd 58.2.0 → 59.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 (133) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +115 -390
  3. package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-CMBAAyRK.mjs} +13409 -3351
  4. package/dist/gtd.bundle.mjs +76 -59
  5. package/dist/testkit/gherkin.mjs +1 -1
  6. package/dist/testkit.mjs +1 -1
  7. package/package.json +3 -42
  8. package/skills/authoring/SKILL.md +31 -25
  9. package/src/cli/Cli.test.ts +15 -19
  10. package/src/cli/Cli.ts +2 -4
  11. package/src/cli/Program.test.ts +60 -29
  12. package/src/cli/Program.ts +5 -7
  13. package/src/edge/Edge.ts +3 -3
  14. package/src/flags/index.test.ts +0 -13
  15. package/src/flags/index.ts +1 -3
  16. package/src/install/Install.test.ts +14 -11
  17. package/src/install/Install.ts +38 -15
  18. package/src/install/index.ts +1 -2
  19. package/src/replay/Replay.test.ts +1 -1
  20. package/src/replay/index.ts +1 -1
  21. package/src/step/planStart.ts +6 -1
  22. package/src/twins/FlowScripts.ts +1 -1
  23. package/src/workflow/index.ts +1 -0
  24. package/src/workflow/load.test.ts +138 -113
  25. package/src/workflow/load.ts +1 -1
  26. package/src/workflow/vars.test.ts +30 -3
  27. package/src/workflow/vars.ts +34 -2
  28. package/src/workflow-kit/index.ts +1 -1
  29. package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
  30. package/dist/chunks/review-Bfaror38.mjs +0 -427
  31. package/dist/chunks/tail-Bd7L940p.mjs +0 -355
  32. package/dist/chunks/testkit-JDtpNKyi.mjs +0 -10410
  33. package/dist/workflows/cleanup.mjs +0 -36
  34. package/dist/workflows/feature.mjs +0 -1018
  35. package/dist/workflows/fix.mjs +0 -41
  36. package/dist/workflows/review.mjs +0 -45
  37. package/dist/workflows/rollout.mjs +0 -48
  38. package/dist/workflows.mjs +0 -40651
  39. package/src/install/ConfigScaffold.ts +0 -22
  40. package/src/workflows/cleanup/cleanup-path.feature +0 -94
  41. package/src/workflows/cleanup/cleanup.feature +0 -315
  42. package/src/workflows/cleanup/cleanup.test.ts +0 -276
  43. package/src/workflows/cleanup/cleanup.ts +0 -52
  44. package/src/workflows/cleanup/index.ts +0 -6
  45. package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
  46. package/src/workflows/feature/acceptance-criteria.feature +0 -389
  47. package/src/workflows/feature/criteria-coverage.feature +0 -102
  48. package/src/workflows/feature/criteria.test.ts +0 -147
  49. package/src/workflows/feature/criteria.ts +0 -267
  50. package/src/workflows/feature/fast-path.feature +0 -93
  51. package/src/workflows/feature/feature.test.ts +0 -338
  52. package/src/workflows/feature/feature.ts +0 -156
  53. package/src/workflows/feature/green.ts +0 -19
  54. package/src/workflows/feature/index.ts +0 -18
  55. package/src/workflows/feature/lens-models.feature +0 -69
  56. package/src/workflows/feature/lens-review.feature +0 -566
  57. package/src/workflows/feature/naive-acceptance.feature +0 -147
  58. package/src/workflows/feature/naive-test.feature +0 -263
  59. package/src/workflows/feature/one-step-land.feature +0 -93
  60. package/src/workflows/feature/planning.ts +0 -41
  61. package/src/workflows/feature/refactor-absorbs.feature +0 -131
  62. package/src/workflows/feature/refactor-first.feature +0 -158
  63. package/src/workflows/feature/refactor-lens-models.feature +0 -79
  64. package/src/workflows/feature/refactor-rebuild.feature +0 -273
  65. package/src/workflows/feature/refactor-review.feature +0 -1528
  66. package/src/workflows/feature/refactor.test.ts +0 -1220
  67. package/src/workflows/feature/refactor.ts +0 -368
  68. package/src/workflows/feature/release-flag.feature +0 -167
  69. package/src/workflows/feature/release-flag.test.ts +0 -49
  70. package/src/workflows/feature/release-flag.ts +0 -46
  71. package/src/workflows/feature/review-gate.feature +0 -241
  72. package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
  73. package/src/workflows/feature/risk-tier.feature +0 -351
  74. package/src/workflows/feature/seam-rejection.feature +0 -146
  75. package/src/workflows/feature/seam.feature +0 -297
  76. package/src/workflows/feature/seam.test.ts +0 -527
  77. package/src/workflows/feature/seam.ts +0 -426
  78. package/src/workflows/feature/standards.feature +0 -354
  79. package/src/workflows/feature/steps.test.ts +0 -65
  80. package/src/workflows/feature/steps.ts +0 -45
  81. package/src/workflows/feature/tier-pin.feature +0 -66
  82. package/src/workflows/feature/tier.test.ts +0 -166
  83. package/src/workflows/feature/tier.ts +0 -56
  84. package/src/workflows/fix/fix-lens-origin.feature +0 -114
  85. package/src/workflows/fix/fix-quality.feature +0 -212
  86. package/src/workflows/fix/fix.feature +0 -79
  87. package/src/workflows/fix/fix.test.ts +0 -74
  88. package/src/workflows/fix/fix.ts +0 -52
  89. package/src/workflows/fix/index.ts +0 -6
  90. package/src/workflows/index.ts +0 -47
  91. package/src/workflows/review/index.ts +0 -6
  92. package/src/workflows/review/review-door.feature +0 -199
  93. package/src/workflows/review/review.test.ts +0 -27
  94. package/src/workflows/review/review.ts +0 -60
  95. package/src/workflows/rollout/index.ts +0 -6
  96. package/src/workflows/rollout/rollout.feature +0 -510
  97. package/src/workflows/rollout/rollout.test.ts +0 -263
  98. package/src/workflows/rollout/rollout.ts +0 -82
  99. package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
  100. package/src/workflows/shared/access.test.ts +0 -62
  101. package/src/workflows/shared/access.ts +0 -24
  102. package/src/workflows/shared/config.fixture.ts +0 -12
  103. package/src/workflows/shared/fences.ts +0 -24
  104. package/src/workflows/shared/health.test.ts +0 -148
  105. package/src/workflows/shared/health.ts +0 -191
  106. package/src/workflows/shared/learn.test.ts +0 -140
  107. package/src/workflows/shared/learn.ts +0 -201
  108. package/src/workflows/shared/lenses.test.ts +0 -39
  109. package/src/workflows/shared/lenses.ts +0 -294
  110. package/src/workflows/shared/options.test.ts +0 -157
  111. package/src/workflows/shared/options.ts +0 -92
  112. package/src/workflows/shared/progress.feature +0 -124
  113. package/src/workflows/shared/prose.ts +0 -263
  114. package/src/workflows/shared/recheck.feature +0 -60
  115. package/src/workflows/shared/registry.test.ts +0 -345
  116. package/src/workflows/shared/registry.ts +0 -183
  117. package/src/workflows/shared/reserved.ts +0 -11
  118. package/src/workflows/shared/review.test.ts +0 -1062
  119. package/src/workflows/shared/review.ts +0 -446
  120. package/src/workflows/shared/skills.test.ts +0 -121
  121. package/src/workflows/shared/skills.ts +0 -49
  122. package/src/workflows/shared/standards.test.ts +0 -90
  123. package/src/workflows/shared/standards.ts +0 -156
  124. package/src/workflows/shared/steps.test.ts +0 -219
  125. package/src/workflows/shared/steps.ts +0 -201
  126. package/src/workflows/shared/tail.test.ts +0 -454
  127. package/src/workflows/shared/tail.ts +0 -264
  128. package/src/workflows/shared/text.fixture.ts +0 -128
  129. package/src/workflows/shared/text.test.ts +0 -1430
  130. package/src/workflows/shared/text.ts +0 -1700
  131. package/src/workflows/shared/threads.test.ts +0 -129
  132. package/src/workflows/shared/vars.ts +0 -47
  133. 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" }