@pmelab/gtd 26.1.0 → 27.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +16 -3
  3. package/dist/chunks/{Schema-B5yK7QrT.mjs → Schema-CyImoHTj.mjs} +56 -3
  4. package/dist/chunks/{reserved-6pAgFGgB.mjs → reserved-BL9krPao.mjs} +1 -1
  5. package/dist/chunks/{wire-DA7q2ikx.mjs → wire-BY1S-obW.mjs} +5 -2
  6. package/dist/flows.mjs +74 -1
  7. package/dist/gtd.bundle.mjs +159 -17
  8. package/dist/testkit.mjs +163 -17
  9. package/dist/wire.mjs +2 -2
  10. package/dist/workflows/feature.mjs +1 -1
  11. package/dist/workflows/fix.mjs +1 -1
  12. package/dist/workflows/review.mjs +1 -1
  13. package/dist/workflows.mjs +5 -0
  14. package/package.json +2 -2
  15. package/src/cli/Cli.test.ts +53 -1
  16. package/src/cli/Cli.ts +48 -6
  17. package/src/cli/Output.ts +3 -1
  18. package/src/cli/Program.test.ts +1 -0
  19. package/src/cli/Program.ts +28 -7
  20. package/src/cli/Strays.test.ts +88 -0
  21. package/src/cli/Strays.ts +56 -0
  22. package/src/flows/helpers.ts +111 -0
  23. package/src/flows/runtime.ts +5 -2
  24. package/src/platform/Git.test.ts +96 -1
  25. package/src/platform/Git.ts +24 -0
  26. package/src/replay/judgeDiffShape.test.ts +208 -0
  27. package/src/step/index.ts +1 -0
  28. package/src/step/strayPaths.test.ts +17 -0
  29. package/src/step/strayPaths.ts +8 -0
  30. package/src/testkit/FakeGitOperations.ts +2 -0
  31. package/src/testkit/GitTiers.ts +67 -1
  32. package/src/testkit/InMemRepo.ts +7 -0
  33. package/src/testkit/Layers.test.ts +34 -1
  34. package/src/testkit/Layers.ts +21 -1
  35. package/src/testkit/TestRepo.ts +1 -1
  36. package/src/wire/ExitCodes.test.ts +11 -3
  37. package/src/wire/ExitCodes.ts +3 -0
  38. package/src/wire/Glob.test.ts +35 -0
  39. package/src/wire/Glob.ts +1 -1
