sequant 2.8.0 → 2.10.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 (211) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +23 -4
  4. package/dist/bin/cli.js +99 -18
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
  7. package/dist/marketplace/external_plugins/sequant/README.md +58 -2
  8. package/dist/marketplace/external_plugins/sequant/hooks/parallel-marker.sh +50 -0
  9. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +38 -10
  10. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +405 -67
  11. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/trust-model.md +18 -0
  12. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +250 -344
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +10 -7
  14. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +4 -4
  15. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +26 -6
  16. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +4 -2
  17. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +38 -2
  18. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +2 -2
  19. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +337 -57
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +6 -6
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +2 -2
  22. package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-review-checklist.md +2 -2
  23. package/dist/marketplace/external_plugins/sequant/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  24. package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +21 -7
  25. package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +1 -1
  26. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +11 -11
  27. package/dist/marketplace/external_plugins/sequant/skills/reflect/SKILL.md +27 -13
  28. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/documentation-tiers.md +80 -68
  29. package/dist/marketplace/external_plugins/sequant/skills/reflect/references/phase-reflection.md +31 -15
  30. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +11 -3
  31. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
  32. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
  33. package/dist/marketplace/external_plugins/sequant/skills/spec/references/verification-criteria.md +1 -1
  34. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  35. package/dist/src/commands/assess-render.d.ts +23 -0
  36. package/dist/src/commands/assess-render.js +60 -0
  37. package/dist/src/commands/doctor.js +8 -13
  38. package/dist/src/commands/init.js +13 -1
  39. package/dist/src/commands/locks.js +2 -0
  40. package/dist/src/commands/logs.js +20 -6
  41. package/dist/src/commands/merge.js +154 -2
  42. package/dist/src/commands/ready-tui-adapter.js +6 -1
  43. package/dist/src/commands/ready.d.ts +25 -3
  44. package/dist/src/commands/ready.js +39 -13
  45. package/dist/src/commands/resume.d.ts +113 -0
  46. package/dist/src/commands/resume.js +193 -0
  47. package/dist/src/commands/run-display.d.ts +20 -0
  48. package/dist/src/commands/run-display.js +117 -5
  49. package/dist/src/commands/run-flags.d.ts +42 -1
  50. package/dist/src/commands/run-flags.js +53 -1
  51. package/dist/src/commands/run-progress.js +26 -1
  52. package/dist/src/commands/run.js +11 -10
  53. package/dist/src/commands/state.js +7 -0
  54. package/dist/src/commands/stats.js +47 -0
  55. package/dist/src/commands/status.d.ts +9 -0
  56. package/dist/src/commands/status.js +24 -1
  57. package/dist/src/commands/sync.js +90 -19
  58. package/dist/src/commands/update.js +28 -5
  59. package/dist/src/lib/ac-parser.d.ts +14 -0
  60. package/dist/src/lib/ac-parser.js +99 -5
  61. package/dist/src/lib/assess/index.d.ts +10 -0
  62. package/dist/src/lib/assess/index.js +9 -0
  63. package/dist/src/lib/assess/renderer.d.ts +64 -0
  64. package/dist/src/lib/assess/renderer.js +481 -0
  65. package/dist/src/lib/assess/types.d.ts +224 -0
  66. package/dist/src/lib/assess/types.js +241 -0
  67. package/dist/src/lib/assess-collision-detect.d.ts +19 -2
  68. package/dist/src/lib/assess-collision-detect.js +68 -4
  69. package/dist/src/lib/ci/outputs.d.ts +15 -2
  70. package/dist/src/lib/ci/outputs.js +17 -5
  71. package/dist/src/lib/cli-flags.d.ts +33 -0
  72. package/dist/src/lib/cli-flags.js +46 -0
  73. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +21 -7
  74. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  75. package/dist/src/lib/cli-ui/run-renderer.js +79 -9
  76. package/dist/src/lib/errors.d.ts +84 -6
  77. package/dist/src/lib/errors.js +142 -17
  78. package/dist/src/lib/locks/index.d.ts +3 -3
  79. package/dist/src/lib/locks/index.js +2 -2
  80. package/dist/src/lib/locks/lock-manager.d.ts +20 -2
  81. package/dist/src/lib/locks/lock-manager.js +47 -3
  82. package/dist/src/lib/locks/types.d.ts +34 -3
  83. package/dist/src/lib/locks/types.js +16 -0
  84. package/dist/src/lib/manifest.js +1 -17
  85. package/dist/src/lib/mcp-config.d.ts +45 -0
  86. package/dist/src/lib/mcp-config.js +77 -1
  87. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  88. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  89. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  90. package/dist/src/lib/merge-check/command-result.js +56 -0
  91. package/dist/src/lib/merge-check/index.d.ts +4 -0
  92. package/dist/src/lib/merge-check/index.js +1 -1
  93. package/dist/src/lib/merge-check/report.js +5 -1
  94. package/dist/src/lib/merge-check/types.d.ts +9 -0
  95. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  96. package/dist/src/lib/merge-check/watch.js +215 -0
  97. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  98. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  99. package/dist/src/lib/settings.d.ts +14 -0
  100. package/dist/src/lib/settings.js +61 -8
  101. package/dist/src/lib/shutdown.d.ts +42 -2
  102. package/dist/src/lib/shutdown.js +75 -5
  103. package/dist/src/lib/skills-check.d.ts +26 -0
  104. package/dist/src/lib/skills-check.js +31 -0
  105. package/dist/src/lib/stacks.d.ts +110 -1
  106. package/dist/src/lib/stacks.js +244 -5
  107. package/dist/src/lib/templates.d.ts +74 -0
  108. package/dist/src/lib/templates.js +171 -16
  109. package/dist/src/lib/test-tautology-detector.d.ts +9 -2
  110. package/dist/src/lib/test-tautology-detector.js +153 -12
  111. package/dist/src/lib/version-check.d.ts +31 -0
  112. package/dist/src/lib/version-check.js +46 -7
  113. package/dist/src/lib/workflow/batch-executor.d.ts +138 -6
  114. package/dist/src/lib/workflow/batch-executor.js +550 -54
  115. package/dist/src/lib/workflow/chain-preflight.d.ts +89 -0
  116. package/dist/src/lib/workflow/chain-preflight.js +199 -0
  117. package/dist/src/lib/workflow/chain-resume.d.ts +118 -0
  118. package/dist/src/lib/workflow/chain-resume.js +170 -0
  119. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  120. package/dist/src/lib/workflow/completed-status.js +57 -0
  121. package/dist/src/lib/workflow/config-resolver.d.ts +26 -0
  122. package/dist/src/lib/workflow/config-resolver.js +54 -2
  123. package/dist/src/lib/workflow/dependency-markers.d.ts +29 -0
  124. package/dist/src/lib/workflow/dependency-markers.js +79 -0
  125. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +7 -0
  126. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  127. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  128. package/dist/src/lib/workflow/drivers/claude-code.d.ts +21 -0
  129. package/dist/src/lib/workflow/drivers/claude-code.js +50 -8
  130. package/dist/src/lib/workflow/error-classifier.d.ts +9 -2
  131. package/dist/src/lib/workflow/error-classifier.js +14 -1
  132. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  133. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  134. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  135. package/dist/src/lib/workflow/heartbeat.js +90 -0
  136. package/dist/src/lib/workflow/log-writer.d.ts +44 -1
  137. package/dist/src/lib/workflow/log-writer.js +81 -14
  138. package/dist/src/lib/workflow/metrics-schema.d.ts +42 -3
  139. package/dist/src/lib/workflow/metrics-schema.js +16 -0
  140. package/dist/src/lib/workflow/metrics-writer.d.ts +2 -1
  141. package/dist/src/lib/workflow/phase-executor.d.ts +234 -13
  142. package/dist/src/lib/workflow/phase-executor.js +533 -56
  143. package/dist/src/lib/workflow/phase-registry.js +2 -1
  144. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  145. package/dist/src/lib/workflow/platforms/github.js +152 -0
  146. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  147. package/dist/src/lib/workflow/qa-cache.js +22 -9
  148. package/dist/src/lib/workflow/ready-gate.d.ts +17 -4
  149. package/dist/src/lib/workflow/ready-gate.js +25 -9
  150. package/dist/src/lib/workflow/run-log-schema.d.ts +37 -0
  151. package/dist/src/lib/workflow/run-log-schema.js +92 -2
  152. package/dist/src/lib/workflow/run-orchestrator.d.ts +33 -0
  153. package/dist/src/lib/workflow/run-orchestrator.js +429 -47
  154. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  155. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  156. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  157. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  158. package/dist/src/lib/workflow/state-manager.js +83 -0
  159. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  160. package/dist/src/lib/workflow/state-schema.js +47 -0
  161. package/dist/src/lib/workflow/status-derivation.d.ts +30 -0
  162. package/dist/src/lib/workflow/status-derivation.js +27 -0
  163. package/dist/src/lib/workflow/types.d.ts +149 -4
  164. package/dist/src/lib/workflow/types.js +1 -0
  165. package/dist/src/lib/workflow/worktree-manager.d.ts +70 -2
  166. package/dist/src/lib/workflow/worktree-manager.js +201 -52
  167. package/dist/src/mcp/tools/run.d.ts +11 -1
  168. package/dist/src/mcp/tools/run.js +22 -1
  169. package/dist/src/mcp/tools/status.js +6 -0
  170. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  171. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  172. package/dist/src/ui/tui/IssueBox.js +10 -2
  173. package/dist/src/ui/tui/row-cap.js +2 -1
  174. package/dist/src/ui/tui/theme.d.ts +7 -0
  175. package/dist/src/ui/tui/theme.js +9 -0
  176. package/package.json +9 -10
  177. package/templates/hooks/parallel-marker.sh +50 -0
  178. package/templates/hooks/post-tool.sh +38 -10
  179. package/templates/hooks/pre-tool.sh +405 -67
  180. package/templates/scripts/cleanup-worktree.sh +282 -30
  181. package/templates/scripts/new-feature.sh +264 -16
  182. package/templates/skills/_shared/references/trust-model.md +18 -0
  183. package/templates/skills/assess/SKILL.md +250 -344
  184. package/templates/skills/assess/references/predicted-collision-detection.md +10 -7
  185. package/templates/skills/docs/SKILL.md +4 -4
  186. package/templates/skills/exec/SKILL.md +26 -6
  187. package/templates/skills/fullsolve/SKILL.md +4 -2
  188. package/templates/skills/loop/SKILL.md +38 -2
  189. package/templates/skills/merger/SKILL.md +2 -2
  190. package/templates/skills/qa/SKILL.md +337 -57
  191. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  192. package/templates/skills/qa/references/call-site-review.md +2 -2
  193. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  194. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  195. package/templates/skills/qa/references/quality-gates.md +21 -7
  196. package/templates/skills/qa/references/testing-requirements.md +1 -1
  197. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  198. package/templates/skills/reflect/SKILL.md +27 -13
  199. package/templates/skills/reflect/references/documentation-tiers.md +80 -68
  200. package/templates/skills/reflect/references/phase-reflection.md +31 -15
  201. package/templates/skills/release/SKILL.md +11 -3
  202. package/templates/skills/setup/SKILL.md +5 -5
  203. package/templates/skills/spec/SKILL.md +2 -0
  204. package/templates/skills/spec/references/verification-criteria.md +1 -1
  205. package/templates/skills/test/SKILL.md +1 -1
  206. package/dist/src/lib/phase-spinner.d.ts +0 -146
  207. package/dist/src/lib/phase-spinner.js +0 -255
  208. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  209. package/dist/src/lib/workflow/pr-operations.js +0 -326
  210. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  211. package/dist/src/lib/workflow/run-summary.js +0 -142
