@pmelab/gtd 27.1.0 → 27.2.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 +16 -9
- package/dist/chunks/registry-CP3YuV9j.mjs +101 -0
- package/dist/chunks/{reserved-BL9krPao.mjs → reserved-B2QaImnN.mjs} +41 -73
- package/dist/chunks/tail-BnhIxCYv.mjs +81 -0
- package/dist/gtd.bundle.mjs +5 -2
- package/dist/testkit.mjs +5 -2
- package/dist/workflows/cleanup.mjs +37 -0
- package/dist/workflows/feature.mjs +2 -1
- package/dist/workflows/fix.mjs +2 -1
- package/dist/workflows/review.mjs +2 -1
- package/dist/workflows/rollout.mjs +33 -0
- package/dist/workflows.mjs +220 -21
- package/package.json +11 -1
- package/src/flags/index.test.ts +28 -0
- package/src/flags/index.ts +9 -4
- package/src/testkit/steps.ts +26 -0
- package/src/testkit/testkit.feature +8 -1
- package/src/workflows/cleanup/cleanup.feature +323 -0
- package/src/workflows/cleanup/cleanup.test.ts +272 -0
- package/src/workflows/cleanup/cleanup.ts +52 -0
- package/src/workflows/cleanup/index.ts +6 -0
- package/src/workflows/index.ts +3 -1
- package/src/workflows/rollout/index.ts +6 -0
- package/src/workflows/rollout/rollout.feature +214 -0
- package/src/workflows/rollout/rollout.test.ts +167 -0
- package/src/workflows/rollout/rollout.ts +44 -0
- package/src/workflows/shared/health.test.ts +39 -0
- package/src/workflows/shared/health.ts +30 -7
- package/src/workflows/shared/options.test.ts +22 -0
- package/src/workflows/shared/options.ts +19 -0
- package/src/workflows/shared/registry.test.ts +305 -0
- package/src/workflows/shared/registry.ts +158 -0
- package/src/workflows/shared/tail.test.ts +36 -2
- package/src/workflows/shared/tail.ts +30 -18
|
@@ -0,0 +1,305 @@
|
|
|
1
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
2
|
+
import { installContext } from "../../flows/index.js"
|
|
3
|
+
import { flagReferences, flipProblems, registryEntry, requireFlag } from "./registry.js"
|
|
4
|
+
|
|
5
|
+
const ONE_LINE = `export const FLAGS = [
|
|
6
|
+
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
|
|
7
|
+
{ name: "beta", owner: "b", expiry: "2027-02-01", kind: "release", defaultOn: true },
|
|
8
|
+
{ name: "gamma", owner: "c", expiry: "2027-03-01", kind: "ops" },
|
|
9
|
+
] as const
|
|
10
|
+
`
|
|
11
|
+
|
|
12
|
+
const WRAPPED = `export const FLAGS = [
|
|
13
|
+
{
|
|
14
|
+
name: "alpha",
|
|
15
|
+
owner: "a",
|
|
16
|
+
expiry: "2027-01-01",
|
|
17
|
+
kind: "release",
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
name: "beta",
|
|
21
|
+
owner: "b",
|
|
22
|
+
expiry: "2027-02-01",
|
|
23
|
+
kind: "release",
|
|
24
|
+
defaultOn: true,
|
|
25
|
+
},
|
|
26
|
+
] as const
|
|
27
|
+
`
|
|
28
|
+
|
|
29
|
+
describe("registryEntry", () => {
|
|
30
|
+
it("finds name, kind and defaultOn in a one-line entry", () => {
|
|
31
|
+
expect(registryEntry(ONE_LINE, "alpha")).toEqual({
|
|
32
|
+
kind: "release",
|
|
33
|
+
defaultOn: false,
|
|
34
|
+
})
|
|
35
|
+
expect(registryEntry(ONE_LINE, "beta")).toEqual({
|
|
36
|
+
kind: "release",
|
|
37
|
+
defaultOn: true,
|
|
38
|
+
})
|
|
39
|
+
expect(registryEntry(ONE_LINE, "gamma")?.kind).toBe("ops")
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
it("finds them in an oxfmt-wrapped entry", () => {
|
|
43
|
+
expect(registryEntry(WRAPPED, "alpha")).toEqual({
|
|
44
|
+
kind: "release",
|
|
45
|
+
defaultOn: false,
|
|
46
|
+
})
|
|
47
|
+
expect(registryEntry(WRAPPED, "beta")?.defaultOn).toBe(true)
|
|
48
|
+
})
|
|
49
|
+
|
|
50
|
+
it("is undefined for an unknown name", () => {
|
|
51
|
+
expect(registryEntry(ONE_LINE, "delta")).toBeUndefined()
|
|
52
|
+
})
|
|
53
|
+
|
|
54
|
+
it("ignores a kind inside a block comment ahead of the real one", () => {
|
|
55
|
+
const text = 'export const FLAGS = [{ name: "alpha", /* kind: "ops" */ kind: "release" }]'
|
|
56
|
+
expect(registryEntry(text, "alpha")?.kind).toBe("release")
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
it("reads no kind from one with no closing quote", () => {
|
|
60
|
+
const text = 'export const FLAGS = [{ name: "alpha", kind: "release-candidate" }]'
|
|
61
|
+
expect(registryEntry(text, "alpha")?.kind).toBe("")
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
it("reads the kind right after a comment with no space between", () => {
|
|
65
|
+
const text = 'export const FLAGS = [{ name: "alpha", /*x*/kind: "release" }]'
|
|
66
|
+
expect(registryEntry(text, "alpha")?.kind).toBe("release")
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it("ignores a kind inside a line comment on a prior line", () => {
|
|
70
|
+
const text =
|
|
71
|
+
'export const FLAGS = [{\n name: "alpha",\n // kind: "ops"\n kind: "release",\n}]'
|
|
72
|
+
expect(registryEntry(text, "alpha")?.kind).toBe("release")
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
it("ignores a kind inside a string ahead of the real one", () => {
|
|
76
|
+
const text = `export const FLAGS = [{ name: "alpha", owner: "kind: 'ops'", kind: "release" }]`
|
|
77
|
+
expect(registryEntry(text, "alpha")?.kind).toBe("release")
|
|
78
|
+
})
|
|
79
|
+
})
|
|
80
|
+
|
|
81
|
+
describe("flipProblems", () => {
|
|
82
|
+
it("is empty when defaultOn is added to the named entry", () => {
|
|
83
|
+
const after = ONE_LINE.replace(
|
|
84
|
+
'kind: "release" },\n { name: "beta"',
|
|
85
|
+
'kind: "release", defaultOn: true },\n { name: "beta"',
|
|
86
|
+
)
|
|
87
|
+
expect(after).not.toBe(ONE_LINE)
|
|
88
|
+
expect(flipProblems(ONE_LINE, after, "alpha")).toEqual([])
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
it("is empty when false flips to true", () => {
|
|
92
|
+
const before = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: false },')
|
|
93
|
+
const after = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: true },')
|
|
94
|
+
expect(flipProblems(before, after, "alpha")).toEqual([])
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it("ignores re-wrapping and a trailing comma inside the entry", () => {
|
|
98
|
+
const after = WRAPPED.replace(
|
|
99
|
+
'kind: "release",\n },',
|
|
100
|
+
'kind: "release",\n defaultOn: true,\n },',
|
|
101
|
+
)
|
|
102
|
+
expect(flipProblems(WRAPPED, after, "alpha")).toEqual([])
|
|
103
|
+
const oneLiner = ONE_LINE.replace(
|
|
104
|
+
'kind: "release" },\n { name: "beta"',
|
|
105
|
+
'kind: "release", defaultOn: true, },\n { name: "beta"',
|
|
106
|
+
)
|
|
107
|
+
expect(flipProblems(ONE_LINE, oneLiner, "alpha")).toEqual([])
|
|
108
|
+
})
|
|
109
|
+
|
|
110
|
+
describe("with a non-BMP character in the entry", () => {
|
|
111
|
+
const EMOJI =
|
|
112
|
+
'export const FLAGS = [\n { name: "alpha", owner: "Team 🚀", kind: "release" },\n]'
|
|
113
|
+
|
|
114
|
+
it("is empty for an oxfmt re-wrap with a trailing comma", () => {
|
|
115
|
+
const after =
|
|
116
|
+
'export const FLAGS = [\n {\n name: "alpha",\n owner: "Team 🚀",\n kind: "release",\n defaultOn: true,\n },\n]'
|
|
117
|
+
expect(flipProblems(EMOJI, after, "alpha")).toEqual([])
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
it("faults an out-of-scope edit to a field after the emoji", () => {
|
|
121
|
+
const before =
|
|
122
|
+
'export const FLAGS = [\n { name: "alpha", owner: "Team 🚀", kind: " release", defaultOn: false },\n]'
|
|
123
|
+
const after =
|
|
124
|
+
'export const FLAGS = [\n { name: "alpha", owner: "Team 🚀", kind: "release", defaultOn: true },\n]'
|
|
125
|
+
expect(flipProblems(before, after, "alpha")).toContain(
|
|
126
|
+
'entry "alpha" changed beyond defaultOn',
|
|
127
|
+
)
|
|
128
|
+
})
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
it("faults a change to a second entry", () => {
|
|
132
|
+
const after = ONE_LINE.replace(
|
|
133
|
+
'kind: "release" },',
|
|
134
|
+
'kind: "release", defaultOn: true },',
|
|
135
|
+
).replace('owner: "c"', 'owner: "z"')
|
|
136
|
+
expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it("faults an owner or expiry change inside the entry", () => {
|
|
140
|
+
const owner = ONE_LINE.replace(
|
|
141
|
+
'owner: "a", expiry: "2027-01-01", kind: "release"',
|
|
142
|
+
'owner: "q", expiry: "2027-01-01", kind: "release", defaultOn: true',
|
|
143
|
+
)
|
|
144
|
+
const expiry = ONE_LINE.replace(
|
|
145
|
+
'owner: "a", expiry: "2027-01-01", kind: "release"',
|
|
146
|
+
'owner: "a", expiry: "2030-01-01", kind: "release", defaultOn: true',
|
|
147
|
+
)
|
|
148
|
+
expect(flipProblems(ONE_LINE, owner, "alpha")).not.toEqual([])
|
|
149
|
+
expect(flipProblems(ONE_LINE, expiry, "alpha")).not.toEqual([])
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it("faults a comment outside the entry", () => {
|
|
153
|
+
const after = `// note\n${ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: true },')}`
|
|
154
|
+
expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
|
|
155
|
+
})
|
|
156
|
+
|
|
157
|
+
it("faults defaultOn: false", () => {
|
|
158
|
+
const after = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: false },')
|
|
159
|
+
expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
it("faults an unchanged registry", () => {
|
|
163
|
+
expect(flipProblems(ONE_LINE, ONE_LINE, "alpha")).toEqual([
|
|
164
|
+
'"alpha" is not set to defaultOn: true',
|
|
165
|
+
])
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
it("faults a removed entry", () => {
|
|
169
|
+
expect(flipProblems(ONE_LINE, "export const FLAGS = []\n", "alpha")).toEqual([
|
|
170
|
+
'entry "alpha" is gone',
|
|
171
|
+
])
|
|
172
|
+
})
|
|
173
|
+
|
|
174
|
+
it("faults defaultOn smuggled into a string instead of set as a property", () => {
|
|
175
|
+
const after = ONE_LINE.replace('owner: "a"', 'owner: "adefaultOn: true"')
|
|
176
|
+
expect(flipProblems(ONE_LINE, after, "alpha")).not.toEqual([])
|
|
177
|
+
})
|
|
178
|
+
|
|
179
|
+
it("faults whitespace edited inside a string", () => {
|
|
180
|
+
const before = ONE_LINE.replace('owner: "a"', 'owner: "a b"')
|
|
181
|
+
const after = before
|
|
182
|
+
.replace('owner: "a b"', 'owner: "ab"')
|
|
183
|
+
.replace('kind: "release" },', 'kind: "release", defaultOn: true },')
|
|
184
|
+
expect(flipProblems(before, after, "alpha")).not.toEqual([])
|
|
185
|
+
})
|
|
186
|
+
|
|
187
|
+
it("is empty for a flip when a JSDoc example of the entry precedes the array", () => {
|
|
188
|
+
const doc = '/** e.g. { name: "alpha", kind: "ops" } */\n'
|
|
189
|
+
const after = ONE_LINE.replace('kind: "release" },', 'kind: "release", defaultOn: true },')
|
|
190
|
+
expect(flipProblems(doc + ONE_LINE, doc + after, "alpha")).toEqual([])
|
|
191
|
+
})
|
|
192
|
+
})
|
|
193
|
+
|
|
194
|
+
describe("flipProblems trailing commas", () => {
|
|
195
|
+
const flip = (s: string) => s.replace('kind: "release" }', 'kind: "release", defaultOn: true }')
|
|
196
|
+
|
|
197
|
+
it("faults a comma-brace dropped inside a string", () => {
|
|
198
|
+
const before = ONE_LINE.replace('owner: "a"', 'owner: "team,}blue"')
|
|
199
|
+
const after = flip(before).replace('"team,}blue"', '"team}blue"')
|
|
200
|
+
expect(flipProblems(before, after, "alpha")).toContain('entry "alpha" changed beyond defaultOn')
|
|
201
|
+
})
|
|
202
|
+
|
|
203
|
+
it("faults a comma-brace dropped inside a comment", () => {
|
|
204
|
+
const before = ONE_LINE.replace('kind: "release" }', 'kind: "release", /* a,} */ }')
|
|
205
|
+
const after = flip(before).replace("/* a,} */", "/* a} */")
|
|
206
|
+
expect(flipProblems(before, after, "alpha")).toContain('entry "alpha" changed beyond defaultOn')
|
|
207
|
+
})
|
|
208
|
+
|
|
209
|
+
it("passes a real trailing comma added or removed", () => {
|
|
210
|
+
const comma = flip(ONE_LINE).replace("defaultOn: true }", "defaultOn: true, }")
|
|
211
|
+
expect(flipProblems(ONE_LINE, comma, "alpha")).toEqual([])
|
|
212
|
+
const before = ONE_LINE.replace('kind: "release" }', 'kind: "release", }')
|
|
213
|
+
expect(flipProblems(before, flip(ONE_LINE), "alpha")).toEqual([])
|
|
214
|
+
})
|
|
215
|
+
})
|
|
216
|
+
|
|
217
|
+
describe("requireFlag", () => {
|
|
218
|
+
afterEach(() => installContext(undefined))
|
|
219
|
+
|
|
220
|
+
const asFile = (text: string | undefined) =>
|
|
221
|
+
installContext({
|
|
222
|
+
read: (path: string) => (path === "reg.ts" ? text : undefined),
|
|
223
|
+
refuse: (message: string): never => {
|
|
224
|
+
throw new Error(message)
|
|
225
|
+
},
|
|
226
|
+
} as never)
|
|
227
|
+
|
|
228
|
+
it("accepts a flag that matches", () => {
|
|
229
|
+
asFile(ONE_LINE)
|
|
230
|
+
expect(() => requireFlag("rollout", "reg.ts", "alpha", false)).not.toThrow()
|
|
231
|
+
expect(() => requireFlag("cleanup", "reg.ts", "beta", true)).not.toThrow()
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
it("refuses an empty name", () => {
|
|
235
|
+
asFile(ONE_LINE)
|
|
236
|
+
expect(() => requireFlag("rollout", "reg.ts", "", false)).toThrow(
|
|
237
|
+
"gtd: rollout needs --var flag=<name>",
|
|
238
|
+
)
|
|
239
|
+
})
|
|
240
|
+
|
|
241
|
+
it("refuses an unknown flag, and a missing registry", () => {
|
|
242
|
+
asFile(ONE_LINE)
|
|
243
|
+
expect(() => requireFlag("cleanup", "reg.ts", "nope", true)).toThrow(
|
|
244
|
+
'gtd: cleanup: no flag "nope" in reg.ts',
|
|
245
|
+
)
|
|
246
|
+
asFile(undefined)
|
|
247
|
+
expect(() => requireFlag("rollout", "reg.ts", "alpha", false)).toThrow(
|
|
248
|
+
'gtd: rollout: no flag "alpha" in reg.ts',
|
|
249
|
+
)
|
|
250
|
+
})
|
|
251
|
+
|
|
252
|
+
it("refuses a non-release flag", () => {
|
|
253
|
+
asFile(ONE_LINE)
|
|
254
|
+
expect(() => requireFlag("rollout", "reg.ts", "gamma", false)).toThrow(
|
|
255
|
+
'gtd: rollout: flag "gamma" is ops, not release',
|
|
256
|
+
)
|
|
257
|
+
})
|
|
258
|
+
|
|
259
|
+
it("refuses the wrong default", () => {
|
|
260
|
+
asFile(ONE_LINE)
|
|
261
|
+
expect(() => requireFlag("rollout", "reg.ts", "beta", false)).toThrow(
|
|
262
|
+
'gtd: rollout: flag "beta" is already default-on',
|
|
263
|
+
)
|
|
264
|
+
expect(() => requireFlag("cleanup", "reg.ts", "alpha", true)).toThrow(
|
|
265
|
+
'gtd: cleanup: flag "alpha" is not default-on — roll it out first',
|
|
266
|
+
)
|
|
267
|
+
})
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
describe("flagReferences", () => {
|
|
271
|
+
it("reports path:line for the flag's env var in shipped sources", () => {
|
|
272
|
+
const files = [
|
|
273
|
+
{ path: "src/a.ts", text: "x\nconst on = env.GTD_FLAG_ALPHA\n" },
|
|
274
|
+
{ path: "src/web/b.tsx", text: "GTD_FLAG_ALPHA\n\nGTD_FLAG_ALPHA" },
|
|
275
|
+
{ path: "src/c.ts", text: "nothing\n" },
|
|
276
|
+
]
|
|
277
|
+
expect(flagReferences(files, "alpha")).toEqual([
|
|
278
|
+
"src/a.ts:2",
|
|
279
|
+
"src/web/b.tsx:1",
|
|
280
|
+
"src/web/b.tsx:3",
|
|
281
|
+
])
|
|
282
|
+
})
|
|
283
|
+
|
|
284
|
+
it("ignores test, fixture and stories files and longer names", () => {
|
|
285
|
+
const text = "GTD_FLAG_ALPHA\n"
|
|
286
|
+
const files = [
|
|
287
|
+
{ path: "src/a.test.ts", text },
|
|
288
|
+
{ path: "src/a.fixture.ts", text },
|
|
289
|
+
{ path: "src/a.stories.tsx", text },
|
|
290
|
+
{ path: "src/b.ts", text: "GTD_FLAG_ALPHA_X\nXGTD_FLAG_ALPHA\n" },
|
|
291
|
+
]
|
|
292
|
+
expect(flagReferences(files, "alpha")).toEqual([])
|
|
293
|
+
})
|
|
294
|
+
|
|
295
|
+
it("matches regex metacharacters in the name literally", () => {
|
|
296
|
+
const files = [
|
|
297
|
+
{ path: "src/a.ts", text: "GTD_FLAG_AXB\nGTD_FLAG_A.B\n" },
|
|
298
|
+
{ path: "src/b.ts", text: "GTD_FLAG_AAB\nGTD_FLAG_A+B\n" },
|
|
299
|
+
]
|
|
300
|
+
expect(flagReferences(files, "a.b")).toEqual(["src/a.ts:2"])
|
|
301
|
+
expect(flagReferences(files, "a+b")).toEqual(["src/b.ts:2"])
|
|
302
|
+
expect(() => flagReferences(files, "a(b")).not.toThrow()
|
|
303
|
+
expect(flagReferences(files, "a(b")).toEqual([])
|
|
304
|
+
})
|
|
305
|
+
})
|
|
@@ -0,0 +1,158 @@
|
|
|
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
|
+
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 const requireFlag = (
|
|
140
|
+
workflow: "rollout" | "cleanup",
|
|
141
|
+
registryPath: string,
|
|
142
|
+
name: string,
|
|
143
|
+
defaultOn: boolean,
|
|
144
|
+
): void => {
|
|
145
|
+
if (name === "") return refuse(`gtd: ${workflow} needs --var flag=<name>`)
|
|
146
|
+
const entry = registryEntry(read(registryPath) ?? "", name)
|
|
147
|
+
if (entry === undefined) return refuse(`gtd: ${workflow}: no flag "${name}" in ${registryPath}`)
|
|
148
|
+
if (entry.kind !== "release") {
|
|
149
|
+
return refuse(`gtd: ${workflow}: flag "${name}" is ${entry.kind}, not release`)
|
|
150
|
+
}
|
|
151
|
+
if (entry.defaultOn !== defaultOn) {
|
|
152
|
+
return refuse(
|
|
153
|
+
defaultOn
|
|
154
|
+
? `gtd: ${workflow}: flag "${name}" is not default-on — roll it out first`
|
|
155
|
+
: `gtd: ${workflow}: flag "${name}" is already default-on`,
|
|
156
|
+
)
|
|
157
|
+
}
|
|
158
|
+
}
|
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
type CodeThreadInfo,
|
|
6
6
|
type StepRequest,
|
|
7
7
|
} from "../../flows/index.js"
|
|
8
|
+
import type { Guard } from "./health.js"
|
|
8
9
|
import { handOffReview, REVIEW_ORIG } from "./tail.js"
|
|
9
10
|
import { fixtureContext } from "./text.fixture.js"
|
|
10
11
|
|
|
@@ -30,7 +31,11 @@ const reviewDoc = (note: string): string =>
|
|
|
30
31
|
*/
|
|
31
32
|
const handOff = async (
|
|
32
33
|
after: string | "deleted" | "unwritten",
|
|
33
|
-
extra: {
|
|
34
|
+
extra: {
|
|
35
|
+
paths?: readonly string[]
|
|
36
|
+
codeThreads?: readonly CodeThreadInfo[]
|
|
37
|
+
guard?: Guard
|
|
38
|
+
} = {},
|
|
34
39
|
) => {
|
|
35
40
|
const log: string[] = []
|
|
36
41
|
const scripts = new Map<string, string>()
|
|
@@ -87,7 +92,7 @@ const handOff = async (
|
|
|
87
92
|
},
|
|
88
93
|
),
|
|
89
94
|
)
|
|
90
|
-
const refusal = await handOffReview("base", { rounds: 0 }).then(
|
|
95
|
+
const refusal = await handOffReview("base", { rounds: 0 }, extra.guard).then(
|
|
91
96
|
() => undefined,
|
|
92
97
|
(e: unknown) => (e as Error).message,
|
|
93
98
|
)
|
|
@@ -162,4 +167,33 @@ describe("the hand-off review", () => {
|
|
|
162
167
|
expect(run.refusal).toMatch(/was deleted — restore it, or leave a note to hand to fix/)
|
|
163
168
|
expect(run.log).toEqual(["review.reviewing", "review.await-review"])
|
|
164
169
|
})
|
|
170
|
+
|
|
171
|
+
describe("guard", () => {
|
|
172
|
+
it("runs after the reviewing turn and never after the human's await-review landing", async () => {
|
|
173
|
+
const seen: string[] = []
|
|
174
|
+
const run = await handOff(reviewDoc("new add function"), {
|
|
175
|
+
guard: () => seen.push("guard"),
|
|
176
|
+
})
|
|
177
|
+
expect(run.log).toEqual(["review.reviewing", "review.await-review", "review.closing"])
|
|
178
|
+
expect(seen).toEqual(["guard"])
|
|
179
|
+
})
|
|
180
|
+
|
|
181
|
+
it("a refusal after reviewing stops the round before the human gate", async () => {
|
|
182
|
+
const run = await handOff(reviewDoc("new add function"), {
|
|
183
|
+
guard: () => {
|
|
184
|
+
throw new Error("guarded: nope")
|
|
185
|
+
},
|
|
186
|
+
})
|
|
187
|
+
expect(run.refusal).toBe("guarded: nope")
|
|
188
|
+
expect(run.log).toEqual(["review.reviewing"])
|
|
189
|
+
})
|
|
190
|
+
|
|
191
|
+
it("does not run after review-missing", async () => {
|
|
192
|
+
let calls = 0
|
|
193
|
+
const run = await handOff("unwritten", { guard: () => void calls++ })
|
|
194
|
+
expect(run.log).toContain("review.review-missing")
|
|
195
|
+
// the fixture aborts the second reviewing turn: only the first was guarded
|
|
196
|
+
expect(calls).toBe(1)
|
|
197
|
+
})
|
|
198
|
+
})
|
|
165
199
|
})
|
|
@@ -1,6 +1,13 @@
|
|
|
1
1
|
import { read, refuse, removeScript, run, scope, showScript, vars } from "../../flows/index.js"
|
|
2
2
|
import * as kit from "../../workflow-kit/index.js"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
escalation,
|
|
5
|
+
FIX_CAP,
|
|
6
|
+
guarded,
|
|
7
|
+
healthy,
|
|
8
|
+
type EscalationCount,
|
|
9
|
+
type Guard,
|
|
10
|
+
} from "./health.js"
|
|
4
11
|
import {
|
|
5
12
|
awaitReview,
|
|
6
13
|
fixCheck,
|
|
@@ -22,16 +29,16 @@ export const REVIEW_ORIG = ".gtd/REVIEW.orig.md"
|
|
|
22
29
|
* finding to `.gtd/QUALITY.md`. Resolves `"findings"` when that file
|
|
23
30
|
* has any.
|
|
24
31
|
*/
|
|
25
|
-
export const qualityLap = (): Promise<"clean" | "findings"> =>
|
|
26
|
-
kit.qualityLap(kit.lensesOf(vars.qualityReviews ?? ""), reviewQuality, QUALITY)
|
|
32
|
+
export const qualityLap = (guard?: Guard): Promise<"clean" | "findings"> =>
|
|
33
|
+
kit.qualityLap(kit.lensesOf(vars.qualityReviews ?? ""), guarded(reviewQuality, guard), QUALITY)
|
|
27
34
|
|
|
28
35
|
/** Resolves `true` once `.gtd/QUALITY.md` is resolved, `false` when the fix cap escalated instead. */
|
|
29
|
-
export const fixQualityFindings = (escalations: EscalationCount): Promise<boolean> =>
|
|
36
|
+
export const fixQualityFindings = (escalations: EscalationCount, guard?: Guard): Promise<boolean> =>
|
|
30
37
|
kit.fixFindings({
|
|
31
|
-
fix: fixQuality,
|
|
38
|
+
fix: guarded(fixQuality, guard),
|
|
32
39
|
findings: QUALITY,
|
|
33
40
|
cap: FIX_CAP,
|
|
34
|
-
escalate: () => escalation(escalations),
|
|
41
|
+
escalate: () => escalation(escalations, guard),
|
|
35
42
|
})
|
|
36
43
|
|
|
37
44
|
/** `.gtd/REVIEW.md` removed at the end of a round at commit `round`. */
|
|
@@ -59,21 +66,22 @@ export const tail = <T>(
|
|
|
59
66
|
fixFirst: boolean,
|
|
60
67
|
finish: (escalations: EscalationCount) => Promise<T>,
|
|
61
68
|
green = false,
|
|
69
|
+
guard?: Guard,
|
|
62
70
|
): Promise<T> =>
|
|
63
71
|
scope("build", async () => {
|
|
64
72
|
const escalations: EscalationCount = { rounds: 0 }
|
|
65
73
|
const fix = fixCheck
|
|
66
74
|
const repair = async () => {
|
|
67
|
-
await fix()
|
|
68
|
-
await healthy(fix, { fixesSoFar: 1, escalations })
|
|
75
|
+
await guarded(fix, guard)()
|
|
76
|
+
await healthy(fix, { fixesSoFar: 1, escalations, guard })
|
|
69
77
|
}
|
|
70
78
|
// fixFirst's own fix + healthy below already is the full run.
|
|
71
79
|
if (fixFirst) await repair()
|
|
72
|
-
else if (!green) await healthy(fix, { escalations })
|
|
80
|
+
else if (!green) await healthy(fix, { escalations, guard })
|
|
73
81
|
// The lap runs once a tail: after its findings are fixed, review follows.
|
|
74
|
-
if ((await qualityLap()) === "findings") {
|
|
75
|
-
if (await fixQualityFindings(escalations)) {
|
|
76
|
-
await healthy(fix, { escalations })
|
|
82
|
+
if ((await qualityLap(guard)) === "findings") {
|
|
83
|
+
if (await fixQualityFindings(escalations, guard)) {
|
|
84
|
+
await healthy(fix, { escalations, guard })
|
|
77
85
|
} else await repair()
|
|
78
86
|
}
|
|
79
87
|
return finish(escalations)
|
|
@@ -84,12 +92,16 @@ export const tail = <T>(
|
|
|
84
92
|
* `.gtd/REVIEW.md`; a note leaves it, with the reviewer's own version as
|
|
85
93
|
* `REVIEW_ORIG`, for the next `fix` to read — never a re-plan.
|
|
86
94
|
*/
|
|
87
|
-
export const handOffReview = (
|
|
95
|
+
export const handOffReview = (
|
|
96
|
+
base: string,
|
|
97
|
+
escalations: EscalationCount,
|
|
98
|
+
guard?: Guard,
|
|
99
|
+
): Promise<void> =>
|
|
88
100
|
kit.reviewToHandOff(base, {
|
|
89
101
|
file: REVIEW,
|
|
90
|
-
reviewing,
|
|
91
|
-
fixRisks,
|
|
92
|
-
keepGreen: () => healthy(fixCheck, { escalations }),
|
|
102
|
+
reviewing: guarded(reviewing, guard),
|
|
103
|
+
fixRisks: guarded(fixRisks, guard),
|
|
104
|
+
keepGreen: () => healthy(fixCheck, { escalations, guard }),
|
|
93
105
|
awaitReview: (b) => awaitReview(b, t.handOffAwaitReviewMessage(b)),
|
|
94
106
|
reviewMissing,
|
|
95
107
|
close: closeReview,
|
|
@@ -107,5 +119,5 @@ export const handOffReview = (base: string, escalations: EscalationCount): Promi
|
|
|
107
119
|
})
|
|
108
120
|
|
|
109
121
|
/** The tail the review and fix workflows run: ends at the hand-off review since `base`. */
|
|
110
|
-
export const handOffTail = (fixFirst: boolean, base: string): Promise<void> =>
|
|
111
|
-
tail(fixFirst, (escalations) => handOffReview(base, escalations))
|
|
122
|
+
export const handOffTail = (fixFirst: boolean, base: string, guard?: Guard): Promise<void> =>
|
|
123
|
+
tail(fixFirst, (escalations) => handOffReview(base, escalations, guard), false, guard)
|