@@ -0,0 +1,208 @@
1
+ import { afterEach, describe, expect, it } from "vitest"
2
+ import {
3
+ installContext,
4
+ judgeDiffShape,
5
+ type Change,
6
+ type FlowContext,
7
+ type JudgeAnswer,
8
+ type StepRequest,
9
+ } from "../flows/index.js"
10
+ import { globMatches } from "../wire/index.js"
11
+
12
+ // Lives in replay, which wires `matches: globMatches` (flows tests may not import wire).
13
+ afterEach(() => installContext(undefined))
14
+
15
+ const change = (
16
+ path: string,
17
+ status: Change["status"],
18
+ before?: string,
19
+ after?: string,
20
+ ): Change => ({
21
+ path,
22
+ status,
23
+ before,
24
+ after,
25
+ })
26
+
27
+ const install = (
28
+ verdict: { shape?: JudgeAnswer | undefined; truncated?: string[] } | "none",
29
+ vars: Record<string, string> = {},
30
+ ) => {
31
+ const requests: StepRequest[] = []
32
+ installContext({
33
+ matches: globMatches,
34
+ vars,
35
+ step: async (request: StepRequest) => {
36
+ requests.push(request)
37
+ return {
38
+ answers: { shape: verdict === "none" ? undefined : verdict.shape },
39
+ truncated: verdict === "none" ? [] : (verdict.truncated ?? []),
40
+ }
41
+ },
42
+ } as unknown as FlowContext)
43
+ return requests
44
+ }
45
+
46
+ const naive = [
47
+ change("src/a.ts", "modified", "a1", "a2"),
48
+ change("z/new.ts", "added", undefined, "new"),
49
+ change("b/old.ts", "deleted", "old", undefined),
50
+ ]
51
+
52
+ describe("judgeDiffShape", () => {
53
+ it("answers land with no judge step when nothing strays", async () => {
54
+ const requests = install("none")
55
+ const result = await judgeDiffShape("shape", {
56
+ changes: naive,
57
+ allow: ["src/**", "z/**", "b/**"],
58
+ })
59
+ expect(result).toEqual({ choice: "land", strays: [], unreadable: [], judgment: undefined })
60
+ expect(requests).toEqual([])
61
+ })
62
+
63
+ it("makes every path a stray when allow is empty", async () => {
64
+ install({ shape: { answer: "land", p: 0.9 } })
65
+ const result = await judgeDiffShape("shape", { changes: naive, allow: [] })
66
+ expect(result.strays).toEqual(["b/old.ts", "src/a.ts", "z/new.ts"])
67
+ })
68
+
69
+ it("asks one choice question named shape with the three labels in its criteria", async () => {
70
+ const requests = install({ shape: { answer: "land", p: 0.9 } })
71
+ await judgeDiffShape("shape", { changes: naive, allow: ["src/**"] })
72
+ expect(requests).toHaveLength(1)
73
+ const request = requests[0]
74
+ if (request?.kind !== "judge") throw new Error("expected a judge step")
75
+ expect(request.name).toBe("shape")
76
+ expect(request.questions).toHaveLength(1)
77
+ const q = request.questions[0]
78
+ expect(q).toMatchObject({ id: "shape", primitive: "choice" })
79
+ for (const label of ["land", "refactor-first", "reject"]) expect(q?.criteria).toContain(label)
80
+ expect(q?.criteria).toContain("strays a refactor could absorb never count as reject")
81
+ })
82
+
83
+ it("carries exactly four evidence keys, with stray content before/after and in-allowlist paths only", async () => {
84
+ const requests = install({ shape: { answer: "land", p: 0.9 } })
85
+ await judgeDiffShape("shape", { changes: naive, allow: ["src/**"] })
86
+ const request = requests[0]
87
+ if (request?.kind !== "judge") throw new Error("expected a judge step")
88
+ expect(Object.keys(request.evidence).sort()).toEqual([
89
+ "allow",
90
+ "inAllowlist",
91
+ "strayContent",
92
+ "strays",
93
+ ])
94
+ expect(request.evidence.strays).toBe(JSON.stringify(["b/old.ts", "z/new.ts"], null, 2))
95
+ expect(request.evidence.allow).toBe(JSON.stringify(["src/**"], null, 2))
96
+ expect(request.evidence.inAllowlist).toBe(JSON.stringify(["src/a.ts"], null, 2))
97
+ expect(request.evidence.strayContent).toBe(
98
+ [
99
+ "=== b/old.ts (deleted)",
100
+ "--- before",
101
+ "old",
102
+ "=== z/new.ts (added)",
103
+ "--- after",
104
+ "new",
105
+ ].join("\n"),
106
+ )
107
+ expect(request.evidence.strayContent).not.toContain("a2")
108
+ })
109
+
110
+ it("keeps modified strays' before and after", async () => {
111
+ const requests = install({ shape: { answer: "land", p: 0.9 } })
112
+ await judgeDiffShape("shape", { changes: naive, allow: ["b/**", "z/**"] })
113
+ const request = requests[0]
114
+ if (request?.kind !== "judge") throw new Error("expected a judge step")
115
+ expect(request.evidence.strayContent).toBe(
116
+ "=== src/a.ts (modified)\n--- before\na1\n--- after\na2",
117
+ )
118
+ })
119
+
120
+ it.each(["land", "refactor-first", "reject"] as const)(
121
+ "returns %s at or above minP",
122
+ async (label) => {
123
+ install({ shape: { answer: label, p: 0.7 } })
124
+ const result = await judgeDiffShape("shape", { changes: naive, allow: [] })
125
+ expect(result.choice).toBe(label)
126
+ },
127
+ )
128
+
129
+ it("falls back to refactor-first below minP", async () => {
130
+ install({ shape: { answer: "reject", p: 0.69 } })
131
+ expect((await judgeDiffShape("shape", { changes: naive, allow: [] })).choice).toBe(
132
+ "refactor-first",
133
+ )
134
+ })
135
+
136
+ it("falls back to refactor-first on no verdict or an unknown answer", async () => {
137
+ install("none")
138
+ expect((await judgeDiffShape("shape", { changes: naive, allow: [] })).choice).toBe(
139
+ "refactor-first",
140
+ )
141
+ install({ shape: { answer: "yolo", p: 1 } })
142
+ expect((await judgeDiffShape("shape", { changes: naive, allow: [] })).choice).toBe(
143
+ "refactor-first",
144
+ )
145
+ })
146
+
147
+ it("prefers the minP option, then vars.judgeShapeMinP, then 0.7", async () => {
148
+ const verdict = { shape: { answer: "land", p: 0.9 } }
149
+ install(verdict, { judgeShapeMinP: "0.95" })
150
+ expect((await judgeDiffShape("s", { changes: naive, allow: [] })).choice).toBe("refactor-first")
151
+ expect((await judgeDiffShape("s", { changes: naive, allow: [], minP: 0.5 })).choice).toBe(
152
+ "land",
153
+ )
154
+ install(verdict, {})
155
+ expect((await judgeDiffShape("s", { changes: naive, allow: [] })).choice).toBe("land")
156
+ install({ shape: { answer: "land", p: 0.6 } }, {})
157
+ expect((await judgeDiffShape("s", { changes: naive, allow: [] })).choice).toBe("refactor-first")
158
+ })
159
+
160
+ it("never lands a blank judgeShapeMinP", async () => {
161
+ install({ shape: { answer: "land", p: 1 } }, { judgeShapeMinP: "" })
162
+ expect((await judgeDiffShape("s", { changes: naive, allow: [] })).choice).toBe("refactor-first")
163
+ })
164
+
165
+ it("takes refactor-first on truncated evidence and still returns the judgment", async () => {
166
+ install({ shape: { answer: "land", p: 0.9 }, truncated: ["strayContent"] })
167
+ const result = await judgeDiffShape("s", { changes: naive, allow: [] })
168
+ expect(result.choice).toBe("refactor-first")
169
+ expect(result.judgment?.truncated).toEqual(["strayContent"])
170
+ })
171
+
172
+ it.each([
173
+ ["modified without before", change("m.ts", "modified", undefined, "a")],
174
+ ["modified without after", change("m.ts", "modified", "a", undefined)],
175
+ ["added without after", change("m.ts", "added", undefined, undefined)],
176
+ ["deleted without before", change("m.ts", "deleted", undefined, undefined)],
177
+ ])("takes refactor-first with no judge step for %s", async (_label, bad) => {
178
+ const requests = install({ shape: { answer: "land", p: 1 } })
179
+ const result = await judgeDiffShape("s", {
180
+ changes: [change("z.ts", "added", undefined, "z"), bad],
181
+ allow: [],
182
+ })
183
+ expect(result).toEqual({
184
+ choice: "refactor-first",
185
+ strays: ["m.ts", "z.ts"],
186
+ unreadable: ["m.ts"],
187
+ judgment: undefined,
188
+ })
189
+ expect(requests).toEqual([])
190
+ })
191
+
192
+ it("reads each lazy side once, so the readable check and the judged content agree", async () => {
193
+ let reads = 0
194
+ const lazy: Change = {
195
+ path: "l.ts",
196
+ status: "modified",
197
+ before: "b",
198
+ get after() {
199
+ reads++
200
+ return reads === 1 ? "a" : undefined
201
+ },
202
+ }
203
+ install({ shape: { answer: "land", p: 0.9 } })
204
+ const result = await judgeDiffShape("s", { changes: [lazy], allow: [] })
205
+ expect(reads).toBe(1)
206
+ expect(result.choice).toBe("land")
207
+ })
208
+ })
package/src/step/index.ts CHANGED
@@ -3,3 +3,4 @@ export { accessRefusal } from "./accessRefusal.js"
3
3
  export { planStart } from "./planStart.js"
