gsd-pi 2.77.0-dev.1d17f366c → 2.77.0-dev.58d3d4d6c

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 (128) hide show
  1. package/dist/resources/extensions/gsd/auto/session.js +6 -0
  2. package/dist/resources/extensions/gsd/auto-post-unit.js +79 -0
  3. package/dist/resources/extensions/gsd/auto-prompts.js +48 -7
  4. package/dist/resources/extensions/gsd/auto-start.js +62 -3
  5. package/dist/resources/extensions/gsd/auto.js +34 -0
  6. package/dist/resources/extensions/gsd/context-store.js +23 -7
  7. package/dist/resources/extensions/gsd/forensics.js +106 -0
  8. package/dist/resources/extensions/gsd/preferences-validation.js +23 -0
  9. package/dist/resources/extensions/gsd/prompt-cache-optimizer.js +4 -0
  10. package/dist/resources/extensions/gsd/slice-cadence.js +238 -0
  11. package/dist/resources/extensions/gsd/tools/validate-milestone.js +7 -2
  12. package/dist/resources/extensions/gsd/worktree-manager.js +51 -0
  13. package/dist/resources/extensions/gsd/worktree-resolver.js +86 -7
  14. package/dist/resources/extensions/gsd/worktree-telemetry.js +198 -0
  15. package/dist/tsconfig.extensions.tsbuildinfo +1 -1
  16. package/dist/web/standalone/.next/BUILD_ID +1 -1
  17. package/dist/web/standalone/.next/app-path-routes-manifest.json +9 -9
  18. package/dist/web/standalone/.next/build-manifest.json +2 -2
  19. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  20. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  21. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  28. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  29. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  35. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  36. package/dist/web/standalone/.next/server/app/index.html +1 -1
  37. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  38. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  39. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  40. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  41. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  42. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  43. package/dist/web/standalone/.next/server/app-paths-manifest.json +9 -9
  44. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  45. package/dist/web/standalone/.next/server/middleware-manifest.json +5 -5
  46. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  47. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  48. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  49. package/package.json +1 -1
  50. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-execution.test.js +36 -5
  51. package/packages/pi-coding-agent/dist/modes/interactive/components/__tests__/tool-execution.test.js.map +1 -1
  52. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.d.ts.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js +30 -12
  54. package/packages/pi-coding-agent/dist/modes/interactive/components/tool-execution.js.map +1 -1
  55. package/packages/pi-coding-agent/src/modes/interactive/components/__tests__/tool-execution.test.ts +49 -3
  56. package/packages/pi-coding-agent/src/modes/interactive/components/tool-execution.ts +48 -9
  57. package/packages/pi-coding-agent/tsconfig.tsbuildinfo +1 -1
  58. package/src/resources/extensions/gsd/auto/session.ts +7 -0
  59. package/src/resources/extensions/gsd/auto-post-unit.ts +81 -0
  60. package/src/resources/extensions/gsd/auto-prompts.ts +59 -7
  61. package/src/resources/extensions/gsd/auto-start.ts +64 -2
  62. package/src/resources/extensions/gsd/auto.ts +37 -0
  63. package/src/resources/extensions/gsd/context-store.ts +25 -8
  64. package/src/resources/extensions/gsd/forensics.ts +118 -1
  65. package/src/resources/extensions/gsd/git-service.ts +16 -0
  66. package/src/resources/extensions/gsd/journal.ts +11 -1
  67. package/src/resources/extensions/gsd/preferences-validation.ts +21 -0
  68. package/src/resources/extensions/gsd/prompt-cache-optimizer.ts +4 -0
  69. package/src/resources/extensions/gsd/slice-cadence.ts +299 -0
  70. package/src/resources/extensions/gsd/tests/artifacts-table-preserved-on-cache-invalidate.test.ts +2 -1
  71. package/src/resources/extensions/gsd/tests/auto-loop.test.ts +5 -8
  72. package/src/resources/extensions/gsd/tests/auto-remediate-slice-status.test.ts +4 -1
  73. package/src/resources/extensions/gsd/tests/auto-retry-mcp-churn-fixes.test.ts +12 -9
  74. package/src/resources/extensions/gsd/tests/auto-start-clean-runtime-db-gated.test.ts +3 -2
  75. package/src/resources/extensions/gsd/tests/auto-start-cold-db-bootstrap.test.ts +2 -2
  76. package/src/resources/extensions/gsd/tests/auto-start-model-capture.test.ts +4 -3
  77. package/src/resources/extensions/gsd/tests/auto-start-worktree-db-path.test.ts +2 -2
  78. package/src/resources/extensions/gsd/tests/auto-thinking-restore.test.ts +3 -2
  79. package/src/resources/extensions/gsd/tests/auto-warning-noise-regression.test.ts +3 -2
  80. package/src/resources/extensions/gsd/tests/bootstrap-derive-state-db-open.test.ts +2 -1
  81. package/src/resources/extensions/gsd/tests/canonical-milestone-root.test.ts +108 -0
  82. package/src/resources/extensions/gsd/tests/complete-slice-verification-gate.test.ts +2 -1
  83. package/src/resources/extensions/gsd/tests/context-store.test.ts +79 -0
  84. package/src/resources/extensions/gsd/tests/copy-planning-artifacts-samepath.test.ts +2 -1
  85. package/src/resources/extensions/gsd/tests/discuss-slice-structured-questions.test.ts +2 -1
  86. package/src/resources/extensions/gsd/tests/discuss-tool-scope-leak.test.ts +2 -1
  87. package/src/resources/extensions/gsd/tests/double-merge-guard.test.ts +4 -3
  88. package/src/resources/extensions/gsd/tests/empty-content-abort-loop.test.ts +4 -3
  89. package/src/resources/extensions/gsd/tests/finalize-timeout-guard.test.ts +2 -2
  90. package/src/resources/extensions/gsd/tests/forensics-hook-key-parse.test.ts +2 -1
  91. package/src/resources/extensions/gsd/tests/forensics-worktree-telemetry.test.ts +145 -0
  92. package/src/resources/extensions/gsd/tests/idle-watchdog-stall-override.test.ts +6 -1
  93. package/src/resources/extensions/gsd/tests/import-done-milestones.test.ts +2 -1
  94. package/src/resources/extensions/gsd/tests/integration/all-milestones-complete-merge.test.ts +2 -1
  95. package/src/resources/extensions/gsd/tests/interactive-routing-bypass.test.ts +9 -3
  96. package/src/resources/extensions/gsd/tests/knowledge.test.ts +93 -1
  97. package/src/resources/extensions/gsd/tests/merge-conflict-stops-loop.test.ts +10 -3
  98. package/src/resources/extensions/gsd/tests/native-git-bridge-exec-fallback.test.ts +2 -1
  99. package/src/resources/extensions/gsd/tests/orphaned-worktree-audit.test.ts +59 -2
  100. package/src/resources/extensions/gsd/tests/parallel-research-dispatch.test.ts +4 -1
  101. package/src/resources/extensions/gsd/tests/preferences-worktree-sync.test.ts +2 -1
  102. package/src/resources/extensions/gsd/tests/prompt-cache-optimizer.test.ts +12 -0
  103. package/src/resources/extensions/gsd/tests/prompt-step-ordering.test.ts +15 -4
  104. package/src/resources/extensions/gsd/tests/queue-draft-detection.test.ts +3 -2
  105. package/src/resources/extensions/gsd/tests/queued-discuss-fast-path.test.ts +4 -5
  106. package/src/resources/extensions/gsd/tests/restore-tools-after-discuss.test.ts +6 -3
  107. package/src/resources/extensions/gsd/tests/sidecar-queue.test.ts +3 -2
  108. package/src/resources/extensions/gsd/tests/slice-cadence.test.ts +242 -0
  109. package/src/resources/extensions/gsd/tests/slice-context-injection.test.ts +3 -2
  110. package/src/resources/extensions/gsd/tests/smart-entry-draft.test.ts +2 -1
  111. package/src/resources/extensions/gsd/tests/stop-auto-race-null-unit.test.ts +3 -3
  112. package/src/resources/extensions/gsd/tests/subagent-model-dispatch.test.ts +7 -6
  113. package/src/resources/extensions/gsd/tests/sync-worktree-skip-current.test.ts +4 -3
  114. package/src/resources/extensions/gsd/tests/test-helpers.test.ts +147 -0
  115. package/src/resources/extensions/gsd/tests/test-helpers.ts +140 -0
  116. package/src/resources/extensions/gsd/tests/token-profile.test.ts +8 -1
  117. package/src/resources/extensions/gsd/tests/verify-artifact-tightened.test.ts +6 -5
  118. package/src/resources/extensions/gsd/tests/worktree-health-monorepo.test.ts +2 -2
  119. package/src/resources/extensions/gsd/tests/worktree-nested-git-safety.test.ts +2 -2
  120. package/src/resources/extensions/gsd/tests/worktree-submodule-safety.test.ts +2 -2
  121. package/src/resources/extensions/gsd/tests/worktree-telemetry.test.ts +210 -0
  122. package/src/resources/extensions/gsd/tests/zombie-gsd-state.test.ts +3 -3
  123. package/src/resources/extensions/gsd/tools/validate-milestone.ts +8 -2
  124. package/src/resources/extensions/gsd/worktree-manager.ts +53 -0
  125. package/src/resources/extensions/gsd/worktree-resolver.ts +96 -9
  126. package/src/resources/extensions/gsd/worktree-telemetry.ts +322 -0
  127. /package/dist/web/standalone/.next/static/{vidAVJkURvTJ0_V2-64ro → Cev5xrAYA3ZGTRLyjR2fX}/_buildManifest.js +0 -0
  128. /package/dist/web/standalone/.next/static/{vidAVJkURvTJ0_V2-64ro → Cev5xrAYA3ZGTRLyjR2fX}/_ssgManifest.js +0 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * Tests for slice-cadence collapse — #4765.
