@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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +115 -390
- package/dist/chunks/{Schema-B3o6eRNU.mjs → testkit-CMBAAyRK.mjs} +13409 -3351
- package/dist/gtd.bundle.mjs +76 -59
- package/dist/testkit/gherkin.mjs +1 -1
- package/dist/testkit.mjs +1 -1
- package/package.json +3 -42
- package/skills/authoring/SKILL.md +31 -25
- package/src/cli/Cli.test.ts +15 -19
- package/src/cli/Cli.ts +2 -4
- package/src/cli/Program.test.ts +60 -29
- package/src/cli/Program.ts +5 -7
- package/src/edge/Edge.ts +3 -3
- package/src/flags/index.test.ts +0 -13
- package/src/flags/index.ts +1 -3
- package/src/install/Install.test.ts +14 -11
- package/src/install/Install.ts +38 -15
- package/src/install/index.ts +1 -2
- package/src/replay/Replay.test.ts +1 -1
- package/src/replay/index.ts +1 -1
- package/src/step/planStart.ts +6 -1
- package/src/twins/FlowScripts.ts +1 -1
- package/src/workflow/index.ts +1 -0
- package/src/workflow/load.test.ts +138 -113
- package/src/workflow/load.ts +1 -1
- package/src/workflow/vars.test.ts +30 -3
- package/src/workflow/vars.ts +34 -2
- package/src/workflow-kit/index.ts +1 -1
- package/dist/chunks/reserved-lMYCaapY.mjs +0 -1990
- package/dist/chunks/review-Bfaror38.mjs +0 -427
- package/dist/chunks/tail-Bd7L940p.mjs +0 -355
- package/dist/chunks/testkit-JDtpNKyi.mjs +0 -10410
- package/dist/workflows/cleanup.mjs +0 -36
- package/dist/workflows/feature.mjs +0 -1018
- package/dist/workflows/fix.mjs +0 -41
- package/dist/workflows/review.mjs +0 -45
- package/dist/workflows/rollout.mjs +0 -48
- package/dist/workflows.mjs +0 -40651
- package/src/install/ConfigScaffold.ts +0 -22
- package/src/workflows/cleanup/cleanup-path.feature +0 -94
- package/src/workflows/cleanup/cleanup.feature +0 -315
- package/src/workflows/cleanup/cleanup.test.ts +0 -276
- package/src/workflows/cleanup/cleanup.ts +0 -52
- package/src/workflows/cleanup/index.ts +0 -6
- package/src/workflows/feature/acceptance-criteria-tier.feature +0 -25
- package/src/workflows/feature/acceptance-criteria.feature +0 -389
- package/src/workflows/feature/criteria-coverage.feature +0 -102
- package/src/workflows/feature/criteria.test.ts +0 -147
- package/src/workflows/feature/criteria.ts +0 -267
- package/src/workflows/feature/fast-path.feature +0 -93
- package/src/workflows/feature/feature.test.ts +0 -338
- package/src/workflows/feature/feature.ts +0 -156
- package/src/workflows/feature/green.ts +0 -19
- package/src/workflows/feature/index.ts +0 -18
- package/src/workflows/feature/lens-models.feature +0 -69
- package/src/workflows/feature/lens-review.feature +0 -566
- package/src/workflows/feature/naive-acceptance.feature +0 -147
- package/src/workflows/feature/naive-test.feature +0 -263
- package/src/workflows/feature/one-step-land.feature +0 -93
- package/src/workflows/feature/planning.ts +0 -41
- package/src/workflows/feature/refactor-absorbs.feature +0 -131
- package/src/workflows/feature/refactor-first.feature +0 -158
- package/src/workflows/feature/refactor-lens-models.feature +0 -79
- package/src/workflows/feature/refactor-rebuild.feature +0 -273
- package/src/workflows/feature/refactor-review.feature +0 -1528
- package/src/workflows/feature/refactor.test.ts +0 -1220
- package/src/workflows/feature/refactor.ts +0 -368
- package/src/workflows/feature/release-flag.feature +0 -167
- package/src/workflows/feature/release-flag.test.ts +0 -49
- package/src/workflows/feature/release-flag.ts +0 -46
- package/src/workflows/feature/review-gate.feature +0 -241
- package/src/workflows/feature/reviewer-walkthrough.feature +0 -142
- package/src/workflows/feature/risk-tier.feature +0 -351
- package/src/workflows/feature/seam-rejection.feature +0 -146
- package/src/workflows/feature/seam.feature +0 -297
- package/src/workflows/feature/seam.test.ts +0 -527
- package/src/workflows/feature/seam.ts +0 -426
- package/src/workflows/feature/standards.feature +0 -354
- package/src/workflows/feature/steps.test.ts +0 -65
- package/src/workflows/feature/steps.ts +0 -45
- package/src/workflows/feature/tier-pin.feature +0 -66
- package/src/workflows/feature/tier.test.ts +0 -166
- package/src/workflows/feature/tier.ts +0 -56
- package/src/workflows/fix/fix-lens-origin.feature +0 -114
- package/src/workflows/fix/fix-quality.feature +0 -212
- package/src/workflows/fix/fix.feature +0 -79
- package/src/workflows/fix/fix.test.ts +0 -74
- package/src/workflows/fix/fix.ts +0 -52
- package/src/workflows/fix/index.ts +0 -6
- package/src/workflows/index.ts +0 -47
- package/src/workflows/review/index.ts +0 -6
- package/src/workflows/review/review-door.feature +0 -199
- package/src/workflows/review/review.test.ts +0 -27
- package/src/workflows/review/review.ts +0 -60
- package/src/workflows/rollout/index.ts +0 -6
- package/src/workflows/rollout/rollout.feature +0 -510
- package/src/workflows/rollout/rollout.test.ts +0 -263
- package/src/workflows/rollout/rollout.ts +0 -82
- package/src/workflows/shared/__fixtures__/feature-collecting-prompt.txt +0 -98
- package/src/workflows/shared/access.test.ts +0 -62
- package/src/workflows/shared/access.ts +0 -24
- package/src/workflows/shared/config.fixture.ts +0 -12
- package/src/workflows/shared/fences.ts +0 -24
- package/src/workflows/shared/health.test.ts +0 -148
- package/src/workflows/shared/health.ts +0 -191
- package/src/workflows/shared/learn.test.ts +0 -140
- package/src/workflows/shared/learn.ts +0 -201
- package/src/workflows/shared/lenses.test.ts +0 -39
- package/src/workflows/shared/lenses.ts +0 -294
- package/src/workflows/shared/options.test.ts +0 -157
- package/src/workflows/shared/options.ts +0 -92
- package/src/workflows/shared/progress.feature +0 -124
- package/src/workflows/shared/prose.ts +0 -263
- package/src/workflows/shared/recheck.feature +0 -60
- package/src/workflows/shared/registry.test.ts +0 -345
- package/src/workflows/shared/registry.ts +0 -183
- package/src/workflows/shared/reserved.ts +0 -11
- package/src/workflows/shared/review.test.ts +0 -1062
- package/src/workflows/shared/review.ts +0 -446
- package/src/workflows/shared/skills.test.ts +0 -121
- package/src/workflows/shared/skills.ts +0 -49
- package/src/workflows/shared/standards.test.ts +0 -90
- package/src/workflows/shared/standards.ts +0 -156
- package/src/workflows/shared/steps.test.ts +0 -219
- package/src/workflows/shared/steps.ts +0 -201
- package/src/workflows/shared/tail.test.ts +0 -454
- package/src/workflows/shared/tail.ts +0 -264
- package/src/workflows/shared/text.fixture.ts +0 -128
- package/src/workflows/shared/text.test.ts +0 -1430
- package/src/workflows/shared/text.ts +0 -1700
- package/src/workflows/shared/threads.test.ts +0 -129
- package/src/workflows/shared/vars.ts +0 -47
- 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" }
|