4
4
  export type { GitWrite, LandStep, Outcome } from "./LandStep.js"
5
5
  export type { Landing, RepoSnapshot } from "./RepoSnapshot.js"
6
+ export { strayPaths } from "./strayPaths.js"
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { strayPaths } from "./index.js"
3
+
4
+ describe("strayPaths", () => {
5
+ it("returns the paths no glob matches, sorted", () => {
6
+ expect(strayPaths(["src/a.ts", "z.md", "a.md"], ["src/**"])).toEqual(["a.md", "z.md"])
7
+ })
8
+
9
+ it("an empty allowlist returns every touched path", () => {
10
+ expect(strayPaths(["b", "a"], [])).toEqual(["a", "b"])
11
+ })
12
+
13
+ it("an empty touched list returns []", () => {
14
+ expect(strayPaths([], ["src/**"])).toEqual([])
15
+ expect(strayPaths([], [])).toEqual([])
16
+ })
17
+ })
@@ -0,0 +1,8 @@
1
+ import { globMatches } from "../wire/index.js"
2
+
3
+ /** Globs use the `access.write` dialect (`globMatches`). */
4
+ export const strayPaths = (
5
+ touched: ReadonlyArray<string>,
6
+ allow: ReadonlyArray<string>,
7
+ ): ReadonlyArray<string> =>
8
+ touched.filter((path) => !allow.some((glob) => globMatches(path, glob))).sort()
@@ -76,6 +76,8 @@ const makeGitReaderOps = (repo: InMemRepo, root: string): GitReaderOperations =>
76
76
  Effect.succeed(repo.subjectHistory(pageSize, skip, head)),
