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