@pmelab/gtd 27.8.0 → 28.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 +59 -30
- package/claude/hooks/doors.ts +12 -0
- package/claude/hooks/register.tsx +2 -4
- package/claude/hooks/ship.ts +115 -20
- package/dist/chunks/{reserved-UdSuHDUJ.mjs → reserved-BIsngMGL.mjs} +168 -27
- package/dist/chunks/{tail-fj60rQ3y.mjs → tail-DHEaOHyp.mjs} +1 -1
- package/dist/chunks/{wire-C12dahrH.mjs → wire-D3Rc5r22.mjs} +14 -1
- package/dist/gtd.bundle.mjs +174 -48
- package/dist/testkit.mjs +158 -49
- package/dist/wire.mjs +2 -2
- package/dist/workflows/cleanup.mjs +3 -4
- package/dist/workflows/feature.mjs +2 -2
- package/dist/workflows/fix.mjs +2 -2
- package/dist/workflows/review.mjs +2 -2
- package/dist/workflows/rollout.mjs +19 -4
- package/dist/workflows.mjs +186 -127
- package/package.json +1 -1
- package/skills/authoring/SKILL.md +4 -3
- package/src/cli/Cli.test.ts +39 -3
- package/src/cli/Cli.ts +27 -6
- package/src/cli/Program.test.ts +3 -1
- package/src/cli/Program.ts +22 -0
- package/src/edge/Edge.test.ts +209 -54
- package/src/edge/Edge.ts +121 -42
- package/src/edge/index.ts +1 -0
- package/src/install/ConfigScaffold.ts +4 -1
- package/src/install/Install.test.ts +10 -0
- package/src/install/Install.ts +5 -2
- package/src/replay/Replay.test.ts +32 -0
- package/src/replay/Replay.ts +3 -1
- package/src/replay/Trailers.test.ts +11 -0
- package/src/replay/Trailers.ts +3 -1
- package/src/replay/index.ts +1 -1
- package/src/wire/Slices.ts +25 -0
- package/src/wire/WireSchema.test.ts +18 -0
- package/src/wire/golden/1/slices.json +11 -0
- package/src/wire/index.ts +1 -0
- package/src/workflow/load.test.ts +82 -5
- package/src/workflow/load.ts +40 -16
- package/src/workflows/cleanup/cleanup.feature +21 -0
- package/src/workflows/cleanup/cleanup.ts +1 -1
- package/src/workflows/index.ts +2 -1
- package/src/workflows/rollout/rollout.feature +282 -2
- package/src/workflows/rollout/rollout.test.ts +96 -2
- package/src/workflows/rollout/rollout.ts +41 -3
- package/src/workflows/shared/options.test.ts +25 -2
- package/src/workflows/shared/options.ts +7 -4
- package/src/workflows/shared/registry.test.ts +41 -1
- package/src/workflows/shared/registry.ts +26 -1
- package/src/workflows/shared/text.test.ts +252 -2
- package/src/workflows/shared/text.ts +44 -1
- package/src/workflows/shared/vars.ts +2 -0
- package/dist/chunks/registry-CP3YuV9j.mjs +0 -101
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
2
|
import { CONFIG_SCAFFOLD } from "../../install/index.js"
|
|
3
|
-
import { testRepo } from "../../testkit/index.js"
|
|
3
|
+
import { runsItsScript, testRepo } from "../../testkit/index.js"
|
|
4
4
|
|
|
5
5
|
const CONFIG = `${CONFIG_SCAFFOLD}export { rollout } from "@pmelab/gtd/workflows"\n`
|
|
6
6
|
|
|
@@ -30,9 +30,13 @@ describe("the rollout workflow", () => {
|
|
|
30
30
|
r.write("src/flags/index.ts", FLIPPED)
|
|
31
31
|
},
|
|
32
32
|
"rollout.suite.check": () => undefined,
|
|
33
|
+
"rollout.mentions": runsItsScript,
|
|
33
34
|
})
|
|
34
35
|
expect(rest.state).toBe("idle")
|
|
35
|
-
expect(repo.subjects().
|
|
36
|
+
expect(repo.subjects().slice(-2)).toEqual([
|
|
37
|
+
"gtd(check): rollout.suite.check → rollout.mentions",
|
|
38
|
+
"gtd(check): rollout.mentions → idle",
|
|
39
|
+
])
|
|
36
40
|
})
|
|
37
41
|
|
|
38
42
|
it("lands a flip when the tree was already dirty at the start", async () => {
|
|
@@ -42,6 +46,7 @@ describe("the rollout workflow", () => {
|
|
|
42
46
|
const rest = await repo.drive({
|
|
43
47
|
"rollout.flipping": (_, r) => r.write("src/flags/index.ts", FLIPPED),
|
|
44
48
|
"rollout.suite.check": () => undefined,
|
|
49
|
+
"rollout.mentions": runsItsScript,
|
|
45
50
|
})
|
|
46
51
|
expect(rest.state).toBe("idle")
|
|
47
52
|
})
|
|
@@ -160,8 +165,97 @@ describe("the rollout workflow", () => {
|
|
|
160
165
|
),
|
|
161
166
|
),
|
|
162
167
|
"rollout.suite.check": () => undefined,
|
|
168
|
+
"rollout.mentions": runsItsScript,
|
|
163
169
|
})
|
|
164
170
|
expect(rest.state).toBe("idle")
|
|
165
171
|
})
|
|
166
172
|
})
|
|
167
173
|
})
|
|
174
|
+
|
|
175
|
+
describe("rollout.mentions", () => {
|
|
176
|
+
const flip = {
|
|
177
|
+
"rollout.flipping": (_: unknown, r: { write: (p: string, c: string) => void }) =>
|
|
178
|
+
r.write("src/flags/index.ts", FLIPPED),
|
|
179
|
+
}
|
|
180
|
+
const finish = async (
|
|
181
|
+
files: Record<string, string>,
|
|
182
|
+
mentions: typeof runsItsScript = runsItsScript,
|
|
183
|
+
) => {
|
|
184
|
+
const repo = testRepo({
|
|
185
|
+
workflow: CONFIG,
|
|
186
|
+
files: { "src/flags/index.ts": REGISTRY, "src/a.ts": "a\n", ...files },
|
|
187
|
+
})
|
|
188
|
+
await repo.start("rollout", "--var", "flag=alpha")
|
|
189
|
+
const rest = await repo.drive({
|
|
190
|
+
...flip,
|
|
191
|
+
"rollout.suite.check": () => undefined,
|
|
192
|
+
"rollout.mentions": mentions,
|
|
193
|
+
})
|
|
194
|
+
expect(rest.state).toBe("idle")
|
|
195
|
+
const marks = (repo.messages().at(-1) ?? "")
|
|
196
|
+
.split("\n")
|
|
197
|
+
.filter((l) => l.startsWith("Gtd-Mark: "))
|
|
198
|
+
.map((l) => l.slice("Gtd-Mark: ".length))
|
|
199
|
+
return { repo, marks }
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
it("lists mentions outside the registry and under no .gtd/ path", async () => {
|
|
203
|
+
const { marks } = await finish({
|
|
204
|
+
"README.md": "intro\non under GTD_FLAG_ALPHA=1\n",
|
|
205
|
+
"src/help.ts": 'export const h = "alpha help"\n',
|
|
206
|
+
".gtd/NOTES.md": "alpha\n",
|
|
207
|
+
})
|
|
208
|
+
expect(marks).toEqual([
|
|
209
|
+
"flag-mentions=2",
|
|
210
|
+
"flag-mention=README.md:2",
|
|
211
|
+
"flag-mention=src/help.ts:1",
|
|
212
|
+
])
|
|
213
|
+
})
|
|
214
|
+
|
|
215
|
+
it("the mentions script removes a feedback file present at the step; marks list the tree", async () => {
|
|
216
|
+
const { repo, marks } = await finish({ "README.md": "alpha\n" }, (beat, r) => {
|
|
217
|
+
r.write(".gtd/FEEDBACK.md", "alpha\n")
|
|
218
|
+
runsItsScript(beat, r)
|
|
219
|
+
})
|
|
220
|
+
expect(repo.read(".gtd/FEEDBACK.md")).toBeUndefined()
|
|
221
|
+
expect(marks).toEqual(["flag-mentions=1", "flag-mention=README.md:1"])
|
|
222
|
+
})
|
|
223
|
+
|
|
224
|
+
it("refuses a stray file written by the mentions script", async () => {
|
|
225
|
+
await expect(
|
|
226
|
+
finish({}, (beat, r) => {
|
|
227
|
+
runsItsScript(beat, r)
|
|
228
|
+
r.write("src/stray.ts", "x\n")
|
|
229
|
+
}),
|
|
230
|
+
).rejects.toThrow("gtd land: rollout-only: src/stray.ts changed")
|
|
231
|
+
})
|
|
232
|
+
|
|
233
|
+
it("refuses a registry edit beyond the flip made by the mentions script", async () => {
|
|
234
|
+
await expect(
|
|
235
|
+
finish({}, (beat, r) => {
|
|
236
|
+
runsItsScript(beat, r)
|
|
237
|
+
r.write("src/flags/index.ts", FLIPPED.replace('owner: "b"', 'owner: "z"'))
|
|
238
|
+
}),
|
|
239
|
+
).rejects.toThrow("rollout-only: other edits in src/flags/index.ts")
|
|
240
|
+
})
|
|
241
|
+
|
|
242
|
+
it("marks 0 when nothing names the flag", async () => {
|
|
243
|
+
const { marks } = await finish({})
|
|
244
|
+
expect(marks).toEqual(["flag-mentions=0"])
|
|
245
|
+
})
|
|
246
|
+
|
|
247
|
+
it("marks the total for 60 mentions but only the first 50 lines", async () => {
|
|
248
|
+
const text = Array.from({ length: 60 }, (_, i) => `alpha ${i}`).join("\n")
|
|
249
|
+
const { marks } = await finish({ "docs/many.md": `${text}\n` })
|
|
250
|
+
expect(marks[0]).toBe("flag-mentions=60")
|
|
251
|
+
expect(marks.filter((m) => m.startsWith("flag-mention="))).toHaveLength(50)
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
it("counts a path the mark grammar rejects without marking it, and never throws", async () => {
|
|
255
|
+
const { marks } = await finish({
|
|
256
|
+
"docs/bad\u0001name.md": "alpha\n",
|
|
257
|
+
"README.md": "alpha\n",
|
|
258
|
+
})
|
|
259
|
+
expect(marks).toEqual(["flag-mentions=2", "flag-mention=README.md:1"])
|
|
260
|
+
})
|
|
261
|
+
})
|
|
@@ -1,7 +1,19 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
agent,
|
|
3
|
+
changesSince,
|
|
4
|
+
glob,
|
|
5
|
+
head,
|
|
6
|
+
mark,
|
|
7
|
+
read,
|
|
8
|
+
refuse,
|
|
9
|
+
removeScript,
|
|
10
|
+
run,
|
|
11
|
+
scope,
|
|
12
|
+
vars,
|
|
13
|
+
} from "../../flows/index.js"
|
|
2
14
|
import { gate } from "../shared/health.js"
|
|
3
15
|
import { flagShipped } from "../shared/options.js"
|
|
4
|
-
import { flipProblems, requireFlag } from "../shared/registry.js"
|
|
16
|
+
import { flagMentions, flipProblems, requireFlag } from "../shared/registry.js"
|
|
5
17
|
import { coder, FEEDBACK } from "../shared/steps.js"
|
|
6
18
|
|
|
7
19
|
const flippingPrompt = (name: string, registry: string): string =>
|
|
@@ -10,6 +22,31 @@ const flippingPrompt = (name: string, registry: string): string =>
|
|
|
10
22
|
const suiteBlockedMessage = (): string =>
|
|
11
23
|
"The suite is red after the rollout. Fix it by hand — a rollout may only touch the flag's registry entry — then land."
|
|
12
24
|
|
|
25
|
+
// Marks past this many are dropped; the summary points at grep for the rest.
|
|
26
|
+
const MARKED_MENTIONS = 50
|
|
27
|
+
|
|
28
|
+
// Lists, never edits: the human fixes the now-stale prose, or cleanup does. The
|
|
29
|
+
// list rides in marks only: a state file left at idle would be published by
|
|
30
|
+
// ship and read as a failed unwind by the next process.
|
|
31
|
+
const listMentions = async (registry: string, name: string, guard: () => void): Promise<void> => {
|
|
32
|
+
await run("mentions", removeScript([FEEDBACK]), { label: "Listing flag mentions" })
|
|
33
|
+
guard()
|
|
34
|
+
const files = glob("**")
|
|
35
|
+
.filter((path) => path !== registry && path !== ".gtd" && !path.startsWith(".gtd/"))
|
|
36
|
+
.sort()
|
|
37
|
+
.map((path) => ({ path, text: read(path) ?? "" }))
|
|
38
|
+
const mentions = flagMentions(files, name)
|
|
39
|
+
mark("flag-mentions", String(mentions.length))
|
|
40
|
+
for (const m of mentions.slice(0, MARKED_MENTIONS)) {
|
|
41
|
+
// A path the mark grammar rejects stays counted above, just unlisted here.
|
|
42
|
+
try {
|
|
43
|
+
mark("flag-mention", `${m.path}:${m.line}`)
|
|
44
|
+
} catch {
|
|
45
|
+
continue
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
13
50
|
// No review gate — a one-field change needs no review lap.
|
|
14
51
|
export const rolloutFlow = async (): Promise<void> => {
|
|
15
52
|
const registry = vars.flagRegistry ?? ""
|
|
@@ -38,7 +75,8 @@ export const rolloutFlow = async (): Promise<void> => {
|
|
|
38
75
|
})
|
|
39
76
|
guard()
|
|
40
77
|
await gate("suite", suiteBlockedMessage(), guard)
|
|
78
|
+
await listMentions(registry, name, guard)
|
|
41
79
|
})
|
|
42
80
|
}
|
|
43
81
|
|
|
44
|
-
export const rollout = flagShipped(rolloutFlow)
|
|
82
|
+
export const rollout = flagShipped(rolloutFlow, "rollout")
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from "vitest"
|
|
2
|
-
import { configure } from "../../flows/index.js"
|
|
2
|
+
import { configure, type WorkflowDoors } from "../../flows/index.js"
|
|
3
3
|
import * as workflows from "../index.js"
|
|
4
4
|
import * as options from "./options.js"
|
|
5
5
|
|
|
@@ -85,7 +85,7 @@ describe("the shipped workflow factories", () => {
|
|
|
85
85
|
})
|
|
86
86
|
|
|
87
87
|
describe("flagShipped", () => {
|
|
88
|
-
const factory = flagShipped(async () => {})
|
|
88
|
+
const factory = flagShipped(async () => {}, "rollout")
|
|
89
89
|
|
|
90
90
|
it("declare flag and flagRegistry as process settings, so --var flag= is accepted", () => {
|
|
91
91
|
expect(configure(factory)).toMatchObject({
|
|
@@ -97,6 +97,29 @@ describe("the shipped workflow factories", () => {
|
|
|
97
97
|
})
|
|
98
98
|
})
|
|
99
99
|
|
|
100
|
+
it.each(["rollout", "cleanup"] as const)(
|
|
101
|
+
"%s declares one door named after it, with one required flag arg",
|
|
102
|
+
(name) => {
|
|
103
|
+
const result = configure(workflows[name]())
|
|
104
|
+
expect(result).toMatchObject({
|
|
105
|
+
ok: true,
|
|
106
|
+
build: { doors: { [name]: { args: [{ name: "flag" }] } } },
|
|
107
|
+
})
|
|
108
|
+
const build = (result as { build?: { doors?: WorkflowDoors } }).build
|
|
109
|
+
expect(Object.keys(build?.doors ?? {})).toEqual([name])
|
|
110
|
+
expect(build?.doors?.[name]?.vars?.({ flag: "x" })).toEqual({ flag: "x" })
|
|
111
|
+
},
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
it.each(["rollout", "cleanup"] as const)("%s builds one door of that name", (name) => {
|
|
115
|
+
const result = configure(flagShipped(async () => {}, name))
|
|
116
|
+
const doors = (result as { build?: { doors?: WorkflowDoors } }).build?.doors
|
|
117
|
+
expect(Object.keys(doors ?? {})).toEqual([name])
|
|
118
|
+
expect(doors?.[name]?.args).toEqual([{ name: "flag" }])
|
|
119
|
+
expect(doors?.[name]?.vars?.({ flag: "x" })).toEqual({ flag: "x" })
|
|
120
|
+
expect(doors?.[name]?.vars?.({})).toEqual({ flag: "" })
|
|
121
|
+
})
|
|
122
|
+
|
|
100
123
|
it("fail the load on an unknown option", () => {
|
|
101
124
|
// @ts-expect-error — `flag` is a --var, not an option
|
|
102
125
|
const result = configure(factory({ flag: "x" }))
|
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
type WorkflowDoors,
|
|
7
7
|
type WorkflowFactory,
|
|
8
8
|
} from "../../flows/index.js"
|
|
9
|
-
import { defaults, envDefaults } from "./vars.js"
|
|
9
|
+
import { defaultFlagRegistry, defaults, envDefaults } from "./vars.js"
|
|
10
10
|
|
|
11
11
|
const setting = Schema.optionalWith(Schema.String, { exact: true })
|
|
12
12
|
|
|
@@ -55,16 +55,19 @@ export const FlagOptions = Schema.Struct({ ...ShippedOptions.fields, flagRegistr
|
|
|
55
55
|
|
|
56
56
|
export type FlagOptions = Schema.Schema.Encoded<typeof FlagOptions>
|
|
57
57
|
|
|
58
|
-
export const flagShipped = (
|
|
58
|
+
export const flagShipped = (
|
|
59
|
+
flow: Flow,
|
|
60
|
+
name: "rollout" | "cleanup",
|
|
61
|
+
): WorkflowFactory<FlagOptions> =>
|
|
59
62
|
defineWorkflow(
|
|
60
63
|
Schema.standardSchemaV1(FlagOptions, { onExcessProperty: "error", errors: "all" }),
|
|
61
64
|
(options) => {
|
|
62
|
-
const flagDefaults = { ...defaults, flag: "", flagRegistry:
|
|
65
|
+
const flagDefaults = { ...defaults, flag: "", flagRegistry: defaultFlagRegistry }
|
|
63
66
|
return {
|
|
64
67
|
flow,
|
|
65
68
|
defaults: over(flagDefaults, options),
|
|
66
69
|
envDefaults: over(envDefaults, options),
|
|
67
|
-
doors: {},
|
|
70
|
+
doors: { [name]: { args: [{ name: "flag" }], vars: ({ flag }) => ({ flag: flag ?? "" }) } },
|
|
68
71
|
base: undefined,
|
|
69
72
|
}
|
|
70
73
|
},
|
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
import { afterEach, describe, expect, it } from "vitest"
|
|
2
2
|
import { installContext } from "../../flows/index.js"
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
flagMentions,
|
|
5
|
+
flagReferences,
|
|
6
|
+
flipProblems,
|
|
7
|
+
registryEntry,
|
|
8
|
+
requireFlag,
|
|
9
|
+
} from "./registry.js"
|
|
4
10
|
|
|
5
11
|
const ONE_LINE = `export const FLAGS = [
|
|
6
12
|
{ name: "alpha", owner: "a", expiry: "2027-01-01", kind: "release" },
|
|
@@ -303,3 +309,37 @@ describe("flagReferences", () => {
|
|
|
303
309
|
expect(flagReferences(files, "a(b")).toEqual([])
|
|
304
310
|
})
|
|
305
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
|
+
})
|
|
@@ -47,7 +47,7 @@ const mask = (text: string): string => {
|
|
|
47
47
|
return out
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
50
|
+
export const escapeRegExp = (s: string): string => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
|
|
51
51
|
|
|
52
52
|
// Entries are flat object literals, so the nearest braces around the name delimit one.
|
|
53
53
|
const entrySpan = (text: string, name: string) => {
|
|
@@ -136,6 +136,31 @@ export const flagReferences = (
|
|
|
136
136
|
)
|
|
137
137
|
}
|
|
138
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
|
+
|
|
139
164
|
export const requireFlag = (
|
|
140
165
|
workflow: "rollout" | "cleanup",
|
|
141
166
|
registryPath: string,
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { execFileSync } from "node:child_process"
|
|
2
|
-
import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
|
|
2
|
+
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"
|
|
3
3
|
import { tmpdir } from "node:os"
|
|
4
4
|
import { join } from "node:path"
|
|
5
|
-
import { describe, expect, it } from "vitest"
|
|
5
|
+
import { afterEach, describe, expect, it } from "vitest"
|
|
6
6
|
import {
|
|
7
7
|
buildReviewTriageMessage,
|
|
8
8
|
deferNotesScript,
|
|
@@ -261,6 +261,38 @@ describe("buildQualityReviewingPrompt", () => {
|
|
|
261
261
|
})
|
|
262
262
|
})
|
|
263
263
|
|
|
264
|
+
// These cases run real git and sh on purpose: they prove the command a person
|
|
265
|
+
// pastes works in real git, which an in-memory twin cannot show. An inherited
|
|
266
|
+
// GIT_DIR would commit into the real repository; the user's git config would
|
|
267
|
+
// change the exact output lines compared.
|
|
268
|
+
const gitEnv = (): NodeJS.ProcessEnv => {
|
|
269
|
+
const env = { ...process.env }
|
|
270
|
+
delete env.GIT_DIR
|
|
271
|
+
delete env.GIT_WORK_TREE
|
|
272
|
+
delete env.GIT_INDEX_FILE
|
|
273
|
+
env.GIT_CONFIG_GLOBAL = "/dev/null"
|
|
274
|
+
env.GIT_CONFIG_NOSYSTEM = "1"
|
|
275
|
+
return env
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
const scratchDirs: string[] = []
|
|
279
|
+
afterEach(() => {
|
|
280
|
+
for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true })
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
const scratchRepo = () => {
|
|
284
|
+
const dir = mkdtempSync(join(tmpdir(), "gtd-grep-"))
|
|
285
|
+
scratchDirs.push(dir)
|
|
286
|
+
const env = gitEnv()
|
|
287
|
+
const git = (...args: string[]) =>
|
|
288
|
+
execFileSync(
|
|
289
|
+
"git",
|
|
290
|
+
["-c", "commit.gpgsign=false", "-c", "user.name=t", "-c", "user.email=t@t", ...args],
|
|
291
|
+
{ cwd: dir, env },
|
|
292
|
+
)
|
|
293
|
+
return { dir, env, git }
|
|
294
|
+
}
|
|
295
|
+
|
|
264
296
|
describe("summaryPrompt", () => {
|
|
265
297
|
it("puts the total and every model's cost on a line of its own", () => {
|
|
266
298
|
const prompt = summaryPrompt({
|
|
@@ -298,6 +330,224 @@ describe("summaryPrompt", () => {
|
|
|
298
330
|
expect(summaryPrompt(base)).not.toContain("Production signals")
|
|
299
331
|
})
|
|
300
332
|
|
|
333
|
+
it("adds the one no-mentions line, naming the flag, for flag-mentions=0", () => {
|
|
334
|
+
const prompt = summaryPrompt({
|
|
335
|
+
...base,
|
|
336
|
+
vars: { flag: "alpha" },
|
|
337
|
+
marks: [{ name: "flag-mentions", value: "0" }],
|
|
338
|
+
})
|
|
339
|
+
expect(prompt).toContain("Flag `alpha`: no line outside the registry names it.")
|
|
340
|
+
})
|
|
341
|
+
|
|
342
|
+
it("keeps a backtick flag name inside one span in the heading and the no-mentions line", () => {
|
|
343
|
+
const zero = summaryPrompt({
|
|
344
|
+
...base,
|
|
345
|
+
vars: { flag: "a`b" },
|
|
346
|
+
marks: [{ name: "flag-mentions", value: "0" }],
|
|
347
|
+
})
|
|
348
|
+
expect(zero).toContain("Flag `` a`b ``: no line outside the registry names it.")
|
|
349
|
+
const some = summaryPrompt({
|
|
350
|
+
...base,
|
|
351
|
+
vars: { flag: "a``b" },
|
|
352
|
+
marks: [
|
|
353
|
+
{ name: "flag-mentions", value: "1" },
|
|
354
|
+
{ name: "flag-mention", value: "README.md:1" },
|
|
355
|
+
],
|
|
356
|
+
})
|
|
357
|
+
expect(some).toContain("still naming flag ``` a``b ``` —")
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
const listedMentions = Array.from({ length: 50 }, (_, i) => ({
|
|
361
|
+
name: "flag-mention",
|
|
362
|
+
value: `README.md:${i + 1}`,
|
|
363
|
+
}))
|
|
364
|
+
|
|
365
|
+
it("lists the flag-mention values as data, then says how many more there are", () => {
|
|
366
|
+
const prompt = summaryPrompt({
|
|
367
|
+
...base,
|
|
368
|
+
vars: { flag: "alpha", flagRegistry: "src/flags/index.ts" },
|
|
369
|
+
marks: [{ name: "flag-mentions", value: "53" }, ...listedMentions],
|
|
370
|
+
})
|
|
371
|
+
expect(prompt).toContain("naming flag `alpha`")
|
|
372
|
+
expect(prompt).toContain("- `README.md:1`\n")
|
|
373
|
+
expect(prompt).toContain("- `README.md:50`\n")
|
|
374
|
+
expect(prompt).toContain(
|
|
375
|
+
"…and 3 more: `git grep --full-name -niIE '(^|[^a-z0-9_])(gtd_flag_|flag_)?alpha([^a-z0-9_]|$)' -- ':(top)' ':(top,exclude,literal)src/flags/index.ts' ':(top,exclude).gtd'`",
|
|
376
|
+
)
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
const listed = (value: string): string =>
|
|
380
|
+
summaryPrompt({
|
|
381
|
+
...base,
|
|
382
|
+
vars: { flag: "alpha", flagRegistry: "src/flags/index.ts" },
|
|
383
|
+
marks: [
|
|
384
|
+
{ name: "flag-mentions", value: "1" },
|
|
385
|
+
{ name: "flag-mention", value },
|
|
386
|
+
],
|
|
387
|
+
})
|
|
388
|
+
|
|
389
|
+
it("prints a listed path with one backtick verbatim inside a double-backtick fence", () => {
|
|
390
|
+
expect(listed("a`b.ts:1")).toContain("- `` a`b.ts:1 ``\n")
|
|
391
|
+
})
|
|
392
|
+
|
|
393
|
+
it("prints a listed path with a double-backtick run verbatim inside a triple-backtick fence", () => {
|
|
394
|
+
expect(listed("a``b.ts:1")).toContain("- ``` a``b.ts:1 ```\n")
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
it("prints a plain listed path as a single-backtick span", () => {
|
|
398
|
+
expect(listed("src/x.ts:7")).toContain("- `src/x.ts:7`\n") // gtd-path-exempt: fixture path
|
|
399
|
+
})
|
|
400
|
+
|
|
401
|
+
it("escapes regex metacharacters in the more-hint", () => {
|
|
402
|
+
const prompt = summaryPrompt({
|
|
403
|
+
...base,
|
|
404
|
+
vars: { flag: "a.b", flagRegistry: "src/flags/index.ts" },
|
|
405
|
+
marks: [{ name: "flag-mentions", value: "53" }, ...listedMentions],
|
|
406
|
+
})
|
|
407
|
+
const command = /more: `(git grep [^`]+)`/.exec(prompt)?.[1] ?? ""
|
|
408
|
+
expect(command).toContain("a\\.b")
|
|
409
|
+
})
|
|
410
|
+
|
|
411
|
+
it("shell-quotes a flag name holding a quote and the command still matches it", () => {
|
|
412
|
+
const prompt = summaryPrompt({
|
|
413
|
+
...base,
|
|
414
|
+
vars: { flag: "it's", flagRegistry: "src/flags/index.ts" },
|
|
415
|
+
marks: [{ name: "flag-mentions", value: "53" }, ...listedMentions],
|
|
416
|
+
})
|
|
417
|
+
const command = /more: `(git grep [^`]+)`/.exec(prompt)?.[1] ?? ""
|
|
418
|
+
expect(command).toContain("it'\\''s")
|
|
419
|
+
const { dir, env, git } = scratchRepo()
|
|
420
|
+
git("init", "-q")
|
|
421
|
+
writeFileSync(join(dir, "a.txt"), "uses it's here\nuses GTD_FLAG_it's too\nunrelated\n")
|
|
422
|
+
git("add", "a.txt")
|
|
423
|
+
git("commit", "-q", "-m", "x")
|
|
424
|
+
const out = execFileSync("sh", ["-c", command], { cwd: dir, env }).toString()
|
|
425
|
+
expect(out.trim().split("\n")).toHaveLength(2)
|
|
426
|
+
expect(out).toContain("it's here")
|
|
427
|
+
expect(out).toContain("GTD_FLAG_it's")
|
|
428
|
+
})
|
|
429
|
+
|
|
430
|
+
it("keeps a backtick in the flag name and wraps the command in a double-backtick span", () => {
|
|
431
|
+
const prompt = summaryPrompt({
|
|
432
|
+
...base,
|
|
433
|
+
vars: { flag: "a`b", flagRegistry: "src/flags/index.ts" },
|
|
434
|
+
marks: [{ name: "flag-mentions", value: "53" }, ...listedMentions],
|
|
435
|
+
})
|
|
436
|
+
const command = /more: `` (git grep .+?) ``\n/.exec(prompt)?.[1] ?? ""
|
|
437
|
+
expect(command).toContain("a`b")
|
|
438
|
+
})
|
|
439
|
+
|
|
440
|
+
it("sizes the span delimiter past a backtick run in the flag name", () => {
|
|
441
|
+
const prompt = summaryPrompt({
|
|
442
|
+
...base,
|
|
443
|
+
vars: { flag: "a``b", flagRegistry: "src/flags/index.ts" },
|
|
444
|
+
marks: [{ name: "flag-mentions", value: "53" }, ...listedMentions],
|
|
445
|
+
})
|
|
446
|
+
const command = /more: ``` (git grep .+?) ```\n/.exec(prompt)?.[1] ?? ""
|
|
447
|
+
expect(command).toContain("a``b")
|
|
448
|
+
})
|
|
449
|
+
|
|
450
|
+
it("shell-quotes a registry path holding a quote, and the command still runs", () => {
|
|
451
|
+
const prompt = summaryPrompt({
|
|
452
|
+
...base,
|
|
453
|
+
vars: { flag: "alpha", flagRegistry: "config/it's flags.ts" },
|
|
454
|
+
marks: [{ name: "flag-mentions", value: "53" }, ...listedMentions],
|
|
455
|
+
})
|
|
456
|
+
const command = /more: `(git grep [^`]+)`/.exec(prompt)?.[1] ?? ""
|
|
457
|
+
expect(command).toContain(`':(top,exclude,literal)config/it'\\''s flags.ts'`)
|
|
458
|
+
const { dir, env, git } = scratchRepo()
|
|
459
|
+
git("init", "-q")
|
|
460
|
+
// git grep exits 1 for no match; 128 would be a rejected pathspec
|
|
461
|
+
expect(() =>
|
|
462
|
+
execFileSync("sh", ["-c", `${command}; test $? -le 1`], { cwd: dir, env }),
|
|
463
|
+
).not.toThrow()
|
|
464
|
+
})
|
|
465
|
+
|
|
466
|
+
it("prints a git grep hint that finds all three mention forms in tracked files only", () => {
|
|
467
|
+
const prompt = summaryPrompt({
|
|
468
|
+
...base,
|
|
469
|
+
vars: { flag: "alpha", flagRegistry: "src/flags/index.ts" },
|
|
470
|
+
marks: [{ name: "flag-mentions", value: "2" }],
|
|
471
|
+
})
|
|
472
|
+
const command = /more: `(git grep [^`]+)`/.exec(prompt)?.[1] ?? ""
|
|
473
|
+
const { dir, env, git } = scratchRepo()
|
|
474
|
+
mkdirSync(join(dir, "src/flags"), { recursive: true })
|
|
475
|
+
mkdirSync(join(dir, ".gtd"))
|
|
476
|
+
writeFileSync(
|
|
477
|
+
join(dir, "a.md"),
|
|
478
|
+
"GTD_FLAG_ALPHA=1\nflag_alpha\nThe alpha flag\nalphabet\nmy_alpha\n",
|
|
479
|
+
)
|
|
480
|
+
writeFileSync(join(dir, "src/flags/index.ts"), "alpha\n")
|
|
481
|
+
writeFileSync(join(dir, ".gtd/TODO.md"), "alpha\n")
|
|
482
|
+
writeFileSync(join(dir, "untracked.md"), "alpha\n")
|
|
483
|
+
writeFileSync(join(dir, "bin.dat"), "alpha\u0000\n")
|
|
484
|
+
git("init", "-q")
|
|
485
|
+
git("add", "a.md", "bin.dat", "src/flags/index.ts")
|
|
486
|
+
git("add", "-f", ".gtd/TODO.md")
|
|
487
|
+
git("commit", "-q", "-m", "init")
|
|
488
|
+
const expected = ["a.md:1:GTD_FLAG_ALPHA=1", "a.md:2:flag_alpha", "a.md:3:The alpha flag"]
|
|
489
|
+
// from a subdirectory the hint must still search the whole repository
|
|
490
|
+
for (const cwd of [dir, join(dir, "src")]) {
|
|
491
|
+
const out = execFileSync("sh", ["-c", `${command}; test $? -le 1`], { cwd, env }).toString()
|
|
492
|
+
expect(out.trim().split("\n")).toEqual(expected)
|
|
493
|
+
}
|
|
494
|
+
})
|
|
495
|
+
|
|
496
|
+
describe("with no usable flagRegistry", () => {
|
|
497
|
+
const run = (vars: Record<string, string>) => {
|
|
498
|
+
const prompt = summaryPrompt({
|
|
499
|
+
...base,
|
|
500
|
+
vars: { flag: "alpha", ...vars },
|
|
501
|
+
marks: [{ name: "flag-mentions", value: "53" }, ...listedMentions],
|
|
502
|
+
})
|
|
503
|
+
const command = /more: `(git grep [^`]+)`/.exec(prompt)?.[1] ?? ""
|
|
504
|
+
const { dir, env, git } = scratchRepo()
|
|
505
|
+
mkdirSync(join(dir, "src/flags"), { recursive: true })
|
|
506
|
+
writeFileSync(join(dir, "a.md"), "The alpha flag\n")
|
|
507
|
+
writeFileSync(join(dir, "src/flags/index.ts"), "alpha\n")
|
|
508
|
+
git("init", "-q")
|
|
509
|
+
git("add", "a.md", "src/flags/index.ts")
|
|
510
|
+
git("commit", "-q", "-m", "init")
|
|
511
|
+
const out = execFileSync("sh", ["-c", `${command}; test $? -le 1`], { cwd: dir, env })
|
|
512
|
+
return { command, out: out.toString().trim() }
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
it("falls back to the default registry when the var is unset", () => {
|
|
516
|
+
const { out } = run({})
|
|
517
|
+
expect(out).toBe("a.md:1:The alpha flag")
|
|
518
|
+
})
|
|
519
|
+
|
|
520
|
+
it("falls back to the default registry when the var is empty, never an empty exclude", () => {
|
|
521
|
+
const { command, out } = run({ flagRegistry: "" })
|
|
522
|
+
expect(command).toContain("':(top,exclude,literal)src/flags/index.ts'")
|
|
523
|
+
expect(command).not.toContain("':(top,exclude,literal)'")
|
|
524
|
+
expect(out).toBe("a.md:1:The alpha flag")
|
|
525
|
+
})
|
|
526
|
+
})
|
|
527
|
+
|
|
528
|
+
it("says nothing of more when every mention is listed", () => {
|
|
529
|
+
const prompt = summaryPrompt({
|
|
530
|
+
...base,
|
|
531
|
+
vars: { flag: "alpha" },
|
|
532
|
+
marks: [
|
|
533
|
+
{ name: "flag-mentions", value: "1" },
|
|
534
|
+
{ name: "flag-mention", value: "README.md:3" },
|
|
535
|
+
],
|
|
536
|
+
})
|
|
537
|
+
expect(prompt).toContain("- `README.md:3`\n")
|
|
538
|
+
expect(prompt).not.toContain("more: ")
|
|
539
|
+
})
|
|
540
|
+
|
|
541
|
+
it("adds no flag section without flag marks, even with a flag var", () => {
|
|
542
|
+
const prompt = summaryPrompt({
|
|
543
|
+
...base,
|
|
544
|
+
vars: { flag: "alpha" },
|
|
545
|
+
marks: [{ name: "risk-tier", value: "low" }],
|
|
546
|
+
})
|
|
547
|
+
expect(prompt).toContain("Token cost: 0\n\nPrint the closing message")
|
|
548
|
+
expect(prompt).not.toContain("alpha")
|
|
549
|
+
})
|
|
550
|
+
|
|
301
551
|
it("adds the advisory risk line once for the last lap's high tier", () => {
|
|
302
552
|
const prompt = summaryPrompt({
|
|
303
553
|
...base,
|