@@ -7,8 +7,29 @@
7
7
  import * as fs from "fs";
8
8
  import * as os from "os";
9
9
  import * as path from "path";
10
+ import { getVersion } from "./version.js";
10
11
  /** Path to the project-level MCP config file used by Claude Code */
11
12
  export const PROJECT_MCP_JSON = ".mcp.json";
13
+ /**
14
+ * npm package specifier used to launch the MCP server (#793).
15
+ *
16
+ * We pin to the installed version rather than `@latest` so that `npx` does not
17
+ * re-resolve and reinstall the package on the first MCP reconnect after every
18
+ * sequant release — the reinstall was surfacing as an opaque
19
+ * `Failed to reconnect to sequant: -32000` when npx's cache was corrupted
20
+ * (npm ENOTEMPTY). `setup`/`update` (re)write this pin, so the MCP server
21
+ * tracks new releases when the user runs `sequant update` — matching how the
22
+ * plugin cache already pins. See docs/troubleshooting.md ("MCP Server Issues").
23
+ *
24
+ * Falls back to `latest` if the installed version cannot be resolved, so a
25
+ * failed lookup never writes a bogus `sequant@0.0.0` pin into `.mcp.json`.
26
+ */
27
+ export function getSequantPackageSpec() {
28
+ const version = getVersion();
29
+ return version && version !== "0.0.0"
30
+ ? `sequant@${version}`
31
+ : "sequant@latest";
32
+ }
12
33
  /**
13
34
  * Clients that need an explicit cwd because they don't run from the project directory.
14
35
  */