3
+ *
4
+ * Covers mergeSliceToMain (squash + advance), resquashMilestoneOnMain,
5
+ * and the preference accessors.
6
+ */
7
+
8
+ import { describe, test, beforeEach, afterEach } from "node:test";
9
+ import assert from "node:assert/strict";
10
+ import { mkdtempSync, mkdirSync, writeFileSync, rmSync, realpathSync, readFileSync, existsSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { tmpdir } from "node:os";
13
+ import { execFileSync } from "node:child_process";
14
+
15
+ import {
16
+ mergeSliceToMain,
17
+ resquashMilestoneOnMain,
18
+ getCollapseCadence,
19
+ getMilestoneResquash,
20
+ } from "../slice-cadence.ts";
21
+ import { MergeConflictError } from "../git-service.ts";
22
+ import { summarizeWorktreeTelemetry } from "../worktree-telemetry.ts";
23
+
24
+ function git(args: string[], cwd: string): string {
25
+ return execFileSync("git", args, { cwd, stdio: ["ignore", "pipe", "pipe"], encoding: "utf-8" }).trim();
26
+ }
27
+
28
+ /** Create a temp git repo with an initial commit on main. */
29
+ function createRepo(): string {
30
+ const dir = realpathSync(mkdtempSync(join(tmpdir(), "slice-cad-test-")));
31
+ git(["init"], dir);
32
+ git(["config", "user.email", "test@test.com"], dir);
33
+ git(["config", "user.name", "Test"], dir);
34
+ writeFileSync(join(dir, "README.md"), "# test\n");
35
+ git(["add", "."], dir);
36
+ git(["commit", "-m", "init"], dir);
37
+ git(["branch", "-M", "main"], dir);
38
+ mkdirSync(join(dir, ".gsd"), { recursive: true });
39
+ return dir;
40
+ }
41
+
42
+ function enterMilestoneBranch(dir: string, milestoneId: string): void {
43
+ git(["checkout", "-b", `milestone/${milestoneId}`], dir);
44
+ }
45
+
46
+ function commitFile(dir: string, file: string, content: string, msg: string): string {
47
+ writeFileSync(join(dir, file), content);
48
+ git(["add", "."], dir);
49
+ git(["commit", "-m", msg], dir);
50
+ return git(["rev-parse", "HEAD"], dir);
51
+ }
52
+
53
+ describe("getCollapseCadence / getMilestoneResquash", () => {
54
+ test("defaults to milestone cadence", () => {
55
+ assert.equal(getCollapseCadence(undefined), "milestone");
56
+ assert.equal(getCollapseCadence(null), "milestone");
57
+ assert.equal(getCollapseCadence({}), "milestone");
58
+ assert.equal(getCollapseCadence({ git: {} }), "milestone");
59
+ });
60
+ test("reads slice cadence when set", () => {
61
+ assert.equal(getCollapseCadence({ git: { collapse_cadence: "slice" } }), "slice");
62
+ });
63
+ test("milestone_resquash defaults to true when not set", () => {
64
+ assert.equal(getMilestoneResquash(undefined), true);
65
+ assert.equal(getMilestoneResquash({ git: {} }), true);
66
+ assert.equal(getMilestoneResquash({ git: { milestone_resquash: true } }), true);
67
+ });
68
+ test("milestone_resquash can be disabled explicitly", () => {
69
+ assert.equal(getMilestoneResquash({ git: { milestone_resquash: false } }), false);
70
+ });
71
+ });
72
+
73
+ describe("mergeSliceToMain", () => {
74
+ let dir: string;
75
+ let originalCwd: string;
76
+
77
+ beforeEach(() => {
78
+ dir = createRepo();
79
+ originalCwd = process.cwd();
80
+ });
81
+
82
+ afterEach(() => {
83
+ try { process.chdir(originalCwd); } catch { /* */ }
84
+ rmSync(dir, { recursive: true, force: true });
85
+ });
86
+
87
+ test("squashes one slice's commits onto main and advances the milestone branch", () => {
88
+ enterMilestoneBranch(dir, "M001");
89
+ commitFile(dir, "feature.txt", "slice 1 work\n", "feat: S01 work");
90
+
91
+ process.chdir(dir);
92
+ const result = mergeSliceToMain(dir, "M001", "S01");
93
+
94
+ assert.equal(result.skipped, false);
95
+ assert.ok(result.commitSha, "expected a commit SHA");
96
+ assert.equal(result.milestoneBranch, "milestone/M001");
97
+ assert.equal(result.mainBranch, "main");
98
+
99
+ const mainLog = git(["log", "main", "--oneline"], dir);
100
+ assert.ok(mainLog.includes("S01 of M001 (slice-cadence)"), `main log: ${mainLog}`);
101
+ assert.equal(readFileSync(join(dir, "feature.txt"), "utf-8"), "slice 1 work\n");
102
+
103
+ const mainSha = git(["rev-parse", "main"], dir);
104
+ const milestoneSha = git(["rev-parse", "milestone/M001"], dir);
105
+ assert.equal(milestoneSha, mainSha, "milestone branch must be advanced to main");
106
+
107
+ const summary = summarizeWorktreeTelemetry(dir);
108
+ assert.equal(summary.slicesMerged, 1);
109
+ assert.equal(summary.sliceMergeConflicts, 0);
110
+ });
111
+
112
+ test("handles sequential slice merges cleanly", () => {
113
+ enterMilestoneBranch(dir, "M001");
114
+ commitFile(dir, "a.txt", "slice 1\n", "feat: S01");
115
+
116
+ process.chdir(dir);
117
+ mergeSliceToMain(dir, "M001", "S01");
118
+
119
+ git(["checkout", "milestone/M001"], dir);
120
+ commitFile(dir, "b.txt", "slice 2\n", "feat: S02");
121
+
122
+ const result = mergeSliceToMain(dir, "M001", "S02");
123
+ assert.equal(result.skipped, false);
124
+
125
+ const mainLog = git(["log", "main", "--oneline"], dir);
126
+ assert.ok(mainLog.includes("S01 of M001"));
127
+ assert.ok(mainLog.includes("S02 of M001"));
128
+
129
+ assert.equal(readFileSync(join(dir, "a.txt"), "utf-8"), "slice 1\n");
130
+ assert.equal(readFileSync(join(dir, "b.txt"), "utf-8"), "slice 2\n");
131
+
132
+ const summary = summarizeWorktreeTelemetry(dir);
133
+ assert.equal(summary.slicesMerged, 2);
134
+ });
135
+
136
+ test("returns skipped when milestone branch has no commits ahead of main", () => {
137
+ enterMilestoneBranch(dir, "M001");
138
+
139
+ process.chdir(dir);
140
+ const result = mergeSliceToMain(dir, "M001", "S01");
141
+
142
+ assert.equal(result.skipped, true);
143
+ assert.equal(result.skippedReason, "no-commits-ahead");
144
+ assert.equal(result.commitSha, null);
145
+ });
146
+
147
+ test("throws MergeConflictError on a real conflict and leaves no merge artifacts", () => {
148
+ writeFileSync(join(dir, "shared.txt"), "main version\n");
149
+ git(["add", "."], dir);
150
+ git(["commit", "-m", "main-seed"], dir);
151
+
152
+ enterMilestoneBranch(dir, "M001");
153
+ commitFile(dir, "shared.txt", "slice version\n", "feat: S01 conflicting");
154
+
155
+ git(["checkout", "main"], dir);
156
+ commitFile(dir, "shared.txt", "main evolved\n", "main evolved");
157
+ git(["checkout", "milestone/M001"], dir);
158
+
159
+ process.chdir(dir);
160
+ assert.throws(
161
+ () => mergeSliceToMain(dir, "M001", "S01"),
162
+ (err: unknown) => err instanceof MergeConflictError,
163
+ );
164
+
165
+ const gitDir = join(dir, ".git");
166
+ for (const f of ["SQUASH_MSG", "MERGE_MSG", "MERGE_HEAD"]) {
167
+ assert.ok(!existsSync(join(gitDir, f)), `${f} should be cleaned up`);
168
+ }
169
+
170
+ const summary = summarizeWorktreeTelemetry(dir);
171
+ assert.equal(summary.sliceMergeConflicts, 1);
172
+ });
173
+
174
+ test("restores cwd even when merge fails (dirty working tree)", () => {
175
+ enterMilestoneBranch(dir, "M001");
176
+ commitFile(dir, "feature.txt", "slice 1\n", "feat: S01");
177
+ // Introduce an untracked file AFTER the slice commit so it's still
178
+ // present when mergeSliceToMain runs its status check.
179
+ writeFileSync(join(dir, "dirty.txt"), "uncommitted\n");
180
+
181
+ process.chdir(dir);
182
+ const cwdBefore = process.cwd();
183
+ assert.throws(() => mergeSliceToMain(dir, "M001", "S01"));
184
+ assert.equal(process.cwd(), cwdBefore, "cwd must be restored on failure");
185
+ });
186
+ });
187
+
188
+ describe("resquashMilestoneOnMain", () => {
189
+ let dir: string;
190
+ let originalCwd: string;
191
+
192
+ beforeEach(() => {
193
+ dir = createRepo();
194
+ originalCwd = process.cwd();
195
+ });
196
+
197
+ afterEach(() => {
198
+ try { process.chdir(originalCwd); } catch { /* */ }
199
+ rmSync(dir, { recursive: true, force: true });
200
+ });
201
+
202
+ test("collapses N slice commits on main into one milestone commit", () => {
203
+ const startSha = git(["rev-parse", "main"], dir);
204
+
205
+ enterMilestoneBranch(dir, "M001");
206
+ commitFile(dir, "a.txt", "slice 1\n", "feat: S01");
207
+ process.chdir(dir);
208
+ mergeSliceToMain(dir, "M001", "S01");
209
+
210
+ git(["checkout", "milestone/M001"], dir);
211
+ commitFile(dir, "b.txt", "slice 2\n", "feat: S02");
212
+ mergeSliceToMain(dir, "M001", "S02");
213
+
214
+ const beforeCount = parseInt(git(["rev-list", "--count", `${startSha}..main`], dir), 10);
215
+ assert.equal(beforeCount, 2);
216
+
217
+ const result = resquashMilestoneOnMain(dir, "M001", startSha);
218
+ assert.equal(result.resquashed, true);
219
+ assert.ok(result.newSha);
220
+
221
+ git(["checkout", "main"], dir);
222
+ const afterCount = parseInt(git(["rev-list", "--count", `${startSha}..main`], dir), 10);
223
+ assert.equal(afterCount, 1, "slice commits collapsed into one milestone commit");
224
+
225
+ const msg = git(["log", "-1", "--format=%s", "main"], dir);
226
+ assert.ok(msg.includes("M001") && msg.includes("2 slices"), `commit message should describe the resquash; got: ${msg}`);
227
+
228
+ assert.equal(readFileSync(join(dir, "a.txt"), "utf-8"), "slice 1\n");
229
+ assert.equal(readFileSync(join(dir, "b.txt"), "utf-8"), "slice 2\n");
230
+
231
+ const summary = summarizeWorktreeTelemetry(dir);
232
+ assert.equal(summary.milestoneResquashes, 1);
233
+ });
234
+
235
+ test("no-op when startSha equals HEAD", () => {
236
+ const startSha = git(["rev-parse", "main"], dir);
237
+ process.chdir(dir);
238
+ const result = resquashMilestoneOnMain(dir, "M001", startSha);
239
+ assert.equal(result.resquashed, false);
240
+ assert.equal(result.newSha, null);
241
+ });
242
+ });
@@ -11,6 +11,7 @@ import assert from "node:assert/strict";
11
11
  import { readFileSync } from "node:fs";
12
12
  import { join, dirname } from "node:path";
13
13
  import { fileURLToPath } from "node:url";
14
+ import { extractSourceRegion } from "./test-helpers.ts";
14
15
 
15
16
  const __dirname = dirname(fileURLToPath(import.meta.url));
16
17
  const autoPromptsPath = join(__dirname, "..", "auto-prompts.ts");
@@ -32,7 +33,7 @@ describe("slice CONTEXT.md injection into prompt builders (#3452)", () => {
32
33
  assert.ok(fnStart !== -1, `${builder} should exist in auto-prompts.ts`);
33
34
 
34
35
  // Get a reasonable chunk after the function start
35
- const chunk = source.slice(fnStart, fnStart + 3000);
36
+ const chunk = extractSourceRegion(source, `export async function ${builder}`);
36
37
 
37
38
  // ADR-011: buildPlanSlicePrompt / buildRefineSlicePrompt now delegate to
38
39
  // a shared helper (renderSlicePrompt) that performs the slice CONTEXT
@@ -42,7 +43,7 @@ describe("slice CONTEXT.md injection into prompt builders (#3452)", () => {
42
43
  ? (() => {
43
44
  const helperStart = source.indexOf("async function renderSlicePrompt");
44
45
  assert.ok(helperStart !== -1, "renderSlicePrompt helper must exist");
45
- return source.slice(helperStart, helperStart + 3000);
46
+ return extractSourceRegion(source, "async function renderSlicePrompt");
46
47
  })()
47
48
  : chunk;
48
49
 
@@ -4,6 +4,7 @@ import { tmpdir } from "node:os";
4
4
 
5
5
  import { deriveState } from "../state.js";
6
6
  import { resolveMilestoneFile } from "../paths.js";
7
+ import { extractSourceRegion } from "./test-helpers.ts";
7
8
 
8
9
  let passed = 0;
9
10
  let failed = 0;
@@ -81,7 +82,7 @@ assert(
81
82
 
82
83
  // Check the branch has draft-aware menu options
83
84
  const branchIdx = guidedFlowSource.indexOf('state.phase === "needs-discussion"');
84
- const branchChunk = guidedFlowSource.slice(branchIdx, branchIdx + 4000);
85
+ const branchChunk = extractSourceRegion(guidedFlowSource, 'state.phase === "needs-discussion"');
85
86
 
86
87
  assert(
87
88
  branchChunk.includes("discuss_draft"),
@@ -13,7 +13,7 @@
13
13
 
14
14
  import { readFileSync } from "node:fs";
15
15
  import { join } from "node:path";
16
- import { createTestContext } from "./test-helpers.ts";
16
+ import { createTestContext, extractSourceRegion } from "./test-helpers.ts";
17
17
 
18
18
  const { assertTrue, report } = createTestContext();
19
19
 
@@ -35,7 +35,7 @@ assertTrue(
35
35
  );
36
36
 
37
37
  // Extract the region from the closeout comment to the next section comment
38
- const closeoutRegion = phasesSrc.slice(closeoutIdx, closeoutIdx + 500);
38
+ const closeoutRegion = extractSourceRegion(phasesSrc, closeoutComment);
39
39
  assertTrue(
40
40
  closeoutRegion.includes("if (s.currentUnit)"),
41
41
  "closeoutUnit call is guarded by `if (s.currentUnit)` check (#2939)",
@@ -52,7 +52,7 @@ assertTrue(
52
52
  "phases.ts contains the 'Zero tool-call guard' comment block",
53
53
  );
54
54
 
55
- const zeroToolRegion = phasesSrc.slice(zeroToolIdx, zeroToolIdx + 600);
55
+ const zeroToolRegion = extractSourceRegion(phasesSrc, zeroToolComment);
56
56
 
57
57
  // The non-null assertion `s.currentUnit!.startedAt` must be replaced with
58
58
  // optional chaining `s.currentUnit?.startedAt`
@@ -15,6 +15,7 @@ import { join, dirname } from "node:path";
15
15
  import { tmpdir } from "node:os";
16
16
  import { fileURLToPath } from "node:url";
17
17
  import { validatePreferences } from "../preferences-validation.ts";
18
+ import { extractSourceRegion } from "./test-helpers.ts";
18
19
 
19
20
  const __dirname = dirname(fileURLToPath(import.meta.url));
20
21
  const promptsSrc = readFileSync(join(__dirname, "..", "auto-prompts.ts"), "utf-8");
@@ -59,7 +60,7 @@ test("reactive_execution: subagent_model rejects empty string", () => {
59
60
  test("buildReactiveExecutePrompt: accepts subagentModel parameter", () => {
60
61
  const fnStart = promptsSrc.indexOf("export async function buildReactiveExecutePrompt");
61
62
  assert.ok(fnStart !== -1, "buildReactiveExecutePrompt should be exported");
62
- const signature = promptsSrc.slice(fnStart, fnStart + 300);
63
+ const signature = extractSourceRegion(promptsSrc, "export async function buildReactiveExecutePrompt", { fromIdx: fnStart });
63
64
  assert.ok(
64
65
  signature.includes("subagentModel"),
65
66
  "buildReactiveExecutePrompt should accept a subagentModel parameter",
@@ -69,7 +70,7 @@ test("buildReactiveExecutePrompt: accepts subagentModel parameter", () => {
69
70
  test("buildParallelResearchSlicesPrompt: accepts subagentModel parameter", () => {
70
71
  const fnStart = promptsSrc.indexOf("export async function buildParallelResearchSlicesPrompt");
71
72
  assert.ok(fnStart !== -1, "buildParallelResearchSlicesPrompt should be exported");
72
- const signature = promptsSrc.slice(fnStart, fnStart + 300);
73
+ const signature = extractSourceRegion(promptsSrc, "export async function buildParallelResearchSlicesPrompt", { fromIdx: fnStart });
73
74
  assert.ok(
74
75
  signature.includes("subagentModel"),
75
76
  "buildParallelResearchSlicesPrompt should accept a subagentModel parameter",
@@ -79,7 +80,7 @@ test("buildParallelResearchSlicesPrompt: accepts subagentModel parameter", () =>
79
80
  test("buildGateEvaluatePrompt: accepts subagentModel parameter", () => {
80
81
  const fnStart = promptsSrc.indexOf("export async function buildGateEvaluatePrompt");
81
82
  assert.ok(fnStart !== -1, "buildGateEvaluatePrompt should be exported");
82
- const signature = promptsSrc.slice(fnStart, fnStart + 300);
83
+ const signature = extractSourceRegion(promptsSrc, "export async function buildGateEvaluatePrompt", { fromIdx: fnStart });
83
84
  assert.ok(
84
85
  signature.includes("subagentModel"),
85
86
  "buildGateEvaluatePrompt should accept a subagentModel parameter",
@@ -129,7 +130,7 @@ test("auto-dispatch: passes model to buildReactiveExecutePrompt", () => {
129
130
  // Find the reactive-execute dispatch rule
130
131
  const ruleStart = dispatchSrc.indexOf("reactive-execute (parallel dispatch)");
131
132
  assert.ok(ruleStart !== -1, "reactive-execute dispatch rule should exist");
132
- const ruleBlock = dispatchSrc.slice(ruleStart, ruleStart + 1000);
133
+ const ruleBlock = extractSourceRegion(dispatchSrc, "reactive-execute (parallel dispatch)", { fromIdx: ruleStart });
133
134
  assert.ok(
134
135
  ruleBlock.includes("subagent_model") || ruleBlock.includes("subagentModel"),
135
136
  "reactive-execute rule should resolve and pass the subagent model",
@@ -140,7 +141,7 @@ test("auto-dispatch: passes model to buildParallelResearchSlicesPrompt", () => {
140
141
  const callIdx = dispatchSrc.indexOf("buildParallelResearchSlicesPrompt(");
141
142
  assert.ok(callIdx !== -1, "buildParallelResearchSlicesPrompt call should exist");
142
143
  // The call site should pass a model argument (not just 4 args)
143
- const callSite = dispatchSrc.slice(callIdx, callIdx + 300);
144
+ const callSite = extractSourceRegion(dispatchSrc, "buildParallelResearchSlicesPrompt(", { fromIdx: callIdx });
144
145
  assert.ok(
145
146
  callSite.includes("subagentModel") || callSite.includes("resolveModelWithFallbacksForUnit"),
146
147
  "buildParallelResearchSlicesPrompt call should include model argument",
@@ -150,7 +151,7 @@ test("auto-dispatch: passes model to buildParallelResearchSlicesPrompt", () => {
150
151
  test("auto-dispatch: passes model to buildGateEvaluatePrompt", () => {
151
152
  const callIdx = dispatchSrc.indexOf("buildGateEvaluatePrompt(");
152
153
  assert.ok(callIdx !== -1, "buildGateEvaluatePrompt call should exist");
153
- const callSite = dispatchSrc.slice(callIdx, callIdx + 300);
154
+ const callSite = extractSourceRegion(dispatchSrc, "buildGateEvaluatePrompt(", { fromIdx: callIdx });
154
155
  assert.ok(
155
156
  callSite.includes("subagentModel") || callSite.includes("resolveModelWithFallbacksForUnit"),
156
157
  "buildGateEvaluatePrompt call should include model argument",
@@ -13,6 +13,7 @@ import { describe, it } from 'node:test'
13
13
  import assert from 'node:assert/strict'
14
14
  import { readFileSync } from 'node:fs'
15
15
  import { resolve } from 'node:path'
16
+ import { extractSourceRegion } from "./test-helpers.ts";
16
17
 
17
18
  const src = readFileSync(
18
19
  resolve(process.cwd(), 'src', 'resources', 'extensions', 'gsd', 'auto-worktree.ts'),
@@ -33,14 +34,14 @@ describe('syncWorktreeStateBack skips current milestone (#3641)', () => {
33
34
  assert.ok(fnStart !== -1)
34
35
 
35
36
  // Get a reasonable portion of the function
36
- const fnBlock = src.slice(fnStart, fnStart + 3000)
37
+ const fnBlock = extractSourceRegion(src, 'function syncWorktreeStateBack(', { fromIdx: fnStart })
37
38
 
38
39
  // Find the for loop iterating milestones
39
40
  const loopIdx = fnBlock.indexOf('for (const mid of wtMilestones)')
40
41
  assert.ok(loopIdx !== -1, 'milestone iteration loop must exist')
41
42
 
42
43
  // After the loop, there should be the skip guard
43
- const loopBody = fnBlock.slice(loopIdx, loopIdx + 300)
44
+ const loopBody = extractSourceRegion(fnBlock, 'for (const mid of wtMilestones)', { fromIdx: loopIdx })
44
45
  assert.ok(
45
46
  loopBody.includes('mid === milestoneId'),
46
47
  'mid === milestoneId skip guard must exist inside the milestone loop',
@@ -55,7 +56,7 @@ describe('syncWorktreeStateBack skips current milestone (#3641)', () => {
55
56
  const fnStart = src.indexOf('function syncWorktreeStateBack(')
56
57
  assert.ok(fnStart !== -1)
57
58
 
58
- const fnBlock = src.slice(fnStart, fnStart + 3000)
59
+ const fnBlock = extractSourceRegion(src, 'function syncWorktreeStateBack(', { fromIdx: fnStart })
59
60
 
60
61
  assert.ok(
61
62
  fnBlock.includes('syncMilestoneDir('),
@@ -0,0 +1,147 @@
1
+ /**
2
+ * Tests for test-helpers.ts — the source-inspection and timing helpers
3
+ * introduced in #4773 / #4774 to replace brittle fixed-byte slice and
4
+ * magic-number sleep patterns in the test suite.
5
+ */
6
+
7
+ import test from "node:test";
8
+ import assert from "node:assert/strict";
9
+
10
+ import { extractSourceRegion, waitForCondition, findLine } from "./test-helpers.ts";
11
+
12
+ // ─── extractSourceRegion ──────────────────────────────────────────────────
13
+
14
+ test("extractSourceRegion returns empty string when start anchor missing", () => {
15
+ assert.equal(extractSourceRegion("const x = 1;", "missing"), "");
16
+ });
17
+
18
+ test("extractSourceRegion uses explicit end anchor when provided", () => {
19
+ const src = "START_TOKEN\nbody line\nEND_TOKEN tail";
20
+ const region = extractSourceRegion(src, "START_TOKEN", "END_TOKEN");
21
+ assert.ok(region.includes("START_TOKEN"));
22
+ assert.ok(region.includes("body line"));
23
+ assert.ok(!region.includes("END_TOKEN"));
24
+ });
25
+
26
+ test("extractSourceRegion stops at next private method boundary", () => {
27
+ const src = [
28
+ "class Foo {",
29
+ " private alpha(): void {",
30
+ " const a = 1;",
31
+ " someCall();",
32
+ " }",
33
+ "",
34
+ " private beta(): void {",
35
+ " const b = 2;",
36
+ " }",
37
+ "}",
38
+ ].join("\n");
39
+
40
+ // Anchor on alpha's declaration; helper should stop at the next
41
+ // private method (beta), not on alpha itself.
42
+ const region = extractSourceRegion(src, "private alpha");
43
+ assert.ok(region.includes("alpha"));
44
+ assert.ok(region.includes("someCall()"));
45
+ assert.ok(!region.includes("beta"));
46
+ });
47
+
48
+ test("extractSourceRegion stops at next top-level function", () => {
49
+ const src = [
50
+ "function alpha() {",
51
+ " throw new Error('alpha');",
52
+ "}",
53
+ "",
54
+ "function beta() {",
55
+ " return 2;",
56
+ "}",
57
+ ].join("\n");
58
+
59
+ const region = extractSourceRegion(src, "function alpha");
60
+ assert.ok(region.includes("throw new Error"));
61
+ assert.ok(!region.includes("beta"));
62
+ });
63
+
64
+ test("extractSourceRegion returns to end-of-source when no terminator found", () => {
65
+ const src = "just one line";
66
+ assert.equal(extractSourceRegion(src, "just"), "just one line");
67
+ });
68
+
69
+ // ─── waitForCondition ─────────────────────────────────────────────────────
70
+
71
+ test("waitForCondition returns immediately when condition is true", async () => {
72
+ const result = await waitForCondition(() => true);
73
+ assert.equal(result, true);
74
+ });
75
+
76
+ test("waitForCondition waits and returns when condition becomes true", async () => {
77
+ let flipped = false;
78
+ setTimeout(() => { flipped = true; }, 30);
79
+ const result = await waitForCondition(() => flipped, { intervalMs: 5 });
80
+ assert.equal(result, true);
81
+ });
82
+
83
+ test("waitForCondition throws after timeout with description", async () => {
84
+ await assert.rejects(
85
+ waitForCondition(() => false, { timeoutMs: 50, intervalMs: 5, description: "the flag to flip" }),
86
+ /waiting for the flag to flip/i,
87
+ );
88
+ });
89
+
90
+ test("waitForCondition surfaces last error on timeout", async () => {
91
+ await assert.rejects(
92
+ waitForCondition(
93
+ () => { throw new Error("probe failed"); },
94
+ { timeoutMs: 30, intervalMs: 5, description: "probe" },
95
+ ),
96
+ /probe failed/,
97
+ );
98
+ });
99
+
100
+ test("waitForCondition returns the truthy value (not just true)", async () => {
101
+ let n = 0;
102
+ const result = await waitForCondition(() => {
103
+ n++;
104
+ return n >= 3 ? { ready: true, iteration: n } : null;
105
+ }, { intervalMs: 5 });
106
+ // The helper only resolves when the condition returns a truthy value, so
107
+ // result cannot be null here. Assert it and narrow for the follow-ups.
108
+ assert.ok(result, "waitForCondition must resolve with a truthy value, not null");
109
+ assert.equal(result.ready, true);
110
+ assert.equal(result.iteration, 3);
111
+ });
112
+
113
+ // ─── findLine ─────────────────────────────────────────────────────────────
114
+
115
+ test("findLine locates a line by regex", () => {
116
+ const output = "header\nstatus: ok\nfooter";
117
+ const match = findLine(output, /^status:/);
118
+ assert.equal(match.index, 1);
119
+ assert.equal(match.text, "status: ok");
120
+ });
121
+
122
+ test("findLine locates a line by predicate", () => {
123
+ const output = "a\nb\nc";
124
+ const match = findLine(output, (l) => l === "b");
125
+ assert.equal(match.index, 1);
126
+ assert.equal(match.text, "b");
127
+ });
128
+
129
+ test("findLine throws with preview when no line matches", () => {
130
+ assert.throws(
131
+ () => findLine("a\nb\nc", /NOTFOUND/),
132
+ /First 10 lines/,
133
+ );
134
+ });
135
+
136
+ test("findLine resets lastIndex between lines for /g regex patterns", () => {
137
+ // Without the reset, RegExp.test with /g flag stateful-advances lastIndex
138
+ // and can skip matches on subsequent calls. Verify the reset keeps
139
+ // per-line testing deterministic.
140
+ const output = "foo\nfoo\nfoo";
141
+ const globalRe = /foo/g;
142
+ const match = findLine(output, globalRe);
143
+ assert.equal(match.index, 0);
144
+ // Second call on the same pattern must also match — would fail without reset
145
+ const match2 = findLine(output, globalRe);
146
+ assert.equal(match2.index, 0);
147
+ });