77
77
 
78
78
  changedPaths: (base?: string) => Effect.succeed(repo.changedPathsWorktree(base)),
79
+
80
+ diffPaths: (base: string, tip: string) => tryCatch(() => repo.diffPaths(base, tip)),
79
81
  })
80
82
 
81
83
  const failOn = (output: string | undefined): void => {
@@ -409,6 +409,7 @@ export const CONTRACT_COVERED_OPERATIONS: ReadonlySet<keyof GitOperations> = new
409
409
  "subjectHistory",
410
410
  "readFileAtRef",
411
411
  "changedPaths",
412
+ "diffPaths",
412
413
  "commitAllWithPrefix",
413
414
  "softResetTo",
414
415
  "commitAsIs",
@@ -434,7 +435,7 @@ const PATHOLOGICAL_PATHS: ReadonlyArray<{ label: string; path: string }> = [
434
435
  ]
435
436
 
436
437
  /**
437
- * Exercise all 21 `GitOperations` methods identically against `makeTier()` —
438
+ * Exercise every `GitOperations` methods identically against `makeTier()` —
438
439
  * called once per tier by `src/platform/Git.test.ts`. A capability-gated group
439
440
  * (`t.capabilities.X`) is skipped, not faked, on a tier that can't support it.
440
441
  */
@@ -480,6 +481,7 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
480
481
  g.readFileAtRef("HEAD", "second.txt"),
481
482
  g.changedPaths(),
482
483
  g.changedPaths("HEAD~1"),
484
+ g.diffPaths("HEAD~1", "HEAD"),
483
485
  ]),
484
486
  )
485
487
  expect(await t.observe.snapshot()).toBe(before)
@@ -988,6 +990,70 @@ export const runGitServiceContract = (makeTier: () => GitTier): void => {
988
990
  })
989
991
  })
990
992
 