@@ -25,7 +46,7 @@ const CLIENTS_NEEDING_CWD = new Set([
25
46
  export function getSequantMcpConfig(options) {
26
47
  const config = {
27
48
  command: "npx",
28
- args: ["-y", "sequant@latest", "serve"],
49
+ args: ["-y", getSequantPackageSpec(), "serve"],
29
50
  };
30
51
  // Add cwd for clients that don't run from the project directory
31
52
  if (options?.clientType && CLIENTS_NEEDING_CWD.has(options.clientType)) {
@@ -170,3 +191,58 @@ export function createProjectMcpJson(projectDir) {
170
191
  }
171
192
  return { created: true, merged: false, skipped: false };
172
193
  }
194
+ /**
195
+ * Re-pin an existing project `.mcp.json` sequant entry to the installed version (#793).
196
+ *
197
+ * Unlike {@link createProjectMcpJson} (which skips when a sequant entry already
198
+ * exists), this is the `update`/`sync` path: it refreshes the version pin so the
199
+ * MCP server tracks the release the user just updated to. It only rewrites the
200
+ * `sequant@<version>` token inside `args` and leaves everything else untouched.
201
+ *
202
+ * No-ops (returns `updated: false`) when:
203
+ * - `.mcp.json` doesn't exist (`no-file`) — we never create it here; that's init's job
204
+ * - there's no sequant server entry (`no-entry`)
205
+ * - the entry uses a local-binary form with no `sequant@…` arg (`no-pin`) — a
206
+ * deliberate contributor override we must not clobber
207
+ * - the pin already matches the installed version (`already-current`)
208
+ *
209
+ * With `opts.dryRun`, computes `from`/`to` and returns `updated: true` for a
210
+ * pending change but does not write the file — the caller reports it as a preview.
211
+ */
212
+ export function syncSequantMcpPin(projectDir, opts) {
213
+ const mcpJsonPath = path.resolve(projectDir ?? ".", PROJECT_MCP_JSON);
214
+ if (!fs.existsSync(mcpJsonPath)) {
215
+ return { updated: false, reason: "no-file" };
216
+ }
217
+ let config;
218
+ try {
219
+ config = JSON.parse(fs.readFileSync(mcpJsonPath, "utf-8"));
220
+ }
221
+ catch {
222
+ // Corrupt file — leave it alone rather than overwriting user content.
223
+ return { updated: false, reason: "no-file" };
224
+ }
225
+ const servers = config?.mcpServers;
226
+ const sequant = servers?.sequant;
227
+ if (!sequant) {
228
+ return { updated: false, reason: "no-entry" };
229
+ }
230
+ const args = sequant.args;
231
+ if (!Array.isArray(args)) {
232
+ return { updated: false, reason: "no-pin" };
233
+ }
234
+ const pinIndex = args.findIndex((a) => typeof a === "string" && a.startsWith("sequant@"));
235
+ if (pinIndex === -1) {
236
+ return { updated: false, reason: "no-pin" };
237
+ }
238
+ const from = args[pinIndex];
239
+ const to = getSequantPackageSpec();
240
+ if (from === to) {
241
+ return { updated: false, reason: "already-current" };
242
+ }
243
+ if (!opts?.dryRun) {
244
+ args[pinIndex] = to;
245
+ fs.writeFileSync(mcpJsonPath, JSON.stringify(config, null, 2) + "\n");
246
+ }
247
+ return { updated: true, from, to };
248
+ }
@@ -2,11 +2,30 @@
2
2
  * Combined branch testing (AC-1)
3
3
  *
4
4
  * Creates a temporary branch merging all feature branches from a run batch,
5
- * runs npm test && npm run build on the combined state, and reports results.
5
+ * reinstalls dependencies when the merge moved a lockfile, then runs the
6
+ * project's test and build scripts on the combined state and reports results.
6
7
  */
7
8
  import type { BranchInfo, CheckResult, BranchCheckResult, CheckFinding } from "./types.js";
9
+ import { resolveFailureReason, type CommandResult } from "./command-result.js";
10
+ export { resolveFailureReason, type CommandResult };
8
11
  /**
9
- * Create temp branch, merge all feature branches, run tests and build.
12
+ * Run a package-manager command line (e.g. "npm ci", "pnpm run build").
13
+ *
14
+ * @param command Full command line, as stored in PM_CONFIG
15
+ * @param cwd Working directory
16
+ * @param timeoutMs Kill the command after this long
17
+ * @internal Exported for testing
18
+ */
19
+ export declare function runPackageManagerCommand(command: string, cwd: string, timeoutMs: number): CommandResult;
20
+ /**
21
+ * Whether any lockfile differs between `baseRef` and the current HEAD.
22
+ *
23
+ * @internal Exported for testing
24
+ */
25
+ export declare function lockfileChanged(repoRoot: string, baseRef: string): boolean;
26
+ /**
27
+ * Create temp branch, merge all feature branches, reinstall dependencies if the
28
+ * lockfile moved, then run tests and build.
10
29
  *
11
30
  * @param branches - Feature branches to merge
12
31
  * @param repoRoot - Path to the git repository root
@@ -2,10 +2,37 @@
2
2
  * Combined branch testing (AC-1)
3
3
  *
4
4
  * Creates a temporary branch merging all feature branches from a run batch,
5
- * runs npm test && npm run build on the combined state, and reports results.
5
+ * reinstalls dependencies when the merge moved a lockfile, then runs the
6
+ * project's test and build scripts on the combined state and reports results.
6
7
  */
7
8
  import { spawnSync } from "child_process";
8
9
  import { getBranchRef } from "./types.js";
10
+ import { detectPackageManagerSync, resolvePackageManagerConfig, } from "../stacks.js";
11
+ import { toCommandResult, resolveFailureReason, } from "./command-result.js";
12
+ // Re-exported for the existing test suite and any downstream consumer that
13
+ // imported these from here before they moved to ./command-result.js.
14
+ export { resolveFailureReason };
15
+ /**
16
+ * Lockfile names for the JS package managers we support. Kept in sync with the
17
+ * equivalent list in `workflow/worktree-manager.ts`.
18
+ */
19
+ const LOCKFILES = [
20
+ "package-lock.json",
21
+ "pnpm-lock.yaml",
22
+ "bun.lockb",
23
+ "bun.lock",
24
+ "yarn.lock",
25
+ ];
26
+ /**
27
+ * Per-step command timeouts. A dependency install and a full test suite are
28
+ * both slower than the previous flat 2-minute budget allowed. An expired
29
+ * timeout kills the process with `status: null` and no captured output, which
30
+ * showed up as a BLOCKED verdict with no stated reason (#803).
31
+ */
32
+ const INSTALL_TIMEOUT_MS = 300_000; // 5 min
33
+ const TEST_BUILD_TIMEOUT_MS = 600_000; // 10 min
34
+ /** Ref the combined state is built on top of, and compared against. */
35
+ const BASE_REF = "origin/main";
9
36
  /**
10
37
  * Run a git command and return the result
11
38
  */
@@ -22,23 +49,39 @@ function git(args, cwd) {
22
49
  };
23
50
  }
24
51
  /**
25
- * Run npm command and return result
52
+ * Run a package-manager command line (e.g. "npm ci", "pnpm run build").
53
+ *
54
+ * @param command Full command line, as stored in PM_CONFIG
55
+ * @param cwd Working directory
56
+ * @param timeoutMs Kill the command after this long
57
+ * @internal Exported for testing
26
58
  */
27
- function npm(args, cwd) {
28
- const result = spawnSync("npm", args, {
59
+ export function runPackageManagerCommand(command, cwd, timeoutMs) {
60
+ const [bin, ...args] = command.split(" ");
61
+ const result = spawnSync(bin, args, {
29
62
  cwd,
30
63
  stdio: "pipe",
31
64
  encoding: "utf-8",
32
- timeout: 120_000, // 2 min timeout for test/build
65
+ timeout: timeoutMs,
66
+ // On Windows the package managers are `.cmd` shims, which CreateProcess
67
+ // will not resolve from a bare "npm"/"pnpm". `command` is always a constant
68
+ // from PM_CONFIG — never user input — so there is nothing to inject here.
69
+ shell: process.platform === "win32",
33
70
  });
34
- return {
35
- ok: result.status === 0,
36
- stdout: result.stdout?.trim() ?? "",
37
- stderr: result.stderr?.trim() ?? "",
38
- };
71
+ return toCommandResult(result);
39
72
  }
40
73
  /**
41
- * Create temp branch, merge all feature branches, run tests and build.
74
+ * Whether any lockfile differs between `baseRef` and the current HEAD.
75
+ *
76
+ * @internal Exported for testing
77
+ */
78
+ export function lockfileChanged(repoRoot, baseRef) {
79
+ const result = git(["diff", "--name-only", baseRef, "HEAD", "--", ...LOCKFILES], repoRoot);
80
+ return result.ok && result.stdout.length > 0;
81
+ }
82
+ /**
83
+ * Create temp branch, merge all feature branches, reinstall dependencies if the
84
+ * lockfile moved, then run tests and build.
42
85
  *
43
86
  * @param branches - Feature branches to merge
44
87
  * @param repoRoot - Path to the git repository root
@@ -49,123 +92,203 @@ export function runCombinedBranchTest(branches, repoRoot) {
49
92
  const tempBranch = `merge-check/temp-${Date.now()}`;
50
93
  const branchResults = [];
51
94
  const batchFindings = [];
52
- const mergeAttempts = [];
53
- // Save current branch to restore in finally block
95
+ const pm = detectPackageManagerSync(repoRoot);
96
+ // Resolved against the repo rather than read straight off PM_CONFIG: yarn's
97
+ // frozen install differs between classic and berry, and `pm` is "yarn" for
98
+ // both (#871).
99
+ //
100
+ // This resolution describes the PRE-MERGE tree, which is what the restore
101
+ // install in the `finally` below runs against — it fires after
102
+ // `git checkout restoreBranch`, so the original branch's yarn major is the
103
+ // right one there. `runChecks` re-resolves for the combined state, where a
104
+ // merged branch may have changed the major.
105
+ const pmConfig = resolvePackageManagerConfig(pm, repoRoot);
106
+ // Flipped the moment we start installing against the combined lockfile, so
107
+ // the caller's node_modules can be put back afterwards. A mutable holder
108
+ // rather than a return value: if runChecks throws, a return value never
109
+ // arrives and the restore would be skipped precisely when the tree is most
110
+ // likely to be inconsistent.
111
+ const installState = { installedCombinedDeps: false };
112
+ // Save current branch to restore in the cleanup step
54
113
  const originalBranch = git(["rev-parse", "--abbrev-ref", "HEAD"], repoRoot);
55
114
  try {
56
- // Fetch latest from remote
57
- git(["fetch", "origin"], repoRoot);
58
- // Create temp branch from main
59
- const createResult = git(["checkout", "-b", tempBranch, "origin/main"], repoRoot);
60
- if (!createResult.ok) {
61
- batchFindings.push({
62
- check: "combined-branch-test",
63
- severity: "error",
64
- message: `Failed to create temp branch: ${createResult.stderr}`,
65
- });
66
- return buildResult(branchResults, batchFindings, startTime);
67
- }
68
- // Merge each feature branch
69
- for (const branch of branches) {
70
- const mergeResult = git(["merge", "--no-ff", "--no-edit", getBranchRef(branch)], repoRoot);
71
- if (mergeResult.ok) {
72
- mergeAttempts.push({
73
- issueNumber: branch.issueNumber,
74
- branch: branch.branch,
75
- success: true,
76
- });
77
- branchResults.push({
78
- issueNumber: branch.issueNumber,
79
- verdict: "PASS",
80
- findings: [
81
- {
82
- check: "combined-branch-test",
83
- severity: "info",
84
- message: `Branch merged cleanly into combined state`,
85
- issueNumber: branch.issueNumber,
86
- },
87
- ],
88
- });
89
- }
90
- else {
91
- // Get conflicting files
92
- const conflictResult = git(["diff", "--name-only", "--diff-filter=U"], repoRoot);
93
- const conflictFiles = conflictResult.stdout
94
- ? conflictResult.stdout.split("\n")
95
- : [];
96
- mergeAttempts.push({
97
- issueNumber: branch.issueNumber,
98
- branch: branch.branch,
99
- success: false,
100
- conflictFiles,
101
- error: mergeResult.stderr,
102
- });
103
- branchResults.push({
104
- issueNumber: branch.issueNumber,
105
- verdict: "FAIL",
106
- findings: [
107
- {
108
- check: "combined-branch-test",
109
- severity: "error",
110
- message: `Merge conflict with ${conflictFiles.length} file(s): ${conflictFiles.join(", ")}`,
111
- issueNumber: branch.issueNumber,
112
- },
113
- ],
115
+ runChecks(branches, repoRoot, tempBranch, pm, branchResults, batchFindings, installState);
116
+ }
117
+ finally {
118
+ // Restore original branch and delete temp branch
119
+ const restoreBranch = originalBranch.ok ? originalBranch.stdout : "main";
120
+ git(["checkout", restoreBranch], repoRoot);
121
+ git(["branch", "-D", tempBranch], repoRoot);
122
+ // We installed the combined state's dependency tree into the caller's
123
+ // node_modules; put it back so the restored branch is not left running
124
+ // against another branch's dependencies.
125
+ //
126
+ // Frozen again, for the same reason as the forward install: a plain
127
+ // `npm install` normalizes and rewrites the lockfile, which would leave
128
+ // the user's restored branch with a dirty working tree that this check
129
+ // never asked them for.
130
+ if (installState.installedCombinedDeps) {
131
+ const restoreInstall = runPackageManagerCommand(pmConfig.ciInstall, repoRoot, INSTALL_TIMEOUT_MS);
132
+ if (!restoreInstall.ok) {
133
+ batchFindings.push({
134
+ check: "combined-branch-test",
135
+ severity: "warning",
136
+ message: `Dependencies were reinstalled for the combined state but could not be restored for \`${restoreBranch}\`. Run \`${pmConfig.ciInstall}\` to resync node_modules: ${resolveFailureReason(restoreInstall)}`,
114
137
  });
115
- // Abort the failed merge and continue
116
- git(["merge", "--abort"], repoRoot);
117
138
  }
118
139
  }
119
- // If any merges failed, skip tests but report what we have
120
- const failedMerges = mergeAttempts.filter((m) => !m.success);
121
- if (failedMerges.length > 0) {
122
- batchFindings.push({
123
- check: "combined-branch-test",
124
- severity: "error",
125
- message: `${failedMerges.length}/${branches.length} branches had merge conflicts skipping test/build`,
140
+ }
141
+ // Built after cleanup so findings pushed during cleanup are reflected in the
142
+ // verdict rather than silently arriving too late to affect `passed`.
143
+ return buildResult(branchResults, batchFindings, startTime);
144
+ }
145
+ /**
146
+ * Assemble the combined state and run install/test/build against it.
147
+ *
148
+ * Mutates `branchResults`, `batchFindings`, and `installState` — the last so
149
+ * the caller's cleanup can restore node_modules even if this function throws.
150
+ */
151
+ function runChecks(branches, repoRoot, tempBranch, pm, branchResults, batchFindings, installState) {
152
+ const mergeAttempts = [];
153
+ // Fetch latest from remote
154
+ git(["fetch", "origin"], repoRoot);
155
+ // Create temp branch from main
156
+ const createResult = git(["checkout", "-b", tempBranch, BASE_REF], repoRoot);
157
+ if (!createResult.ok) {
158
+ batchFindings.push({
159
+ check: "combined-branch-test",
160
+ severity: "error",
161
+ message: `Failed to create temp branch: ${createResult.stderr}`,
162
+ });
163
+ return;
164
+ }
165
+ // Merge each feature branch
166
+ for (const branch of branches) {
167
+ const mergeResult = git(["merge", "--no-ff", "--no-edit", getBranchRef(branch)], repoRoot);
168
+ if (mergeResult.ok) {
169
+ mergeAttempts.push({
170
+ issueNumber: branch.issueNumber,
171
+ branch: branch.branch,
172
+ success: true,
126
173
  });
127
- return buildResult(branchResults, batchFindings, startTime);
128
- }
129
- // Run npm test
130
- const testResult = npm(["test"], repoRoot);
131
- if (testResult.ok) {
132
- batchFindings.push({
133
- check: "combined-branch-test",
134
- severity: "info",
135
- message: "npm test passed on combined state",
174
+ branchResults.push({
175
+ issueNumber: branch.issueNumber,
176
+ verdict: "PASS",
177
+ findings: [
178
+ {
179
+ check: "combined-branch-test",
180
+ severity: "info",
181
+ message: `Branch merged cleanly into combined state`,
182
+ issueNumber: branch.issueNumber,
183
+ },
184
+ ],
136
185
  });
137
186
  }
138
187
  else {
139
- batchFindings.push({
140
- check: "combined-branch-test",
141
- severity: "error",
142
- message: `npm test failed on combined state: ${testResult.stderr.slice(0, 500)}`,
188
+ // Get conflicting files
189
+ const conflictResult = git(["diff", "--name-only", "--diff-filter=U"], repoRoot);
190
+ const conflictFiles = conflictResult.stdout
191
+ ? conflictResult.stdout.split("\n")
192
+ : [];
193
+ mergeAttempts.push({
194
+ issueNumber: branch.issueNumber,
195
+ branch: branch.branch,
196
+ success: false,
197
+ conflictFiles,
198
+ error: mergeResult.stderr,
143
199
  });
144
- }
145
- // Run npm run build
146
- const buildResult2 = npm(["run", "build"], repoRoot);
147
- if (buildResult2.ok) {
148
- batchFindings.push({
149
- check: "combined-branch-test",
150
- severity: "info",
151
- message: "npm run build passed on combined state",
200
+ branchResults.push({
201
+ issueNumber: branch.issueNumber,
202
+ verdict: "FAIL",
203
+ findings: [
204
+ {
205
+ check: "combined-branch-test",
206
+ severity: "error",
207
+ message: `Merge conflict with ${conflictFiles.length} file(s): ${conflictFiles.join(", ")}`,
208
+ issueNumber: branch.issueNumber,
209
+ },
210
+ ],
152
211
  });
212
+ // Abort the failed merge and continue
213
+ git(["merge", "--abort"], repoRoot);
153
214
  }
154
- else {
215
+ }
216
+ // If any merges failed, skip tests but report what we have
217
+ const failedMerges = mergeAttempts.filter((m) => !m.success);
218
+ if (failedMerges.length > 0) {
219
+ batchFindings.push({
220
+ check: "combined-branch-test",
221
+ severity: "error",
222
+ message: `${failedMerges.length}/${branches.length} branches had merge conflicts — skipping test/build`,
223
+ });
224
+ return;
225
+ }
226
+ // Resolved HERE, after the merges landed, not from the caller's pre-merge
227
+ // resolution: the commands below run against the combined tree, and a merged
228
+ // branch can move the yarn major (a `packageManager` bump, a new
229
+ // `.yarnrc.yml`). Installing the combined state with the pre-merge major's
230
+ // frozen-install flag would fail on an unknown option and report a false
231
+ // BLOCKED — the exact failure class #803 added this install to prevent (#871).
232
+ const pmConfig = resolvePackageManagerConfig(pm, repoRoot);
233
+ // Reinstall dependencies when the merged branches moved the lockfile (#803).
234
+ // Without this, test/build run against the node_modules of whatever branch
235
+ // the user happened to be on, so a batch that merely added a dependency fails
236
+ // with module-not-found errors — a false BLOCKED on a healthy stack.
237
+ //
238
+ // A frozen install is used rather than a plain one: it mirrors what CI does,
239
+ // it will not rewrite the lockfile (which would dirty the temp branch and
240
+ // break the checkout during cleanup), and it fails loudly on a lockfile that
241
+ // is inconsistent with package.json.
242
+ if (lockfileChanged(repoRoot, BASE_REF)) {
243
+ // Marked before the install runs, not after: a frozen install deletes
244
+ // node_modules up front, so even a failed or interrupted one leaves the
245
+ // caller's tree needing a restore.
246
+ installState.installedCombinedDeps = true;
247
+ const installResult = runPackageManagerCommand(pmConfig.ciInstall, repoRoot, INSTALL_TIMEOUT_MS);
248
+ if (!installResult.ok) {
249
+ // Surface install failures on their own terms rather than letting them
250
+ // resurface downstream as an unexplained test failure.
155
251
  batchFindings.push({
156
252
  check: "combined-branch-test",
157
253
  severity: "error",
158
- message: `npm run build failed on combined state: ${buildResult2.stderr.slice(0, 500)}`,
254
+ message: `Dependency install failed on combined state (\`${pmConfig.ciInstall}\`) — skipping test/build. The merged lockfile may be inconsistent with package.json: ${resolveFailureReason(installResult)}`,
159
255
  });
256
+ return;
160
257
  }
161
- return buildResult(branchResults, batchFindings, startTime);
162
- }
163
- finally {
164
- // Clean up: restore original branch and delete temp branch
165
- const restoreBranch = originalBranch.ok ? originalBranch.stdout : "main";
166
- git(["checkout", restoreBranch], repoRoot);
167
- git(["branch", "-D", tempBranch], repoRoot);
258
+ batchFindings.push({
259
+ check: "combined-branch-test",
260
+ severity: "info",
261
+ message: `Lockfile changed in the combined state reinstalled dependencies with \`${pmConfig.ciInstall}\``,
262
+ });
168
263
  }
264
+ // Run the test suite
265
+ const testCommand = `${pmConfig.run} test`;
266
+ const testResult = runPackageManagerCommand(testCommand, repoRoot, TEST_BUILD_TIMEOUT_MS);
267
+ batchFindings.push(testResult.ok
268
+ ? {
269
+ check: "combined-branch-test",
270
+ severity: "info",
271
+ message: `\`${testCommand}\` passed on combined state`,
272
+ }
273
+ : {
274
+ check: "combined-branch-test",
275
+ severity: "error",
276
+ message: `\`${testCommand}\` failed on combined state: ${resolveFailureReason(testResult)}`,
277
+ });
278
+ // Run the build
279
+ const buildCommand = `${pmConfig.run} build`;
280
+ const buildOutcome = runPackageManagerCommand(buildCommand, repoRoot, TEST_BUILD_TIMEOUT_MS);
281
+ batchFindings.push(buildOutcome.ok
282
+ ? {
283
+ check: "combined-branch-test",
284
+ severity: "info",
285
+ message: `\`${buildCommand}\` passed on combined state`,
286
+ }
287
+ : {
288
+ check: "combined-branch-test",
289
+ severity: "error",
290
+ message: `\`${buildCommand}\` failed on combined state: ${resolveFailureReason(buildOutcome)}`,
291
+ });
169
292
  }
170
293
  export function buildResult(branchResults, batchFindings, startTime) {
171
294
  const hasErrors = batchFindings.some((f) => f.severity === "error");
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Normalized spawn results and never-empty failure reasons (#803)
3
+ *
4
+ * Shared by every merge-check module that reports on a spawned command. The
5
+ * motivating defect: a failure reason built from `stderr` alone renders as
6
+ * `... failed: ` with nothing after it whenever the command wrote its
7
+ * diagnostics to stdout — which is where vitest, tsc, and npm put theirs.
8
+ */
9
+ import type { SpawnSyncReturns } from "child_process";
10
+ /** Max characters of captured output included in a failure message. */
11
+ export declare const REASON_MAX_CHARS = 500;
12
+ /**
13
+ * Outcome of a spawned command, normalized across success, non-zero exit,
14
+ * signal termination, and spawn failure.
15
+ */
16
+ export interface CommandResult {
17
+ ok: boolean;
18
+ stdout: string;
19
+ stderr: string;
20
+ status: number | null;
21
+ signal: string | null;
22
+ spawnError?: string;
23
+ }
24
+ /**
25
+ * Normalize a raw `spawnSync` return into a {@link CommandResult}.
26
+ */
27
+ export declare function toCommandResult(result: SpawnSyncReturns<string>): CommandResult;
28
+ /**
29
+ * Produce a human-readable, never-empty reason for a failed command.
30
+ *
31
+ * Falls through: stderr → stdout tail → spawn error → signal → exit code. The
32
+ * stdout *tail* is used because test runners put the failure summary last,
33
+ * while the stderr *head* is kept because the first error is usually the
34
+ * proximate cause.
35
+ */
36
+ export declare function resolveFailureReason(result: CommandResult): string;
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Normalized spawn results and never-empty failure reasons (#803)
3
+ *
4
+ * Shared by every merge-check module that reports on a spawned command. The
5
+ * motivating defect: a failure reason built from `stderr` alone renders as
6
+ * `... failed: ` with nothing after it whenever the command wrote its
7
+ * diagnostics to stdout — which is where vitest, tsc, and npm put theirs.
8
+ */
9
+ /** Max characters of captured output included in a failure message. */
10
+ export const REASON_MAX_CHARS = 500;
11
+ /**
12
+ * Normalize a raw `spawnSync` return into a {@link CommandResult}.
13
+ */
14
+ export function toCommandResult(result) {
15
+ return {
16
+ ok: result.status === 0,
17
+ stdout: result.stdout?.trim() ?? "",
18
+ stderr: result.stderr?.trim() ?? "",
19
+ status: result.status ?? null,
20
+ signal: result.signal ?? null,
21
+ spawnError: result.error?.message,
22
+ };
23
+ }
24
+ /**
25
+ * Produce a human-readable, never-empty reason for a failed command.
26
+ *
27
+ * Falls through: stderr → stdout tail → spawn error → signal → exit code. The
28
+ * stdout *tail* is used because test runners put the failure summary last,
29
+ * while the stderr *head* is kept because the first error is usually the
30
+ * proximate cause.
31
+ */
32
+ export function resolveFailureReason(result) {
33
+ if (result.stderr) {
34
+ return truncateHead(result.stderr);
35
+ }
36
+ if (result.stdout) {
37
+ return truncateTail(result.stdout);
38
+ }
39
+ if (result.spawnError) {
40
+ return result.spawnError;
41
+ }
42
+ if (result.signal) {
43
+ return `no output; killed by signal ${result.signal} (likely a timeout)`;
44
+ }
45
+ return `no output; exited with code ${result.status ?? "unknown"}`;
46
+ }
47
+ function truncateHead(text) {
48
+ return text.length > REASON_MAX_CHARS
49
+ ? `${text.slice(0, REASON_MAX_CHARS)}…`
50
+ : text;
51
+ }
52
+ function truncateTail(text) {
53
+ return text.length > REASON_MAX_CHARS
54
+ ? `…${text.slice(-REASON_MAX_CHARS)}`
55
+ : text;
56
+ }
@@ -7,6 +7,10 @@
7
7
  import { type RunLog } from "../workflow/run-log-schema.js";
8
8
  import type { BranchInfo, CheckResult, MergeCommandOptions, MergeReport } from "./types.js";
9
9
  import { formatReportMarkdown, formatBranchReportMarkdown, postReportToGitHub } from "./report.js";
10
+ /**
11
+ * Resolve log directory path
12
+ */
13
+ export declare function resolveLogDir(customPath?: string): string;
10
14
  /**
11
15
  * Find the most recent run log file
12
16
  */
@@ -20,7 +20,7 @@ import { buildReport, formatReportMarkdown, formatBranchReportMarkdown, postRepo
20
20
  /**
21
21
  * Resolve log directory path
22
22
  */
23
- function resolveLogDir(customPath) {
23
+ export function resolveLogDir(customPath) {
24
24
  if (customPath) {
25
25
  return customPath.replace("~", os.homedir());
26
26
  }
@@ -5,6 +5,7 @@
5
5
  * batch-level verdict. Optionally posts to GitHub as PR comment.
6
6
  */
7
7
  import { spawnSync } from "child_process";
8
+ import { resolveFailureReason, toCommandResult } from "./command-result.js";
8
9
  /**
9
10
  * Compute per-issue verdicts from check results
10
11
  */
@@ -232,7 +233,10 @@ export function postReportToGitHub(report) {
232
233
  const markdown = formatBranchReportMarkdown(report, branch.issueNumber);
233
234
  const result = spawnSync("gh", ["pr", "comment", String(branch.prNumber), "--body", markdown], { stdio: "pipe", encoding: "utf-8" });
234
235
  if (result.status !== 0) {
235
- console.error(`Failed to post comment on PR #${branch.prNumber}: ${result.stderr}`);
236
+ // Same never-empty reason resolution as the combined-branch test (#803):
237
+ // `gh` usually writes to stderr, but a killed or unspawnable process
238
+ // leaves it empty, which would print a message with nothing after it.
239
+ console.error(`Failed to post comment on PR #${branch.prNumber}: ${resolveFailureReason(toCommandResult(result))}`);
236
240
  }
237
241
  }
238
242
  }