From 9990029a625dd2f66a1468edb709eb98d6000e7a Mon Sep 17 00:00:00 2001 From: Jack McIntyre Date: Wed, 24 Jun 2026 09:10:13 +1000 Subject: [PATCH] feat(01KVTB3Z): resolve each target repo's own build toolchain structurally instead of hardcoding pnpm at plugins/flow Add resolve-project-toolchain.ts: one resolver consumed by BOTH the dev pre-PR build/test/bloat gates and the reviewer's vitest runner, so they agree on where and how to build/test any target repo. Resolution order: (1) optional .flow/ config.yaml build: block (escape hatch, zod-validated, typed ToolchainConfigError on a bad packageManager); (2) structural build-home detection (pnpm-workspace member owning a build script, else nearest package.json with a build script, else repo root); (3) package-manager detection by lockfile at the resolved cwd. The Flow dogfood path no longer depends on the gitignored .flow/config.yaml: structural detection alone resolves pnpm + plugins/flow for the crew repo on a clean worktree. An external npm repo resolves to npm + repo root with the bloat (knip) gate skipped when no dead-code check applies. run-reviewer-session.ts aligns its vitest invocation to the resolved toolchain (local node_modules/.bin/vitest first, else the package manager's run command) while preserving the findPackageRoot re-export and leaving find-package-root.ts intact. Claude-Session: https://claude.ai/code/session_01E5VyKQMGJSL6fRfRmCzuvs --- plugins/flow/mcp-server/dist/cli.js | 477 +++++++++++++--- plugins/flow/mcp-server/dist/index.js | 465 +++++++++++++--- .../src/__tests__/dev-pre-pr-gate.test.ts | 18 + .../__tests__/inline-ac-extraction.test.ts | 11 + plugins/flow/mcp-server/src/errors.ts | 26 + .../lib/__tests__/flow-shaped-build-home.ts | 54 ++ .../resolve-project-toolchain.test.ts | 369 +++++++++++++ .../run-project-build-toolchain.test.ts | 143 +++++ .../src/lib/resolve-project-toolchain.ts | 515 ++++++++++++++++++ .../mcp-server/src/lib/run-project-build.ts | 255 +++++---- .../concurrent-runs-isolation.test.ts | 5 + .../__tests__/dev-edits-in-worktree.test.ts | 5 + .../__tests__/dev-prepr-build-gate.test.ts | 35 +- .../__tests__/reviewer-vitest-cwd.test.ts | 77 ++- ...un-dev-terminal-action.integration.test.ts | 8 + .../__tests__/run-reviewer-session.test.ts | 8 + .../src/tools/run-dev-terminal-action.ts | 6 +- .../src/tools/run-reviewer-session.ts | 69 ++- .../tests/canonical-fs-guard.test.ts | 8 + 19 files changed, 2250 insertions(+), 304 deletions(-) create mode 100644 plugins/flow/mcp-server/src/lib/__tests__/flow-shaped-build-home.ts create mode 100644 plugins/flow/mcp-server/src/lib/__tests__/resolve-project-toolchain.test.ts create mode 100644 plugins/flow/mcp-server/src/lib/__tests__/run-project-build-toolchain.test.ts create mode 100644 plugins/flow/mcp-server/src/lib/resolve-project-toolchain.ts diff --git a/plugins/flow/mcp-server/dist/cli.js b/plugins/flow/mcp-server/dist/cli.js index 60eec488..a195f25f 100644 --- a/plugins/flow/mcp-server/dist/cli.js +++ b/plugins/flow/mcp-server/dist/cli.js @@ -9208,12 +9208,12 @@ var require_isexe = __commonJS({ if (typeof Promise !== "function") { throw new TypeError("callback not provided"); } - return new Promise(function(resolve20, reject) { + return new Promise(function(resolve21, reject) { isexe(path86, options || {}, function(er, is) { if (er) { reject(er); } else { - resolve20(is); + resolve21(is); } }); }); @@ -9279,27 +9279,27 @@ var require_which = __commonJS({ opt = {}; const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt); const found = []; - const step = (i2) => new Promise((resolve20, reject) => { + const step = (i2) => new Promise((resolve21, reject) => { if (i2 === pathEnv.length) - return opt.all && found.length ? resolve20(found) : reject(getNotFoundError(cmd)); + return opt.all && found.length ? resolve21(found) : reject(getNotFoundError(cmd)); const ppRaw = pathEnv[i2]; const pathPart = /^".*"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw; const pCmd = path86.join(pathPart, cmd); const p = !pathPart && /^\.[\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd : pCmd; - resolve20(subStep(p, i2, 0)); + resolve21(subStep(p, i2, 0)); }); - const subStep = (p, i2, ii) => new Promise((resolve20, reject) => { + const subStep = (p, i2, ii) => new Promise((resolve21, reject) => { if (ii === pathExt.length) - return resolve20(step(i2 + 1)); + return resolve21(step(i2 + 1)); const ext = pathExt[ii]; isexe(p + ext, { pathExt: pathExtExe }, (er, is) => { if (!er && is) { if (opt.all) found.push(p + ext); else - return resolve20(p + ext); + return resolve21(p + ext); } - return resolve20(subStep(p, i2, ii + 1)); + return resolve21(subStep(p, i2, ii + 1)); }); }); return cb ? step(0).then((res) => cb(null, res), cb) : step(0); @@ -9636,6 +9636,19 @@ var InvalidWorkspaceConfigError = class extends DomainError { this.schemaModule = opts.schemaModule; } }; +var ToolchainConfigError = class extends DomainError { + configPath; + yamlPath; + detail; + constructor(opts) { + super( + `${opts.configPath} has an invalid 'build:' block at '${opts.yamlPath}': ${opts.detail}. Fix the build override (or remove it to fall back to structural toolchain detection). Recognised packageManager values: pnpm | npm | yarn | bun. See mcp-server/src/lib/resolve-project-toolchain.ts.` + ); + this.configPath = opts.configPath; + this.yamlPath = opts.yamlPath; + this.detail = opts.detail; + } +}; var NoAdapterMatchedError = class extends DomainError { targetRepoRoot; registeredAdapters; @@ -34251,8 +34264,8 @@ var disconnect = (anyProcess) => { // ../node_modules/.pnpm/execa@9.6.1/node_modules/execa/lib/utils/deferred.js var createDeferred = () => { const methods = {}; - const promise2 = new Promise((resolve20, reject) => { - Object.assign(methods, { resolve: resolve20, reject }); + const promise2 = new Promise((resolve21, reject) => { + Object.assign(methods, { resolve: resolve21, reject }); }); return Object.assign(promise2, methods); }; @@ -38894,11 +38907,11 @@ var addConcurrentStream = (concurrentStreams, stream, waitName) => { const promises = weakMap.get(stream); const promise2 = createDeferred(); promises.push(promise2); - const resolve20 = promise2.resolve.bind(promise2); - return { resolve: resolve20, promises }; + const resolve21 = promise2.resolve.bind(promise2); + return { resolve: resolve21, promises }; }; -var waitForConcurrentStreams = async ({ resolve: resolve20, promises }, subprocess) => { - resolve20(); +var waitForConcurrentStreams = async ({ resolve: resolve21, promises }, subprocess) => { + resolve21(); const [isSubprocessExit] = await Promise.race([ Promise.allSettled([true, subprocess]), Promise.all([false, ...promises]) @@ -39529,7 +39542,7 @@ function gitLockBackoffMs(attempt, random = Math.random) { return Math.floor(random() * window2); } function defaultGitLockSleep(ms) { - return new Promise((resolve20) => setTimeout(resolve20, ms)); + return new Promise((resolve21) => setTimeout(resolve21, ms)); } function isGitLockContention(value) { const stderr = typeof value === "string" ? value : String( @@ -41649,62 +41662,324 @@ async function loadRolePermissions(opts) { return { ...result.data, sourcePath: specPath }; } -// src/lib/run-project-build.ts +// src/lib/resolve-project-toolchain.ts +var import_yaml23 = __toESM(require_dist(), 1); import * as path50 from "node:path"; +import { + existsSync as existsSync3, + readFileSync as readFileSync5, + readdirSync as readdirSync3 +} from "node:fs"; +var PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"]; +var LOCKFILE_TO_PACKAGE_MANAGER = [ + ["pnpm-lock.yaml", "pnpm"], + ["package-lock.json", "npm"], + ["yarn.lock", "yarn"], + ["bun.lockb", "bun"] +]; +var KNIP_CONFIG_FILENAMES = [ + "knip.json", + "knip.jsonc", + "knip.ts", + "knip.js", + "knip.config.ts", + "knip.config.js" +]; +var BuildConfigSchema = external_exports.object({ + packageManager: external_exports.enum(PACKAGE_MANAGERS).optional(), + cwd: external_exports.string().min(1).optional(), + buildCmd: external_exports.string().min(1).optional(), + testCmd: external_exports.string().min(1).optional(), + knipCmd: external_exports.string().min(1).optional() +}).strict(); +function readPackageJson(dir) { + try { + const raw = readFileSync5(path50.join(dir, "package.json"), "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed; + return null; + } catch { + return null; + } +} +function hasScript(dir, script) { + const pkg = readPackageJson(dir); + const value = pkg?.scripts?.[script]; + return typeof value === "string" && value.trim().length > 0; +} +function scriptInvocation(pm2, script) { + switch (pm2) { + case "npm": + return ["npm", "run", script]; + case "bun": + return ["bun", "run", script]; + case "pnpm": + return ["pnpm", script]; + case "yarn": + return ["yarn", script]; + } +} +function splitCommand(cmd) { + return cmd.trim().split(/\s+/); +} +function detectPackageManagerAt(cwd) { + for (const [lockfile, pm2] of LOCKFILE_TO_PACKAGE_MANAGER) { + if (existsSync3(path50.join(cwd, lockfile))) { + return { pm: pm2, assumed: false }; + } + } + return { pm: "npm", assumed: true }; +} +function hasKnipConfig(cwd) { + return KNIP_CONFIG_FILENAMES.some((name) => existsSync3(path50.join(cwd, name))); +} +function findWorkspaceMemberWithBuild(workspaceDir) { + let packages; + try { + const raw = readFileSync5(path50.join(workspaceDir, "pnpm-workspace.yaml"), "utf8"); + const parsed = (0, import_yaml23.parse)(raw); + packages = parsed?.packages; + } catch { + return null; + } + if (!Array.isArray(packages)) return null; + const candidates = []; + for (const pattern of packages) { + if (typeof pattern !== "string") continue; + const segments = pattern.split("/"); + const globIdx = segments.findIndex((s) => s === "*" || s === "**"); + if (globIdx === -1) { + candidates.push(path50.join(workspaceDir, pattern)); + } else if (segments[globIdx] === "*") { + const parentDir = path50.join(workspaceDir, ...segments.slice(0, globIdx)); + try { + for (const entry of readdirSync3(parentDir, { withFileTypes: true })) { + if (entry.isDirectory()) candidates.push(path50.join(parentDir, entry.name)); + } + } catch { + } + } + } + for (const member of candidates) { + if (hasScript(member, "build")) return member; + } + return null; +} +function findNearestPackageWithBuild(root, maxDepth) { + let frontier = [root]; + for (let depth = 0; depth <= maxDepth; depth++) { + const next = []; + for (const dir of frontier) { + if (hasScript(dir, "build")) return dir; + let entries; + try { + entries = readdirSync3(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const name = entry.name; + if (name === "node_modules" || name === ".git") continue; + next.push(path50.join(dir, name)); + } + } + frontier = next; + } + return null; +} +function findWorkspaceYamlDir(root, maxDepth) { + let frontier = [root]; + for (let depth = 0; depth <= maxDepth; depth++) { + const next = []; + for (const dir of frontier) { + if (existsSync3(path50.join(dir, "pnpm-workspace.yaml"))) return dir; + let entries; + try { + entries = readdirSync3(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const name = entry.name; + if (name === "node_modules" || name === ".git") continue; + next.push(path50.join(dir, name)); + } + } + frontier = next; + } + return null; +} +var STRUCTURAL_SEARCH_MAX_DEPTH = 4; +function loadBuildConfigBlock(targetRepoRoot) { + const configPath = path50.join(targetRepoRoot, ".flow", "config.yaml"); + try { + const raw = readFileSync5(configPath, "utf8"); + const parsed = (0, import_yaml23.parse)(raw); + if (parsed && typeof parsed === "object" && "build" in parsed) { + return parsed.build; + } + } catch { + } + return void 0; +} +function resolveProjectToolchain(opts) { + const targetRepoRoot = path50.resolve(opts.targetRepoRoot); + const configPath = path50.join(targetRepoRoot, ".flow", "config.yaml"); + const rawBuildBlock = opts.buildConfigOverride !== void 0 ? opts.buildConfigOverride : loadBuildConfigBlock(targetRepoRoot); + if (rawBuildBlock !== void 0 && rawBuildBlock !== null) { + const parsed = BuildConfigSchema.safeParse(rawBuildBlock); + if (!parsed.success) { + const issue2 = parsed.error.issues[0]; + throw new ToolchainConfigError({ + configPath, + yamlPath: issue2.path.length === 0 ? "build" : `build.${issue2.path.join(".")}`, + detail: issue2.message + }); + } + const cfg = parsed.data; + const structural2 = detectStructuralBuildHome(targetRepoRoot); + const cwd2 = cfg.cwd ? path50.resolve(targetRepoRoot, cfg.cwd) : structural2.cwd; + const pmDetection = detectPackageManagerAt(cwd2); + const pm3 = cfg.packageManager ?? pmDetection.pm; + const pmAssumed = cfg.packageManager ? false : pmDetection.assumed; + const buildCmd2 = cfg.buildCmd ? splitCommand(cfg.buildCmd) : scriptInvocation(pm3, "build"); + const testCmd2 = cfg.testCmd ? splitCommand(cfg.testCmd) : scriptInvocation(pm3, "test"); + let knipCmd2; + if (cfg.knipCmd) { + knipCmd2 = splitCommand(cfg.knipCmd); + } else { + knipCmd2 = resolveKnipCmd(cwd2, pm3); + } + return { packageManager: pm3, cwd: cwd2, buildCmd: buildCmd2, testCmd: testCmd2, knipCmd: knipCmd2, pmAssumed, source: "config" }; + } + const structural = detectStructuralBuildHome(targetRepoRoot); + const cwd = structural.cwd; + const { pm: pm2, assumed } = detectPackageManagerAt(cwd); + const buildCmd = scriptInvocation(pm2, "build"); + const testCmd = scriptInvocation(pm2, "test"); + const knipCmd = resolveKnipCmd(cwd, pm2); + return { + packageManager: pm2, + cwd, + buildCmd, + testCmd, + knipCmd, + pmAssumed: assumed, + source: structural.source + }; +} +function detectStructuralBuildHome(targetRepoRoot) { + const workspaceDir = findWorkspaceYamlDir(targetRepoRoot, STRUCTURAL_SEARCH_MAX_DEPTH); + if (workspaceDir !== null) { + const member = findWorkspaceMemberWithBuild(workspaceDir); + if (member !== null) { + if (hasScript(workspaceDir, "build")) { + return { cwd: workspaceDir, source: "workspace" }; + } + return { cwd: member, source: "workspace" }; + } + if (hasScript(workspaceDir, "build")) { + return { cwd: workspaceDir, source: "workspace" }; + } + } + const pkgDir = findNearestPackageWithBuild(targetRepoRoot, STRUCTURAL_SEARCH_MAX_DEPTH); + if (pkgDir !== null) { + return { cwd: pkgDir, source: "package" }; + } + return { cwd: targetRepoRoot, source: "repo-root" }; +} +function resolveKnipCmd(cwd, pm2) { + if (hasScript(cwd, "knip")) { + return scriptInvocation(pm2, "knip"); + } + if (hasKnipConfig(cwd)) { + switch (pm2) { + case "pnpm": + return ["pnpm", "knip", "--no-progress"]; + case "yarn": + return ["yarn", "knip", "--no-progress"]; + case "bun": + return ["bun", "run", "knip", "--no-progress"]; + case "npm": + return ["npx", "knip", "--no-progress"]; + } + } + return null; +} + +// src/lib/run-project-build.ts var DEFAULT_BUILD_TEST_TIMEOUT_MS = 20 * 60 * 1e3; -var PROJECT_BUILD_COMMAND = "pnpm"; -var PROJECT_BUILD_ARGS = ["build"]; -function deriveProjectBuildCwd(devWorkingDir) { - return path50.join(devWorkingDir, "plugins", "flow"); +function resolveBuildToolchain(devWorkingDir) { + return resolveProjectToolchain({ targetRepoRoot: devWorkingDir }); +} +function normaliseTimedOutExit(result) { + const timedOut = "timedOut" in result && typeof result.timedOut === "boolean" ? result.timedOut : false; + const rawExit = typeof result.exitCode === "number" ? result.exitCode : 1; + const exitCode = timedOut ? rawExit !== 0 ? rawExit : 1 : rawExit; + return { exitCode, timedOut }; } async function runProjectBuild(opts) { const execaImpl = opts.execaImpl ?? execa; - const cwd = deriveProjectBuildCwd(opts.devWorkingDir); + const toolchain = resolveBuildToolchain(opts.devWorkingDir); + const cwd = toolchain.cwd; + const [command, ...args] = toolchain.buildCmd; const timeoutMs = opts.timeoutMs ?? DEFAULT_BUILD_TEST_TIMEOUT_MS; - const result = await execaImpl(PROJECT_BUILD_COMMAND, [...PROJECT_BUILD_ARGS], { + const result = await execaImpl(command, args, { cwd, reject: false, ...timeoutMs > 0 ? { timeout: timeoutMs } : {} }); - const timedOut = "timedOut" in result && typeof result.timedOut === "boolean" ? result.timedOut : false; + const { exitCode, timedOut } = normaliseTimedOutExit(result); return { - exitCode: timedOut ? typeof result.exitCode === "number" && result.exitCode !== 0 ? result.exitCode : 1 : typeof result.exitCode === "number" ? result.exitCode : 1, + exitCode, stdout: typeof result.stdout === "string" ? result.stdout : "", stderr: typeof result.stderr === "string" ? result.stderr : "", cwd, - commandLine: `${PROJECT_BUILD_COMMAND} ${PROJECT_BUILD_ARGS.join(" ")}`, + commandLine: toolchain.buildCmd.join(" "), timedOut, timeoutMs }; } -var PROJECT_TEST_COMMAND = "pnpm"; -var PROJECT_TEST_ARGS = ["test"]; async function runProjectTests(opts) { const execaImpl = opts.execaImpl ?? execa; - const cwd = deriveProjectBuildCwd(opts.devWorkingDir); + const toolchain = resolveBuildToolchain(opts.devWorkingDir); + const cwd = toolchain.cwd; + const [command, ...args] = toolchain.testCmd; const timeoutMs = opts.timeoutMs ?? DEFAULT_BUILD_TEST_TIMEOUT_MS; - const result = await execaImpl(PROJECT_TEST_COMMAND, [...PROJECT_TEST_ARGS], { + const result = await execaImpl(command, args, { cwd, reject: false, ...timeoutMs > 0 ? { timeout: timeoutMs } : {} }); - const timedOut = "timedOut" in result && typeof result.timedOut === "boolean" ? result.timedOut : false; + const { exitCode, timedOut } = normaliseTimedOutExit(result); return { - exitCode: timedOut ? typeof result.exitCode === "number" && result.exitCode !== 0 ? result.exitCode : 1 : typeof result.exitCode === "number" ? result.exitCode : 1, + exitCode, stdout: typeof result.stdout === "string" ? result.stdout : "", stderr: typeof result.stderr === "string" ? result.stderr : "", cwd, - commandLine: `${PROJECT_TEST_COMMAND} ${PROJECT_TEST_ARGS.join(" ")}`, + commandLine: toolchain.testCmd.join(" "), timedOut, timeoutMs }; } -var PROJECT_BLOAT_COMMAND = "pnpm"; -var PROJECT_BLOAT_ARGS = ["knip"]; async function runProjectBloatCheck(opts) { const execaImpl = opts.execaImpl ?? execa; - const cwd = deriveProjectBuildCwd(opts.devWorkingDir); - const result = await execaImpl(PROJECT_BLOAT_COMMAND, [...PROJECT_BLOAT_ARGS], { + const toolchain = resolveBuildToolchain(opts.devWorkingDir); + const cwd = toolchain.cwd; + if (toolchain.knipCmd === null) { + return { + exitCode: 0, + stdout: "", + stderr: "", + cwd, + commandLine: "(no dead-code check \u2014 bloat gate skipped)", + skipped: true + }; + } + const [command, ...args] = toolchain.knipCmd; + const result = await execaImpl(command, args, { cwd, reject: false }); @@ -41713,7 +41988,8 @@ async function runProjectBloatCheck(opts) { stdout: typeof result.stdout === "string" ? result.stdout : "", stderr: typeof result.stderr === "string" ? result.stderr : "", cwd, - commandLine: `${PROJECT_BLOAT_COMMAND} ${PROJECT_BLOAT_ARGS.join(" ")}` + commandLine: toolchain.knipCmd.join(" "), + skipped: false }; } @@ -41826,7 +42102,7 @@ async function runDevTerminalAction(opts) { role: ROLE, session_id: sessionUlid, story_id: ref, - expected: "pnpm build exits 0 (no type errors)", + expected: `${buildResult.commandLine} exits 0 (no type errors)`, observed: buildObserved }); throw new PrePrBuildFailedError({ @@ -41852,7 +42128,7 @@ async function runDevTerminalAction(opts) { role: ROLE, session_id: sessionUlid, story_id: ref, - expected: "pnpm test exits 0 (no failing tests)", + expected: `${testResult.commandLine} exits 0 (no failing tests)`, observed: testObserved }); throw new PrePrTestFailedError({ @@ -41876,7 +42152,7 @@ async function runDevTerminalAction(opts) { role: ROLE, session_id: sessionUlid, story_id: ref, - expected: "pnpm knip exits 0 (no dead code)", + expected: `${bloatResult.commandLine} exits 0 (no dead code)`, observed: `pre-PR bloat gate failed (exit ${bloatResult.exitCode})` }); throw new PrePrBloatFailedError({ @@ -42027,6 +42303,7 @@ async function runDevTerminalAction(opts) { // src/tools/run-reviewer-session.ts import * as path53 from "node:path"; import * as fs37 from "node:fs/promises"; +import { existsSync as existsSync4 } from "node:fs"; // src/lib/slugify-standards-criterion.ts function slugifyStandardsCriterion(name) { @@ -42267,6 +42544,24 @@ function countExecutedTests(output) { const failed = /(\d+)\s+failed/.exec(seg); return (passed ? Number(passed[1]) : 0) + (failed ? Number(failed[1]) : 0); } +function resolveVitestInvocation(packageRoot) { + const localBin = path53.join(packageRoot, "node_modules", ".bin", "vitest"); + if (existsSync4(localBin)) { + return { command: localBin, args: [] }; + } + const pm2 = resolveProjectToolchain({ targetRepoRoot: packageRoot }).packageManager; + switch (pm2) { + case "npm": + return { command: "npm", args: ["exec", "vitest"] }; + case "yarn": + return { command: "yarn", args: ["vitest"] }; + case "bun": + return { command: "bun", args: ["x", "vitest"] }; + case "pnpm": + default: + return { command: "pnpm", args: ["vitest"] }; + } +} async function runVitestCheck(index, tag, testNameFilter, testFilePath, checkRoot, execaImpl) { const testFilePathAbs = path53.resolve(checkRoot, testFilePath); const pkgRoot = findPackageRoot({ testFilePathAbs, checkRoot }); @@ -42286,8 +42581,12 @@ async function runVitestCheck(index, tag, testNameFilter, testFilePath, checkRoo const testFilePathAbs2 = path53.resolve(checkRoot, testFilePath); const relativeToPackage = path53.relative(pkgRoot.packageRoot, testFilePathAbs2); const looksLikeFilePath = (testFilePath.includes("/") || testFilePath.includes("\\")) && !relativeToPackage.startsWith("..") && relativeToPackage !== testFilePath; - const vitestArgs = looksLikeFilePath ? ["vitest", "--run", relativeToPackage] : ["vitest", "--run", "-t", testNameFilter]; - const result = await execaImpl("pnpm", vitestArgs, { + const vitestRunArgs = looksLikeFilePath ? ["--run", relativeToPackage] : ["--run", "-t", testNameFilter]; + const { command: vitestCommand, args: vitestPrefixArgs } = resolveVitestInvocation( + pkgRoot.packageRoot + ); + const vitestArgs = [...vitestPrefixArgs, ...vitestRunArgs]; + const result = await execaImpl(vitestCommand, vitestArgs, { cwd: pkgRoot.packageRoot, reject: false, timeout: VITEST_TIMEOUT_MS @@ -43059,7 +43358,7 @@ async function applyReviewerLabels(opts) { // src/tools/run-auto-merge-gate.ts import * as path56 from "node:path"; import { promises as fs39 } from "node:fs"; -var import_yaml23 = __toESM(require_dist(), 1); +var import_yaml24 = __toESM(require_dist(), 1); // src/lib/auto-merge-gate.ts function decideAutoMerge(input) { @@ -43274,7 +43573,7 @@ async function loadWorkspaceConfig(targetRepoRoot) { } throw err; } - const parsed = (0, import_yaml23.parse)(raw); + const parsed = (0, import_yaml24.parse)(raw); if (parsed === null || parsed === void 0 || typeof parsed !== "object" || !("plugin" in parsed)) { return PluginSettingsSchema.parse({}); } @@ -43329,7 +43628,7 @@ async function waitForCiGreen(opts) { return { kind: "ci-status-unreadable", reason }; } if (Date.now() - start >= CI_GATE_TIMEOUT_MS) return "pending-timeout"; - await new Promise((resolve20) => setTimeout(resolve20, CI_GATE_POLL_INTERVAL_MS)); + await new Promise((resolve21) => setTimeout(resolve21, CI_GATE_POLL_INTERVAL_MS)); continue; } let rollup = []; @@ -43346,7 +43645,7 @@ async function waitForCiGreen(opts) { if (state === "green") return "green"; if (state === "failed") return "failed"; if (Date.now() - start >= CI_GATE_TIMEOUT_MS) return "pending-timeout"; - await new Promise((resolve20) => setTimeout(resolve20, CI_GATE_POLL_INTERVAL_MS)); + await new Promise((resolve21) => setTimeout(resolve21, CI_GATE_POLL_INTERVAL_MS)); } } async function runAutoMergeGate(opts) { @@ -43560,7 +43859,7 @@ async function runAutoMergeGate(opts) { } // src/tools/complete-story.ts -var import_yaml24 = __toESM(require_dist(), 1); +var import_yaml25 = __toESM(require_dist(), 1); import { promises as fs40 } from "node:fs"; import * as path57 from "node:path"; function stripUndefined3(obj) { @@ -43588,7 +43887,7 @@ async function completeStory(opts) { } throw err; } - const parsed = (0, import_yaml24.parse)(rawText); + const parsed = (0, import_yaml25.parse)(rawText); const manifest = parseExecutionManifest(parsed, { absPath: absInProgressPath }); if (manifest.claimed_by !== sessionUlid) { throw new WrongClaimantError({ @@ -43612,7 +43911,7 @@ async function completeStory(opts) { const reparsed = parseExecutionManifest(updatedManifest, { absPath: absDonePath }); - const yamlText = (0, import_yaml24.stringify)( + const yamlText = (0, import_yaml25.stringify)( stripUndefined3(reparsed), { lineWidth: 0 } ); @@ -43987,7 +44286,7 @@ async function processReviewerYield(opts) { } // src/tools/scan-orphaned-in-progress.ts -var import_yaml25 = __toESM(require_dist(), 1); +var import_yaml26 = __toESM(require_dist(), 1); import { promises as fs44 } from "node:fs"; import * as path61 from "node:path"; async function scanOrphanedInProgress(opts) { @@ -44018,7 +44317,7 @@ async function scanOrphanedInProgress(opts) { } throw err; } - const parsed = (0, import_yaml25.parse)(raw); + const parsed = (0, import_yaml26.parse)(raw); const manifest = parseExecutionManifest(parsed, { absPath }); if (!manifest.claimed_by) { continue; @@ -44279,12 +44578,12 @@ async function reapStaleWorktrees(opts) { } // src/tools/mark-story-ready.ts -var import_yaml27 = __toESM(require_dist(), 1); +var import_yaml28 = __toESM(require_dist(), 1); import { promises as fs46 } from "node:fs"; import * as path65 from "node:path"; // src/tools/mark-withdrawn.ts -var import_yaml26 = __toESM(require_dist(), 1); +var import_yaml27 = __toESM(require_dist(), 1); var MarkWithdrawnInputSchema = external_exports.object({ targetRepoRoot: external_exports.string().min(1), ref: external_exports.string().min(1) @@ -44295,7 +44594,7 @@ function stripUndefined4(obj) { ); } function serialiseManifest(manifest) { - return (0, import_yaml26.stringify)( + return (0, import_yaml27.stringify)( stripUndefined4(manifest), { lineWidth: 0 } ); @@ -44338,7 +44637,7 @@ async function markStoryReady(rawInput) { throw new NotAnEligibleBacklogItemError({ ref, foundState, reason: "not-in-to-do" }); } const rawText = await fs46.readFile(foundAbsPath, "utf8"); - const parsed = (0, import_yaml27.parse)(rawText); + const parsed = (0, import_yaml28.parse)(rawText); const manifest = parseExecutionManifest(parsed, { absPath: foundAbsPath }); if (manifest.withdrawn === true) { throw new NotAnEligibleBacklogItemError({ ref, foundState, reason: "withdrawn" }); @@ -44734,7 +45033,7 @@ async function dismissMaintainerFeedback(opts) { } // src/tools/resolve-lens-roles.ts -var import_yaml28 = __toESM(require_dist(), 1); +var import_yaml29 = __toESM(require_dist(), 1); import { promises as fs50 } from "node:fs"; import * as path70 from "node:path"; async function resolveLensRoles(opts) { @@ -44798,7 +45097,7 @@ async function readRoleCapabilities(teamDir, roleId) { const frontmatterRaw = normalised.slice(4, closeIdx); let parsedYaml; try { - parsedYaml = (0, import_yaml28.parse)(frontmatterRaw); + parsedYaml = (0, import_yaml29.parse)(frontmatterRaw); } catch { return { id: roleId, reviewLenses: void 0 }; } @@ -44821,7 +45120,7 @@ function isEnoent12(err) { } // src/tools/resolve-run-slot.ts -var import_yaml29 = __toESM(require_dist(), 1); +var import_yaml30 = __toESM(require_dist(), 1); import { promises as fs51 } from "node:fs"; import * as path71 from "node:path"; var RUN_JOB_GENERALISTS = { @@ -44896,7 +45195,7 @@ async function readRunJobs(teamDir, roleId) { const frontmatterRaw = normalised.slice(4, closeIdx); let parsedYaml; try { - parsedYaml = (0, import_yaml29.parse)(frontmatterRaw); + parsedYaml = (0, import_yaml30.parse)(frontmatterRaw); } catch { return void 0; } @@ -44954,7 +45253,7 @@ async function readReviewerLesson(opts) { } // src/tools/record-story-retro.ts -var import_yaml30 = __toESM(require_dist(), 1); +var import_yaml31 = __toESM(require_dist(), 1); import { promises as fs52 } from "node:fs"; import * as path72 from "node:path"; var NON_DONE_STATES = ["in-progress", "to-do", "blocked"]; @@ -45009,7 +45308,7 @@ async function recordStoryRetro(opts) { duration_seconds: retro.duration_seconds }; const reparsed = parseExecutionManifest(merged, { absPath: absDonePath }); - const yamlText = (0, import_yaml30.stringify)( + const yamlText = (0, import_yaml31.stringify)( stripUndefined5(reparsed), { lineWidth: 0 } ); @@ -45100,7 +45399,7 @@ async function readDevLesson(opts) { // src/tools/recall-lesson.ts import * as path75 from "node:path"; import { promises as fs55 } from "node:fs"; -var import_yaml31 = __toESM(require_dist(), 1); +var import_yaml32 = __toESM(require_dist(), 1); var _LESSON_BLOCK_PREFIX = LESSON_BLOCK_PREFIX; var _LESSON_BLOCK_SUFFIX = LESSON_BLOCK_SUFFIX; var TOOL_NAME2 = "recallLesson"; @@ -45252,7 +45551,7 @@ function reconstructPersonaFile(parsed, newKnowledgeBody) { hired_at: parsed.hired_at, catalogue_version: parsed.catalogue_version }; - const yamlBlock = (0, import_yaml31.stringify)(frontmatter).replace(/\n$/, ""); + const yamlBlock = (0, import_yaml32.stringify)(frontmatter).replace(/\n$/, ""); const h1 = parsed.role.split("-").map( (part) => part.length === 0 ? part : part[0].toUpperCase() + part.slice(1) ).join(" "); @@ -45347,7 +45646,7 @@ function resolveJudgePlan(opts) { } // src/tools/resolve-build-plan.ts -var import_yaml32 = __toESM(require_dist(), 1); +var import_yaml33 = __toESM(require_dist(), 1); import { readFile as readFile2 } from "node:fs/promises"; var FAST_LANE_MODEL = "haiku"; var FULL_LANE_MODEL = "sonnet"; @@ -45370,7 +45669,7 @@ var BuildPlanSchema = external_exports.object({ async function readLaneFromManifest(manifestPath) { try { const raw = await readFile2(manifestPath, "utf8"); - const parsed = (0, import_yaml32.parse)(raw); + const parsed = (0, import_yaml33.parse)(raw); const lane = parsed?.lane; if (lane === "fast" || lane === "full") return lane; return void 0; @@ -45401,7 +45700,7 @@ async function resolveBuildPlan(opts) { // src/tools/discard-draft.ts import { promises as fs56 } from "node:fs"; import * as path76 from "node:path"; -var import_yaml33 = __toESM(require_dist(), 1); +var import_yaml34 = __toESM(require_dist(), 1); var DiscardDraftInputSchema = external_exports.object({ targetRepoRoot: external_exports.string().min(1), ref: external_exports.string().min(1) @@ -45434,7 +45733,7 @@ async function discardDraft(rawInput) { }); } const rawText = await fs56.readFile(foundAbsPath, "utf8"); - const parsed = (0, import_yaml33.parse)(rawText); + const parsed = (0, import_yaml34.parse)(rawText); const manifest = parseExecutionManifest(parsed, { absPath: foundAbsPath }); if (manifest.withdrawn === true) { throw new NotAnEligibleDraftError({ ref, foundState, reason: "withdrawn" }); @@ -45471,7 +45770,7 @@ function isEnoent14(err) { } // src/tools/block-story.ts -var import_yaml34 = __toESM(require_dist(), 1); +var import_yaml35 = __toESM(require_dist(), 1); import { promises as fs57 } from "node:fs"; import * as path77 from "node:path"; @@ -45527,7 +45826,7 @@ async function blockStory(opts) { } throw err; } - const parsed = (0, import_yaml34.parse)(rawText); + const parsed = (0, import_yaml35.parse)(rawText); const manifest = parseExecutionManifest(parsed, { absPath: absInProgressPath }); if (manifest.claimed_by !== sessionUlid) { throw new WrongClaimantError({ @@ -45549,7 +45848,7 @@ async function blockStory(opts) { const reparsed = parseExecutionManifest(updatedManifest, { absPath: absBlockedPath }); - const yamlText = (0, import_yaml34.stringify)( + const yamlText = (0, import_yaml35.stringify)( stripUndefined6(reparsed), { lineWidth: 0 } ); @@ -45587,7 +45886,7 @@ async function extractNativeStoryAcs(opts) { } // src/tools/capture-skill-invoke.ts -var import_yaml35 = __toESM(require_dist(), 1); +var import_yaml36 = __toESM(require_dist(), 1); import * as path79 from "node:path"; import { promises as fs58 } from "node:fs"; @@ -45665,7 +45964,7 @@ async function resolveActiveStoryRef(targetRepoRoot, readInProgressDirImpl, read let parsed; try { const raw = await readFileImpl(path79.join(inProgressDir, file2)); - parsed = (0, import_yaml35.parse)(raw); + parsed = (0, import_yaml36.parse)(raw); } catch { continue; } @@ -45723,12 +46022,12 @@ async function captureSkillInvoke(rawHookPayload, deps = {}) { } // src/tools/auto-absorb-retro-proposals.ts -var import_yaml38 = __toESM(require_dist(), 1); +var import_yaml39 = __toESM(require_dist(), 1); import { promises as fs61 } from "node:fs"; import * as path82 from "node:path"; // src/lib/locate-proposal.ts -var import_yaml36 = __toESM(require_dist(), 1); +var import_yaml37 = __toESM(require_dist(), 1); import { promises as fs59 } from "node:fs"; import * as path80 from "node:path"; @@ -45917,7 +46216,7 @@ async function locateProposal(opts) { const absPath = path80.join(proposalsDir, file2); const raw = await fs59.readFile(absPath, "utf8"); const { frontmatterRaw } = splitFrontmatter(raw, absPath); - const parsedYaml = (0, import_yaml36.parse)(frontmatterRaw); + const parsedYaml = (0, import_yaml37.parse)(frontmatterRaw); const parsedFile = parseRetroProposalFile(parsedYaml); parsedFile.proposals.forEach((proposal, index) => { if (proposal.id === proposalId) { @@ -45953,7 +46252,7 @@ function isEnoent15(err) { // src/lib/apply-persona-append.ts import { promises as fs60 } from "node:fs"; import * as path81 from "node:path"; -var import_yaml37 = __toESM(require_dist(), 1); +var import_yaml38 = __toESM(require_dist(), 1); var TOOL_NAME3 = "acceptProposal"; function personaRelPath(targetRole) { return `team/${targetRole}/PERSONA.md`; @@ -45980,7 +46279,7 @@ function reconstructPersonaFile2(parsed, newKnowledgeBody) { hired_at: parsed.hired_at, catalogue_version: parsed.catalogue_version }; - const yamlBlock = (0, import_yaml37.stringify)(frontmatter).replace(/\n$/, ""); + const yamlBlock = (0, import_yaml38.stringify)(frontmatter).replace(/\n$/, ""); const h1 = parsed.role.split("-").map( (part) => part.length === 0 ? part : part[0].toUpperCase() + part.slice(1) ).join(" "); @@ -46148,7 +46447,7 @@ function stampProposalAutoAbsorbed(rawFile, located, appliedAt, idempotencyKey, (p, i2) => i2 === located.index ? { ...p, applied: appliedBlock } : p ) }; - const fm = (0, import_yaml38.stringify)( + const fm = (0, import_yaml39.stringify)( { iso_timestamp: file2.iso_timestamp, cycle_window: file2.cycle_window, @@ -46325,7 +46624,7 @@ async function autoAbsorbProposalFile(opts) { let proposals; try { const { frontmatterRaw } = splitFrontmatter(raw, absPath); - const parsedYaml = (0, import_yaml38.parse)(frontmatterRaw); + const parsedYaml = (0, import_yaml39.parse)(frontmatterRaw); const file2 = parseRetroProposalFile(parsedYaml); proposals = file2.proposals; } catch (err) { @@ -46346,13 +46645,13 @@ async function autoAbsorbProposalFile(opts) { } // src/tools/summarise-retro-proposal.ts -var import_yaml39 = __toESM(require_dist(), 1); +var import_yaml40 = __toESM(require_dist(), 1); import { promises as fs62 } from "node:fs"; async function summariseRetroProposal(opts) { const { absPath } = opts; const raw = await fs62.readFile(absPath, "utf8"); const { frontmatterRaw } = splitFrontmatter(raw, absPath); - const parsedYaml = (0, import_yaml39.parse)(frontmatterRaw); + const parsedYaml = (0, import_yaml40.parse)(frontmatterRaw); const file2 = parseRetroProposalFile(parsedYaml); const proposals = file2.proposals.map((p) => ({ type: p.type, @@ -46369,7 +46668,7 @@ async function summariseRetroProposal(opts) { } // src/tools/unhire-persona.ts -var import_yaml40 = __toESM(require_dist(), 1); +var import_yaml41 = __toESM(require_dist(), 1); import { promises as fs63 } from "node:fs"; import * as path83 from "node:path"; async function readPersonaCapabilities(personaPath) { @@ -46390,7 +46689,7 @@ async function readPersonaCapabilities(personaPath) { const frontmatterRaw = normalised.slice(4, closeIdx); let parsedYaml; try { - parsedYaml = (0, import_yaml40.parse)(frontmatterRaw); + parsedYaml = (0, import_yaml41.parse)(frontmatterRaw); } catch { return void 0; } @@ -46651,7 +46950,7 @@ function isEnoent17(err) { } // src/tools/match-story-specialist.ts -var import_yaml41 = __toESM(require_dist(), 1); +var import_yaml42 = __toESM(require_dist(), 1); import { promises as fs65 } from "node:fs"; async function matchStorySpecialist(opts) { const { targetRepoRoot, manifestPath } = opts; @@ -46663,7 +46962,7 @@ async function matchStorySpecialist(opts) { } let manifest; try { - const parsed = (0, import_yaml41.parse)(raw); + const parsed = (0, import_yaml42.parse)(raw); manifest = parseExecutionManifest(parsed, { absPath: manifestPath }); } catch { return { role: null, domain: null }; @@ -46680,7 +46979,7 @@ async function matchStorySpecialist(opts) { } // src/tools/record-specialist-engagement.ts -var import_yaml42 = __toESM(require_dist(), 1); +var import_yaml43 = __toESM(require_dist(), 1); import { promises as fs66 } from "node:fs"; import * as path85 from "node:path"; function stripUndefined7(obj) { @@ -46711,7 +47010,7 @@ async function recordSpecialistEngagement(opts) { } throw err; } - const parsed = (0, import_yaml42.parse)(rawText); + const parsed = (0, import_yaml43.parse)(rawText); const manifest = parseExecutionManifest(parsed, { absPath }); if (manifest.claimed_by !== sessionUlid) { throw new Error( @@ -46720,7 +47019,7 @@ async function recordSpecialistEngagement(opts) { } const updated = { ...manifest, engaged_specialist: specialistRole }; const reparsed = parseExecutionManifest(updated, { absPath }); - const yamlText = (0, import_yaml42.stringify)( + const yamlText = (0, import_yaml43.stringify)( stripUndefined7(reparsed), { lineWidth: 0 } ); diff --git a/plugins/flow/mcp-server/dist/index.js b/plugins/flow/mcp-server/dist/index.js index b0fce5ad..e801de2a 100644 --- a/plugins/flow/mcp-server/dist/index.js +++ b/plugins/flow/mcp-server/dist/index.js @@ -2988,7 +2988,7 @@ var require_compile = __commonJS({ const schOrFunc = root.refs[ref]; if (schOrFunc) return schOrFunc; - let _sch = resolve22.call(this, root, ref); + let _sch = resolve23.call(this, root, ref); if (_sch === void 0) { const schema = (_a3 = root.localRefs) === null || _a3 === void 0 ? void 0 : _a3[ref]; const { schemaId } = this.opts; @@ -3015,7 +3015,7 @@ var require_compile = __commonJS({ function sameSchemaEnv(s1, s2) { return s1.schema === s2.schema && s1.root === s2.root && s1.baseId === s2.baseId; } - function resolve22(root, ref) { + function resolve23(root, ref) { let sch; while (typeof (sch = this.refs[ref]) == "string") ref = sch; @@ -3646,7 +3646,7 @@ var require_fast_uri = __commonJS({ } return uri; } - function resolve22(baseURI, relativeURI, options) { + function resolve23(baseURI, relativeURI, options) { const schemelessOptions = options ? Object.assign({ scheme: "null" }, options) : { scheme: "null" }; const resolved = resolveComponent(parse4(baseURI, schemelessOptions), parse4(relativeURI, schemelessOptions), schemelessOptions, true); schemelessOptions.skipEscape = true; @@ -3904,7 +3904,7 @@ var require_fast_uri = __commonJS({ var fastUri = { SCHEMES, normalize: normalize2, - resolve: resolve22, + resolve: resolve23, resolveComponent, equal, serialize: serialize2, @@ -16064,12 +16064,12 @@ var require_isexe = __commonJS({ if (typeof Promise !== "function") { throw new TypeError("callback not provided"); } - return new Promise(function(resolve22, reject) { + return new Promise(function(resolve23, reject) { isexe(path95, options || {}, function(er, is) { if (er) { reject(er); } else { - resolve22(is); + resolve23(is); } }); }); @@ -16135,27 +16135,27 @@ var require_which = __commonJS({ opt = {}; const { pathEnv, pathExt, pathExtExe } = getPathInfo(cmd, opt); const found = []; - const step = (i2) => new Promise((resolve22, reject) => { + const step = (i2) => new Promise((resolve23, reject) => { if (i2 === pathEnv.length) - return opt.all && found.length ? resolve22(found) : reject(getNotFoundError(cmd)); + return opt.all && found.length ? resolve23(found) : reject(getNotFoundError(cmd)); const ppRaw = pathEnv[i2]; const pathPart = /^".*"$/.test(ppRaw) ? ppRaw.slice(1, -1) : ppRaw; const pCmd = path95.join(pathPart, cmd); const p = !pathPart && /^\.[\\\/]/.test(cmd) ? cmd.slice(0, 2) + pCmd : pCmd; - resolve22(subStep(p, i2, 0)); + resolve23(subStep(p, i2, 0)); }); - const subStep = (p, i2, ii) => new Promise((resolve22, reject) => { + const subStep = (p, i2, ii) => new Promise((resolve23, reject) => { if (ii === pathExt.length) - return resolve22(step(i2 + 1)); + return resolve23(step(i2 + 1)); const ext = pathExt[ii]; isexe(p + ext, { pathExt: pathExtExe }, (er, is) => { if (!er && is) { if (opt.all) found.push(p + ext); else - return resolve22(p + ext); + return resolve23(p + ext); } - return resolve22(subStep(p, i2, ii + 1)); + return resolve23(subStep(p, i2, ii + 1)); }); }); return cb ? step(0).then((res) => cb(null, res), cb) : step(0); @@ -32581,12 +32581,12 @@ var StdioServerTransport = class { this.onclose?.(); } send(message) { - return new Promise((resolve22) => { + return new Promise((resolve23) => { const json2 = serializeMessage(message); if (this._stdout.write(json2)) { - resolve22(); + resolve23(); } else { - this._stdout.once("drain", resolve22); + this._stdout.once("drain", resolve23); } }); } @@ -33184,7 +33184,7 @@ var Protocol = class { return; } const pollInterval = task2.pollInterval ?? this._options?.defaultTaskPollInterval ?? 1e3; - await new Promise((resolve22) => setTimeout(resolve22, pollInterval)); + await new Promise((resolve23) => setTimeout(resolve23, pollInterval)); options?.signal?.throwIfAborted(); } } catch (error51) { @@ -33201,7 +33201,7 @@ var Protocol = class { */ request(request, resultSchema, options) { const { relatedRequestId, resumptionToken, onresumptiontoken, task, relatedTask } = options ?? {}; - return new Promise((resolve22, reject) => { + return new Promise((resolve23, reject) => { const earlyReject = (error51) => { reject(error51); }; @@ -33279,7 +33279,7 @@ var Protocol = class { if (!parseResult.success) { reject(parseResult.error); } else { - resolve22(parseResult.data); + resolve23(parseResult.data); } } catch (error51) { reject(error51); @@ -33540,12 +33540,12 @@ var Protocol = class { } } catch { } - return new Promise((resolve22, reject) => { + return new Promise((resolve23, reject) => { if (signal.aborted) { reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled")); return; } - const timeoutId = setTimeout(resolve22, interval); + const timeoutId = setTimeout(resolve23, interval); signal.addEventListener("abort", () => { clearTimeout(timeoutId); reject(new McpError(ErrorCode.InvalidRequest, "Request cancelled")); @@ -34414,6 +34414,19 @@ var InvalidWorkspaceConfigError = class extends DomainError { this.schemaModule = opts.schemaModule; } }; +var ToolchainConfigError = class extends DomainError { + configPath; + yamlPath; + detail; + constructor(opts) { + super( + `${opts.configPath} has an invalid 'build:' block at '${opts.yamlPath}': ${opts.detail}. Fix the build override (or remove it to fall back to structural toolchain detection). Recognised packageManager values: pnpm | npm | yarn | bun. See mcp-server/src/lib/resolve-project-toolchain.ts.` + ); + this.configPath = opts.configPath; + this.yamlPath = opts.yamlPath; + this.detail = opts.detail; + } +}; var NoAdapterMatchedError = class extends DomainError { targetRepoRoot; registeredAdapters; @@ -41305,8 +41318,8 @@ var disconnect = (anyProcess) => { // ../node_modules/.pnpm/execa@9.6.1/node_modules/execa/lib/utils/deferred.js var createDeferred = () => { const methods = {}; - const promise2 = new Promise((resolve22, reject) => { - Object.assign(methods, { resolve: resolve22, reject }); + const promise2 = new Promise((resolve23, reject) => { + Object.assign(methods, { resolve: resolve23, reject }); }); return Object.assign(promise2, methods); }; @@ -45948,11 +45961,11 @@ var addConcurrentStream = (concurrentStreams, stream, waitName) => { const promises = weakMap.get(stream); const promise2 = createDeferred(); promises.push(promise2); - const resolve22 = promise2.resolve.bind(promise2); - return { resolve: resolve22, promises }; + const resolve23 = promise2.resolve.bind(promise2); + return { resolve: resolve23, promises }; }; -var waitForConcurrentStreams = async ({ resolve: resolve22, promises }, subprocess) => { - resolve22(); +var waitForConcurrentStreams = async ({ resolve: resolve23, promises }, subprocess) => { + resolve23(); const [isSubprocessExit] = await Promise.race([ Promise.allSettled([true, subprocess]), Promise.all([false, ...promises]) @@ -46583,7 +46596,7 @@ function gitLockBackoffMs(attempt, random = Math.random) { return Math.floor(random() * window2); } function defaultGitLockSleep(ms) { - return new Promise((resolve22) => setTimeout(resolve22, ms)); + return new Promise((resolve23) => setTimeout(resolve23, ms)); } function isGitLockContention(value) { const stderr = typeof value === "string" ? value : String( @@ -55773,62 +55786,324 @@ async function loadRolePermissions(opts) { return { ...result.data, sourcePath: specPath }; } -// src/lib/run-project-build.ts +// src/lib/resolve-project-toolchain.ts +var import_yaml35 = __toESM(require_dist2(), 1); import * as path73 from "node:path"; +import { + existsSync as existsSync3, + readFileSync as readFileSync5, + readdirSync as readdirSync3 +} from "node:fs"; +var PACKAGE_MANAGERS = ["pnpm", "npm", "yarn", "bun"]; +var LOCKFILE_TO_PACKAGE_MANAGER = [ + ["pnpm-lock.yaml", "pnpm"], + ["package-lock.json", "npm"], + ["yarn.lock", "yarn"], + ["bun.lockb", "bun"] +]; +var KNIP_CONFIG_FILENAMES = [ + "knip.json", + "knip.jsonc", + "knip.ts", + "knip.js", + "knip.config.ts", + "knip.config.js" +]; +var BuildConfigSchema = external_exports.object({ + packageManager: external_exports.enum(PACKAGE_MANAGERS).optional(), + cwd: external_exports.string().min(1).optional(), + buildCmd: external_exports.string().min(1).optional(), + testCmd: external_exports.string().min(1).optional(), + knipCmd: external_exports.string().min(1).optional() +}).strict(); +function readPackageJson(dir) { + try { + const raw = readFileSync5(path73.join(dir, "package.json"), "utf8"); + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === "object") return parsed; + return null; + } catch { + return null; + } +} +function hasScript(dir, script) { + const pkg = readPackageJson(dir); + const value = pkg?.scripts?.[script]; + return typeof value === "string" && value.trim().length > 0; +} +function scriptInvocation(pm2, script) { + switch (pm2) { + case "npm": + return ["npm", "run", script]; + case "bun": + return ["bun", "run", script]; + case "pnpm": + return ["pnpm", script]; + case "yarn": + return ["yarn", script]; + } +} +function splitCommand(cmd) { + return cmd.trim().split(/\s+/); +} +function detectPackageManagerAt(cwd) { + for (const [lockfile, pm2] of LOCKFILE_TO_PACKAGE_MANAGER) { + if (existsSync3(path73.join(cwd, lockfile))) { + return { pm: pm2, assumed: false }; + } + } + return { pm: "npm", assumed: true }; +} +function hasKnipConfig(cwd) { + return KNIP_CONFIG_FILENAMES.some((name) => existsSync3(path73.join(cwd, name))); +} +function findWorkspaceMemberWithBuild(workspaceDir) { + let packages; + try { + const raw = readFileSync5(path73.join(workspaceDir, "pnpm-workspace.yaml"), "utf8"); + const parsed = (0, import_yaml35.parse)(raw); + packages = parsed?.packages; + } catch { + return null; + } + if (!Array.isArray(packages)) return null; + const candidates = []; + for (const pattern of packages) { + if (typeof pattern !== "string") continue; + const segments = pattern.split("/"); + const globIdx = segments.findIndex((s) => s === "*" || s === "**"); + if (globIdx === -1) { + candidates.push(path73.join(workspaceDir, pattern)); + } else if (segments[globIdx] === "*") { + const parentDir = path73.join(workspaceDir, ...segments.slice(0, globIdx)); + try { + for (const entry of readdirSync3(parentDir, { withFileTypes: true })) { + if (entry.isDirectory()) candidates.push(path73.join(parentDir, entry.name)); + } + } catch { + } + } + } + for (const member of candidates) { + if (hasScript(member, "build")) return member; + } + return null; +} +function findNearestPackageWithBuild(root, maxDepth) { + let frontier = [root]; + for (let depth = 0; depth <= maxDepth; depth++) { + const next = []; + for (const dir of frontier) { + if (hasScript(dir, "build")) return dir; + let entries; + try { + entries = readdirSync3(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const name = entry.name; + if (name === "node_modules" || name === ".git") continue; + next.push(path73.join(dir, name)); + } + } + frontier = next; + } + return null; +} +function findWorkspaceYamlDir(root, maxDepth) { + let frontier = [root]; + for (let depth = 0; depth <= maxDepth; depth++) { + const next = []; + for (const dir of frontier) { + if (existsSync3(path73.join(dir, "pnpm-workspace.yaml"))) return dir; + let entries; + try { + entries = readdirSync3(dir, { withFileTypes: true }); + } catch { + continue; + } + for (const entry of entries) { + if (!entry.isDirectory()) continue; + const name = entry.name; + if (name === "node_modules" || name === ".git") continue; + next.push(path73.join(dir, name)); + } + } + frontier = next; + } + return null; +} +var STRUCTURAL_SEARCH_MAX_DEPTH = 4; +function loadBuildConfigBlock(targetRepoRoot) { + const configPath = path73.join(targetRepoRoot, ".flow", "config.yaml"); + try { + const raw = readFileSync5(configPath, "utf8"); + const parsed = (0, import_yaml35.parse)(raw); + if (parsed && typeof parsed === "object" && "build" in parsed) { + return parsed.build; + } + } catch { + } + return void 0; +} +function resolveProjectToolchain(opts) { + const targetRepoRoot = path73.resolve(opts.targetRepoRoot); + const configPath = path73.join(targetRepoRoot, ".flow", "config.yaml"); + const rawBuildBlock = opts.buildConfigOverride !== void 0 ? opts.buildConfigOverride : loadBuildConfigBlock(targetRepoRoot); + if (rawBuildBlock !== void 0 && rawBuildBlock !== null) { + const parsed = BuildConfigSchema.safeParse(rawBuildBlock); + if (!parsed.success) { + const issue2 = parsed.error.issues[0]; + throw new ToolchainConfigError({ + configPath, + yamlPath: issue2.path.length === 0 ? "build" : `build.${issue2.path.join(".")}`, + detail: issue2.message + }); + } + const cfg = parsed.data; + const structural2 = detectStructuralBuildHome(targetRepoRoot); + const cwd2 = cfg.cwd ? path73.resolve(targetRepoRoot, cfg.cwd) : structural2.cwd; + const pmDetection = detectPackageManagerAt(cwd2); + const pm3 = cfg.packageManager ?? pmDetection.pm; + const pmAssumed = cfg.packageManager ? false : pmDetection.assumed; + const buildCmd2 = cfg.buildCmd ? splitCommand(cfg.buildCmd) : scriptInvocation(pm3, "build"); + const testCmd2 = cfg.testCmd ? splitCommand(cfg.testCmd) : scriptInvocation(pm3, "test"); + let knipCmd2; + if (cfg.knipCmd) { + knipCmd2 = splitCommand(cfg.knipCmd); + } else { + knipCmd2 = resolveKnipCmd(cwd2, pm3); + } + return { packageManager: pm3, cwd: cwd2, buildCmd: buildCmd2, testCmd: testCmd2, knipCmd: knipCmd2, pmAssumed, source: "config" }; + } + const structural = detectStructuralBuildHome(targetRepoRoot); + const cwd = structural.cwd; + const { pm: pm2, assumed } = detectPackageManagerAt(cwd); + const buildCmd = scriptInvocation(pm2, "build"); + const testCmd = scriptInvocation(pm2, "test"); + const knipCmd = resolveKnipCmd(cwd, pm2); + return { + packageManager: pm2, + cwd, + buildCmd, + testCmd, + knipCmd, + pmAssumed: assumed, + source: structural.source + }; +} +function detectStructuralBuildHome(targetRepoRoot) { + const workspaceDir = findWorkspaceYamlDir(targetRepoRoot, STRUCTURAL_SEARCH_MAX_DEPTH); + if (workspaceDir !== null) { + const member = findWorkspaceMemberWithBuild(workspaceDir); + if (member !== null) { + if (hasScript(workspaceDir, "build")) { + return { cwd: workspaceDir, source: "workspace" }; + } + return { cwd: member, source: "workspace" }; + } + if (hasScript(workspaceDir, "build")) { + return { cwd: workspaceDir, source: "workspace" }; + } + } + const pkgDir = findNearestPackageWithBuild(targetRepoRoot, STRUCTURAL_SEARCH_MAX_DEPTH); + if (pkgDir !== null) { + return { cwd: pkgDir, source: "package" }; + } + return { cwd: targetRepoRoot, source: "repo-root" }; +} +function resolveKnipCmd(cwd, pm2) { + if (hasScript(cwd, "knip")) { + return scriptInvocation(pm2, "knip"); + } + if (hasKnipConfig(cwd)) { + switch (pm2) { + case "pnpm": + return ["pnpm", "knip", "--no-progress"]; + case "yarn": + return ["yarn", "knip", "--no-progress"]; + case "bun": + return ["bun", "run", "knip", "--no-progress"]; + case "npm": + return ["npx", "knip", "--no-progress"]; + } + } + return null; +} + +// src/lib/run-project-build.ts var DEFAULT_BUILD_TEST_TIMEOUT_MS = 20 * 60 * 1e3; -var PROJECT_BUILD_COMMAND = "pnpm"; -var PROJECT_BUILD_ARGS = ["build"]; -function deriveProjectBuildCwd(devWorkingDir) { - return path73.join(devWorkingDir, "plugins", "flow"); +function resolveBuildToolchain(devWorkingDir) { + return resolveProjectToolchain({ targetRepoRoot: devWorkingDir }); +} +function normaliseTimedOutExit(result) { + const timedOut = "timedOut" in result && typeof result.timedOut === "boolean" ? result.timedOut : false; + const rawExit = typeof result.exitCode === "number" ? result.exitCode : 1; + const exitCode = timedOut ? rawExit !== 0 ? rawExit : 1 : rawExit; + return { exitCode, timedOut }; } async function runProjectBuild(opts) { const execaImpl = opts.execaImpl ?? execa; - const cwd = deriveProjectBuildCwd(opts.devWorkingDir); + const toolchain = resolveBuildToolchain(opts.devWorkingDir); + const cwd = toolchain.cwd; + const [command, ...args] = toolchain.buildCmd; const timeoutMs = opts.timeoutMs ?? DEFAULT_BUILD_TEST_TIMEOUT_MS; - const result = await execaImpl(PROJECT_BUILD_COMMAND, [...PROJECT_BUILD_ARGS], { + const result = await execaImpl(command, args, { cwd, reject: false, ...timeoutMs > 0 ? { timeout: timeoutMs } : {} }); - const timedOut = "timedOut" in result && typeof result.timedOut === "boolean" ? result.timedOut : false; + const { exitCode, timedOut } = normaliseTimedOutExit(result); return { - exitCode: timedOut ? typeof result.exitCode === "number" && result.exitCode !== 0 ? result.exitCode : 1 : typeof result.exitCode === "number" ? result.exitCode : 1, + exitCode, stdout: typeof result.stdout === "string" ? result.stdout : "", stderr: typeof result.stderr === "string" ? result.stderr : "", cwd, - commandLine: `${PROJECT_BUILD_COMMAND} ${PROJECT_BUILD_ARGS.join(" ")}`, + commandLine: toolchain.buildCmd.join(" "), timedOut, timeoutMs }; } -var PROJECT_TEST_COMMAND = "pnpm"; -var PROJECT_TEST_ARGS = ["test"]; async function runProjectTests(opts) { const execaImpl = opts.execaImpl ?? execa; - const cwd = deriveProjectBuildCwd(opts.devWorkingDir); + const toolchain = resolveBuildToolchain(opts.devWorkingDir); + const cwd = toolchain.cwd; + const [command, ...args] = toolchain.testCmd; const timeoutMs = opts.timeoutMs ?? DEFAULT_BUILD_TEST_TIMEOUT_MS; - const result = await execaImpl(PROJECT_TEST_COMMAND, [...PROJECT_TEST_ARGS], { + const result = await execaImpl(command, args, { cwd, reject: false, ...timeoutMs > 0 ? { timeout: timeoutMs } : {} }); - const timedOut = "timedOut" in result && typeof result.timedOut === "boolean" ? result.timedOut : false; + const { exitCode, timedOut } = normaliseTimedOutExit(result); return { - exitCode: timedOut ? typeof result.exitCode === "number" && result.exitCode !== 0 ? result.exitCode : 1 : typeof result.exitCode === "number" ? result.exitCode : 1, + exitCode, stdout: typeof result.stdout === "string" ? result.stdout : "", stderr: typeof result.stderr === "string" ? result.stderr : "", cwd, - commandLine: `${PROJECT_TEST_COMMAND} ${PROJECT_TEST_ARGS.join(" ")}`, + commandLine: toolchain.testCmd.join(" "), timedOut, timeoutMs }; } -var PROJECT_BLOAT_COMMAND = "pnpm"; -var PROJECT_BLOAT_ARGS = ["knip"]; async function runProjectBloatCheck(opts) { const execaImpl = opts.execaImpl ?? execa; - const cwd = deriveProjectBuildCwd(opts.devWorkingDir); - const result = await execaImpl(PROJECT_BLOAT_COMMAND, [...PROJECT_BLOAT_ARGS], { + const toolchain = resolveBuildToolchain(opts.devWorkingDir); + const cwd = toolchain.cwd; + if (toolchain.knipCmd === null) { + return { + exitCode: 0, + stdout: "", + stderr: "", + cwd, + commandLine: "(no dead-code check \u2014 bloat gate skipped)", + skipped: true + }; + } + const [command, ...args] = toolchain.knipCmd; + const result = await execaImpl(command, args, { cwd, reject: false }); @@ -55837,7 +56112,8 @@ async function runProjectBloatCheck(opts) { stdout: typeof result.stdout === "string" ? result.stdout : "", stderr: typeof result.stderr === "string" ? result.stderr : "", cwd, - commandLine: `${PROJECT_BLOAT_COMMAND} ${PROJECT_BLOAT_ARGS.join(" ")}` + commandLine: toolchain.knipCmd.join(" "), + skipped: false }; } @@ -55950,7 +56226,7 @@ async function runDevTerminalAction(opts) { role: ROLE, session_id: sessionUlid, story_id: ref, - expected: "pnpm build exits 0 (no type errors)", + expected: `${buildResult.commandLine} exits 0 (no type errors)`, observed: buildObserved }); throw new PrePrBuildFailedError({ @@ -55976,7 +56252,7 @@ async function runDevTerminalAction(opts) { role: ROLE, session_id: sessionUlid, story_id: ref, - expected: "pnpm test exits 0 (no failing tests)", + expected: `${testResult.commandLine} exits 0 (no failing tests)`, observed: testObserved }); throw new PrePrTestFailedError({ @@ -56000,7 +56276,7 @@ async function runDevTerminalAction(opts) { role: ROLE, session_id: sessionUlid, story_id: ref, - expected: "pnpm knip exits 0 (no dead code)", + expected: `${bloatResult.commandLine} exits 0 (no dead code)`, observed: `pre-PR bloat gate failed (exit ${bloatResult.exitCode})` }); throw new PrePrBloatFailedError({ @@ -56151,6 +56427,7 @@ async function runDevTerminalAction(opts) { // src/tools/run-reviewer-session.ts import * as path76 from "node:path"; import * as fs58 from "node:fs/promises"; +import { existsSync as existsSync4 } from "node:fs"; // src/lib/materialise-pr-branch-worktree.ts import * as path75 from "node:path"; @@ -56386,6 +56663,24 @@ function countExecutedTests(output) { const failed = /(\d+)\s+failed/.exec(seg); return (passed ? Number(passed[1]) : 0) + (failed ? Number(failed[1]) : 0); } +function resolveVitestInvocation(packageRoot) { + const localBin = path76.join(packageRoot, "node_modules", ".bin", "vitest"); + if (existsSync4(localBin)) { + return { command: localBin, args: [] }; + } + const pm2 = resolveProjectToolchain({ targetRepoRoot: packageRoot }).packageManager; + switch (pm2) { + case "npm": + return { command: "npm", args: ["exec", "vitest"] }; + case "yarn": + return { command: "yarn", args: ["vitest"] }; + case "bun": + return { command: "bun", args: ["x", "vitest"] }; + case "pnpm": + default: + return { command: "pnpm", args: ["vitest"] }; + } +} async function runVitestCheck(index, tag, testNameFilter, testFilePath, checkRoot, execaImpl) { const testFilePathAbs = path76.resolve(checkRoot, testFilePath); const pkgRoot = findPackageRoot({ testFilePathAbs, checkRoot }); @@ -56405,8 +56700,12 @@ async function runVitestCheck(index, tag, testNameFilter, testFilePath, checkRoo const testFilePathAbs2 = path76.resolve(checkRoot, testFilePath); const relativeToPackage = path76.relative(pkgRoot.packageRoot, testFilePathAbs2); const looksLikeFilePath = (testFilePath.includes("/") || testFilePath.includes("\\")) && !relativeToPackage.startsWith("..") && relativeToPackage !== testFilePath; - const vitestArgs = looksLikeFilePath ? ["vitest", "--run", relativeToPackage] : ["vitest", "--run", "-t", testNameFilter]; - const result = await execaImpl("pnpm", vitestArgs, { + const vitestRunArgs = looksLikeFilePath ? ["--run", relativeToPackage] : ["--run", "-t", testNameFilter]; + const { command: vitestCommand, args: vitestPrefixArgs } = resolveVitestInvocation( + pkgRoot.packageRoot + ); + const vitestArgs = [...vitestPrefixArgs, ...vitestRunArgs]; + const result = await execaImpl(vitestCommand, vitestArgs, { cwd: pkgRoot.packageRoot, reject: false, timeout: VITEST_TIMEOUT_MS @@ -57395,7 +57694,7 @@ async function recordSkillInvoke(opts) { // src/tools/run-auto-merge-gate.ts import * as path79 from "node:path"; import { promises as fs60 } from "node:fs"; -var import_yaml35 = __toESM(require_dist2(), 1); +var import_yaml36 = __toESM(require_dist2(), 1); // src/lib/auto-merge-gate.ts function decideAutoMerge(input) { @@ -57458,7 +57757,7 @@ async function loadWorkspaceConfig(targetRepoRoot) { } throw err; } - const parsed = (0, import_yaml35.parse)(raw); + const parsed = (0, import_yaml36.parse)(raw); if (parsed === null || parsed === void 0 || typeof parsed !== "object" || !("plugin" in parsed)) { return PluginSettingsSchema.parse({}); } @@ -57513,7 +57812,7 @@ async function waitForCiGreen(opts) { return { kind: "ci-status-unreadable", reason }; } if (Date.now() - start >= CI_GATE_TIMEOUT_MS) return "pending-timeout"; - await new Promise((resolve22) => setTimeout(resolve22, CI_GATE_POLL_INTERVAL_MS)); + await new Promise((resolve23) => setTimeout(resolve23, CI_GATE_POLL_INTERVAL_MS)); continue; } let rollup = []; @@ -57530,7 +57829,7 @@ async function waitForCiGreen(opts) { if (state === "green") return "green"; if (state === "failed") return "failed"; if (Date.now() - start >= CI_GATE_TIMEOUT_MS) return "pending-timeout"; - await new Promise((resolve22) => setTimeout(resolve22, CI_GATE_POLL_INTERVAL_MS)); + await new Promise((resolve23) => setTimeout(resolve23, CI_GATE_POLL_INTERVAL_MS)); } } async function runAutoMergeGate(opts) { @@ -57783,7 +58082,7 @@ async function createSmokeScratchRepo(opts) { } // src/tools/scan-orphaned-in-progress.ts -var import_yaml36 = __toESM(require_dist2(), 1); +var import_yaml37 = __toESM(require_dist2(), 1); import { promises as fs62 } from "node:fs"; import * as path81 from "node:path"; async function scanOrphanedInProgress(opts) { @@ -57814,7 +58113,7 @@ async function scanOrphanedInProgress(opts) { } throw err; } - const parsed = (0, import_yaml36.parse)(raw); + const parsed = (0, import_yaml37.parse)(raw); const manifest = parseExecutionManifest(parsed, { absPath }); if (!manifest.claimed_by) { continue; @@ -58269,7 +58568,7 @@ async function dismissMaintainerFeedback(opts) { } // src/tools/resolve-lens-roles.ts -var import_yaml37 = __toESM(require_dist2(), 1); +var import_yaml38 = __toESM(require_dist2(), 1); import { promises as fs66 } from "node:fs"; import * as path87 from "node:path"; async function resolveLensRoles(opts) { @@ -58333,7 +58632,7 @@ async function readRoleCapabilities(teamDir, roleId) { const frontmatterRaw = normalised.slice(4, closeIdx); let parsedYaml; try { - parsedYaml = (0, import_yaml37.parse)(frontmatterRaw); + parsedYaml = (0, import_yaml38.parse)(frontmatterRaw); } catch { return { id: roleId, reviewLenses: void 0 }; } @@ -58356,7 +58655,7 @@ function isEnoent19(err) { } // src/tools/resolve-run-slot.ts -var import_yaml38 = __toESM(require_dist2(), 1); +var import_yaml39 = __toESM(require_dist2(), 1); import { promises as fs67 } from "node:fs"; import * as path88 from "node:path"; var RUN_JOB_GENERALISTS = { @@ -58431,7 +58730,7 @@ async function readRunJobs(teamDir, roleId) { const frontmatterRaw = normalised.slice(4, closeIdx); let parsedYaml; try { - parsedYaml = (0, import_yaml38.parse)(frontmatterRaw); + parsedYaml = (0, import_yaml39.parse)(frontmatterRaw); } catch { return void 0; } @@ -58453,7 +58752,7 @@ function isEnoent20(err) { // src/tools/recall-lesson.ts import * as path89 from "node:path"; import { promises as fs68 } from "node:fs"; -var import_yaml39 = __toESM(require_dist2(), 1); +var import_yaml40 = __toESM(require_dist2(), 1); var _LESSON_BLOCK_PREFIX = LESSON_BLOCK_PREFIX; var _LESSON_BLOCK_SUFFIX = LESSON_BLOCK_SUFFIX; var TOOL_NAME9 = "recallLesson"; @@ -58605,7 +58904,7 @@ function reconstructPersonaFile4(parsed, newKnowledgeBody) { hired_at: parsed.hired_at, catalogue_version: parsed.catalogue_version }; - const yamlBlock = (0, import_yaml39.stringify)(frontmatter).replace(/\n$/, ""); + const yamlBlock = (0, import_yaml40.stringify)(frontmatter).replace(/\n$/, ""); const h1 = parsed.role.split("-").map( (part) => part.length === 0 ? part : part[0].toUpperCase() + part.slice(1) ).join(" "); @@ -58700,7 +58999,7 @@ function resolveJudgePlan(opts) { } // src/tools/resolve-build-plan.ts -var import_yaml40 = __toESM(require_dist2(), 1); +var import_yaml41 = __toESM(require_dist2(), 1); import { readFile as readFile3 } from "node:fs/promises"; var FAST_LANE_MODEL = "haiku"; var FULL_LANE_MODEL = "sonnet"; @@ -58723,7 +59022,7 @@ var BuildPlanSchema = external_exports.object({ async function readLaneFromManifest(manifestPath) { try { const raw = await readFile3(manifestPath, "utf8"); - const parsed = (0, import_yaml40.parse)(raw); + const parsed = (0, import_yaml41.parse)(raw); const lane = parsed?.lane; if (lane === "fast" || lane === "full") return lane; return void 0; @@ -58752,13 +59051,13 @@ async function resolveBuildPlan(opts) { } // src/tools/summarise-retro-proposal.ts -var import_yaml41 = __toESM(require_dist2(), 1); +var import_yaml42 = __toESM(require_dist2(), 1); import { promises as fs69 } from "node:fs"; async function summariseRetroProposal(opts) { const { absPath } = opts; const raw = await fs69.readFile(absPath, "utf8"); const { frontmatterRaw } = splitFrontmatter(raw, absPath); - const parsedYaml = (0, import_yaml41.parse)(frontmatterRaw); + const parsedYaml = (0, import_yaml42.parse)(frontmatterRaw); const file2 = parseRetroProposalFile(parsedYaml); const proposals = file2.proposals.map((p) => ({ type: p.type, @@ -58777,7 +59076,7 @@ async function summariseRetroProposal(opts) { // src/tools/discard-draft.ts import { promises as fs70 } from "node:fs"; import * as path90 from "node:path"; -var import_yaml42 = __toESM(require_dist2(), 1); +var import_yaml43 = __toESM(require_dist2(), 1); var DiscardDraftInputSchema = external_exports.object({ targetRepoRoot: external_exports.string().min(1), ref: external_exports.string().min(1) @@ -58810,7 +59109,7 @@ async function discardDraft(rawInput) { }); } const rawText = await fs70.readFile(foundAbsPath, "utf8"); - const parsed = (0, import_yaml42.parse)(rawText); + const parsed = (0, import_yaml43.parse)(rawText); const manifest = parseExecutionManifest(parsed, { absPath: foundAbsPath }); if (manifest.withdrawn === true) { throw new NotAnEligibleDraftError({ ref, foundState, reason: "withdrawn" }); @@ -58849,7 +59148,7 @@ function isEnoent21(err) { // src/tools/requeue-blocked-story.ts import { promises as fs71 } from "node:fs"; import * as path91 from "node:path"; -var import_yaml43 = __toESM(require_dist2(), 1); +var import_yaml44 = __toESM(require_dist2(), 1); var RequeueBlockedStoryInputSchema = external_exports.object({ targetRepoRoot: external_exports.string().min(1), ref: external_exports.string().min(1) @@ -58880,7 +59179,7 @@ async function requeueBlockedStory(rawInput) { throw new NotABlockedStoryError({ ref, foundState }); } const rawText = await fs71.readFile(foundAbsPath, "utf8"); - const parsed = (0, import_yaml43.parse)(rawText); + const parsed = (0, import_yaml44.parse)(rawText); const manifest = parseExecutionManifest(parsed, { absPath: foundAbsPath }); const moveResult = await moveBetweenStates({ targetRepoRoot, @@ -58901,7 +59200,7 @@ async function requeueBlockedStory(rawInput) { const reparsed = parseExecutionManifest(updatedManifest, { absPath: moveResult.absToPath }); - const yamlText = (0, import_yaml43.stringify)( + const yamlText = (0, import_yaml44.stringify)( stripUndefined6(reparsed), { lineWidth: 0 } ); @@ -58915,7 +59214,7 @@ async function requeueBlockedStory(rawInput) { } // src/tools/help-advisor.ts -var import_yaml44 = __toESM(require_dist2(), 1); +var import_yaml45 = __toESM(require_dist2(), 1); import { promises as fs72 } from "node:fs"; import * as path92 from "node:path"; async function hasHiredTeam(targetRepoRoot) { @@ -58975,7 +59274,7 @@ async function readBacklogSummary(targetRepoRoot) { } throw err; } - const parsed = (0, import_yaml44.parse)(raw); + const parsed = (0, import_yaml45.parse)(raw); const manifest = parseExecutionManifest(parsed, { absPath }); if (!isClaimable(manifest)) { continue; @@ -59078,7 +59377,7 @@ function isEnoent22(err) { } // src/tools/match-story-specialist.ts -var import_yaml45 = __toESM(require_dist2(), 1); +var import_yaml46 = __toESM(require_dist2(), 1); import { promises as fs73 } from "node:fs"; async function matchStorySpecialist(opts) { const { targetRepoRoot, manifestPath } = opts; @@ -59090,7 +59389,7 @@ async function matchStorySpecialist(opts) { } let manifest; try { - const parsed = (0, import_yaml45.parse)(raw); + const parsed = (0, import_yaml46.parse)(raw); manifest = parseExecutionManifest(parsed, { absPath: manifestPath }); } catch { return { role: null, domain: null }; @@ -59107,7 +59406,7 @@ async function matchStorySpecialist(opts) { } // src/tools/record-specialist-engagement.ts -var import_yaml46 = __toESM(require_dist2(), 1); +var import_yaml47 = __toESM(require_dist2(), 1); import { promises as fs74 } from "node:fs"; import * as path93 from "node:path"; function stripUndefined7(obj) { @@ -59138,7 +59437,7 @@ async function recordSpecialistEngagement(opts) { } throw err; } - const parsed = (0, import_yaml46.parse)(rawText); + const parsed = (0, import_yaml47.parse)(rawText); const manifest = parseExecutionManifest(parsed, { absPath }); if (manifest.claimed_by !== sessionUlid) { throw new Error( @@ -59147,7 +59446,7 @@ async function recordSpecialistEngagement(opts) { } const updated = { ...manifest, engaged_specialist: specialistRole }; const reparsed = parseExecutionManifest(updated, { absPath }); - const yamlText = (0, import_yaml46.stringify)( + const yamlText = (0, import_yaml47.stringify)( stripUndefined7(reparsed), { lineWidth: 0 } ); diff --git a/plugins/flow/mcp-server/src/__tests__/dev-pre-pr-gate.test.ts b/plugins/flow/mcp-server/src/__tests__/dev-pre-pr-gate.test.ts index 6039744f..a28099ed 100644 --- a/plugins/flow/mcp-server/src/__tests__/dev-pre-pr-gate.test.ts +++ b/plugins/flow/mcp-server/src/__tests__/dev-pre-pr-gate.test.ts @@ -87,6 +87,24 @@ async function setupRepo(): Promise { const srcDir = path.join(repoRoot, "src"); await fs.mkdir(srcDir, { recursive: true }); await atomicWriteFile(path.join(srcDir, "index.ts"), "export const x = 1;\n"); + + // Flow-SHAPED build home so the structural toolchain resolver (Story + // native:01KVTB3Z) resolves cwd=plugins/flow + packageManager=pnpm + a knip + // script (so the bloat gate runs) PURELY from on-disk structure — no + // `.flow/config.yaml`. Mirrors the real Flow repo's dogfood path. + const flowDir = path.join(repoRoot, "plugins", "flow"); + await fs.mkdir(flowDir, { recursive: true }); + await atomicWriteFile( + path.join(flowDir, "package.json"), + JSON.stringify( + { name: "flow", private: true, scripts: { build: "pnpm -r build", test: "pnpm -r test", knip: "knip --no-progress" } }, + null, + 2, + ), + ); + await atomicWriteFile(path.join(flowDir, "pnpm-workspace.yaml"), "packages:\n - mcp-server\n"); + await atomicWriteFile(path.join(flowDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + await realExeca("git", ["-C", repoRoot, "add", "."]); await realExeca("git", ["-C", repoRoot, "commit", "-m", "chore: initial commit"]); diff --git a/plugins/flow/mcp-server/src/__tests__/inline-ac-extraction.test.ts b/plugins/flow/mcp-server/src/__tests__/inline-ac-extraction.test.ts index baec7329..b57b00fa 100644 --- a/plugins/flow/mcp-server/src/__tests__/inline-ac-extraction.test.ts +++ b/plugins/flow/mcp-server/src/__tests__/inline-ac-extraction.test.ts @@ -106,6 +106,17 @@ async function setupRepo(): Promise { const srcDir = path.join(repoRoot, "src"); await fs.mkdir(srcDir, { recursive: true }); await atomicWriteFile(path.join(srcDir, "index.ts"), "export const x = 1;\n"); + // Flow-shaped build home (Story native:01KVTB3Z) so the structural toolchain + // resolver lands on plugins/flow + pnpm. NO `knip` script here, so the bloat + // gate is SKIPPED (knipCmd null) — this fixture's stub only handles build/test. + const flowDir = path.join(repoRoot, "plugins", "flow"); + await fs.mkdir(flowDir, { recursive: true }); + await atomicWriteFile( + path.join(flowDir, "package.json"), + JSON.stringify({ name: "flow", private: true, scripts: { build: "pnpm -r build", test: "pnpm -r test" } }, null, 2), + ); + await atomicWriteFile(path.join(flowDir, "pnpm-workspace.yaml"), "packages:\n - mcp-server\n"); + await atomicWriteFile(path.join(flowDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); await realExeca("git", ["-C", repoRoot, "add", "."]); await realExeca("git", ["-C", repoRoot, "commit", "-m", "chore: initial commit"]); diff --git a/plugins/flow/mcp-server/src/errors.ts b/plugins/flow/mcp-server/src/errors.ts index 98402769..491230ec 100644 --- a/plugins/flow/mcp-server/src/errors.ts +++ b/plugins/flow/mcp-server/src/errors.ts @@ -52,6 +52,32 @@ export class InvalidWorkspaceConfigError extends DomainError { } } +/** + * `.flow/config.yaml` carries a `build:` block (the toolchain escape hatch) that + * failed schema validation — e.g. an unrecognised `packageManager` value, or a + * non-string command field. The resolver surfaces this TYPED error rather than + * silently falling back to structural detection, so a deliberate-but-malformed + * override is loud (the operator fixes the block) instead of being quietly + * ignored. (Story native:01KVTB3Z AC3.) + */ +export class ToolchainConfigError extends DomainError { + readonly configPath: string; + readonly yamlPath: string; + readonly detail: string; + + constructor(opts: { configPath: string; yamlPath: string; detail: string }) { + super( + `${opts.configPath} has an invalid 'build:' block at '${opts.yamlPath}': ${opts.detail}. ` + + `Fix the build override (or remove it to fall back to structural toolchain ` + + `detection). Recognised packageManager values: pnpm | npm | yarn | bun. ` + + `See mcp-server/src/lib/resolve-project-toolchain.ts.`, + ); + this.configPath = opts.configPath; + this.yamlPath = opts.yamlPath; + this.detail = opts.detail; + } +} + /** * `.flow/config.yaml` declares an `adapter:` name that does not match * any registered adapter. The user must either install the matching diff --git a/plugins/flow/mcp-server/src/lib/__tests__/flow-shaped-build-home.ts b/plugins/flow/mcp-server/src/lib/__tests__/flow-shaped-build-home.ts new file mode 100644 index 00000000..f5c337f9 --- /dev/null +++ b/plugins/flow/mcp-server/src/lib/__tests__/flow-shaped-build-home.ts @@ -0,0 +1,54 @@ +/** + * Test helper: seed a Flow-SHAPED build home under a tmpdir repo root. + * + * Story native:01KVTB3Z made the dev pre-PR gates and the reviewer derive their + * build/test/bloat toolchain from `resolveProjectToolchain`, which detects the + * build home STRUCTURALLY (a pnpm-workspace.yaml whose root package.json owns a + * `build` script → `plugins/flow`, pnpm). Tests that previously relied on the + * hardcoded `/plugins/flow` + `pnpm` assumption must now seed that on-disk + * structure so the resolver lands on the same place — mirroring the real Flow + * repo's dogfood path on a clean worktree (where `.flow/config.yaml`, being + * gitignored, is absent). + * + * Writes: + * /plugins/flow/package.json (scripts: build/test/knip) + * /plugins/flow/pnpm-workspace.yaml (packages: [mcp-server]) + * /plugins/flow/pnpm-lock.yaml (so PM detects pnpm) + */ + +import * as path from "node:path"; +import { promises as fs } from "node:fs"; + +/** Seed the Flow-shaped build home at `/plugins/flow`. */ +export async function seedFlowShapedBuildHome(repoRoot: string): Promise { + const flowDir = path.join(repoRoot, "plugins", "flow"); + await fs.mkdir(flowDir, { recursive: true }); + await fs.writeFile( + path.join(flowDir, "package.json"), + JSON.stringify( + { + name: "flow", + private: true, + scripts: { + build: "pnpm -r build", + test: "pnpm -r test", + knip: "knip --no-progress", + }, + }, + null, + 2, + ), + "utf8", + ); + await fs.writeFile( + path.join(flowDir, "pnpm-workspace.yaml"), + "packages:\n - mcp-server\n", + "utf8", + ); + await fs.writeFile( + path.join(flowDir, "pnpm-lock.yaml"), + "lockfileVersion: '9.0'\n", + "utf8", + ); + return flowDir; +} diff --git a/plugins/flow/mcp-server/src/lib/__tests__/resolve-project-toolchain.test.ts b/plugins/flow/mcp-server/src/lib/__tests__/resolve-project-toolchain.test.ts new file mode 100644 index 00000000..e80060f1 --- /dev/null +++ b/plugins/flow/mcp-server/src/lib/__tests__/resolve-project-toolchain.test.ts @@ -0,0 +1,369 @@ +/** + * `resolveProjectToolchain` — Story native:01KVTB3Z. + * + * The ONE resolver consumed by both the dev pre-PR gates and the reviewer's + * vitest runner. These tests exercise the full contract: + * + * - AC1 (external npm repo): root package.json with build+test scripts + + * package-lock.json, no plugins/ dir, no knip → npm + repo root + knip skipped. + * - AC2 (config override wins): a `build:` block overrides both structural + * detection and lockfile auto-detection. + * - AC3 (malformed config → typed error): an unrecognised packageManager value + * raises ToolchainConfigError rather than falling back silently. + * - AC4 (THE critical one — dogfood path): a Flow-SHAPED fixture (pnpm-workspace + * .yaml + a member package.json with a build script + pnpm-lock.yaml, and NO + * `.flow/config.yaml`) resolves to pnpm + the workspace-member cwd PURELY from + * structure. + * - Lockfile → package-manager mapping (each lockfile resolves to its manager). + * - knipCmd null behaviour (no knip script + no knip config + no override). + * + * `vitest: plugins/flow/mcp-server/src/lib/__tests__/resolve-project-toolchain.test.ts` + */ + +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + resolveProjectToolchain, + LOCKFILE_TO_PACKAGE_MANAGER, + type PackageManager, +} from "../resolve-project-toolchain.js"; +import { ToolchainConfigError } from "../../errors.js"; + +function write(p: string, content: string): void { + mkdirSync(path.dirname(p), { recursive: true }); + writeFileSync(p, content, "utf8"); +} + +function writePkg(dir: string, scripts: Record): void { + write( + path.join(dir, "package.json"), + JSON.stringify({ name: path.basename(dir), private: true, scripts }, null, 2), + ); +} + +let tmp: string; + +beforeEach(() => { + tmp = mkdtempSync(path.join(os.tmpdir(), "resolve-toolchain-")); +}); + +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +// --------------------------------------------------------------------------- +// AC4 — THE critical one: structural detection alone yields pnpm + plugins/flow. +// --------------------------------------------------------------------------- + +describe("AC4 — Flow-shaped repo resolves pnpm + plugins/flow PURELY from structure (no config)", () => { + /** + * Mirror the real Flow repo's ON-DISK truth: + * /plugins/flow/pnpm-workspace.yaml (packages: [mcp-server]) + * /plugins/flow/pnpm-lock.yaml + * /plugins/flow/package.json (scripts.build = "pnpm -r build", knip) + * NO root package.json, NO root pnpm-workspace.yaml, NO `.flow/config.yaml`. + */ + function seedFlowRepo(root: string): string { + const flowDir = path.join(root, "plugins", "flow"); + write(path.join(flowDir, "pnpm-workspace.yaml"), "packages:\n - mcp-server\n"); + write(path.join(flowDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + writePkg(flowDir, { build: "pnpm -r build", test: "pnpm -r test", knip: "knip --no-progress" }); + // A member package (no build script of its own — the workspace ROOT owns it). + writePkg(path.join(flowDir, "mcp-server"), { typecheck: "tsc" }); + return flowDir; + } + + it("returns packageManager=pnpm and cwd=plugins/flow with NO .flow/config.yaml present", () => { + const flowDir = seedFlowRepo(tmp); + + const result = resolveProjectToolchain({ targetRepoRoot: tmp }); + + expect(result.packageManager).toBe("pnpm"); + expect(result.cwd).toBe(flowDir); + expect(result.buildCmd).toEqual(["pnpm", "build"]); + expect(result.testCmd).toEqual(["pnpm", "test"]); + // The plugins/flow package.json carries a `knip` script → knipCmd present. + expect(result.knipCmd).toEqual(["pnpm", "knip"]); + // The package manager came from a lockfile, not an assumption. + expect(result.pmAssumed).toBe(false); + expect(result.source).toBe("workspace"); + }); + + it("does NOT consult .flow/config.yaml for the dogfood path (gitignored-config correction)", () => { + // Even when a SABOTAGING build block exists, the dogfood path must not break: + // but to PROVE structural detection stands alone, we assert the result with + // NO config present (the worktree/clean-checkout reality). A config block is + // the escape hatch tested separately in AC2. + const flowDir = seedFlowRepo(tmp); + // No .flow/config.yaml written at all. + const result = resolveProjectToolchain({ targetRepoRoot: tmp }); + expect(result.cwd).toBe(flowDir); + expect(result.packageManager).toBe("pnpm"); + }); +}); + +// --------------------------------------------------------------------------- +// AC1 — external npm repo: npm + repo root, knip skipped. +// --------------------------------------------------------------------------- + +describe("AC1 — external npm repo resolves npm + repo root, knip skipped", () => { + it("root package.json with build+test + package-lock.json, no plugins/, no knip", () => { + writePkg(tmp, { build: "tsc", test: "node --test" }); + write(path.join(tmp, "package-lock.json"), "{}\n"); + + const result = resolveProjectToolchain({ targetRepoRoot: tmp }); + + expect(result.packageManager).toBe("npm"); + expect(result.cwd).toBe(tmp); + expect(result.buildCmd).toEqual(["npm", "run", "build"]); + expect(result.testCmd).toEqual(["npm", "run", "test"]); + // No knip script and no knip config → knipCmd is null (bloat gate skipped). + expect(result.knipCmd).toBeNull(); + expect(result.pmAssumed).toBe(false); + expect(result.source).toBe("package"); + }); +}); + +// --------------------------------------------------------------------------- +// AC2 — config override wins over structural + lockfile detection. +// --------------------------------------------------------------------------- + +describe("AC2 — a build: config block overrides structural + lockfile detection", () => { + it("configured packageManager/cwd/buildCmd/testCmd/knipCmd all win", () => { + // Seed a Flow-shaped repo (which would structurally resolve pnpm + plugins/flow) + // but override every field via config. + const flowDir = path.join(tmp, "plugins", "flow"); + write(path.join(flowDir, "pnpm-workspace.yaml"), "packages:\n - mcp-server\n"); + write(path.join(flowDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + writePkg(flowDir, { build: "pnpm -r build", test: "pnpm -r test" }); + + // A custom build home with a yarn lockfile (so we can prove config wins over + // the lockfile detection too). + const customDir = path.join(tmp, "custom-home"); + write(path.join(customDir, "yarn.lock"), ""); + writePkg(customDir, { build: "make build" }); + + const result = resolveProjectToolchain({ + targetRepoRoot: tmp, + buildConfigOverride: { + packageManager: "bun", + cwd: "custom-home", + buildCmd: "make build", + testCmd: "make test", + knipCmd: "knip --strict", + }, + }); + + expect(result.source).toBe("config"); + expect(result.packageManager).toBe("bun"); // config wins over the yarn.lock at customDir + expect(result.cwd).toBe(customDir); + expect(result.buildCmd).toEqual(["make", "build"]); + expect(result.testCmd).toEqual(["make", "test"]); + expect(result.knipCmd).toEqual(["knip", "--strict"]); + }); + + it("a config block that overrides ONLY packageManager still derives the structural cwd", () => { + const flowDir = path.join(tmp, "plugins", "flow"); + write(path.join(flowDir, "pnpm-workspace.yaml"), "packages:\n - mcp-server\n"); + writePkg(flowDir, { build: "pnpm -r build", test: "pnpm -r test" }); + + const result = resolveProjectToolchain({ + targetRepoRoot: tmp, + buildConfigOverride: { packageManager: "yarn" }, + }); + + expect(result.packageManager).toBe("yarn"); + expect(result.cwd).toBe(flowDir); // structural cwd still applies + expect(result.buildCmd).toEqual(["yarn", "build"]); + }); + + it("reads the build: block from .flow/config.yaml on disk when no override passed", () => { + writePkg(tmp, { build: "tsc", test: "node --test" }); + write(path.join(tmp, "package-lock.json"), "{}\n"); + write( + path.join(tmp, ".flow", "config.yaml"), + "adapter: native\nadapter_config: {}\nbuild:\n packageManager: pnpm\n buildCmd: pnpm run compile\n", + ); + + const result = resolveProjectToolchain({ targetRepoRoot: tmp }); + + expect(result.source).toBe("config"); + expect(result.packageManager).toBe("pnpm"); // config wins over the package-lock.json + expect(result.buildCmd).toEqual(["pnpm", "run", "compile"]); + // testCmd not overridden → derived from the (config-resolved) manager. + expect(result.testCmd).toEqual(["pnpm", "test"]); + }); +}); + +// --------------------------------------------------------------------------- +// AC3 — malformed config → typed error (no silent fallback). +// --------------------------------------------------------------------------- + +describe("AC3 — a malformed build: block surfaces a typed ToolchainConfigError", () => { + it("an unrecognised packageManager value throws ToolchainConfigError", () => { + writePkg(tmp, { build: "tsc" }); + + expect(() => + resolveProjectToolchain({ + targetRepoRoot: tmp, + buildConfigOverride: { packageManager: "rush" }, + }), + ).toThrow(ToolchainConfigError); + }); + + it("an unknown extra field throws ToolchainConfigError (strict schema)", () => { + writePkg(tmp, { build: "tsc" }); + + let caught: unknown; + try { + resolveProjectToolchain({ + targetRepoRoot: tmp, + buildConfigOverride: { packageManager: "pnpm", bogusField: 1 }, + }); + } catch (err) { + caught = err; + } + expect(caught).toBeInstanceOf(ToolchainConfigError); + expect((caught as ToolchainConfigError).yamlPath).toContain("build"); + }); + + it("does NOT silently fall back to structural detection on a malformed block", () => { + // A Flow-shaped repo would structurally resolve pnpm — but a malformed config + // must throw, not quietly use the structural result. + const flowDir = path.join(tmp, "plugins", "flow"); + write(path.join(flowDir, "pnpm-workspace.yaml"), "packages:\n - mcp-server\n"); + writePkg(flowDir, { build: "pnpm -r build" }); + + expect(() => + resolveProjectToolchain({ + targetRepoRoot: tmp, + buildConfigOverride: { packageManager: "not-a-manager" }, + }), + ).toThrow(ToolchainConfigError); + }); +}); + +// --------------------------------------------------------------------------- +// Lockfile → package manager mapping. +// --------------------------------------------------------------------------- + +describe("package-manager detection — each lockfile resolves to its manager", () => { + const cases: Array<[string, PackageManager]> = [ + ["pnpm-lock.yaml", "pnpm"], + ["package-lock.json", "npm"], + ["yarn.lock", "yarn"], + ["bun.lockb", "bun"], + ]; + + for (const [lockfile, expectedPm] of cases) { + it(`${lockfile} → ${expectedPm}`, () => { + writePkg(tmp, { build: "tsc", test: "vitest run" }); + write(path.join(tmp, lockfile), ""); + + const result = resolveProjectToolchain({ targetRepoRoot: tmp }); + expect(result.packageManager).toBe(expectedPm); + expect(result.pmAssumed).toBe(false); + }); + } + + it("no lockfile → npm assumed (pmAssumed: true)", () => { + writePkg(tmp, { build: "tsc", test: "vitest run" }); + + const result = resolveProjectToolchain({ targetRepoRoot: tmp }); + expect(result.packageManager).toBe("npm"); + expect(result.pmAssumed).toBe(true); + }); + + it("the exported LOCKFILE_TO_PACKAGE_MANAGER table covers all four managers", () => { + const managers = LOCKFILE_TO_PACKAGE_MANAGER.map(([, pm]) => pm); + expect(new Set(managers)).toEqual(new Set(["pnpm", "npm", "yarn", "bun"])); + }); +}); + +// --------------------------------------------------------------------------- +// knipCmd resolution. +// --------------------------------------------------------------------------- + +describe("knipCmd resolution — null when no dead-code check applies", () => { + it("knipCmd is null with no knip script, no knip config, no override", () => { + writePkg(tmp, { build: "tsc", test: "node --test" }); + write(path.join(tmp, "package-lock.json"), "{}\n"); + + const result = resolveProjectToolchain({ targetRepoRoot: tmp }); + expect(result.knipCmd).toBeNull(); + }); + + it("knipCmd present when the package.json has a knip script", () => { + writePkg(tmp, { build: "tsc", knip: "knip --no-progress" }); + write(path.join(tmp, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + + const result = resolveProjectToolchain({ targetRepoRoot: tmp }); + expect(result.knipCmd).toEqual(["pnpm", "knip"]); + }); + + it("knipCmd present (via npx) when a knip config file exists but no knip script", () => { + writePkg(tmp, { build: "tsc" }); + write(path.join(tmp, "package-lock.json"), "{}\n"); + write(path.join(tmp, "knip.json"), "{}\n"); + + const result = resolveProjectToolchain({ targetRepoRoot: tmp }); + expect(result.knipCmd).toEqual(["npx", "knip", "--no-progress"]); + }); +}); + +// --------------------------------------------------------------------------- +// Workspace-member build-home resolution variants. +// --------------------------------------------------------------------------- + +describe("workspace build-home — member owns the build script (root does not)", () => { + it("resolves to the workspace MEMBER directory when the root package.json has no build script", () => { + const wsRoot = path.join(tmp, "ws"); + // Root: workspace yaml + a package.json WITHOUT a build script. + write(path.join(wsRoot, "pnpm-workspace.yaml"), "packages:\n - app\n"); + writePkg(wsRoot, { lint: "eslint ." }); + write(path.join(wsRoot, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + // Member: owns the build script. + const member = path.join(wsRoot, "app"); + writePkg(member, { build: "vite build", test: "vitest run" }); + write(path.join(member, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + + const result = resolveProjectToolchain({ targetRepoRoot: wsRoot }); + expect(result.source).toBe("workspace"); + expect(result.cwd).toBe(member); + expect(result.packageManager).toBe("pnpm"); + }); + + it("resolves a single-level glob (`packages/*`) member that owns the build script", () => { + const wsRoot = path.join(tmp, "glob-ws"); + write(path.join(wsRoot, "pnpm-workspace.yaml"), "packages:\n - 'packages/*'\n"); + writePkg(wsRoot, { lint: "eslint ." }); + write(path.join(wsRoot, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + // Two members under packages/; only the second owns a build script. + writePkg(path.join(wsRoot, "packages", "utils"), { test: "vitest run" }); + const core = path.join(wsRoot, "packages", "core"); + writePkg(core, { build: "tsc -b", test: "vitest run" }); + + const result = resolveProjectToolchain({ targetRepoRoot: wsRoot }); + expect(result.source).toBe("workspace"); + expect(result.cwd).toBe(core); + }); +}); + +// --------------------------------------------------------------------------- +// Repo-root fallback (plain single-package repo with no build script). +// --------------------------------------------------------------------------- + +describe("repo-root fallback", () => { + it("a repo with no build script anywhere falls back to the repo root", () => { + // A package.json with no build script, no workspace. + writePkg(tmp, { test: "node --test" }); + + const result = resolveProjectToolchain({ targetRepoRoot: tmp }); + expect(result.cwd).toBe(tmp); + expect(result.source).toBe("repo-root"); + // build/test commands still derive from the (assumed) manager. + expect(result.buildCmd).toEqual(["npm", "run", "build"]); + }); +}); diff --git a/plugins/flow/mcp-server/src/lib/__tests__/run-project-build-toolchain.test.ts b/plugins/flow/mcp-server/src/lib/__tests__/run-project-build-toolchain.test.ts new file mode 100644 index 00000000..c31d06fd --- /dev/null +++ b/plugins/flow/mcp-server/src/lib/__tests__/run-project-build-toolchain.test.ts @@ -0,0 +1,143 @@ +/** + * `runProjectBuild` / `runProjectTests` / `runProjectBloatCheck` toolchain + * integration — Story native:01KVTB3Z. + * + * Asserts the dev-side pre-PR gate runners drive the command + cwd derived from + * the structural toolchain resolver, and that the bloat gate is a NO-OP (skipped, + * success) when the resolved toolchain has no dead-code check. + * + * - AC1 (external npm repo): runProjectBuild runs `npm run build` at the repo + * ROOT; runProjectBloatCheck is SKIPPED (knipCmd null → no subprocess spawned). + * - AC4 (Flow repo): runProjectBuild runs `pnpm build` at plugins/flow, purely + * from structure. + * + * `vitest: plugins/flow/mcp-server/src/lib/__tests__/run-project-build-toolchain.test.ts` + */ + +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { mkdtempSync, rmSync, writeFileSync, mkdirSync } from "node:fs"; +import * as path from "node:path"; +import * as os from "node:os"; +import { + runProjectBuild, + runProjectTests, + runProjectBloatCheck, + resolveBuildToolchain, +} from "../run-project-build.js"; + +function write(p: string, content: string): void { + mkdirSync(path.dirname(p), { recursive: true }); + writeFileSync(p, content, "utf8"); +} +function writePkg(dir: string, scripts: Record): void { + write( + path.join(dir, "package.json"), + JSON.stringify({ name: path.basename(dir), private: true, scripts }, null, 2), + ); +} + +/** A stub execa that records the (cmd, args, cwd) of each call and returns green. */ +function makeRecordingExeca(): { + spy: ReturnType; + calls: Array<{ cmd: string; args: string[]; cwd?: string }>; +} { + const calls: Array<{ cmd: string; args: string[]; cwd?: string }> = []; + const spy = vi.fn( + async (cmd: string, args: readonly string[], options?: Record) => { + calls.push({ + cmd, + args: [...args], + cwd: typeof options?.cwd === "string" ? (options.cwd as string) : undefined, + }); + return { stdout: "ok", stderr: "", exitCode: 0 }; + }, + ); + return { spy, calls }; +} + +let tmp: string; +beforeEach(() => { + tmp = mkdtempSync(path.join(os.tmpdir(), "run-build-toolchain-")); +}); +afterEach(() => { + rmSync(tmp, { recursive: true, force: true }); +}); + +describe("AC1 — external npm repo: build/test at repo root, bloat gate SKIPPED", () => { + beforeEach(() => { + writePkg(tmp, { build: "tsc", test: "node --test" }); + write(path.join(tmp, "package-lock.json"), "{}\n"); + }); + + it("runProjectBuild runs `npm run build` at the repo root", async () => { + const { spy, calls } = makeRecordingExeca(); + const result = await runProjectBuild({ + devWorkingDir: tmp, + execaImpl: spy as unknown as Parameters[0]["execaImpl"], + }); + expect(result.exitCode).toBe(0); + expect(result.cwd).toBe(tmp); + expect(result.commandLine).toBe("npm run build"); + expect(calls[0]).toEqual({ cmd: "npm", args: ["run", "build"], cwd: tmp }); + }); + + it("runProjectTests runs `npm run test` at the repo root", async () => { + const { spy, calls } = makeRecordingExeca(); + const result = await runProjectTests({ + devWorkingDir: tmp, + execaImpl: spy as unknown as Parameters[0]["execaImpl"], + }); + expect(result.cwd).toBe(tmp); + expect(calls[0]).toEqual({ cmd: "npm", args: ["run", "test"], cwd: tmp }); + }); + + it("runProjectBloatCheck is a NO-OP (skipped, success, no subprocess) when there is no knip", async () => { + const { spy, calls } = makeRecordingExeca(); + const result = await runProjectBloatCheck({ + devWorkingDir: tmp, + execaImpl: spy as unknown as Parameters[0]["execaImpl"], + }); + expect(result.skipped).toBe(true); + expect(result.exitCode).toBe(0); + // No subprocess was spawned — the gate short-circuited on knipCmd: null. + expect(calls).toHaveLength(0); + }); +}); + +describe("AC4 — Flow repo: build/test at plugins/flow with pnpm, knip runs", () => { + let flowDir: string; + beforeEach(() => { + flowDir = path.join(tmp, "plugins", "flow"); + write(path.join(flowDir, "pnpm-workspace.yaml"), "packages:\n - mcp-server\n"); + write(path.join(flowDir, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + writePkg(flowDir, { build: "pnpm -r build", test: "pnpm -r test", knip: "knip --no-progress" }); + }); + + it("runProjectBuild runs `pnpm build` at plugins/flow purely from structure", async () => { + const { spy, calls } = makeRecordingExeca(); + const result = await runProjectBuild({ + devWorkingDir: tmp, + execaImpl: spy as unknown as Parameters[0]["execaImpl"], + }); + expect(result.cwd).toBe(flowDir); + expect(result.commandLine).toBe("pnpm build"); + expect(calls[0]).toEqual({ cmd: "pnpm", args: ["build"], cwd: flowDir }); + }); + + it("runProjectBloatCheck runs `pnpm knip` (not skipped) when a knip script exists", async () => { + const { spy, calls } = makeRecordingExeca(); + const result = await runProjectBloatCheck({ + devWorkingDir: tmp, + execaImpl: spy as unknown as Parameters[0]["execaImpl"], + }); + expect(result.skipped).toBe(false); + expect(calls[0]).toEqual({ cmd: "pnpm", args: ["knip"], cwd: flowDir }); + }); + + it("resolveBuildToolchain exposes the structural resolution for the Flow repo", () => { + const tc = resolveBuildToolchain(tmp); + expect(tc.packageManager).toBe("pnpm"); + expect(tc.cwd).toBe(flowDir); + expect(tc.source).toBe("workspace"); + }); +}); diff --git a/plugins/flow/mcp-server/src/lib/resolve-project-toolchain.ts b/plugins/flow/mcp-server/src/lib/resolve-project-toolchain.ts new file mode 100644 index 00000000..e93a089a --- /dev/null +++ b/plugins/flow/mcp-server/src/lib/resolve-project-toolchain.ts @@ -0,0 +1,515 @@ +/** + * `resolveProjectToolchain` — Story native:01KVTB3Z. + * + * ONE resolver that discovers a target repo's OWN build toolchain — its package + * manager, the directory that owns the build script (the "build home"), and the + * build / test / dead-code commands. Consumed by BOTH the dev pre-PR build/test/ + * bloat gates (`run-project-build.ts` via `run-dev-terminal-action.ts`) AND the + * reviewer's vitest runner (`run-reviewer-session.ts`), so the gate and the + * reviewer always agree on where and how to build/test a target repo. + * + * WHY THIS EXISTS — the dogfood-on-a-clean-worktree hole: + * Flow's pre-PR gates and reviewer used to HARDCODE the target as "a pnpm + * monorepo rooted at plugins/flow". That assumption is true for the Flow repo + * itself, but: + * (a) it breaks Flow run against ANY external repo (an npm project with no + * plugins/ dir): the build gate runs `pnpm build` at a non-existent + * `plugins/flow`, fails before a PR can open, and the dev's only escape + * is fabricating a fake plugins/flow/ package — contaminating the target; + * (b) even for the Flow repo, the hardcode was DRESSED UP via `.flow/config. + * yaml`, which is GITIGNORED. A per-story git worktree (cut clean from a + * branch) and a clean checkout do NOT carry `.flow/config.yaml`, so the + * dogfood path cannot lean on it. STRUCTURAL detection ALONE must yield + * the right answer (pnpm + plugins/flow) for the Flow repo. + * + * RESOLUTION ORDER (Story native:01KVTB3Z): + * 1. CONFIG OVERRIDE (optional escape hatch — NEVER the dogfood mechanism): + * if `.flow/config.yaml` has a `build:` block, zod-validate it. Its + * packageManager / cwd / buildCmd / testCmd / knipCmd win over BOTH + * structural detection and lockfile auto-detection. An unrecognised + * packageManager (or other malformed field) raises a TYPED + * `ToolchainConfigError` rather than silently falling back. + * 2. STRUCTURAL build-home detection (no config consulted): locate the dir that + * OWNS the build script — prefer a `pnpm-workspace.yaml` member whose + * package.json defines a `build` script; else the nearest package.json + * (searched from the repo root downward) with a `build` script; else the + * repo root (a plain single-package repo). For the Flow repo this lands on + * `plugins/flow`; for an external npm repo (root package.json with a build + * script, no workspace) it lands on the repo root — unchanged behaviour. + * 3. PACKAGE-MANAGER detection at the resolved cwd, by lockfile: + * pnpm-lock.yaml→pnpm, package-lock.json→npm, yarn.lock→yarn, bun.lockb→bun, + * default npm (the assumption is logged via the returned `pmAssumed` flag). + * + * The returned `buildCmd` / `testCmd` / `knipCmd` are the FULL argv the caller + * spawns. Script invocation per manager: `npm run