993
+ describe("diffPaths", () => {
994
+ const sorted = (paths: ReadonlyArray<string>) => [...paths].sort()
995
+
996
+ it("reports added, modified and deleted paths between two commits", async () => {
997
+ t.seed.commit("chore: seed", { "mod.txt": "one", "del.txt": "bye" })
998
+ const base = t.observe.resolveRef("HEAD")
999
+ t.seed.writeFile("mod.txt", "two")
1000
+ t.seed.stageAll()
1001
+ t.seed.commitDeletion("del.txt", "chore: delete")
1002
+ t.seed.commit("feat: add", { "add.txt": "new", "mod.txt": "two" })
1003
+ const tip = t.observe.resolveRef("HEAD")
1004
+ const paths = await runGit(t, (g) => g.diffPaths(base, tip))
1005
+ expect(sorted(paths)).toEqual(["add.txt", "del.txt", "mod.txt"])
1006
+ })
1007
+
1008
+ it("reports both paths of a rename", async () => {
1009
+ t.seed.commit("chore: seed", { "old.txt": "same content unique-marker\n" })
1010
+ const base = t.observe.resolveRef("HEAD")
1011
+ t.seed.deleteFile("old.txt")
1012
+ t.seed.commit("refactor: rename", { "new.txt": "same content unique-marker\n" })
1013
+ const tip = t.observe.resolveRef("HEAD")
1014
+ expect(sorted(await runGit(t, (g) => g.diffPaths(base, tip)))).toEqual(["new.txt", "old.txt"])
1015
+ })
1016
+
1017
+ it("omits a file changed then restored between the endpoints", async () => {
1018
+ t.seed.commit("chore: seed", { "flip.txt": "one" })
1019
+ const base = t.observe.resolveRef("HEAD")
1020
+ t.seed.commit("chore: change", { "flip.txt": "two" })
1021
+ t.seed.commit("chore: restore", { "flip.txt": "one" })
1022
+ const tip = t.observe.resolveRef("HEAD")
1023
+ expect(await runGit(t, (g) => g.diffPaths(base, tip))).toEqual([])
1024
+ })
1025
+
1026
+ it("never reports staged, unstaged or untracked changes", async () => {
1027
+ t.seed.commit("chore: seed", { "a.txt": "a", "b.txt": "b" })
1028
+ const base = t.observe.resolveRef("HEAD")
1029
+ t.seed.writeFile("a.txt", "staged")
1030
+ t.seed.stageAll()
1031
+ t.seed.writeFile("b.txt", "unstaged")
1032
+ t.seed.writeFile("untracked.txt", "u")
1033
+ expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([])
1034
+ })
1035
+
1036
+ it("is [] for diffPaths(x, x)", async () => {
1037
+ const head = t.observe.resolveRef("HEAD")
1038
+ expect(await runGit(t, (g) => g.diffPaths(head, head))).toEqual([])
1039
+ })
1040
+
1041
+ for (const { label, path } of PATHOLOGICAL_PATHS) {
1042
+ it(`reports a path containing ${label}, verbatim`, async () => {
1043
+ const base = t.observe.resolveRef("HEAD")
1044
+ t.seed.commit("feat: pathological", { [path]: "content" })
1045
+ expect(await runGit(t, (g) => g.diffPaths(base, "HEAD"))).toEqual([path])
1046
+ })
1047
+ }
1048
+
1049
+ it("fails when either side does not resolve", async () => {
1050
+ const bad = await runGitExit(t, (g) => g.diffPaths("totally-invalid-ref-xyz", "HEAD"))
1051
+ expect(Exit.isFailure(bad)).toBe(true)
1052
+ const badTip = await runGitExit(t, (g) => g.diffPaths("HEAD", "totally-invalid-ref-xyz"))
1053
+ expect(Exit.isFailure(badTip)).toBe(true)
1054
+ })
1055
+ })
1056
+
991
1057
  describe("changedPaths", () => {
992
1058
  it("unions a tracked modification, a tracked deletion, and an untracked addition, deduplicated by path", async () => {
993
1059
  t.seed.commit("chore: seed", { "modme.txt": "orig", "delme.txt": "bye" })
@@ -270,6 +270,13 @@ export class InMemRepo {
270
270
  return diffTrees(baseTree, worktreeTree)
271
271
  }
272
272
 
273
+ diffPaths(base: string, tip: string): string[] {
274
+ for (const ref of [base, tip]) {
275
+ if (this.resolveRef(ref) === null) throw new Error(`Invalid ref: ${ref}`)
276
+ }
277
+ return diffTrees(this.treeAt(base), this.treeAt(tip)).map((e) => e.path)
278
+ }
279
+
273
280
  /** The worktree content of `path`, or `undefined` when absent. A LIVE read — never a snapshot. */
274
281
  readFile(path: string): string | undefined {
275
282
  return this.worktree.get(path)
@@ -2,7 +2,8 @@ import { Effect } from "effect"
2
2
  import { describe, expect, it } from "vitest"
3
3
  import { ConfigDiscovery, LEGACY_RC_FILES, walkUp } from "../workflow/index.js"
4
4
  import { InMemRepo } from "./index.js"
5
- import { testLayers } from "./Layers.js"
5
+ import { ambientFlagEnv, testLayers } from "./Layers.js"
6
+ import { Host } from "../platform/index.js"
6
7
 
7
8
  // The in-memory `ConfigDiscovery` counterpart walks the SAME root→home chain
8
9
  // and looks for the SAME `LEGACY_RC_FILES` as `ConfigDiscovery.Live` — both
@@ -56,3 +57,35 @@ describe("the in-memory ConfigDiscovery counterpart", () => {
56
57
  expect(found).toEqual([`${HOME}/${LEGACY_RC_FILES[1]}`])
57
58
  })
58
59
  })
60
+
61
+ describe("ambient GTD_FLAG_* reaches the in-memory Host", () => {
62
+ const hostEnv = (ambient: Record<string, string>, env?: Record<string, string>) => {
63
+ const before = { ...process.env }
64
+ Object.assign(process.env, ambient)
65
+ return Effect.runPromise(
66
+ Effect.map(Host, (h) => h.env).pipe(
67
+ Effect.provide(testLayers(new InMemRepo(), env ? { env } : {})),
68
+ Effect.ensuring(
69
+ Effect.sync(() => {
70
+ for (const k of Object.keys(ambient)) {
71
+ if (before[k] === undefined) delete process.env[k]
72
+ else process.env[k] = before[k]
73
+ }
74
+ }),
75
+ ),
76
+ ),
77
+ )
78
+ }
79
+
80
+ it("carries the ambient flag, and explicit env wins", async () => {
81
+ expect((await hostEnv({ GTD_FLAG_ALPHA: "1" }))["GTD_FLAG_ALPHA"]).toBe("1")
82
+ const env = await hostEnv({ GTD_FLAG_ALPHA: "1" }, { GTD_FLAG_ALPHA: "0" })
83
+ expect(env["GTD_FLAG_ALPHA"]).toBe("0")
84
+ })
85
+
86
+ it("picks only GTD_FLAG_* keys", () => {
87
+ expect(ambientFlagEnv({ GTD_FLAG_X: "1", PATH: "/bin", GTD_FLAG_U: undefined })).toEqual({
88
+ GTD_FLAG_X: "1",
89
+ })
90
+ })
91
+ })
@@ -216,6 +216,21 @@ const missingToolCommands: ToolCommands["Type"] = {
216
216
  export const gitTestLayer = (repo: InMemRepo, root = "/repo"): Layer.Layer<GitService> =>
217
217
  Layer.succeed(GitService, fakeGitOperations(repo, root))
218
218
 
219
+ /**
220
+ * The one sanctioned leak of the real `process.env` into the in-memory `Host`:
221
+ * the runner's `GTD_FLAG_*` state, so a flag-on `npm test` state exercises the
222
+ * flag in memory as it does live. A test asserting one flag side sets the flag
223
+ * itself; explicit env wins.
224
+ */
225
+ export const ambientFlagEnv = (
226
+ env: Readonly<Record<string, string | undefined>> = process.env,
227
+ ): Record<string, string> =>
228
+ Object.fromEntries(
229
+ Object.entries(env).filter(
230
+ (e): e is [string, string] => e[0].startsWith("GTD_FLAG_") && e[1] !== undefined,
231
+ ),
232
+ )
233
+
219
234
  /** Every layer `makeProgram` needs — `CommandRequirements`'s return type here IS the guarantee: a new port added there fails this function's typecheck instead of silently under-providing. */
220
235
  export function testLayers(
221
236
  repo: InMemRepo,
@@ -231,7 +246,12 @@ export function testLayers(
231
246
  // symlinks against): both sides of `assertRunningFromRepoRoot`'s
232
247
  // comparison collapse to `root` when given anything, exactly like the
233
248
  // fake's `root` "resolving" to itself before this port existed.
234
- Host.layer({ root, home, env: opts.env ?? {}, realPath: () => Effect.succeed(root) }),
249
+ Host.layer({
250
+ root,
251
+ home,
252
+ env: { ...ambientFlagEnv(), ...opts.env },
253
+ realPath: () => Effect.succeed(root),
254
+ }),
235
255
  ConfigService.Live,
236
256
  makeInMemoryConfigDiscovery(repo, root),
237
257
  Narrator.layer(opts.narrate ?? (() => {}), opts.verbose ?? true),
@@ -19,7 +19,7 @@ export interface TestRepoOptions {
19
19
  readonly workflow: WorkflowModule
20
20
  /** Committed alongside the config, before any process starts. */
21
21
  readonly files?: Readonly<Record<string, string>>
22
- /** The environment gtd sees; the real `process.env` is never read. */
22
+ /** The environment gtd sees; the real `process.env` is never read, except ambient `GTD_FLAG_*` (explicit env wins). */
23
23
  readonly env?: Readonly<Record<string, string>>
24
24
  }
25
25
 
@@ -5,16 +5,24 @@ import {
5
5
  EXIT_RUNTIME_ERROR,
6
6
  EXIT_SIGINT,
7
7
  EXIT_SIGTERM,
8
+ EXIT_STRAYS_FOUND,
8
9
  EXIT_USAGE_ERROR,
9
10
  } from "./index.js"
10
11
 
11
12
  describe("EXIT_CODES", () => {
12
- it("is frozen at exactly {0, 1, 2, 130, 143} — a new code breaks every driver", () => {
13
- expect(EXIT_CODES).toEqual(new Set([0, 1, 2, 130, 143]))
13
+ it("is frozen at exactly {0, 1, 2, 10, 130, 143} — a new code breaks every driver", () => {
14
+ expect(EXIT_CODES).toEqual(new Set([0, 1, 2, 10, 130, 143]))
14
15
  })
15
16
 
16
17
  it("each named constant is a member of the set", () => {
17
- for (const code of [EXIT_OK, EXIT_RUNTIME_ERROR, EXIT_USAGE_ERROR, EXIT_SIGINT, EXIT_SIGTERM]) {
18
+ for (const code of [
19
+ EXIT_OK,
20
+ EXIT_RUNTIME_ERROR,
21
+ EXIT_USAGE_ERROR,
22
+ EXIT_STRAYS_FOUND,
23
+ EXIT_SIGINT,
24
+ EXIT_SIGTERM,
25
+ ]) {
18
26
  expect(EXIT_CODES.has(code)).toBe(true)
19
27
  }
20
28
  })
@@ -3,6 +3,8 @@ export const EXIT_OK = 0
3
3
  export const EXIT_RUNTIME_ERROR = 1
4
4
  /** gtd was invoked wrong — an unknown option/command, bad arity, a scope violation, a decode failure. */
5
5
  export const EXIT_USAGE_ERROR = 2
6
+ /** `gtd strays` found touched paths outside the allowlist — that command's only, and it carries no other meaning. */
7
+ export const EXIT_STRAYS_FOUND = 10
6
8
  export const EXIT_SIGINT = 130
7
9
  export const EXIT_SIGTERM = 143
8
10
 
@@ -11,6 +13,7 @@ export const EXIT_CODES: ReadonlySet<number> = new Set([
11
13
  EXIT_OK,
12
14
  EXIT_RUNTIME_ERROR,
13
15
  EXIT_USAGE_ERROR,
16
+ EXIT_STRAYS_FOUND,
14
17
  EXIT_SIGINT,
15
18
  EXIT_SIGTERM,
16
19
  ])
@@ -0,0 +1,35 @@
1
+ import { describe, expect, it } from "vitest"
2
+ import { globMatches } from "./Glob.js"
3
+
4
+ describe("globMatches", () => {
5
+ it("`*` stays within one segment; `**` crosses segments, zero included", () => {
6
+ expect(globMatches("a.md", "*.md")).toBe(true)
7
+ expect(globMatches("docs/a.md", "*.md")).toBe(false)
8
+ expect(globMatches("src/x/y/a.ts", "src/**")).toBe(true)
9
+ expect(globMatches("a.ts", "**/a.ts")).toBe(true)
10
+ expect(globMatches("x/a.ts", "**/a.ts")).toBe(true)
11
+ })
12
+
13
+ it("dotfiles match `*` and `**` like any other path", () => {
14
+ expect(globMatches(".env", "*")).toBe(true)
15
+ expect(globMatches("dir/.hidden", "dir/*")).toBe(true)
16
+ expect(globMatches(".github/ci.yml", "**")).toBe(true)
17
+ expect(globMatches(".github/ci.yml", "*")).toBe(false)
18
+ })
19
+
20
+ it("matches case-sensitively", () => {
21
+ expect(globMatches("README.md", "readme.md")).toBe(false)
22
+ })
23
+
24
+ it("`?` and `[` are literal", () => {
25
+ expect(globMatches("a?.txt", "a?.txt")).toBe(true)
26
+ expect(globMatches("ab.txt", "a?.txt")).toBe(false)
27
+ expect(globMatches("[a].txt", "[a].txt")).toBe(true)
28
+ expect(globMatches("a.txt", "[a].txt")).toBe(false)
29
+ })
30
+
31
+ it("`**` crosses a path containing a line break", () => {
32
+ expect(globMatches("weird\nname.txt", "**")).toBe(true)
33
+ expect(globMatches("d/a\nb", "d/**")).toBe(true)
34
+ })
35
+ })
package/src/wire/Glob.ts CHANGED
@@ -23,7 +23,7 @@ const compile = (glob: string): RegExp => {
23
23
  i += 2
24
24
  }
25
25
  }
26
- return new RegExp(`${pattern}$`)
26
+ return new RegExp(`${pattern}$`, "s")
27
27
  }
28
28
 
29
29
  export const globMatches = (path: string, glob: string): boolean => compile(glob).test(path)