diff --git a/plugins/flow/mcp-server/dist/cli.js b/plugins/flow/mcp-server/dist/cli.js index 160869f6..50460de5 100644 --- a/plugins/flow/mcp-server/dist/cli.js +++ b/plugins/flow/mcp-server/dist/cli.js @@ -26304,8 +26304,53 @@ function validateStoryAgainstDiscipline(story, opts) { // src/adapters/bmad/parse-bmad-story.ts import { createHash } from "node:crypto"; +import { existsSync as existsSync2 } from "node:fs"; import * as path4 from "node:path"; -function parseBmadStory(absPath, fileContents) { + +// src/adapters/bmad/derive-bmad-ac-verification.ts +var TEST_FILE_RE = /([A-Za-z0-9_][A-Za-z0-9_./-]*\.(?:test|spec)\.tsx?)/g; +function normaliseCandidate(raw) { + return raw.trim().replace(/^[`'"]+|[`'"]+$/g, "").replace(/^\.\//, ""); +} +function collectTestFileCandidates(blob) { + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const m of blob.matchAll(TEST_FILE_RE)) { + const candidate = normaliseCandidate(m[1]); + if (candidate.length === 0 || seen.has(candidate)) continue; + seen.add(candidate); + out.push(candidate); + } + return out; +} +function firstResolvableTest(blob, exists) { + for (const candidate of collectTestFileCandidates(blob)) { + if (exists(candidate)) return candidate; + } + return void 0; +} +function deriveBmadAcVerification(args) { + const { kind, acBodyText, implementationNotes, epic, story, slug, exists } = args; + const ownTest = firstResolvableTest(acBodyText, exists); + if (ownTest) { + return { type: "vitest", target: ownTest }; + } + if (kind === "integration") { + const artifactTarget = `_bmad-output/implementation-artifacts/${epic}-${story}-${slug}.md`; + if (exists(artifactTarget)) { + return { type: "artifact", target: artifactTarget }; + } + return void 0; + } + const notesTest = firstResolvableTest(implementationNotes ?? "", exists); + if (notesTest) { + return { type: "vitest", target: notesTest }; + } + return void 0; +} + +// src/adapters/bmad/parse-bmad-story.ts +function parseBmadStory(absPath, fileContents, opts = {}) { const filename = path4.basename(absPath); const filenameMatch = /^(\d+)-(\d+)-([a-z0-9-]+)\.md$/.exec(filename); if (!filenameMatch) { @@ -26376,12 +26421,18 @@ function parseBmadStory(absPath, fileContents) { const sections = splitTopLevelSections(lines, h1Idx + 1); const storySection = sections.get("Story"); const narrative = storySection ? extractNarrativeFromStorySection(storySection) : ""; + const implSection = sections.get("Dev Notes") ?? sections.get("Implementation Notes"); + const implementation_notes = implSection ? implSection.bodyLines.join("\n").trim() || void 0 : void 0; const acSection = sections.get("Acceptance Criteria"); - const acceptance_criteria = acSection ? parseAcceptanceCriteria(acSection, absPath) : []; + const acceptance_criteria = acSection ? parseAcceptanceCriteria(acSection, absPath, { + implementationNotes: implementation_notes, + epic: epicFromName, + story: storyFromName, + slug, + repoRoot: opts.repoRoot + }) : []; const depSection = sections.get("Dependencies"); const depends_on = depSection ? parseDependencies(depSection) : []; - const implSection = sections.get("Dev Notes") ?? sections.get("Implementation Notes"); - const implementation_notes = implSection ? implSection.bodyLines.join("\n").trim() || void 0 : void 0; let shipGate; for (let i2 = h1Idx + 1; i2 < lines.length; i2++) { const line = lines[i2]; @@ -26442,7 +26493,7 @@ function extractNarrativeFromStorySection(section) { } return out.join("\n").trim(); } -function parseAcceptanceCriteria(section, absPath) { +function parseAcceptanceCriteria(section, absPath, derivation) { const headingRe = /^\*\*AC(\d+)(?:\s+—\s+[^()]*?)?(?:\s*\(([^)]+)\))?:\*\*\s*$/; const acs = []; let current = null; @@ -26466,7 +26517,20 @@ function parseAcceptanceCriteria(section, absPath) { const text = ac.body.join("\n").replace(//g, "").split("\n").map((l) => l.replace(/\s+$/, "")).join("\n").trim(); const tag = (ac.tag ?? "").toLowerCase(); const kind = tag === "integration" || tag === "user-surface" ? "integration" : "unit"; - return { text, kind }; + const repoRoot = derivation.repoRoot; + if (repoRoot === void 0) { + return { text, kind }; + } + const verification = deriveBmadAcVerification({ + kind, + acBodyText: text, + implementationNotes: derivation.implementationNotes, + epic: derivation.epic, + story: derivation.story, + slug: derivation.slug, + exists: (relPath) => existsSync2(path4.join(repoRoot, relPath)) + }); + return verification ? { text, kind, verification } : { text, kind }; }); } function parseDependencies(section) { @@ -26635,7 +26699,7 @@ var BmadAdapter = { const parsed = []; for (const file2 of files) { const contents = await fs4.readFile(file2, "utf8"); - const story = parseBmadStory(file2, contents); + const story = parseBmadStory(file2, contents, { repoRoot: ctx.targetRepo }); const status = story.raw_frontmatter["status"]; if (status === "optional") continue; parsed.push(story); @@ -26662,7 +26726,7 @@ var BmadAdapter = { throw new UnknownBmadRefError({ ref, storiesRoot: absStoriesRoot(ctx) }); } const contents = await fs4.readFile(file2, "utf8"); - return parseBmadStory(file2, contents); + return parseBmadStory(file2, contents, { repoRoot: ctx.targetRepo }); }, resolveSourcePath(ref) { const ctx = requireContext(); @@ -28694,7 +28758,7 @@ async function computeSkillEffectiveness(opts) { // src/tools/write-native-story.ts import { createHash as createHash3 } from "node:crypto"; -import { existsSync as existsSync2 } from "node:fs"; +import { existsSync as existsSync3 } from "node:fs"; import * as path19 from "node:path"; // src/validators/discipline-resolvability.ts @@ -29151,14 +29215,14 @@ var DEP_BUMP_BASENAMES = /* @__PURE__ */ new Set([ ]); function classifyPath(filePath) { const types = []; - const basename4 = path15.basename(filePath); + const basename5 = path15.basename(filePath); if (isMigrationPath(filePath)) { types.push("migration"); } if (isSchemaPath(filePath)) { types.push("schema"); } - if (DEP_BUMP_BASENAMES.has(basename4)) { + if (DEP_BUMP_BASENAMES.has(basename5)) { types.push("dep-bump"); } return types; @@ -30420,7 +30484,7 @@ function resolveDefaultDefinitionOfDone(targetRepoRoot, execSyncImpl, existsImpl } return PROJECT_AGNOSTIC_DEFINITION_OF_DONE; } - const checkExists = existsImpl ?? existsSync2; + const checkExists = existsImpl ?? existsSync3; const sentinelPath = path19.join(targetRepoRoot, FLOW_REPO_DISK_SENTINEL); if (checkExists(sentinelPath)) { return FLOW_DEFINITION_OF_DONE; @@ -30695,13 +30759,13 @@ async function readBacklogInventory(rawInput) { } for (const filename of nativeFiles) { if (!filename.endsWith(".md")) continue; - const basename4 = filename.slice(0, -3); - if (!ULID_PATTERN.test(basename4)) continue; - const ref = `native:${basename4}`; + const basename5 = filename.slice(0, -3); + if (!ULID_PATTERN.test(basename5)) continue; + const ref = `native:${basename5}`; if (seenRefs.has(ref)) continue; const absPath = path20.join(nativeStoriesDir2, filename); const content = await fs14.readFile(absPath, "utf8"); - const title = extractH1Title(content, basename4); + const title = extractH1Title(content, basename5); const entry = { ref, title, @@ -41676,7 +41740,7 @@ async function loadRolePermissions(opts) { var import_yaml23 = __toESM(require_dist(), 1); import * as path50 from "node:path"; import { - existsSync as existsSync3, + existsSync as existsSync4, readFileSync as readFileSync5, readdirSync as readdirSync3 } from "node:fs"; @@ -41734,14 +41798,14 @@ function splitCommand(cmd) { } function detectPackageManagerAt(cwd) { for (const [lockfile, pm2] of LOCKFILE_TO_PACKAGE_MANAGER) { - if (existsSync3(path50.join(cwd, lockfile))) { + if (existsSync4(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))); + return KNIP_CONFIG_FILENAMES.some((name) => existsSync4(path50.join(cwd, name))); } function findWorkspaceMemberWithBuild(workspaceDir) { let packages; @@ -41803,7 +41867,7 @@ function findWorkspaceYamlDir(root, maxDepth) { for (let depth = 0; depth <= maxDepth; depth++) { const next = []; for (const dir of frontier) { - if (existsSync3(path50.join(dir, "pnpm-workspace.yaml"))) return dir; + if (existsSync4(path50.join(dir, "pnpm-workspace.yaml"))) return dir; let entries; try { entries = readdirSync3(dir, { withFileTypes: true }); @@ -42313,7 +42377,7 @@ async function runDevTerminalAction(opts) { // src/tools/run-reviewer-session.ts import * as path54 from "node:path"; import * as fs37 from "node:fs/promises"; -import { existsSync as existsSync5 } from "node:fs"; +import { existsSync as existsSync6 } from "node:fs"; // src/lib/slugify-standards-criterion.ts function slugifyStandardsCriterion(name) { @@ -42489,7 +42553,7 @@ async function materialisePrBranchWorktree(opts) { // src/lib/prepare-review-worktree.ts import * as path53 from "node:path"; -import { existsSync as existsSync4 } from "node:fs"; +import { existsSync as existsSync5 } from "node:fs"; var DEFAULT_INSTALL_TIMEOUT_MS = 10 * 60 * 1e3; function frozenInstallInvocation(pm2) { switch (pm2) { @@ -42513,7 +42577,7 @@ function resolveWorktreeInstallPlan(worktreeRoot) { } while (dir === stop || dir.startsWith(stop + path53.sep)) { for (const [lockfile, pm2] of LOCKFILE_TO_PACKAGE_MANAGER) { - if (existsSync4(path53.join(dir, lockfile))) { + if (existsSync5(path53.join(dir, lockfile))) { const { command, args } = frozenInstallInvocation(pm2); return { installRoot: dir, packageManager: pm2, command, args }; } @@ -42576,6 +42640,13 @@ function classifyAc(bodyLines) { } return { applicability: "manual-check-required" }; } +var BMAD_FILENAME_RE2 = /^(\d+)-(\d+)-([a-z0-9-]+)\.md$/; +function deriveBmadFilenameParts(adapterName, rawPath) { + if (adapterName !== "bmad") return null; + const m = BMAD_FILENAME_RE2.exec(path54.basename(rawPath)); + if (!m) return null; + return { epic: m[1], story: m[2], slug: m[3] }; +} async function runArtifactCheck(index, tag, artifactPath, checkRoot) { const resolved = path54.resolve(checkRoot, artifactPath); try { @@ -42625,7 +42696,7 @@ function countExecutedTests(output) { } function resolveVitestInvocation(packageRoot) { const localBin = path54.join(packageRoot, "node_modules", ".bin", "vitest"); - if (existsSync5(localBin)) { + if (existsSync6(localBin)) { return { command: localBin, args: [] }; } const pm2 = resolveProjectToolchain({ targetRepoRoot: packageRoot }).packageManager; @@ -42875,6 +42946,7 @@ ${installResult.stderr.slice(0, 2e3)}` : ""); } const acResults = {}; let riskTierBlock; + const bmadDerivation = deriveBmadFilenameParts(workspace.activeAdapter.name, sourceStory.raw_path); try { for (const ac of acEntries) { const classification = classifyAc(ac.body); @@ -42910,12 +42982,41 @@ ${installResult.stderr.slice(0, 2e3)}` : ""); execaImpl ); } else { - acResults[ac.index] = { - index: ac.index, - tag: ac.tag, - applicability: "manual-check-required", - reason: "AC body has no `artifact:` or `vitest:` marker \u2014 manual check required before merge" - }; + const derived = bmadDerivation ? deriveBmadAcVerification({ + kind: ac.tag === "integration" || ac.tag === "user-surface" ? "integration" : "unit", + acBodyText: ac.body.join("\n"), + implementationNotes: sourceStory.implementation_notes, + epic: bmadDerivation.epic, + story: bmadDerivation.story, + slug: bmadDerivation.slug, + exists: (relPath) => existsSync6(path54.join(worktreePath, relPath)) + }) : void 0; + if (derived?.type === "artifact") { + acResults[ac.index] = await runArtifactCheck( + ac.index, + ac.tag, + derived.target, + worktreePath + // checkRoot — AC2 + ); + } else if (derived?.type === "vitest") { + acResults[ac.index] = await runVitestCheck( + ac.index, + ac.tag, + derived.target, + derived.target, + worktreePath, + // checkRoot — AC2 + execaImpl + ); + } else { + acResults[ac.index] = { + index: ac.index, + tag: ac.tag, + applicability: "manual-check-required", + reason: "AC body has no `artifact:` or `vitest:` marker \u2014 manual check required before merge" + }; + } } } try { diff --git a/plugins/flow/mcp-server/dist/index.js b/plugins/flow/mcp-server/dist/index.js index 62db0b41..0e612dcc 100644 --- a/plugins/flow/mcp-server/dist/index.js +++ b/plugins/flow/mcp-server/dist/index.js @@ -38939,14 +38939,14 @@ var DEP_BUMP_BASENAMES = /* @__PURE__ */ new Set([ ]); function classifyPath(filePath) { const types = []; - const basename6 = path13.basename(filePath); + const basename7 = path13.basename(filePath); if (isMigrationPath(filePath)) { types.push("migration"); } if (isSchemaPath(filePath)) { types.push("schema"); } - if (DEP_BUMP_BASENAMES.has(basename6)) { + if (DEP_BUMP_BASENAMES.has(basename7)) { types.push("dep-bump"); } return types; @@ -49679,7 +49679,7 @@ async function computeSkillEffectiveness(opts) { // src/tools/write-native-story.ts import { createHash as createHash3 } from "node:crypto"; -import { existsSync as existsSync2 } from "node:fs"; +import { existsSync as existsSync3 } from "node:fs"; import * as path48 from "node:path"; // src/adapters/native/parse-native-story.ts @@ -50146,8 +50146,53 @@ function validateBacklogAgainstDiscipline(stories, opts) { // src/adapters/bmad/parse-bmad-story.ts import { createHash as createHash2 } from "node:crypto"; +import { existsSync as existsSync2 } from "node:fs"; import * as path40 from "node:path"; -function parseBmadStory(absPath, fileContents) { + +// src/adapters/bmad/derive-bmad-ac-verification.ts +var TEST_FILE_RE = /([A-Za-z0-9_][A-Za-z0-9_./-]*\.(?:test|spec)\.tsx?)/g; +function normaliseCandidate(raw) { + return raw.trim().replace(/^[`'"]+|[`'"]+$/g, "").replace(/^\.\//, ""); +} +function collectTestFileCandidates(blob) { + const seen = /* @__PURE__ */ new Set(); + const out = []; + for (const m of blob.matchAll(TEST_FILE_RE)) { + const candidate = normaliseCandidate(m[1]); + if (candidate.length === 0 || seen.has(candidate)) continue; + seen.add(candidate); + out.push(candidate); + } + return out; +} +function firstResolvableTest(blob, exists) { + for (const candidate of collectTestFileCandidates(blob)) { + if (exists(candidate)) return candidate; + } + return void 0; +} +function deriveBmadAcVerification(args) { + const { kind, acBodyText, implementationNotes, epic, story, slug, exists } = args; + const ownTest = firstResolvableTest(acBodyText, exists); + if (ownTest) { + return { type: "vitest", target: ownTest }; + } + if (kind === "integration") { + const artifactTarget = `_bmad-output/implementation-artifacts/${epic}-${story}-${slug}.md`; + if (exists(artifactTarget)) { + return { type: "artifact", target: artifactTarget }; + } + return void 0; + } + const notesTest = firstResolvableTest(implementationNotes ?? "", exists); + if (notesTest) { + return { type: "vitest", target: notesTest }; + } + return void 0; +} + +// src/adapters/bmad/parse-bmad-story.ts +function parseBmadStory(absPath, fileContents, opts = {}) { const filename = path40.basename(absPath); const filenameMatch = /^(\d+)-(\d+)-([a-z0-9-]+)\.md$/.exec(filename); if (!filenameMatch) { @@ -50218,12 +50263,18 @@ function parseBmadStory(absPath, fileContents) { const sections = splitTopLevelSections2(lines, h1Idx + 1); const storySection = sections.get("Story"); const narrative = storySection ? extractNarrativeFromStorySection(storySection) : ""; + const implSection = sections.get("Dev Notes") ?? sections.get("Implementation Notes"); + const implementation_notes = implSection ? implSection.bodyLines.join("\n").trim() || void 0 : void 0; const acSection = sections.get("Acceptance Criteria"); - const acceptance_criteria = acSection ? parseAcceptanceCriteria2(acSection, absPath) : []; + const acceptance_criteria = acSection ? parseAcceptanceCriteria2(acSection, absPath, { + implementationNotes: implementation_notes, + epic: epicFromName, + story: storyFromName, + slug, + repoRoot: opts.repoRoot + }) : []; const depSection = sections.get("Dependencies"); const depends_on = depSection ? parseDependencies2(depSection) : []; - const implSection = sections.get("Dev Notes") ?? sections.get("Implementation Notes"); - const implementation_notes = implSection ? implSection.bodyLines.join("\n").trim() || void 0 : void 0; let shipGate; for (let i2 = h1Idx + 1; i2 < lines.length; i2++) { const line = lines[i2]; @@ -50284,7 +50335,7 @@ function extractNarrativeFromStorySection(section) { } return out.join("\n").trim(); } -function parseAcceptanceCriteria2(section, absPath) { +function parseAcceptanceCriteria2(section, absPath, derivation) { const headingRe = /^\*\*AC(\d+)(?:\s+—\s+[^()]*?)?(?:\s*\(([^)]+)\))?:\*\*\s*$/; const acs = []; let current = null; @@ -50308,7 +50359,20 @@ function parseAcceptanceCriteria2(section, absPath) { const text = ac.body.join("\n").replace(//g, "").split("\n").map((l) => l.replace(/\s+$/, "")).join("\n").trim(); const tag = (ac.tag ?? "").toLowerCase(); const kind = tag === "integration" || tag === "user-surface" ? "integration" : "unit"; - return { text, kind }; + const repoRoot = derivation.repoRoot; + if (repoRoot === void 0) { + return { text, kind }; + } + const verification = deriveBmadAcVerification({ + kind, + acBodyText: text, + implementationNotes: derivation.implementationNotes, + epic: derivation.epic, + story: derivation.story, + slug: derivation.slug, + exists: (relPath) => existsSync2(path40.join(repoRoot, relPath)) + }); + return verification ? { text, kind, verification } : { text, kind }; }); } function parseDependencies2(section) { @@ -50477,7 +50541,7 @@ var BmadAdapter = { const parsed = []; for (const file2 of files) { const contents = await fs33.readFile(file2, "utf8"); - const story = parseBmadStory(file2, contents); + const story = parseBmadStory(file2, contents, { repoRoot: ctx.targetRepo }); const status = story.raw_frontmatter["status"]; if (status === "optional") continue; parsed.push(story); @@ -50504,7 +50568,7 @@ var BmadAdapter = { throw new UnknownBmadRefError({ ref, storiesRoot: absStoriesRoot(ctx) }); } const contents = await fs33.readFile(file2, "utf8"); - return parseBmadStory(file2, contents); + return parseBmadStory(file2, contents, { repoRoot: ctx.targetRepo }); }, resolveSourcePath(ref) { const ctx = requireContext(); @@ -52040,7 +52104,7 @@ function resolveDefaultDefinitionOfDone(targetRepoRoot, execSyncImpl, existsImpl } return PROJECT_AGNOSTIC_DEFINITION_OF_DONE; } - const checkExists = existsImpl ?? existsSync2; + const checkExists = existsImpl ?? existsSync3; const sentinelPath = path48.join(targetRepoRoot, FLOW_REPO_DISK_SENTINEL); if (checkExists(sentinelPath)) { return FLOW_DEFINITION_OF_DONE; @@ -52315,13 +52379,13 @@ async function readBacklogInventory(rawInput) { } for (const filename of nativeFiles) { if (!filename.endsWith(".md")) continue; - const basename6 = filename.slice(0, -3); - if (!ULID_PATTERN.test(basename6)) continue; - const ref = `native:${basename6}`; + const basename7 = filename.slice(0, -3); + if (!ULID_PATTERN.test(basename7)) continue; + const ref = `native:${basename7}`; if (seenRefs.has(ref)) continue; const absPath = path49.join(nativeStoriesDir2, filename); const content = await fs39.readFile(absPath, "utf8"); - const title = extractH1Title(content, basename6); + const title = extractH1Title(content, basename7); const entry = { ref, title, @@ -55800,7 +55864,7 @@ async function loadRolePermissions(opts) { var import_yaml35 = __toESM(require_dist2(), 1); import * as path73 from "node:path"; import { - existsSync as existsSync3, + existsSync as existsSync4, readFileSync as readFileSync5, readdirSync as readdirSync3 } from "node:fs"; @@ -55858,14 +55922,14 @@ function splitCommand(cmd) { } function detectPackageManagerAt(cwd) { for (const [lockfile, pm2] of LOCKFILE_TO_PACKAGE_MANAGER) { - if (existsSync3(path73.join(cwd, lockfile))) { + if (existsSync4(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))); + return KNIP_CONFIG_FILENAMES.some((name) => existsSync4(path73.join(cwd, name))); } function findWorkspaceMemberWithBuild(workspaceDir) { let packages; @@ -55927,7 +55991,7 @@ function findWorkspaceYamlDir(root, maxDepth) { for (let depth = 0; depth <= maxDepth; depth++) { const next = []; for (const dir of frontier) { - if (existsSync3(path73.join(dir, "pnpm-workspace.yaml"))) return dir; + if (existsSync4(path73.join(dir, "pnpm-workspace.yaml"))) return dir; let entries; try { entries = readdirSync3(dir, { withFileTypes: true }); @@ -56437,7 +56501,7 @@ async function runDevTerminalAction(opts) { // src/tools/run-reviewer-session.ts import * as path77 from "node:path"; import * as fs58 from "node:fs/promises"; -import { existsSync as existsSync5 } from "node:fs"; +import { existsSync as existsSync6 } from "node:fs"; // src/lib/materialise-pr-branch-worktree.ts import * as path75 from "node:path"; @@ -56608,7 +56672,7 @@ async function materialisePrBranchWorktree(opts) { // src/lib/prepare-review-worktree.ts import * as path76 from "node:path"; -import { existsSync as existsSync4 } from "node:fs"; +import { existsSync as existsSync5 } from "node:fs"; var DEFAULT_INSTALL_TIMEOUT_MS = 10 * 60 * 1e3; function frozenInstallInvocation(pm2) { switch (pm2) { @@ -56632,7 +56696,7 @@ function resolveWorktreeInstallPlan(worktreeRoot) { } while (dir === stop || dir.startsWith(stop + path76.sep)) { for (const [lockfile, pm2] of LOCKFILE_TO_PACKAGE_MANAGER) { - if (existsSync4(path76.join(dir, lockfile))) { + if (existsSync5(path76.join(dir, lockfile))) { const { command, args } = frozenInstallInvocation(pm2); return { installRoot: dir, packageManager: pm2, command, args }; } @@ -56695,6 +56759,13 @@ function classifyAc(bodyLines) { } return { applicability: "manual-check-required" }; } +var BMAD_FILENAME_RE2 = /^(\d+)-(\d+)-([a-z0-9-]+)\.md$/; +function deriveBmadFilenameParts(adapterName, rawPath) { + if (adapterName !== "bmad") return null; + const m = BMAD_FILENAME_RE2.exec(path77.basename(rawPath)); + if (!m) return null; + return { epic: m[1], story: m[2], slug: m[3] }; +} async function runArtifactCheck(index, tag, artifactPath, checkRoot) { const resolved = path77.resolve(checkRoot, artifactPath); try { @@ -56744,7 +56815,7 @@ function countExecutedTests(output) { } function resolveVitestInvocation(packageRoot) { const localBin = path77.join(packageRoot, "node_modules", ".bin", "vitest"); - if (existsSync5(localBin)) { + if (existsSync6(localBin)) { return { command: localBin, args: [] }; } const pm2 = resolveProjectToolchain({ targetRepoRoot: packageRoot }).packageManager; @@ -56994,6 +57065,7 @@ ${installResult.stderr.slice(0, 2e3)}` : ""); } const acResults = {}; let riskTierBlock; + const bmadDerivation = deriveBmadFilenameParts(workspace.activeAdapter.name, sourceStory.raw_path); try { for (const ac of acEntries) { const classification = classifyAc(ac.body); @@ -57029,12 +57101,41 @@ ${installResult.stderr.slice(0, 2e3)}` : ""); execaImpl ); } else { - acResults[ac.index] = { - index: ac.index, - tag: ac.tag, - applicability: "manual-check-required", - reason: "AC body has no `artifact:` or `vitest:` marker \u2014 manual check required before merge" - }; + const derived = bmadDerivation ? deriveBmadAcVerification({ + kind: ac.tag === "integration" || ac.tag === "user-surface" ? "integration" : "unit", + acBodyText: ac.body.join("\n"), + implementationNotes: sourceStory.implementation_notes, + epic: bmadDerivation.epic, + story: bmadDerivation.story, + slug: bmadDerivation.slug, + exists: (relPath) => existsSync6(path77.join(worktreePath, relPath)) + }) : void 0; + if (derived?.type === "artifact") { + acResults[ac.index] = await runArtifactCheck( + ac.index, + ac.tag, + derived.target, + worktreePath + // checkRoot — AC2 + ); + } else if (derived?.type === "vitest") { + acResults[ac.index] = await runVitestCheck( + ac.index, + ac.tag, + derived.target, + derived.target, + worktreePath, + // checkRoot — AC2 + execaImpl + ); + } else { + acResults[ac.index] = { + index: ac.index, + tag: ac.tag, + applicability: "manual-check-required", + reason: "AC body has no `artifact:` or `vitest:` marker \u2014 manual check required before merge" + }; + } } } try { diff --git a/plugins/flow/mcp-server/src/adapters/bmad/__tests__/parse-bmad-story-ac-headings.test.ts b/plugins/flow/mcp-server/src/adapters/bmad/__tests__/parse-bmad-story-ac-headings.test.ts index 1ebece21..5f30a862 100644 --- a/plugins/flow/mcp-server/src/adapters/bmad/__tests__/parse-bmad-story-ac-headings.test.ts +++ b/plugins/flow/mcp-server/src/adapters/bmad/__tests__/parse-bmad-story-ac-headings.test.ts @@ -10,8 +10,12 @@ * en-dash (U+2013), or double-hyphen. */ -import { describe, it, expect } from "vitest"; +import { describe, it, expect, afterEach } from "vitest"; +import { mkdtempSync, rmSync, mkdirSync } from "node:fs"; +import * as os from "node:os"; +import * as nodePath from "node:path"; import { parseBmadStory } from "../parse-bmad-story.js"; +import { atomicWriteFile } from "../../../lib/managed-fs.js"; import { MalformedBmadStoryError } from "../../../errors.js"; /** Minimal valid story file skeleton. Accepts a replacement AC section body. */ @@ -193,3 +197,246 @@ describe("parseBmadStory — AC heading shapes (Story 5.17 AC1)", () => { } }); }); + +// --------------------------------------------------------------------------- +// Story native:01KW5W081X3TJPQBCYF3WAK9RZ — per-AC verification derivation. +// +// When `parseBmadStory` is given a `repoRoot`, each AC for which a real test or +// artifact target can be derived from the story's own signals carries that +// derived marker (AC1); an AC whose candidate target does NOT resolve on disk +// falls back to manual verification (verification undefined) rather than emitting +// a non-existent path (AC2). +// --------------------------------------------------------------------------- + +describe("parseBmadStory — derived per-AC verification (Story native:01KW5W081X3TJPQBCYF3WAK9RZ)", () => { + const tmpDirs: string[] = []; + + afterEach(() => { + while (tmpDirs.length > 0) { + const dir = tmpDirs.pop()!; + rmSync(dir, { recursive: true, force: true }); + } + }); + + function makeRepoRoot(): string { + const dir = mkdtempSync(nodePath.join(os.tmpdir(), "bmad-derive-")); + tmpDirs.push(dir); + return dir; + } + + /** Write a file (creating parent dirs) under `repoRoot` at repo-relative `rel`. */ + async function writeRepoFile(repoRoot: string, rel: string, contents = "x"): Promise { + const abs = nodePath.join(repoRoot, rel); + mkdirSync(nodePath.dirname(abs), { recursive: true }); + await atomicWriteFile(abs, contents); + } + + /** Build a 1.3 story whose AC section and Dev Notes are supplied by the caller. */ + function makeStory13(acSection: string, devNotes: string): string { + return [ + "# Story 1.3: Derive markers", + "", + "Status: ready-for-dev", + "", + "## Story", + "", + "As a user, I want something, so that I get value.", + "", + "## Acceptance Criteria", + "", + acSection, + "", + "## Dev Notes", + "", + devNotes, + "", + ].join("\n"); + } + + const STORY_13_PATH = "/repo/_bmad-output/planning-artifacts/stories/1-3-derive-markers.md"; + + it("AC1: a unit AC whose Dev Notes cite an existing test file derives a vitest marker", async () => { + const repoRoot = makeRepoRoot(); + const testRel = "plugins/flow/mcp-server/src/foo/__tests__/foo.test.ts"; + await writeRepoFile(repoRoot, testRel); + + const content = makeStory13( + [ + "**AC1:**", + "**Given** a repo,", + "**When** the user runs the command,", + "**Then** the build passes.", + ].join("\n"), + `Add coverage in \`${testRel}\` for the new branch.`, + ); + + const result = parseBmadStory(STORY_13_PATH, content, { repoRoot }); + expect(result.acceptance_criteria).toHaveLength(1); + expect(result.acceptance_criteria[0]!.verification).toEqual({ + type: "vitest", + target: testRel, + }); + }); + + it("AC1: a test path cited in the AC prose itself is derived as a vitest marker", async () => { + const repoRoot = makeRepoRoot(); + const testRel = "plugins/flow/mcp-server/src/bar/bar.spec.ts"; + await writeRepoFile(repoRoot, testRel); + + const content = makeStory13( + [ + "**AC1:**", + "**Given** a repo,", + `**When** \`${testRel}\` runs,`, + "**Then** it passes.", + ].join("\n"), + "No extra notes.", + ); + + const result = parseBmadStory(STORY_13_PATH, content, { repoRoot }); + expect(result.acceptance_criteria[0]!.verification).toEqual({ + type: "vitest", + target: testRel, + }); + }); + + it("AC1: an integration AC with no test reference derives an artifact marker from the implementation-artifact convention", async () => { + const repoRoot = makeRepoRoot(); + const artifactRel = "_bmad-output/implementation-artifacts/1-3-derive-markers.md"; + await writeRepoFile(repoRoot, artifactRel, "# impl doc"); + + const content = makeStory13( + [ + "**AC1 (integration):**", + "**Given** a live MCP server,", + "**When** the adapter scans stories,", + "**Then** the manifest is populated.", + ].join("\n"), + "No test references here.", + ); + + const result = parseBmadStory(STORY_13_PATH, content, { repoRoot }); + expect(result.acceptance_criteria[0]!.kind).toBe("integration"); + expect(result.acceptance_criteria[0]!.verification).toEqual({ + type: "artifact", + target: artifactRel, + }); + }); + + it("AC2: a cited test path that does NOT resolve on disk falls back to manual (verification undefined)", () => { + const repoRoot = makeRepoRoot(); + // Deliberately do NOT create the referenced file. + const content = makeStory13( + [ + "**AC1:**", + "**Given** a repo,", + "**When** the user runs the command,", + "**Then** the build passes.", + ].join("\n"), + "Add coverage in `plugins/flow/mcp-server/src/ghost/__tests__/ghost.test.ts`.", + ); + + const result = parseBmadStory(STORY_13_PATH, content, { repoRoot }); + expect(result.acceptance_criteria[0]!.verification).toBeUndefined(); + }); + + it("AC2: an AC with no derivable signal at all falls back to manual (verification undefined)", () => { + const repoRoot = makeRepoRoot(); + const content = makeStory13( + [ + "**AC1:**", + "**Given** a repo,", + "**When** the user runs the command,", + "**Then** the build passes.", + ].join("\n"), + "Nothing mechanical to check here.", + ); + + const result = parseBmadStory(STORY_13_PATH, content, { repoRoot }); + expect(result.acceptance_criteria[0]!.verification).toBeUndefined(); + }); + + it("AC2: an integration AC whose implementation-artifact doc is absent falls back to manual", () => { + const repoRoot = makeRepoRoot(); + // No implementation-artifact doc written and no test reference. + const content = makeStory13( + [ + "**AC1 (integration):**", + "**Given** a live MCP server,", + "**When** the adapter scans stories,", + "**Then** the manifest is populated.", + ].join("\n"), + "No test references here.", + ); + + const result = parseBmadStory(STORY_13_PATH, content, { repoRoot }); + expect(result.acceptance_criteria[0]!.verification).toBeUndefined(); + }); + + it("does not derive a marker (verification undefined) when no repoRoot is supplied — pure mode", () => { + // The referenced test file would resolve under cwd, but without repoRoot the + // parser must not perform any I/O or derivation. + const content = makeStory13( + [ + "**AC1:**", + "**Given** a repo,", + "**When** the user runs the command,", + "**Then** the build passes.", + ].join("\n"), + "Add coverage in `plugins/flow/mcp-server/src/foo/__tests__/foo.test.ts`.", + ); + const result = parseBmadStory(STORY_13_PATH, content); + expect(result.acceptance_criteria[0]!.verification).toBeUndefined(); + }); + + it("prefers a test cited in the integration AC's own prose over the artifact convention", async () => { + const repoRoot = makeRepoRoot(); + const testRel = "plugins/flow/mcp-server/src/baz/__tests__/baz.integration.test.ts"; + await writeRepoFile(repoRoot, testRel); + await writeRepoFile(repoRoot, "_bmad-output/implementation-artifacts/1-3-derive-markers.md", "# doc"); + + // The test path is cited in the integration AC's OWN body — the precise, + // per-AC signal — so it wins over the artifact-doc convention fallback. + const content = makeStory13( + [ + "**AC1 (integration):**", + "**Given** a live system,", + `**When** \`${testRel}\` runs,`, + "**Then** results are produced.", + ].join("\n"), + "No notes here.", + ); + + const result = parseBmadStory(STORY_13_PATH, content, { repoRoot }); + expect(result.acceptance_criteria[0]!.verification).toEqual({ + type: "vitest", + target: testRel, + }); + }); + + it("an integration AC does NOT borrow a notes-only test reference (notes apply to unit ACs)", async () => { + const repoRoot = makeRepoRoot(); + const testRel = "plugins/flow/mcp-server/src/qux/__tests__/qux.test.ts"; + await writeRepoFile(repoRoot, testRel); + const artifactRel = "_bmad-output/implementation-artifacts/1-3-derive-markers.md"; + await writeRepoFile(repoRoot, artifactRel, "# doc"); + + // The only test reference is in the notes; an integration AC must fall through + // to the artifact convention rather than spraying the notes test onto it. + const content = makeStory13( + [ + "**AC1 (integration):**", + "**Given** a live system,", + "**When** integration runs,", + "**Then** results are produced.", + ].join("\n"), + `All coverage lives in \`${testRel}\`.`, + ); + + const result = parseBmadStory(STORY_13_PATH, content, { repoRoot }); + expect(result.acceptance_criteria[0]!.verification).toEqual({ + type: "artifact", + target: artifactRel, + }); + }); +}); diff --git a/plugins/flow/mcp-server/src/adapters/bmad/derive-bmad-ac-verification.ts b/plugins/flow/mcp-server/src/adapters/bmad/derive-bmad-ac-verification.ts new file mode 100644 index 00000000..7d9a7179 --- /dev/null +++ b/plugins/flow/mcp-server/src/adapters/bmad/derive-bmad-ac-verification.ts @@ -0,0 +1,135 @@ +/** + * Derive a per-AC verification marker for a BMad acceptance criterion from the + * story's own signals — Story native:01KW5W081X3TJPQBCYF3WAK9RZ. + * + * BMad source stories do NOT carry the inline `vitest:`/`artifact:` markers the + * native author/parse path requires, so every BMad AC the reviewer sees is + * classified `manual-check-required` — which deterministically blocks the + * verdict and stalls `/flow:run` on a marker-only gap (recurred 5x across Epic 1). + * + * This helper closes that gap WITHOUT the operator hand-editing gitignored source + * files: it mines a candidate verification target from the AC's own prose plus the + * story's implementation notes, and emits a marker ONLY when that target resolves + * on the tree it is given (`exists`). When nothing real can be derived it returns + * `undefined` and the caller falls back to manual verification — it NEVER fabricates + * a path. That resolvability guard is the whole point: a fabricated `vitest:`/ + * `artifact:` target would turn a benign marker gap into a hard non-runnable-target + * failure (the mirror of #422), making the stall worse, not better. + * + * The function is pure given its `exists` resolver — it performs no I/O itself, so + * the same logic runs at scan time (resolving against the dev working tree) and at + * review time (resolving against the PR-branch worktree the checks actually run in). + */ + +/** + * Match a repo-relative test-file path anywhere in a blob of prose. Restricted to + * the runnable test conventions (`*.test.ts(x)` / `*.spec.ts(x)`), so a derived + * `vitest:` target is structurally guaranteed to be a runnable test and never an + * ordinary source file (the `non-runnable-test-target` flaw). The leading + * backtick / quote a story may wrap the path in is excluded from the character + * class, so it is naturally trimmed. + */ +const TEST_FILE_RE = /([A-Za-z0-9_][A-Za-z0-9_./-]*\.(?:test|spec)\.tsx?)/g; + +export interface DeriveBmadAcVerificationArgs { + /** The AC's resolved kind (`integration` ACs additionally consider the artifact convention). */ + kind: "integration" | "unit"; + /** The AC's Given/When/Then prose (one of the mining sources for a test reference). */ + acBodyText: string; + /** The story's `## Dev Notes` / `## Implementation Notes` body, if any (the other mining source). */ + implementationNotes: string | undefined; + /** Epic number from the story filename (`--.md`). */ + epic: string; + /** Story number from the story filename. */ + story: string; + /** Slug from the story filename. */ + slug: string; + /** + * Resolver: does `relPath` (repo-relative) exist on the tree being checked? + * Scan time passes the dev working tree; review time passes the PR worktree. + */ + exists: (relPath: string) => boolean; +} + +/** + * Normalise a mined path token: trim surrounding whitespace, backticks, quotes, + * and a leading `./` so it resolves cleanly against a repo root. + */ +function normaliseCandidate(raw: string): string { + return raw.trim().replace(/^[`'"]+|[`'"]+$/g, "").replace(/^\.\//, ""); +} + +/** + * Collect distinct test-file path candidates from a single blob of prose, in + * first-seen order. + */ +function collectTestFileCandidates(blob: string): string[] { + const seen = new Set(); + const out: string[] = []; + for (const m of blob.matchAll(TEST_FILE_RE)) { + const candidate = normaliseCandidate(m[1]!); + if (candidate.length === 0 || seen.has(candidate)) continue; + seen.add(candidate); + out.push(candidate); + } + return out; +} + +/** First test-file candidate in `blob` that resolves on disk, or `undefined`. */ +function firstResolvableTest(blob: string, exists: (relPath: string) => boolean): string | undefined { + for (const candidate of collectTestFileCandidates(blob)) { + if (exists(candidate)) return candidate; + } + return undefined; +} + +/** + * Derive a verification marker for one BMad AC, or `undefined` to fall back to + * manual verification. + * + * Resolution order (first resolvable signal wins). The two AC kinds borrow + * DIFFERENT story-global fallbacks on purpose, because the implementation notes + * are not attributable to a specific AC — spraying one notes-test across every AC + * (including integration ones the artifact convention covers) would misattribute + * the check: + * 1. A `*.test.ts(x)` / `*.spec.ts(x)` path cited in the AC's OWN prose → + * `{ type: "vitest", target }`. This is the precise, per-AC signal, preferred + * for either kind. + * 2. `integration` ACs → the implementation-artifact path convention + * `_bmad-output/implementation-artifacts/--.md` → + * `{ type: "artifact", target }`. + * 3. `unit` ACs → a test-file path cited in the story's implementation notes → + * `{ type: "vitest", target }` (the task's "test references found in + * implementation notes for unit ACs"). + * + * Every candidate is gated through `exists`; if none resolve, returns `undefined`. + */ +export function deriveBmadAcVerification( + args: DeriveBmadAcVerificationArgs, +): { type: "vitest" | "artifact"; target: string } | undefined { + const { kind, acBodyText, implementationNotes, epic, story, slug, exists } = args; + + // 1. A test cited in the AC's OWN prose (precise, per-AC) — preferred either kind. + const ownTest = firstResolvableTest(acBodyText, exists); + if (ownTest) { + return { type: "vitest", target: ownTest }; + } + + // 2. Integration ACs: the implementation-artifact doc convention (artifact). + if (kind === "integration") { + const artifactTarget = `_bmad-output/implementation-artifacts/${epic}-${story}-${slug}.md`; + if (exists(artifactTarget)) { + return { type: "artifact", target: artifactTarget }; + } + return undefined; + } + + // 3. Unit ACs: a test referenced in the story's implementation notes. + const notesTest = firstResolvableTest(implementationNotes ?? "", exists); + if (notesTest) { + return { type: "vitest", target: notesTest }; + } + + // Nothing real could be derived — caller falls back to manual verification. + return undefined; +} diff --git a/plugins/flow/mcp-server/src/adapters/bmad/index.ts b/plugins/flow/mcp-server/src/adapters/bmad/index.ts index ca927602..8c38a7a8 100644 --- a/plugins/flow/mcp-server/src/adapters/bmad/index.ts +++ b/plugins/flow/mcp-server/src/adapters/bmad/index.ts @@ -225,7 +225,10 @@ export const BmadAdapter: PlanningAdapter = { const parsed: SourceStory[] = []; for (const file of files) { const contents = await fs.readFile(file, "utf8"); - const story = parseBmadStory(file, contents); + // Pass the bound target repo so per-AC verification markers are derived and + // resolved against the working tree at scan time (Story + // native:01KW5W081X3TJPQBCYF3WAK9RZ). + const story = parseBmadStory(file, contents, { repoRoot: ctx.targetRepo }); const status = story.raw_frontmatter["status"] as BmadStatus; if (status === "optional") continue; parsed.push(story); @@ -253,7 +256,7 @@ export const BmadAdapter: PlanningAdapter = { throw new UnknownBmadRefError({ ref, storiesRoot: absStoriesRoot(ctx) }); } const contents = await fs.readFile(file, "utf8"); - return parseBmadStory(file, contents); + return parseBmadStory(file, contents, { repoRoot: ctx.targetRepo }); }, resolveSourcePath(ref: string): string { diff --git a/plugins/flow/mcp-server/src/adapters/bmad/parse-bmad-story.ts b/plugins/flow/mcp-server/src/adapters/bmad/parse-bmad-story.ts index 1a84cc51..87dbe2ac 100644 --- a/plugins/flow/mcp-server/src/adapters/bmad/parse-bmad-story.ts +++ b/plugins/flow/mcp-server/src/adapters/bmad/parse-bmad-story.ts @@ -1,18 +1,39 @@ import { createHash } from "node:crypto"; +import { existsSync } from "node:fs"; import * as path from "node:path"; import { MalformedBmadStoryError } from "../../errors.js"; import type { AC, SourceStory } from "../adapter.js"; import type { BmadStatus } from "./map-bmad-status.js"; +import { deriveBmadAcVerification } from "./derive-bmad-ac-verification.js"; /** - * Pure BMad story parser — no I/O. The caller (the adapter's - * `listSourceStories`/`readSourceStory`) is responsible for reading the - * file and passing the bytes in. + * Optional inputs to {@link parseBmadStory}. + * + * `repoRoot` (Story native:01KW5W081X3TJPQBCYF3WAK9RZ) arms per-AC verification + * derivation: when present, each AC for which a real test or artifact target can + * be derived from the story's own signals carries that marker, resolved against + * `repoRoot` on disk. When absent the parser stays the original pure, no-I/O + * function and every AC's `verification` is left `undefined`. The adapter passes + * its bound `targetRepo`; tests that exercise pure parsing omit it. + */ +export interface ParseBmadStoryOptions { + repoRoot?: string; +} + +/** + * BMad story parser. Pure (no I/O) UNLESS `opts.repoRoot` is supplied, in which + * case per-AC verification markers are derived and resolved against that tree + * (Story native:01KW5W081X3TJPQBCYF3WAK9RZ). The caller (the adapter's + * `listSourceStories`/`readSourceStory`) reads the file and passes the bytes in. * * See {@link plugins/flow/docs/spikes/bmad-format.md} for the source * shape this parser handles. */ -export function parseBmadStory(absPath: string, fileContents: string): SourceStory { +export function parseBmadStory( + absPath: string, + fileContents: string, + opts: ParseBmadStoryOptions = {}, +): SourceStory { const filename = path.basename(absPath); const filenameMatch = /^(\d+)-(\d+)-([a-z0-9-]+)\.md$/.exec(filename); if (!filenameMatch) { @@ -98,20 +119,30 @@ export function parseBmadStory(absPath: string, fileContents: string): SourceSto const storySection = sections.get("Story"); const narrative = storySection ? extractNarrativeFromStorySection(storySection) : ""; - // Acceptance criteria. + // Implementation notes — extracted BEFORE the acceptance criteria so they can + // be mined for per-AC verification derivation (Story native:01KW5W081X3TJPQBCYF3WAK9RZ). + const implSection = sections.get("Dev Notes") ?? sections.get("Implementation Notes"); + const implementation_notes = implSection + ? implSection.bodyLines.join("\n").trim() || undefined + : undefined; + + // Acceptance criteria. When `opts.repoRoot` is supplied, derive a per-AC + // verification marker (resolved against that tree); otherwise leave it undefined. const acSection = sections.get("Acceptance Criteria"); - const acceptance_criteria = acSection ? parseAcceptanceCriteria(acSection, absPath) : []; + const acceptance_criteria = acSection + ? parseAcceptanceCriteria(acSection, absPath, { + implementationNotes: implementation_notes, + epic: epicFromName, + story: storyFromName, + slug, + repoRoot: opts.repoRoot, + }) + : []; // Dependencies. const depSection = sections.get("Dependencies"); const depends_on = depSection ? parseDependencies(depSection) : []; - // Implementation notes. - const implSection = sections.get("Dev Notes") ?? sections.get("Implementation Notes"); - const implementation_notes = implSection - ? implSection.bodyLines.join("\n").trim() || undefined - : undefined; - // Ship-gate detection (Story 3.5 Task 4.1). // BMad stories can be tagged as ship-gate via a `tags:` frontmatter line // (if present) or a YAML block before the H1. In practice, BMad story files @@ -205,7 +236,24 @@ function extractNarrativeFromStorySection(section: Section): string { return out.join("\n").trim(); } -function parseAcceptanceCriteria(section: Section, absPath: string): AC[] { +/** + * Per-AC verification derivation context (Story native:01KW5W081X3TJPQBCYF3WAK9RZ). + * `repoRoot` undefined → no derivation (pure mode); every AC's verification stays + * `undefined`. + */ +interface AcDerivationContext { + implementationNotes: string | undefined; + epic: string; + story: string; + slug: string; + repoRoot: string | undefined; +} + +function parseAcceptanceCriteria( + section: Section, + absPath: string, + derivation: AcDerivationContext, +): AC[] { // AC headings look like `**AC1:**`, `**AC2 (user-surface):**`, or // `**AC3 — descriptive title:**` (the descriptive token between em-dashes is // documentation only and is discarded by this parser). @@ -242,7 +290,26 @@ function parseAcceptanceCriteria(section: Section, absPath: string): AC[] { .trim(); const tag = (ac.tag ?? "").toLowerCase(); const kind: AC["kind"] = tag === "integration" || tag === "user-surface" ? "integration" : "unit"; - return { text, kind }; + + // Story native:01KW5W081X3TJPQBCYF3WAK9RZ — derive a verification marker from + // the story's own signals, but ONLY when a repoRoot is supplied (the adapter + // path) AND the derived target resolves on disk. A non-resolving (or absent) + // derivation leaves `verification` undefined → the reviewer falls back to + // manual verification rather than chasing a fabricated path. + const repoRoot = derivation.repoRoot; + if (repoRoot === undefined) { + return { text, kind }; + } + const verification = deriveBmadAcVerification({ + kind, + acBodyText: text, + implementationNotes: derivation.implementationNotes, + epic: derivation.epic, + story: derivation.story, + slug: derivation.slug, + exists: (relPath) => existsSync(path.join(repoRoot, relPath)), + }); + return verification ? { text, kind, verification } : { text, kind }; }); } diff --git a/plugins/flow/mcp-server/src/tools/__tests__/run-reviewer-session.test.ts b/plugins/flow/mcp-server/src/tools/__tests__/run-reviewer-session.test.ts index 57299f89..ac932edd 100644 --- a/plugins/flow/mcp-server/src/tools/__tests__/run-reviewer-session.test.ts +++ b/plugins/flow/mcp-server/src/tools/__tests__/run-reviewer-session.test.ts @@ -27,6 +27,8 @@ import { promises as fs, mkdtempSync, rmSync } from "node:fs"; import * as path from "node:path"; import * as os from "node:os"; import { runReviewerSession } from "../run-reviewer-session.js"; +import { scanSources } from "../scan-sources.js"; +import { resetBmadAdapter } from "../../adapters/bmad/index.js"; import { DuplicateStandardsCriterionIdError, GhRecoverableError, @@ -1347,3 +1349,223 @@ describe("worktree dependency install — AC2: a failed install is a setup-error expect(result.recommendedVerdict).toBe("NEEDS CHANGES"); }); }); + +// --------------------------------------------------------------------------- +// Story native:01KW5W081X3TJPQBCYF3WAK9RZ AC3 — derived BMad markers reach the +// reviewer's classifier through the manifest-to-reviewer data flow. +// +// A BMad source story carries NO inline `vitest:`/`artifact:` markers, so before +// this story every BMad AC classified manual-check-required and the verdict +// stalled. This integration test builds a real BMad workspace, scans it (so the +// manifest carries the derived markers — AC1), and runs `runReviewerSession` +// against it, asserting each derived-marker AC classifies runnable-artifact / +// runnable-vitest (NOT manual-check-required), while a genuinely markerless AC +// still falls back to manual. +// --------------------------------------------------------------------------- + +describe("Story native:01KW5W081X3TJPQBCYF3WAK9RZ AC3 — BMad derived markers reach the reviewer", () => { + const BMAD_REF = "bmad:1.3"; + const STORIES_ROOT = "_bmad-output/planning-artifacts/stories"; + const ARTIFACT_REL = "_bmad-output/implementation-artifacts/1-3-derive-markers.md"; + const TEST_REL = "src/derive/__tests__/derived.test.ts"; + + // Integration AC1 (no test reference → artifact convention), unit AC2 (its own + // prose cites a real test → vitest), unit AC3 (no signal anywhere → stays manual). + // The Dev Notes deliberately carry NO test path, so AC3 cannot borrow one. + const BMAD_STORY = [ + "# Story 1.3: Derive markers", + "", + "Status: ready-for-dev", + "", + "## Story", + "", + "As an operator, I want derived markers, so that the reviewer does not stall.", + "", + "## Acceptance Criteria", + "", + "**AC1 (integration):**", + "**Given** a live MCP server,", + "**When** the adapter scans stories,", + "**Then** the manifest is populated.", + "", + "**AC2:**", + "**Given** a repo,", + `**When** \`${TEST_REL}\` runs,`, + "**Then** the new branch is covered.", + "", + "**AC3:**", + "**Given** a subjective design call,", + "**When** the reviewer reads it,", + "**Then** a human must judge it.", + "", + "## Dev Notes", + "", + "AC1 is verified by its implementation-artifact doc. AC3 has no mechanical check.", + "", + ].join("\n"); + + const TEST_FILE_CONTENTS = [ + 'import { describe, it, expect } from "vitest";', + 'describe("derived", () => {', + ' it("derived passing test", () => {', + " expect(true).toBe(true);", + " });", + "});", + ].join("\n"); + + let bmadRoot: string; + + beforeEach(async () => { + bmadRoot = mkdtempSync(path.join(os.tmpdir(), "flow-bmad-derive-")); + + // .flow/config.yaml — BMad adapter. + await fs.mkdir(path.join(bmadRoot, ".flow"), { recursive: true }); + await atomicWriteFile( + path.join(bmadRoot, ".flow", "config.yaml"), + `adapter: bmad\nadapter_config:\n stories_root: ${STORIES_ROOT}\n`, + ); + + // BMad source story. + await fs.mkdir(path.join(bmadRoot, STORIES_ROOT), { recursive: true }); + await atomicWriteFile( + path.join(bmadRoot, STORIES_ROOT, "1-3-derive-markers.md"), + BMAD_STORY, + ); + + // The two derivation targets, present on the dev tree so scan-time resolution + // succeeds: the implementation-artifact doc (AC1) and the unit test (AC2). + await fs.mkdir(path.join(bmadRoot, path.dirname(ARTIFACT_REL)), { recursive: true }); + await atomicWriteFile(path.join(bmadRoot, ARTIFACT_REL), "# impl doc\n"); + await fs.mkdir(path.join(bmadRoot, path.dirname(TEST_REL)), { recursive: true }); + await atomicWriteFile(path.join(bmadRoot, TEST_REL), TEST_FILE_CONTENTS); + + // docs/standards.md + await fs.mkdir(path.join(bmadRoot, "docs"), { recursive: true }); + await atomicWriteFile(path.join(bmadRoot, "docs", "standards.md"), FIXTURE_STANDARDS); + + __resetGhErrorMapCacheForTests(); + resetBmadAdapter(); + }); + + afterEach(() => { + rmSync(bmadRoot, { recursive: true, force: true }); + resetBmadAdapter(); + }); + + const FAKE_BMAD_DIFF = `diff --git a/${TEST_REL} b/${TEST_REL} +new file mode 100644 +index 0000000..e69de29 +--- /dev/null ++++ b/${TEST_REL} +@@ -0,0 +1 @@ ++test +`; + + /** + * Stub that materialises a worktree containing the derived targets directly (the + * artifact doc, the unit test, and a package.json + lockfile so the vitest runner + * resolves a package root and the install runs). Avoids recursive copy so the + * worktree, which lives under bmadRoot, cannot copy itself. + */ + function makeBmadStub() { + return vi.fn().mockImplementation( + async (cmd: string, args: string[]) => { + const argv = args as string[]; + + // Worktree dependency install (lockfile present) → succeed. + if (cmd === "pnpm" && argv[0] === "install") { + return { stdout: "", stderr: "", exitCode: 0, timedOut: false }; + } + + if (cmd === "gh") { + if (argv.includes("diff")) { + return { stdout: FAKE_BMAD_DIFF, stderr: "", exitCode: 0, timedOut: false }; + } + if (argv.includes("headRefName,headRefOid")) { + return { + stdout: JSON.stringify({ + headRefName: "pr-head", + headRefOid: "aabbccddaabbccddaabbccddaabbccddaabbccdd", + }), + stderr: "", + exitCode: 0, + timedOut: false, + }; + } + // pr-view --json commits (risk-tier) and any other gh call. + return { stdout: '["feat: derive markers"]', stderr: "", exitCode: 0, timedOut: false }; + } + + if (cmd === "git") { + if (argv[0] === "worktree" && argv[1] === "add") { + const worktreePath = argv[2]!; + await fs.mkdir(path.join(worktreePath, path.dirname(ARTIFACT_REL)), { recursive: true }); + await fs.writeFile(path.join(worktreePath, ARTIFACT_REL), "# impl doc\n"); + await fs.mkdir(path.join(worktreePath, path.dirname(TEST_REL)), { recursive: true }); + await fs.writeFile(path.join(worktreePath, TEST_REL), TEST_FILE_CONTENTS); + await fs.writeFile( + path.join(worktreePath, "package.json"), + JSON.stringify({ name: "bmad-fixture", version: "0.0.0", private: true }), + ); + await fs.writeFile(path.join(worktreePath, "pnpm-lock.yaml"), "lockfileVersion: '9.0'\n"); + return { stdout: "", stderr: "", exitCode: 0, timedOut: false }; + } + if (argv[0] === "worktree" && argv[1] === "remove") { + const removePath = argv[2]; + if (removePath) { + await fs.rm(removePath, { recursive: true, force: true }).catch(() => {}); + } + return { stdout: "", stderr: "", exitCode: 0, timedOut: false }; + } + return { stdout: "", stderr: "", exitCode: 0, timedOut: false }; + } + + if (cmd === "pnpm") { + // vitest run — a realistic passing summary so the zero-executed guard is satisfied. + return { + stdout: "\n Test Files 1 passed (1)\n Tests 1 passed (1)\n", + stderr: "", + exitCode: 0, + timedOut: false, + }; + } + + return { stdout: "", stderr: `unexpected command: ${cmd}`, exitCode: 1, timedOut: false }; + }, + ) as unknown as typeof import("execa").execa; + } + + it("scan persists derived markers and the reviewer classifies them runnable (artifact + vitest), not manual", async () => { + // --- AC1: scan persists the derived markers into the to-do manifest. --- + await scanSources({ targetRepoRoot: bmadRoot }); + const manifestPath = path.join(bmadRoot, ".flow", "state", "to-do", `${BMAD_REF}.yaml`); + const manifestText = await fs.readFile(manifestPath, "utf8"); + // AC1 integration → artifact convention; AC2 unit → vitest from the cited test. + expect(manifestText).toContain(`type: artifact`); + expect(manifestText).toContain(ARTIFACT_REL); + expect(manifestText).toContain(`type: vitest`); + expect(manifestText).toContain(TEST_REL); + + // --- AC3: the reviewer classifies derived markers as runnable, not manual. --- + const result = await runReviewerSession({ + targetRepoRoot: bmadRoot, + sessionUlid: "01HZSESSION0000000000BMADREV", + ref: BMAD_REF, + prNumber: 7, + execaImpl: makeBmadStub(), + }); + + const ac1 = result.acResults[1]!; + expect(ac1.applicability).toBe("runnable-artifact-check"); + if (ac1.applicability === "runnable-artifact-check") { + expect(ac1.status).toBe("pass"); + } + const ac2 = result.acResults[2]!; + expect(ac2.applicability).toBe("runnable-vitest"); + if (ac2.applicability === "runnable-vitest") { + expect(ac2.status).toBe("pass"); + } + // AC3 had no derivable signal → it must still fall back to manual verification. + expect(result.acResults[3]!.applicability).toBe("manual-check-required"); + }); +}); diff --git a/plugins/flow/mcp-server/src/tools/run-reviewer-session.ts b/plugins/flow/mcp-server/src/tools/run-reviewer-session.ts index 7286bf79..2d8d28ee 100644 --- a/plugins/flow/mcp-server/src/tools/run-reviewer-session.ts +++ b/plugins/flow/mcp-server/src/tools/run-reviewer-session.ts @@ -60,6 +60,7 @@ import { } from "../lib/prepare-review-worktree.js"; import { classifyRiskTier } from "./classify-risk-tier.js"; import { emitFriction } from "../lib/emit-friction.js"; +import { deriveBmadAcVerification } from "../adapters/bmad/derive-bmad-ac-verification.js"; import type { SourceStory } from "../adapters/adapter.js"; import type { Criterion, StandardsDoc } from "../schemas/standards-doc.js"; import type { RiskTierBlock } from "./classify-risk-tier.js"; @@ -228,6 +229,30 @@ function classifyAc(bodyLines: string[]): { return { applicability: "manual-check-required" }; } +/** + * BMad source-story filename shape (`--.md`), kept identical + * to the BMad parser's pattern. Used to recover the epic/story/slug for the + * implementation-artifact convention when deriving a verification marker at + * review time (Story native:01KW5W081X3TJPQBCYF3WAK9RZ). + */ +const BMAD_FILENAME_RE = /^(\d+)-(\d+)-([a-z0-9-]+)\.md$/; + +/** + * Recover `{ epic, story, slug }` from a BMad story's source path, or `null` when + * the active adapter is not BMad or the filename does not match. Native stories + * carry inline `vitest:`/`artifact:` markers and never reach the manual branch, so + * derivation is gated to the BMad adapter to leave the native path untouched. + */ +function deriveBmadFilenameParts( + adapterName: string, + rawPath: string, +): { epic: string; story: string; slug: string } | null { + if (adapterName !== "bmad") return null; + const m = BMAD_FILENAME_RE.exec(path.basename(rawPath)); + if (!m) return null; + return { epic: m[1]!, story: m[2]!, slug: m[3]! }; +} + // --------------------------------------------------------------------------- // AC runners // --------------------------------------------------------------------------- @@ -844,6 +869,12 @@ export async function runReviewerSession( const acResults: Record = {}; let riskTierBlock: RiskTierBlock | undefined; + // Story native:01KW5W081X3TJPQBCYF3WAK9RZ — arm BMad per-AC verification + // derivation. BMad source ACs carry no inline marker, so without this every one + // classifies manual-check-required and the verdict stalls. `null` for non-BMad + // adapters (native ACs carry inline markers and never reach the manual branch). + const bmadDerivation = deriveBmadFilenameParts(workspace.activeAdapter.name, sourceStory.raw_path); + try { for (const ac of acEntries) { const classification = classifyAc(ac.body); @@ -879,13 +910,52 @@ export async function runReviewerSession( execaImpl, ); } else { - // manual-check-required (spec §2c) - acResults[ac.index] = { - index: ac.index, - tag: ac.tag, - applicability: "manual-check-required", - reason: "AC body has no `artifact:` or `vitest:` marker — manual check required before merge", - }; + // No inline `artifact:`/`vitest:` marker in the spec body. For a BMad + // source story — which never carries inline markers — attempt to DERIVE + // one from the story's own signals (Story + // native:01KW5W081X3TJPQBCYF3WAK9RZ), resolving the candidate against the + // PR-branch worktree the checks actually run in. The derivation's + // resolvability guard means a derived marker can only upgrade this AC from + // manual to a runnable check when its target genuinely exists on the + // worktree — it can never turn a benign marker gap into a hard failure + // (the #422-mirror risk). + const derived = bmadDerivation + ? deriveBmadAcVerification({ + kind: ac.tag === "integration" || ac.tag === "user-surface" ? "integration" : "unit", + acBodyText: ac.body.join("\n"), + implementationNotes: sourceStory.implementation_notes, + epic: bmadDerivation.epic, + story: bmadDerivation.story, + slug: bmadDerivation.slug, + exists: (relPath) => existsSync(path.join(worktreePath, relPath)), + }) + : undefined; + + if (derived?.type === "artifact") { + acResults[ac.index] = await runArtifactCheck( + ac.index, + ac.tag, + derived.target, + worktreePath, // checkRoot — AC2 + ); + } else if (derived?.type === "vitest") { + acResults[ac.index] = await runVitestCheck( + ac.index, + ac.tag, + derived.target, + derived.target, + worktreePath, // checkRoot — AC2 + execaImpl, + ); + } else { + // manual-check-required (spec §2c) + acResults[ac.index] = { + index: ac.index, + tag: ac.tag, + applicability: "manual-check-required", + reason: "AC body has no `artifact:` or `vitest:` marker — manual check required before merge", + }; + } } }