@ivorycanvas/qamap 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +44 -578
  3. package/dist/agent-init.d.ts +16 -0
  4. package/dist/agent-init.js +110 -0
  5. package/dist/agent-init.js.map +1 -0
  6. package/dist/cli.js +17 -1
  7. package/dist/cli.js.map +1 -1
  8. package/dist/context.d.ts +1 -0
  9. package/dist/context.js +4 -0
  10. package/dist/context.js.map +1 -1
  11. package/dist/domain-language.js +48 -5
  12. package/dist/domain-language.js.map +1 -1
  13. package/dist/e2e.d.ts +4 -0
  14. package/dist/e2e.js +385 -74
  15. package/dist/e2e.js.map +1 -1
  16. package/dist/fixture-insight.d.ts +8 -0
  17. package/dist/fixture-insight.js +193 -0
  18. package/dist/fixture-insight.js.map +1 -0
  19. package/dist/fs.js +11 -0
  20. package/dist/fs.js.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/manifest.d.ts +5 -0
  25. package/dist/manifest.js +615 -57
  26. package/dist/manifest.js.map +1 -1
  27. package/dist/qa.d.ts +4 -0
  28. package/dist/qa.js +125 -18
  29. package/dist/qa.js.map +1 -1
  30. package/dist/terminal.d.ts +2 -0
  31. package/dist/terminal.js +51 -0
  32. package/dist/terminal.js.map +1 -0
  33. package/dist/test-plan.js +56 -6
  34. package/dist/test-plan.js.map +1 -1
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/docs/adoption.md +62 -0
  38. package/docs/agent-format.md +52 -0
  39. package/docs/agent-skill.md +17 -1
  40. package/docs/benchmarking.md +65 -20
  41. package/docs/commands.md +242 -0
  42. package/docs/configuration.md +11 -0
  43. package/docs/e2e-output-examples.md +8 -4
  44. package/docs/guardrails.md +64 -0
  45. package/docs/manifest.md +7 -3
  46. package/docs/quickstart-demo.md +1 -1
  47. package/docs/release-validation.md +35 -34
  48. package/docs/releasing.md +13 -10
  49. package/docs/roadmap.md +9 -14
  50. package/package.json +4 -3
  51. package/schema/qamap-agent.schema.json +171 -0
  52. package/skills/qamap-pr-qa/SKILL.md +1 -0
package/dist/e2e.js CHANGED
@@ -3,6 +3,7 @@ import path from "node:path";
3
3
  import YAML from "yaml";
4
4
  import { buildDomainLanguageSummary } from "./domain-language.js";
5
5
  import { defaultDomainManifestPath, loadDomainManifest, matchDomains } from "./domains.js";
6
+ import { analyzeFixtureSource, insightCoversEndpoint } from "./fixture-insight.js";
6
7
  import { collectProjectFiles } from "./fs.js";
7
8
  import { buildReverseImportIndex, expandChangedFilesWithImporters, findImportingSurfaces } from "./import-graph.js";
8
9
  import { loadCoreFlowManifest, matchCoreFlows } from "./flows.js";
@@ -10,6 +11,19 @@ import { loadVerificationManifest, matchVerificationManifest } from "./manifest.
10
11
  import { collectTestSuiteInventory, evaluateFlowCoverageEvidence, summarizeTestSuiteInventory, } from "./test-evidence.js";
11
12
  import { collectAddedDiffText, generateTestPlan } from "./test-plan.js";
12
13
  import { TOOL_NAME, VERSION } from "./version.js";
14
+ // Human reports render readiness as a stage on a fixed journey instead of a
15
+ // verdict, so a first run reads as "you are at the start", not "you failed".
16
+ // JSON/agent output keeps the raw level values as the stable contract.
17
+ const draftReadinessStages = {
18
+ blocked: { position: 1, label: "setup needed" },
19
+ "needs-work": { position: 2, label: "draft in progress" },
20
+ "near-runnable": { position: 3, label: "almost runnable" },
21
+ ready: { position: 4, label: "ready to run" },
22
+ };
23
+ export function formatDraftReadinessStage(summary) {
24
+ const stage = draftReadinessStages[summary.level];
25
+ return `${stage.label} (${stage.position} of 4) — readiness ${summary.score}/100`;
26
+ }
13
27
  const maxFilesPerFlow = 8;
14
28
  const workspacePackageSearchLimit = 200;
15
29
  const workspacePackageIgnoredDirectories = new Set([
@@ -30,7 +44,10 @@ export async function generateE2ePlan(rootInput, options = {}) {
30
44
  const root = path.resolve(rootInput);
31
45
  const testPlan = await generateTestPlan(root, options);
32
46
  const project = await detectProjectProfile(root, testPlan.workspaceRoot);
33
- const recommendedRunner = options.runner ? overrideRunner(project, options.runner) : recommendRunner(project);
47
+ const changedPaths = testPlan.changedFiles.map((file) => file.path);
48
+ const recommendedRunner = options.runner
49
+ ? overrideRunner(project, options.runner)
50
+ : recommendRunnerForChange(project, changedPaths);
34
51
  const executionProfile = await buildExecutionProfile(root, testPlan.workspaceRoot, project, recommendedRunner.name);
35
52
  const testSuiteInventory = await collectTestSuiteInventory(root);
36
53
  const coreFlowRoot = testPlan.workspaceRoot ?? root;
@@ -459,6 +476,7 @@ function buildE2eValidationMatrix(flows, coreFlows) {
459
476
  }
460
477
  function buildE2eBootstrapPlan(input) {
461
478
  const steps = [];
479
+ const verificationOnly = input.flows.length > 0 && input.flows.every(isVerificationOnlyFlow);
462
480
  const runnerGap = input.missingTestability.find((gap) => /No \.maestro|No Playwright config/i.test(gap));
463
481
  const draftCommand = `qamap e2e draft . --base ${input.base} --head ${input.head}`;
464
482
  const planHistoryCommand = `qamap e2e plan . --base ${input.base} --head ${input.head} --record-history`;
@@ -471,7 +489,10 @@ function buildE2eBootstrapPlan(input) {
471
489
  ? `${concreteTargets.length} changed workspace target${concreteTargets.length === 1 ? "" : "s"} have clearer app or service signals than the workspace root.`
472
490
  : "Changed files map to workspace packages, but their app surface still needs a package-scoped review.", "Run the suggested package-scoped plan commands, then generate drafts from the package whose changed flow is user-facing.", input.workspaceTargets.map((target) => target.suggestedCommand).slice(0, 4), input.workspaceTargets.flatMap((target) => target.changedFiles).slice(0, maxFilesPerFlow)));
473
491
  }
474
- if (input.recommendedRunner.name === "manual") {
492
+ if (verificationOnly) {
493
+ steps.push(bootstrapStep("runner", "ready", "Use repository validation for this verification-only change", "The diff maps to configuration, documentation, generated output, or existing test evidence rather than a new product journey.", "Run the nearest repository validation command and attach the result as PR evidence; no UI runner setup is required by this diff alone.", input.suggestedCommands.slice(0, 4), input.flows.flatMap((flow) => flow.files).slice(0, maxFilesPerFlow)));
494
+ }
495
+ else if (input.recommendedRunner.name === "manual") {
475
496
  steps.push(bootstrapStep("runner", "required", manualBootstrapTitle(input.projectType), manualBootstrapReason(input.projectType), manualBootstrapAction(input.projectType), [], []));
476
497
  }
477
498
  else if (runnerGap) {
@@ -487,27 +508,30 @@ function buildE2eBootstrapPlan(input) {
487
508
  else {
488
509
  steps.push(bootstrapStep("runner", "ready", `${formatRunnerName(input.recommendedRunner.name)} setup signal detected`, "QAMap found enough runner setup evidence to generate runnable drafts.", "Keep runner setup documented and linked from PR validation notes.", [], []));
489
510
  }
490
- if (input.recommendedRunner.name !== "manual" && executionProfileBlockers.length > 0) {
511
+ if (!verificationOnly && input.recommendedRunner.name !== "manual" && executionProfileBlockers.length > 0) {
491
512
  steps.push(bootstrapStep("runner", "required", "Complete the E2E execution profile", executionProfileBlockers.slice(0, 3).join(" "), "Document or configure the missing command, URL, app id, or runner file before treating generated drafts as runnable regression coverage.", uniqueStrings([input.executionProfile.startCommand, input.executionProfile.testCommand].filter(Boolean)), input.executionProfile.configFiles));
492
513
  }
493
- if (!input.testSuite.hasTestSuite) {
514
+ if (verificationOnly) {
515
+ steps.push(bootstrapStep("draft", "ready", "No new E2E draft required for this change", "The changed files are verification inputs or existing evidence, so generating another product-journey test would duplicate or invent coverage.", "Run the changed evidence or nearest build, lint, typecheck, documentation, or artifact validation command and record its result.", input.suggestedCommands.slice(0, 4), input.flows.flatMap((flow) => flow.files).slice(0, maxFilesPerFlow)));
516
+ }
517
+ else if (!input.testSuite.hasTestSuite) {
494
518
  steps.push(bootstrapStep("draft", "required", "Create the first changed-flow E2E draft", "No existing test files were detected, so this branch needs a concrete first draft before QAMap can compare coverage evidence.", "Generate the first draft, replace TODO selectors and setup values, then decide which paths should become required regression coverage.", [draftCommand], input.flows.flatMap((flow) => flow.files).slice(0, maxFilesPerFlow)));
495
519
  }
496
520
  else {
497
521
  steps.push(bootstrapStep("draft", "ready", "Existing test suite detected", `${input.testSuite.testFileCount} test file${input.testSuite.testFileCount === 1 ? "" : "s"} can be used as coverage evidence.`, "Use the validation matrix to expand existing tests or generated drafts only where evidence is weak.", [], []));
498
522
  }
499
- if (!input.domainManifestPath && input.domainLanguage.terms.length > 0) {
523
+ if (!verificationOnly && !input.domainManifestPath && input.domainLanguage.terms.length > 0) {
500
524
  steps.push(bootstrapStep("domain-language", "recommended", "Promote repeated product words into a domain manifest", `QAMap inferred ${input.domainLanguage.terms.length} domain term${input.domainLanguage.terms.length === 1 ? "" : "s"} from changed files, but no shared domain manifest was found.`, "Generate a suggested domain manifest from this branch, review names and routes with the team, then commit only the terms that match team language.", [domainsSuggestCommand, `${domainsSuggestCommand} --write .qamap/domains.yml`], input.domainLanguage.terms.flatMap((term) => term.files).slice(0, maxFilesPerFlow)));
501
525
  }
502
- else if (input.domainManifestPath || input.domains.length > 0) {
526
+ else if (!verificationOnly && (input.domainManifestPath || input.domains.length > 0)) {
503
527
  steps.push(bootstrapStep("domain-language", "ready", "Domain language has shared policy evidence", input.domainManifestPath
504
528
  ? `Domain manifest found at ${input.domainManifestPath}.`
505
529
  : "Matched domain definitions were found.", "Keep committed domain language aligned with the names used in generated E2E drafts.", [], []));
506
530
  }
507
- if (!input.coreFlowManifestPath) {
531
+ if (!verificationOnly && !input.coreFlowManifestPath) {
508
532
  steps.push(bootstrapStep("core-flow", input.flows.length > 0 ? "recommended" : "required", "Capture the first durable core flows", "No .qamap/flows.yml manifest was found, so QAMap can infer changed-flow candidates but cannot distinguish team-critical journeys yet.", "Generate suggested flow entries from this branch, keep only the journeys humans agree are durable, then commit them as team policy.", [flowsSuggestCommand, `${flowsSuggestCommand} --write .qamap/flows.yml`], input.flows.flatMap((flow) => flow.files).slice(0, maxFilesPerFlow)));
509
533
  }
510
- else {
534
+ else if (!verificationOnly) {
511
535
  steps.push(bootstrapStep("core-flow", input.coreFlows.length > 0 ? "ready" : "recommended", input.coreFlows.length > 0 ? "Core flow manifest matched this change" : "Review core flow manifest coverage", input.coreFlows.length > 0
512
536
  ? `${input.coreFlows.length} declared core flow${input.coreFlows.length === 1 ? "" : "s"} matched the changed files.`
513
537
  : `Core flow manifest found at ${input.coreFlowManifestPath}, but this change did not match a declared flow.`, input.coreFlows.length > 0
@@ -527,24 +551,30 @@ function buildE2eBootstrapPlan(input) {
527
551
  : `${partialFixtureRows.length} API-dependent flows have only partial fixture evidence.`, "Cover success plus one empty, unauthorized, timeout, rejected, or server-error response before requiring the generated draft.", [], uniqueStrings([...missingFixtureRows, ...partialFixtureRows].flatMap((row) => row.files)).slice(0, maxFilesPerFlow)));
528
552
  }
529
553
  const nonRunnerTestabilityGaps = input.missingTestability.filter((gap) => !/No \.maestro|No Playwright config/i.test(gap));
530
- if (nonRunnerTestabilityGaps.length > 0) {
554
+ if (!verificationOnly && nonRunnerTestabilityGaps.length > 0) {
531
555
  steps.push(bootstrapStep("testability", "required", "Add stable selectors for changed user actions", `${nonRunnerTestabilityGaps.length} selector or interaction gap${nonRunnerTestabilityGaps.length === 1 ? "" : "s"} were detected.`, "Add stable test ids, accessibility labels, roles, or durable visible copy for the controls the draft must tap, type into, or assert.", [], input.flows.flatMap((flow) => flow.files).slice(0, maxFilesPerFlow)));
532
556
  }
533
- else if (input.flows.some((flow) => flow.selectors.length > 0 || flow.entrypoints.length > 0)) {
557
+ else if (!verificationOnly && input.flows.some((flow) => flow.selectors.length > 0 || flow.entrypoints.length > 0)) {
534
558
  steps.push(bootstrapStep("testability", "ready", "Entrypoint or selector evidence detected", "Generated drafts can reuse detected routes, screens, selectors, labels, or visible copy.", "Review the generated locators and replace weak starter assertions with domain-specific checks before promoting the draft.", [], input.flows.flatMap((flow) => flow.selectors.map((selector) => selector.file)).slice(0, maxFilesPerFlow)));
535
559
  }
536
560
  if (input.validationMatrix.summary.missing > 0) {
537
561
  steps.push(bootstrapStep("validation", "required", "Close missing validation matrix rows", input.validationMatrix.summary.missing === 1
538
562
  ? "1 validation row is missing evidence."
539
- : `${input.validationMatrix.summary.missing} validation rows are missing evidence.`, "Resolve missing coverage, fixture, setup, and testability rows before calling generated E2E coverage sufficient.", [], input.validationMatrix.rows.filter((row) => row.status === "missing").flatMap((row) => row.files).slice(0, maxFilesPerFlow)));
563
+ : `${input.validationMatrix.summary.missing} validation rows are missing evidence.`, verificationOnly
564
+ ? "Run or record the missing repository validation checks before treating this change as verified."
565
+ : "Resolve missing coverage, fixture, setup, and testability rows before calling generated E2E coverage sufficient.", [], input.validationMatrix.rows.filter((row) => row.status === "missing").flatMap((row) => row.files).slice(0, maxFilesPerFlow)));
540
566
  }
541
567
  else if (input.validationMatrix.summary.partial > 0) {
542
568
  steps.push(bootstrapStep("validation", "recommended", "Strengthen partial validation evidence", input.validationMatrix.summary.partial === 1
543
569
  ? "1 validation row is only partial."
544
- : `${input.validationMatrix.summary.partial} validation rows are only partial.`, "Expand the generated draft or existing tests until the critical rows have concrete evidence.", [], input.validationMatrix.rows.filter((row) => row.status === "partial").flatMap((row) => row.files).slice(0, maxFilesPerFlow)));
570
+ : `${input.validationMatrix.summary.partial} validation rows are only partial.`, verificationOnly
571
+ ? "Run or record the partial repository checks until each critical row has concrete evidence."
572
+ : "Expand the generated draft or existing tests until the critical rows have concrete evidence.", [], input.validationMatrix.rows.filter((row) => row.status === "partial").flatMap((row) => row.files).slice(0, maxFilesPerFlow)));
545
573
  }
546
574
  else if (input.validationMatrix.rows.length > 0) {
547
- steps.push(bootstrapStep("validation", "ready", "Validation matrix is ready", "All validation matrix rows currently have ready evidence.", "Keep the matrix linked in PR evidence when the generated draft is promoted.", [], []));
575
+ steps.push(bootstrapStep("validation", "ready", "Validation matrix is ready", "All validation matrix rows currently have ready evidence.", verificationOnly
576
+ ? "Keep the completed repository checks linked in PR evidence."
577
+ : "Keep the matrix linked in PR evidence when the generated draft is promoted.", [], []));
548
578
  }
549
579
  steps.push(bootstrapStep("history", "recommended", "Record local plan history while iterating", "Local run history lets the team compare how domain language, core flows, fixtures, and validation gaps evolve without spending more agent tokens.", "Run the plan with --record-history after important draft or manifest changes.", [planHistoryCommand], []));
550
580
  if (input.suggestedCommands.length === 0) {
@@ -557,7 +587,7 @@ function buildE2eBootstrapPlan(input) {
557
587
  ready: sortedSteps.filter((step) => step.status === "ready").length,
558
588
  };
559
589
  return {
560
- summary: bootstrapSummary(counts, sortedSteps),
590
+ summary: bootstrapSummary(counts, sortedSteps, verificationOnly),
561
591
  steps: sortedSteps,
562
592
  counts,
563
593
  };
@@ -661,9 +691,12 @@ function bootstrapCategoryRank(category) {
661
691
  ];
662
692
  return order.indexOf(category);
663
693
  }
664
- function bootstrapSummary(counts, steps) {
694
+ function bootstrapSummary(counts, steps, verificationOnly = false) {
665
695
  const firstRequired = steps.find((step) => step.status === "required");
666
696
  if (firstRequired) {
697
+ if (verificationOnly) {
698
+ return `${counts.required} required verification step${counts.required === 1 ? "" : "s"} must be resolved before this change has repeatable PR evidence. Start with: ${firstRequired.title}.`;
699
+ }
667
700
  return `${counts.required} required bootstrap step${counts.required === 1 ? "" : "s"} must be resolved before generated E2E drafts should be treated as regression coverage. Start with: ${firstRequired.title}.`;
668
701
  }
669
702
  const firstRecommended = steps.find((step) => step.status === "recommended");
@@ -922,6 +955,7 @@ function buildDraftPromotionGuidance(flow) {
922
955
  }
923
956
  function buildDraftActionItems(plan, flow, runner, validationSummary, promotionGuidance, selfCheck) {
924
957
  const items = [];
958
+ const verificationOnly = isVerificationOnlyFlow(flow);
925
959
  const runnerGap = runnerSetupGap(plan, runner);
926
960
  if (runnerGap) {
927
961
  const setupCommand = plan.runnerSetup.setupCommand ? ` Run \`${plan.runnerSetup.setupCommand}\` after accepting this runner setup.` : "";
@@ -931,7 +965,7 @@ function buildDraftActionItems(plan, flow, runner, validationSummary, promotionG
931
965
  items.push(draftActionItem("runner", "required", `Configure ${formatRunnerName(runner)} execution`, setupDetail));
932
966
  }
933
967
  const executionBlockers = remainingExecutionProfileBlockers(plan.executionProfile.blockers, runnerGap);
934
- if (runner !== "manual" && executionBlockers.length > 0) {
968
+ if (!verificationOnly && runner !== "manual" && executionBlockers.length > 0) {
935
969
  items.push(draftActionItem("runner", "required", "Complete execution profile", executionBlockers.slice(0, 3).join(" ")));
936
970
  }
937
971
  const route = primaryRouteEntrypoint(flow);
@@ -942,34 +976,42 @@ function buildDraftActionItems(plan, flow, runner, validationSummary, promotionG
942
976
  items.push(draftActionItem("fixture", "required", "Replace dynamic route parameters", `Provide real fixture values for ${unresolvedParams.map((param) => param.name).join(", ")} before running ${route.value}.`));
943
977
  }
944
978
  }
979
+ // Endpoints ride in the title because the agent format keeps only titles.
980
+ const fixtureEndpointSuffix = flow.fixtureReadiness.apiEndpoints.length > 0
981
+ ? ` for ${formatEndpointSummary(flow.fixtureReadiness.apiEndpoints)}`
982
+ : "";
945
983
  if (flow.fixtureReadiness.status === "missing") {
946
- items.push(draftActionItem("fixture", "required", "Add deterministic fixture or mock data", flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason));
984
+ items.push(draftActionItem("fixture", "required", `Add deterministic fixture or mock data${fixtureEndpointSuffix}`, flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason));
947
985
  }
948
986
  else if (flow.fixtureReadiness.status === "partial") {
949
- items.push(draftActionItem("fixture", "recommended", "Confirm fixture coverage", flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason));
987
+ items.push(draftActionItem("fixture", "recommended", `Confirm fixture coverage${fixtureEndpointSuffix}`, flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason));
950
988
  }
951
- if (flow.missingTestability.length > 0) {
989
+ if (!verificationOnly && flow.missingTestability.length > 0) {
952
990
  items.push(draftActionItem("selector", "required", "Replace weak or missing selectors", flow.missingTestability[0]));
953
991
  }
954
- else if (flow.selectors.length === 0 && runner !== "manual") {
992
+ else if (!verificationOnly && flow.selectors.length === 0 && runner !== "manual") {
955
993
  items.push(draftActionItem("selector", "recommended", "Confirm stable selectors", "No stable selector hints were inferred, so review the generated locators before making this draft required."));
956
994
  }
957
- if (flow.steps.length > 0 && draftNeedsAssertionWork(selfCheck)) {
995
+ if (!verificationOnly && flow.steps.length > 0 && draftNeedsAssertionWork(selfCheck)) {
958
996
  items.push(draftActionItem("assertion", "required", "Replace starter smoke assertions with domain assertions", `Preserve the success signal "${flow.languageBrief.successSignal}" while replacing weak fallback interactions and generic expects.`));
959
997
  }
960
- if (selfCheck?.status === "fail") {
998
+ if (!verificationOnly && selfCheck?.status === "fail") {
961
999
  items.push(draftActionItem("validation", "required", "Resolve generated draft self-check", selfCheck.blockers[0] ?? selfCheck.summary));
962
1000
  }
963
- else if (selfCheck?.status === "warning") {
1001
+ else if (!verificationOnly && selfCheck?.status === "warning") {
964
1002
  items.push(draftActionItem("validation", "recommended", "Review generated draft self-check", selfCheck.summary));
965
1003
  }
966
1004
  if (validationSummary.blockingGapCount > 0) {
967
- items.push(draftActionItem("validation", "required", "Resolve missing validation evidence", `${validationSummary.blockingGapCount} blocking validation gap${validationSummary.blockingGapCount === 1 ? "" : "s"} must be closed before using this draft as PR evidence.`));
1005
+ items.push(draftActionItem("validation", "required", "Resolve missing validation evidence", verificationOnly
1006
+ ? `${validationSummary.blockingGapCount} blocking validation gap${validationSummary.blockingGapCount === 1 ? "" : "s"} must be closed before treating this change as verified PR evidence.`
1007
+ : `${validationSummary.blockingGapCount} blocking validation gap${validationSummary.blockingGapCount === 1 ? "" : "s"} must be closed before using this draft as PR evidence.`));
968
1008
  }
969
1009
  else if (validationSummary.gapCount > 0) {
970
- items.push(draftActionItem("validation", "recommended", "Close partial validation evidence", `${validationSummary.gapCount} validation gap${validationSummary.gapCount === 1 ? "" : "s"} remain before this draft is stable regression coverage.`));
1010
+ items.push(draftActionItem("validation", "recommended", "Close partial validation evidence", verificationOnly
1011
+ ? `${validationSummary.gapCount} validation gap${validationSummary.gapCount === 1 ? "" : "s"} remain before this change has stable verification evidence.`
1012
+ : `${validationSummary.gapCount} validation gap${validationSummary.gapCount === 1 ? "" : "s"} remain before this draft is stable regression coverage.`));
971
1013
  }
972
- if (promotionGuidance.status !== "commit-candidate") {
1014
+ if (!verificationOnly && promotionGuidance.status !== "commit-candidate") {
973
1015
  items.push(draftActionItem("manifest", "recommended", "Promote durable product language", promotionGuidance.action));
974
1016
  }
975
1017
  return uniqueDraftActionItems(items).slice(0, 8);
@@ -1177,8 +1219,8 @@ function draftReadinessRecommendation(level, blockers) {
1177
1219
  : "Close required action items before treating the drafts as regression evidence.";
1178
1220
  }
1179
1221
  return blocker
1180
- ? `Do not treat these drafts as runnable yet. Start with: ${withTerminalPeriod(blocker)}`
1181
- : "Do not treat these drafts as runnable until required setup and validation gaps are closed.";
1222
+ ? `Keep these drafts review-only for now and start with: ${withTerminalPeriod(blocker)}`
1223
+ : "Keep these drafts review-only until the required setup and validation gaps are closed.";
1182
1224
  }
1183
1225
  function withTerminalPeriod(value) {
1184
1226
  return /[.!?)]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`;
@@ -1360,6 +1402,10 @@ function inferFlowSuccessSignal(flow) {
1360
1402
  if (/\bconfiguration verification\b/i.test(flow.title)) {
1361
1403
  return "the affected build or runtime variant starts cleanly and handles fallback values";
1362
1404
  }
1405
+ const visibleOutcome = flow.selectors.find((selector) => selector.kind === "visible-text" && isVisibleSuccessOutcome(selector.value));
1406
+ if (visibleOutcome) {
1407
+ return `visible text "${visibleOutcome.value}" appears`;
1408
+ }
1363
1409
  const verificationStep = flow.steps.find((step) => isAssertionStep(step));
1364
1410
  if (verificationStep) {
1365
1411
  return stripTerminalPunctuation(verificationStep);
@@ -1370,32 +1416,36 @@ function inferFlowSuccessSignal(flow) {
1370
1416
  }
1371
1417
  return "the changed journey reaches a visible, stable success state";
1372
1418
  }
1419
+ function isVisibleSuccessOutcome(value) {
1420
+ return /\b(?:confirmed|saved|refreshed|succeeded|success|completed|created|updated|deleted|sent|approved|accepted)\b/i.test(value) ||
1421
+ /(?:완료|성공|저장(?:됨|됐|되)|등록(?:됨|됐|되)|제출(?:됨|됐|되)|승인(?:됨|됐|되))/.test(value);
1422
+ }
1373
1423
  function inferFlowReviewQuestion(flow, successSignal) {
1374
1424
  if (isApiContractFocusedFlow(flow)) {
1375
- return `Can a reviewer confirm that the changed endpoint, handler, or service contract is exercised and that "${successSignal}" is asserted?`;
1425
+ return `Can a reviewer confirm that the changed endpoint, handler, or service contract is exercised and this outcome is verified: ${successSignal}?`;
1376
1426
  }
1377
1427
  if (isDesignTokenFocusedFlow(flow)) {
1378
- return `Can a reviewer confirm that the changed token artifact is regenerated, consumed, and that "${successSignal}" is asserted?`;
1428
+ return `Can a reviewer confirm that the changed token artifact is regenerated, consumed, and this outcome is verified: ${successSignal}?`;
1379
1429
  }
1380
1430
  if (isCatalogFocusedFlow(flow)) {
1381
- return `Can a reviewer confirm that the changed catalog artifact is regenerated, consumed, and that "${successSignal}" is asserted?`;
1431
+ return `Can a reviewer confirm that the changed catalog artifact is regenerated, consumed, and this outcome is verified: ${successSignal}?`;
1382
1432
  }
1383
1433
  if (isTestEvidenceFocusedFlow(flow)) {
1384
- return `Can a reviewer confirm that the changed tests are run and that "${successSignal}" is documented as PR evidence?`;
1434
+ return `Can a reviewer confirm that the changed tests are run and this outcome is documented as PR evidence: ${successSignal}?`;
1385
1435
  }
1386
1436
  if (isDocumentationFocusedFlow(flow)) {
1387
- return `Can a reviewer confirm that docs validation ran and that "${successSignal}" is true?`;
1437
+ return `Can a reviewer confirm that docs validation ran and this outcome is true: ${successSignal}?`;
1388
1438
  }
1389
1439
  if (isGeneratedArtifactFocusedFlow(flow)) {
1390
- return `Can a reviewer confirm that the artifact was regenerated and that "${successSignal}" is true?`;
1440
+ return `Can a reviewer confirm that the artifact was regenerated and this outcome is true: ${successSignal}?`;
1391
1441
  }
1392
1442
  if (isCliCommandFocusedFlow(flow)) {
1393
- return `Can a reviewer confirm that the changed command path is run and that "${successSignal}" is asserted?`;
1443
+ return `Can a reviewer confirm that the changed command path is run and this outcome is verified: ${successSignal}?`;
1394
1444
  }
1395
1445
  if (/\bconfiguration verification\b/i.test(flow.title)) {
1396
- return `Can a reviewer confirm that the affected build, startup, or release variant is exercised and that "${successSignal}" is asserted?`;
1446
+ return `Can a reviewer confirm that the affected build, startup, or release variant is exercised and this outcome is verified: ${successSignal}?`;
1397
1447
  }
1398
- return `Can a reviewer confirm that ${flow.title} still works from this entrypoint and that "${successSignal}" is asserted?`;
1448
+ return `Can a reviewer confirm that ${flow.title} still works from this entrypoint and this outcome is verified: ${successSignal}?`;
1399
1449
  }
1400
1450
  function isApiContractFocusedFlow(flow) {
1401
1451
  if (isEvidenceVerificationFocusedFlow(flow)) {
@@ -1792,7 +1842,7 @@ export function formatMarkdownE2eDraft(result) {
1792
1842
  lines.push("");
1793
1843
  lines.push("## Draft Readiness Summary");
1794
1844
  lines.push("");
1795
- lines.push(`Score: ${result.readinessSummary.score}/100 (${result.readinessSummary.level}) - ${escapeMarkdownInline(result.readinessSummary.recommendation)}`);
1845
+ lines.push(`Stage: ${formatDraftReadinessStage(result.readinessSummary)}. ${escapeMarkdownInline(result.readinessSummary.recommendation)}`);
1796
1846
  lines.push(`Summary: ${result.actionSummary.required} required action${result.actionSummary.required === 1 ? "" : "s"}, ${result.actionSummary.recommended} recommended action${result.actionSummary.recommended === 1 ? "" : "s"}.`);
1797
1847
  lines.push(`- Runnable candidates: ${result.readinessSummary.runnableCandidates}`);
1798
1848
  lines.push(`- Near-runnable files: ${result.readinessSummary.nearRunnable}`);
@@ -1906,7 +1956,7 @@ export function formatMarkdownE2eSetup(result) {
1906
1956
  lines.push(`- Output directory: \`${escapeMarkdownInline(result.draftOutputDirectory)}\``);
1907
1957
  }
1908
1958
  if (result.draftReadinessSummary) {
1909
- lines.push(`- Readiness: ${result.draftReadinessSummary.level} (${result.draftReadinessSummary.score}/100)`);
1959
+ lines.push(`- Stage: ${formatDraftReadinessStage(result.draftReadinessSummary)}`);
1910
1960
  }
1911
1961
  for (const file of result.draftFiles) {
1912
1962
  const details = [
@@ -2221,6 +2271,21 @@ function recommendRunner(project) {
2221
2271
  reason: "No clear app platform was detected, so start with a manual smoke checklist before choosing a runnable E2E framework.",
2222
2272
  };
2223
2273
  }
2274
+ function recommendRunnerForChange(project, changedFiles) {
2275
+ if (isConfigurationOnlyChange(changedFiles)) {
2276
+ return {
2277
+ name: "manual",
2278
+ reason: "Use repository build and configuration validation because this diff changes configuration only; a browser or device journey would invent product coverage.",
2279
+ };
2280
+ }
2281
+ if (isDocumentationOnlyChange(changedFiles) || isGeneratedOutputOnlyChange(changedFiles)) {
2282
+ return {
2283
+ name: "manual",
2284
+ reason: "Use repository documentation or artifact validation because this diff does not change a runtime product surface.",
2285
+ };
2286
+ }
2287
+ return recommendRunner(project);
2288
+ }
2224
2289
  function overrideRunner(project, runner) {
2225
2290
  if (runner === "maestro") {
2226
2291
  return {
@@ -3049,7 +3114,13 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
3049
3114
  const behaviorFiles = files.filter((file) => !isTestLikeFile(file));
3050
3115
  const candidateFiles = behaviorFiles.length > 0 ? behaviorFiles : files;
3051
3116
  const impactSurfaceFiles = importImpacts.map((impact) => impact.surface).filter((surface) => !candidateFiles.includes(surface));
3052
- const uiFiles = uniqueStrings([...candidateFiles.filter(isUserFacingFile), ...impactSurfaceFiles]);
3117
+ // Backend route modules often live under `routes/`, which is also a common
3118
+ // frontend surface directory. Once the repository is classified as an API
3119
+ // service, keep those files in contract analysis instead of inventing a UI
3120
+ // journey that the project cannot run.
3121
+ const uiFiles = projectType === "api-service"
3122
+ ? []
3123
+ : uniqueStrings([...impactSurfaceFiles, ...candidateFiles.filter(isUserFacingFile)]);
3053
3124
  const apiFiles = candidateFiles.filter(isApiLikeFile);
3054
3125
  const apiServiceSourceFiles = projectType === "api-service"
3055
3126
  ? candidateFiles.filter((file) => !apiFiles.includes(file) && !isConfigLikeFile(file) && isServiceSourceFile(file))
@@ -3067,7 +3138,8 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
3067
3138
  const domainFiles = candidateFiles.filter(isDomainOwnedFile);
3068
3139
  const candidates = [];
3069
3140
  if (uiFiles.length > 0) {
3070
- const subject = summarizeFlowSubject(uiFiles, "Changed", domainLanguage);
3141
+ const subjectFiles = impactSurfaceFiles.length > 0 ? impactSurfaceFiles : uiFiles;
3142
+ const subject = summarizeFlowSubject(subjectFiles, "Changed", domainLanguage);
3071
3143
  const impactReason = importImpacts.length > 0
3072
3144
  ? ` Changed shared files reach these surfaces through imports: ${importImpacts.slice(0, 3).map(describeImportChain).join("; ")}.`
3073
3145
  : "";
@@ -3186,7 +3258,9 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
3186
3258
  if (configFiles.length > 0) {
3187
3259
  const subject = isReleaseMetadataOnlyChange(configFiles)
3188
3260
  ? "Release metadata"
3189
- : summarizeFlowSubject(configFiles, "Changed", domainLanguage);
3261
+ : (projectType === "expo-react-native" || projectType === "react-native") && configFiles.some(isMobileNativeConfigFile)
3262
+ ? "Mobile build"
3263
+ : summarizeFlowSubject(configFiles, "Changed", domainLanguage);
3190
3264
  candidates.push({
3191
3265
  kind: "config",
3192
3266
  title: `${subject} configuration verification ${runner === "manual" ? "checklist" : "flow"}`,
@@ -3315,7 +3389,10 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
3315
3389
  }
3316
3390
  const coverage = buildCoverageTargets(candidate.kind, files, runner);
3317
3391
  const setupHints = await inferFlowSetupHints(root, files, candidate.kind);
3318
- const selectors = await inferFlowSelectors(root, files, runner, addedDiffText);
3392
+ const interactionEvidenceApplies = !isVerificationOnlyKind(candidate.kind);
3393
+ const selectors = interactionEvidenceApplies
3394
+ ? await inferFlowSelectors(root, files, runner, addedDiffText)
3395
+ : [];
3319
3396
  const flow = {
3320
3397
  title: candidate.title,
3321
3398
  reason: candidate.reason,
@@ -3323,17 +3400,20 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
3323
3400
  steps: refineStepsForInferredSelectors(candidate.steps, selectors),
3324
3401
  coverage,
3325
3402
  coverageEvidence: evaluateFlowCoverageEvidence({ title: candidate.title, files, coverage }, testSuiteInventory),
3326
- entrypoints: await inferFlowEntrypoints(root, files, runner),
3403
+ entrypoints: interactionEvidenceApplies ? await inferFlowEntrypoints(root, files, runner) : [],
3327
3404
  setupHints,
3328
3405
  fixtureReadiness: await inferFlowFixtureReadiness(root, files, candidate.kind, setupHints, fixtureContext),
3329
3406
  selectors,
3330
- missingTestability: await findFlowTestabilityGaps(root, files, runner),
3407
+ missingTestability: interactionEvidenceApplies ? await findFlowTestabilityGaps(root, files, runner) : [],
3331
3408
  };
3332
3409
  return {
3333
3410
  ...flow,
3334
3411
  languageBrief: buildFlowLanguageBrief(flow),
3335
3412
  };
3336
3413
  }
3414
+ function isVerificationOnlyKind(kind) {
3415
+ return kind === "config" || kind === "test-evidence" || kind === "documentation" || kind === "generated-artifact";
3416
+ }
3337
3417
  function preferDiffAdded(selectors, predicate) {
3338
3418
  return selectors.find((selector) => selector.addedInDiff && predicate(selector)) ?? selectors.find(predicate);
3339
3419
  }
@@ -3387,7 +3467,11 @@ function selectorStepLabel(selector) {
3387
3467
  return raw.length > 0 ? `"${raw}"` : `the ${selector.kind} control`;
3388
3468
  }
3389
3469
  function actionVerbForSelector(selector) {
3390
- if (/\b(?:submit|send|apply|complete|confirm|save|continue|next|upload)\b/i.test(selector.value.replace(/[-_]+/g, " "))) {
3470
+ const normalized = selector.value.replace(/[-_]+/g, " ");
3471
+ if (/\b(?:submit|send|apply|complete|confirm|save|continue|next|upload)\b/i.test(normalized)) {
3472
+ return "Submit";
3473
+ }
3474
+ if (/(?:제출|저장|보내기|전송|등록|신청|결제|구매|확인|완료)/.test(normalized)) {
3391
3475
  return "Submit";
3392
3476
  }
3393
3477
  return "Activate";
@@ -3464,12 +3548,38 @@ function setupHint(kind, title, detail, files, confidence) {
3464
3548
  confidence,
3465
3549
  };
3466
3550
  }
3551
+ const maxAnalyzedMockFiles = 24;
3467
3552
  async function collectFixtureReadinessContext(root, changedFiles) {
3468
3553
  const projectFiles = await collectProjectFiles(root, 12000);
3554
+ const changedMockFiles = changedFiles.filter(isMockOrFixtureFile).slice(0, maxFilesPerFlow);
3555
+ const projectMockEntries = projectFiles.filter((file) => isMockOrFixtureFile(file.path));
3556
+ // The project walk already loaded these files' text (with its own size and
3557
+ // text-extension guards); parsing it here is what lets fixture guidance
3558
+ // name concrete exports, handled routes, and keys.
3559
+ const walkTexts = new Map(projectFiles.map((file) => [file.path, file.text]));
3560
+ const mockFileInsights = new Map();
3561
+ for (const file of changedMockFiles) {
3562
+ let text = walkTexts.get(file);
3563
+ if (text === undefined && fixtureEvidenceSourceExtensions.has(path.extname(file).toLowerCase())) {
3564
+ text = await readTextIfExists(path.join(root, file));
3565
+ }
3566
+ if (text !== undefined) {
3567
+ mockFileInsights.set(file, analyzeFixtureSource(file, text));
3568
+ }
3569
+ }
3570
+ for (const file of projectMockEntries) {
3571
+ if (mockFileInsights.size >= maxAnalyzedMockFiles) {
3572
+ break;
3573
+ }
3574
+ if (!mockFileInsights.has(file.path) && file.text !== undefined) {
3575
+ mockFileInsights.set(file.path, analyzeFixtureSource(file.path, file.text));
3576
+ }
3577
+ }
3469
3578
  return {
3470
3579
  changedBackendFiles: changedFiles.filter(isBackendImplementationFile).slice(0, maxFilesPerFlow),
3471
- changedMockFiles: changedFiles.filter(isMockOrFixtureFile).slice(0, maxFilesPerFlow),
3472
- projectMockFiles: projectFiles.map((file) => file.path).filter(isMockOrFixtureFile).slice(0, maxFilesPerFlow),
3580
+ changedMockFiles,
3581
+ projectMockFiles: projectMockEntries.map((file) => file.path).slice(0, maxFilesPerFlow),
3582
+ mockFileInsights,
3473
3583
  };
3474
3584
  }
3475
3585
  async function inferFlowFixtureReadiness(root, files, kind, setupHints, context) {
@@ -3541,6 +3651,7 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
3541
3651
  const fallbackProjectMockSignals = projectMockSignals.length > 0 ? projectMockSignals : context.projectMockFiles;
3542
3652
  const mockSignals = uniqueStrings([...changedMockSignals, ...fallbackProjectMockSignals]).slice(0, maxFilesPerFlow);
3543
3653
  if (changedMockSignals.length > 0) {
3654
+ const changedInsights = insightsForMockSignals(changedMockSignals, context);
3544
3655
  return {
3545
3656
  status: "ready",
3546
3657
  reason: "This branch includes mock or fixture evidence for an API-dependent flow.",
@@ -3549,13 +3660,15 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
3549
3660
  backendSignals,
3550
3661
  mockSignals,
3551
3662
  nextActions: [
3552
- `Keep changed fixture/mock evidence aligned with this flow: ${formatFileSummary(changedMockSignals)}.`,
3663
+ `Keep changed fixture/mock evidence aligned with this flow: ${describeFixtureEvidence(changedMockSignals, changedInsights)}.`,
3553
3664
  "Keep deterministic success, empty, unauthorized, and failure fixture cases aligned with the changed flow.",
3554
3665
  ],
3666
+ ...(changedInsights.length > 0 ? { mockInsights: changedInsights } : {}),
3555
3667
  };
3556
3668
  }
3557
3669
  if (context.projectMockFiles.length > 0) {
3558
3670
  const reusableMockSignals = mockSignals.length > 0 ? mockSignals : context.projectMockFiles;
3671
+ const reusableInsights = insightsForMockSignals(reusableMockSignals, context);
3559
3672
  return {
3560
3673
  status: "partial",
3561
3674
  reason: "Mock or fixture infrastructure exists, but this branch does not add flow-specific fixture evidence.",
@@ -3564,9 +3677,10 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
3564
3677
  backendSignals,
3565
3678
  mockSignals,
3566
3679
  nextActions: [
3567
- `Reuse or extend existing fixture/mock evidence for this flow: ${formatFileSummary(reusableMockSignals)}.`,
3680
+ reuseFixtureAction(reusableMockSignals, reusableInsights, apiEndpoints),
3568
3681
  "Cover the primary success response and one empty, rejected, or server-error response.",
3569
3682
  ],
3683
+ ...(reusableInsights.length > 0 ? { mockInsights: reusableInsights } : {}),
3570
3684
  };
3571
3685
  }
3572
3686
  if (backendSignals.length > 0) {
@@ -3578,7 +3692,9 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
3578
3692
  backendSignals,
3579
3693
  mockSignals,
3580
3694
  nextActions: [
3581
- "Confirm the test environment can serve the changed API path, or add a mock response for local E2E runs.",
3695
+ apiEndpoints.length > 0
3696
+ ? `Confirm the test environment can serve ${formatEndpointSummary(apiEndpoints)}, or add a mock response for local E2E runs.`
3697
+ : "Confirm the test environment can serve the changed API path, or add a mock response for local E2E runs.",
3582
3698
  "Seed realistic response data for success and failure paths.",
3583
3699
  ],
3584
3700
  };
@@ -3591,11 +3707,59 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
3591
3707
  backendSignals,
3592
3708
  mockSignals,
3593
3709
  nextActions: [
3594
- "Add a deterministic mock or fixture response, such as MSW handlers, Playwright route fulfillment, mock data, or seeded test data.",
3710
+ apiEndpoints.length > 0
3711
+ ? `Add a deterministic mock or fixture response for ${formatEndpointSummary(apiEndpoints)}, such as an MSW handler, Playwright route fulfillment, mock data, or seeded test data.`
3712
+ : "Add a deterministic mock or fixture response, such as MSW handlers, Playwright route fulfillment, mock data, or seeded test data.",
3595
3713
  "Include success plus one empty, unauthorized, rejected, timeout, or server-error response.",
3596
3714
  ],
3597
3715
  };
3598
3716
  }
3717
+ function insightsForMockSignals(mockSignals, context) {
3718
+ return mockSignals
3719
+ .map((file) => context.mockFileInsights.get(file))
3720
+ .filter((insight) => insight !== undefined)
3721
+ .slice(0, 3);
3722
+ }
3723
+ function describeFixtureEvidence(mockSignals, insights) {
3724
+ if (insights.length === 0) {
3725
+ return formatFileSummary(mockSignals);
3726
+ }
3727
+ return insights.map(describeFixtureFile).join("; ");
3728
+ }
3729
+ function describeFixtureFile(insight) {
3730
+ if (insight.handledEndpoints.length > 0) {
3731
+ return `${insight.file} (already handles ${formatEndpointSummary(insight.handledEndpoints)})`;
3732
+ }
3733
+ if (insight.exports.length > 0) {
3734
+ return `${insight.file} (exports ${insight.exports.slice(0, 3).join(", ")})`;
3735
+ }
3736
+ return insight.file;
3737
+ }
3738
+ // Turns "reuse something somewhere" into "extend this file for these routes"
3739
+ // whenever the analyzed mock files give us that specificity.
3740
+ function reuseFixtureAction(reusableMockSignals, insights, apiEndpoints) {
3741
+ if (insights.length === 0 || apiEndpoints.length === 0) {
3742
+ return `Reuse or extend existing fixture/mock evidence for this flow: ${describeFixtureEvidence(reusableMockSignals, insights)}.`;
3743
+ }
3744
+ const uncovered = apiEndpoints.filter((endpoint) => !insights.some((insight) => insightCoversEndpoint(insight, endpoint)));
3745
+ if (uncovered.length === 0) {
3746
+ return `Wire the existing mock coverage into this flow's E2E run: ${describeFixtureEvidence(reusableMockSignals, insights)} for ${formatEndpointSummary(apiEndpoints)}.`;
3747
+ }
3748
+ const handlerInsight = insights.find((insight) => insight.handledEndpoints.length > 0);
3749
+ if (handlerInsight) {
3750
+ return `Extend ${describeFixtureFile(handlerInsight)} to also cover ${formatEndpointSummary(uncovered)}.`;
3751
+ }
3752
+ const exporterInsight = insights.find((insight) => insight.exports.length > 0);
3753
+ if (exporterInsight) {
3754
+ return `Reuse ${describeFixtureFile(exporterInsight)} to build a deterministic response for ${formatEndpointSummary(uncovered)}.`;
3755
+ }
3756
+ return `Reuse or extend existing fixture/mock evidence for ${formatEndpointSummary(uncovered)}: ${describeFixtureEvidence(reusableMockSignals, insights)}.`;
3757
+ }
3758
+ function formatEndpointSummary(endpoints) {
3759
+ const shown = endpoints.slice(0, 3);
3760
+ const remaining = endpoints.length - shown.length;
3761
+ return remaining > 0 ? `${shown.join(", ")} and ${remaining} more` : shown.join(", ");
3762
+ }
3599
3763
  async function findApiDependencySignals(root, files, kind, setupHints) {
3600
3764
  const signals = new Set();
3601
3765
  if (kind === "api") {
@@ -3658,7 +3822,7 @@ function normalizeApiEndpointHint(value) {
3658
3822
  if (!/(?:\/api(?:\/|$)|\/graphql(?:\/|$)|\/trpc(?:\/|$))/i.test(endpoint)) {
3659
3823
  return undefined;
3660
3824
  }
3661
- if (/[\s<>{}]/.test(endpoint)) {
3825
+ if (/[\s<>{}`]/.test(endpoint)) {
3662
3826
  return undefined;
3663
3827
  }
3664
3828
  return endpoint;
@@ -3703,10 +3867,40 @@ function isMockOrFixtureFile(file) {
3703
3867
  const stem = basename.replace(/\.[^.]+$/g, "");
3704
3868
  const extension = path.extname(basename).toLowerCase();
3705
3869
  const canUseBroadFilenameMatch = fixtureEvidenceSourceExtensions.has(extension);
3706
- return /(?:^|\/)(?:__mocks__|mocks?|fixtures?|factories|seeds?|test-data|testData|msw|mirage)\//i.test(file) ||
3707
- /(?:mock|fixture|factory|seed|handler|msw)\.[cm]?[jt]sx?$/i.test(basename) ||
3708
- (canUseBroadFilenameMatch && /(?:mock|fixture|factory|seed|msw|mirage)/i.test(stem));
3709
- }
3870
+ if (/(?:^|\/)(?:__mocks__|mocks?|fixtures?|factories|seeds?|test-data|testData|msw|mirage)\//i.test(file)) {
3871
+ return true;
3872
+ }
3873
+ // Match whole name tokens, not substrings: demoSeedService, APIMockClient,
3874
+ // and mock-users qualify, but a useSeedlingCatalog hook or an errorHandler
3875
+ // utility does not. "handler" alone is an ordinary code word, so it only
3876
+ // counts as mock evidence when the mock-ish directory rule above already
3877
+ // matched. Derived -ing forms stay excluded on purpose: the canonical
3878
+ // seed-data conventions (seeds/ and seeders/ directories, seed.* files)
3879
+ // are already covered by the rules above, and an -ing filename is
3880
+ // ambiguous between fixture data and application behavior — ambiguity
3881
+ // must not count as mock evidence.
3882
+ const stemTokens = stem
3883
+ .split(/[^a-zA-Z0-9]+|(?<=[a-z0-9])(?=[A-Z])|(?<=[A-Z])(?=[A-Z][a-z])/)
3884
+ .map((token) => token.toLowerCase());
3885
+ return canUseBroadFilenameMatch && stemTokens.some((token) => mockEvidenceNameTokens.has(token));
3886
+ }
3887
+ const mockEvidenceNameTokens = new Set([
3888
+ "mock",
3889
+ "mocks",
3890
+ "mocked",
3891
+ "mocking",
3892
+ "fixture",
3893
+ "fixtures",
3894
+ "factory",
3895
+ "factories",
3896
+ "seed",
3897
+ "seeds",
3898
+ "seeded",
3899
+ "seeder",
3900
+ "seeders",
3901
+ "msw",
3902
+ "mirage",
3903
+ ]);
3710
3904
  function isFixtureEvidenceIgnoredPath(file) {
3711
3905
  return /(?:^|\/)(?:node_modules|vendor|vendors|Pods|build|dist|coverage|\.next|\.nuxt|\.expo|\.turbo|\.yarn\/cache)\//i.test(file);
3712
3906
  }
@@ -4080,7 +4274,7 @@ async function buildSetupNotes(root, runner, project) {
4080
4274
  return ["Choose an E2E runner after documenting the primary user-facing entry point for this project."];
4081
4275
  }
4082
4276
  function isUserFacingFile(file) {
4083
- if (isApiRouteFile(file)) {
4277
+ if (isApiRouteFile(file) || isConfigLikeFile(file) || isTestLikeFile(file)) {
4084
4278
  return false;
4085
4279
  }
4086
4280
  return (/(?:^|\/)(app|pages|routes|screens|components|ui|navigation)\//i.test(file) ||
@@ -4136,7 +4330,13 @@ function isCatalogDataFile(file) {
4136
4330
  /(?:^|\/)(?:catalog|taxonomy)-site\/index\.html$/i.test(file);
4137
4331
  }
4138
4332
  function isConfigLikeFile(file) {
4139
- return isReleaseMetadataFile(file) || /(?:(?:^|\/)(?:\.agents?|\.claude|\.cursor|\.dev|\.gemini|\.github|docs?)\/|(?:^|\/)(?:AGENTS|CLAUDE|CODEX|DECISIONS|GEMINI|PLAN|README|SKILL)\.md$|\.gitignore|package\.json|pnpm-lock\.yaml|yarn\.lock|package-lock\.json|bun\.lockb|pyproject\.toml|requirements\.txt|go\.mod|go\.sum|Cargo\.toml|Cargo\.lock|pom\.xml|build\.gradle|gradle\.properties|vite|webpack|babel|tsconfig|next\.config|app\.config|eas\.json|release-please|docker|env|feature-?flags?|experiments?)/i.test(file);
4333
+ return isReleaseMetadataFile(file) || isMobileNativeConfigFile(file) || /(?:(?:^|\/)(?:\.agents?|\.claude|\.cursor|\.dev|\.gemini|\.github|docs?)\/|(?:^|\/)(?:AGENTS|CLAUDE|CODEX|DECISIONS|GEMINI|PLAN|README|SKILL)\.md$|\.gitignore|package\.json|pnpm-lock\.yaml|yarn\.lock|package-lock\.json|bun\.lockb|pyproject\.toml|requirements\.txt|go\.mod|go\.sum|Cargo\.toml|Cargo\.lock|pom\.xml|build\.gradle|gradle\.properties|vite|webpack|babel|tsconfig|next\.config|app\.config|eas\.json|release-please|docker|env|feature-?flags?|experiments?)/i.test(file);
4334
+ }
4335
+ function isMobileNativeConfigFile(file) {
4336
+ return /(?:^|\/)(?:app\.json|eas\.json|Podfile(?:\.lock)?|Info\.plist|project\.pbxproj|AndroidManifest\.xml|[^/]+\.entitlements)$/i.test(file) ||
4337
+ /(?:^|\/)app\.config\.[cm]?[jt]s$/i.test(file) ||
4338
+ /(?:^|\/)android\/.+\.(?:gradle|properties)$/i.test(file) ||
4339
+ /(?:^|\/)ios\/[^/]+\.xcodeproj\/project\.pbxproj$/i.test(file);
4140
4340
  }
4141
4341
  function isReleaseMetadataFile(file) {
4142
4342
  return /(?:^|\/)(?:CHANGELOG|RELEASES?|release-notes?|\.release-please-manifest)\.(?:md|json)$/i.test(file) ||
@@ -4169,7 +4369,11 @@ function isTestLikeFile(file) {
4169
4369
  /(?:^|\/)test_[^/]+\.py$/i.test(file) ||
4170
4370
  /(?:^|\/)[^/]+_test\.(?:py|go)$/i.test(file) ||
4171
4371
  /(?:^|\/)[^/]+(?:Test|Tests|Spec)\.(?:java|kt|cs|swift)$/i.test(file) ||
4172
- /(?:^|\/)[^/]+_(?:test|spec)\.rs$/i.test(file));
4372
+ /(?:^|\/)[^/]+_(?:test|spec)\.rs$/i.test(file) ||
4373
+ /(?:^|\/)\.maestro\/[^/]+\.ya?ml$/i.test(file));
4374
+ }
4375
+ function isConfigurationOnlyChange(files) {
4376
+ return files.length > 0 && files.every(isConfigLikeFile);
4173
4377
  }
4174
4378
  function isDocumentationFile(file) {
4175
4379
  if (isReleaseMetadataFile(file)) {
@@ -4540,7 +4744,7 @@ function isImportantBaseDraftFlow(flow) {
4540
4744
  }
4541
4745
  function shouldUseDomainScenariosForDraft(plan) {
4542
4746
  const files = plan.changedFiles.map((file) => file.path);
4543
- if (isLowSignalVerificationOnlyChange(files)) {
4747
+ if (isLowSignalVerificationOnlyChange(files) || isConfigurationOnlyChange(files)) {
4544
4748
  return false;
4545
4749
  }
4546
4750
  return !plan.flows.every(isEvidenceVerificationFocusedFlow);
@@ -4548,6 +4752,9 @@ function shouldUseDomainScenariosForDraft(plan) {
4548
4752
  function isEvidenceVerificationFocusedFlow(flow) {
4549
4753
  return isTestEvidenceFocusedFlow(flow) || isDocumentationFocusedFlow(flow) || isGeneratedArtifactFocusedFlow(flow);
4550
4754
  }
4755
+ function isVerificationOnlyFlow(flow) {
4756
+ return isEvidenceVerificationFocusedFlow(flow) || /\bconfiguration verification\b/i.test(flow.title);
4757
+ }
4551
4758
  function dedupeDraftFlowsByOutputPath(flows, runner) {
4552
4759
  const flowsByOutput = new Map();
4553
4760
  for (const flow of flows) {
@@ -4615,7 +4822,43 @@ async function buildManifestDraftFlows(plan, baseFlows) {
4615
4822
  .filter((match) => match.kind === "flow")
4616
4823
  .slice(0, 4);
4617
4824
  const checkMatches = plan.verificationManifestMatches.filter((match) => match.kind === "check");
4618
- return Promise.all(flowMatches.map((match) => buildManifestDraftFlow(plan, match, checkMatches, baseFlows)));
4825
+ const flowDrafts = await Promise.all(flowMatches.map((match) => buildManifestDraftFlow(plan, match, checkMatches, baseFlows)));
4826
+ const flowMatchedFiles = new Set(flowMatches.flatMap((match) => match.matchedFiles));
4827
+ const domainDrafts = await Promise.all(plan.verificationManifestMatches
4828
+ .filter((match) => match.kind === "domain" && match.matchedFiles.some((file) => !flowMatchedFiles.has(file)))
4829
+ .slice(0, 4)
4830
+ .map((match) => buildManifestDomainDraftFlow(plan, match, baseFlows)));
4831
+ return [...flowDrafts, ...domainDrafts.filter((flow) => Boolean(flow))].slice(0, 4);
4832
+ }
4833
+ async function buildManifestDomainDraftFlow(plan, match, baseFlows) {
4834
+ const manifestFiles = normalizeScenarioFilesForRoot(plan, match.matchedFiles);
4835
+ const baseFlow = bestOverlappingBaseFlowForManifestMatch(manifestFiles, baseFlows);
4836
+ if (!baseFlow || isVerificationOnlyFlow(baseFlow)) {
4837
+ return undefined;
4838
+ }
4839
+ // A domain path can cover many independent flows. Preserve manifest
4840
+ // provenance on the best overlapping flow without claiming every matched
4841
+ // domain file belongs to that single draft.
4842
+ const files = baseFlow.files;
4843
+ const flow = {
4844
+ ...baseFlow,
4845
+ reason: `${match.reason} ${baseFlow.reason}`,
4846
+ files,
4847
+ entrypoints: uniqueEntrypoints([
4848
+ ...baseFlow.entrypoints,
4849
+ ...(await inferFlowEntrypoints(plan.root, files, plan.recommendedRunner.name)),
4850
+ ]),
4851
+ selectors: uniqueSelectors([
4852
+ ...baseFlow.selectors,
4853
+ ...(await inferFlowSelectors(plan.root, files, plan.recommendedRunner.name)),
4854
+ ]),
4855
+ draftSource: "verification-manifest",
4856
+ manifestMatch: match,
4857
+ };
4858
+ return {
4859
+ ...flow,
4860
+ languageBrief: buildFlowLanguageBrief(flow),
4861
+ };
4619
4862
  }
4620
4863
  async function buildManifestDraftFlow(plan, match, checkMatches, baseFlows) {
4621
4864
  const relatedChecks = checkMatches.filter((check) => check.id.startsWith(`${match.id}.`));
@@ -4672,6 +4915,12 @@ function bestBaseFlowForManifestMatch(files, baseFlows) {
4672
4915
  }
4673
4916
  return best && best.score > 0 ? best.flow : baseFlows[0];
4674
4917
  }
4918
+ function bestOverlappingBaseFlowForManifestMatch(files, baseFlows) {
4919
+ const ranked = baseFlows
4920
+ .map((flow) => ({ flow, score: fileOverlapScore(files, flow.files) }))
4921
+ .sort((left, right) => right.score - left.score);
4922
+ return ranked[0]?.score > 0 ? ranked[0].flow : undefined;
4923
+ }
4675
4924
  function coreFlowForManifestMatch(plan, match) {
4676
4925
  return plan.coreFlows.find((flow) => flow.name === match.name || flow.id === match.id);
4677
4926
  }
@@ -4742,7 +4991,11 @@ async function buildDomainScenarioDraftFlow(plan, scenario, baseFlows) {
4742
4991
  const reason = specializedScenario?.reason ?? scenario.intent;
4743
4992
  const steps = specializedScenario?.steps ?? (scenario.checks.length > 0 ? scenario.checks : (baseFlow?.steps ?? []));
4744
4993
  const scenarioFiles = normalizeScenarioFilesForRoot(plan, scenario.files);
4745
- const files = uniqueStrings(scenarioFiles.length > 0 ? scenarioFiles : (baseFlow?.files ?? [])).slice(0, 20);
4994
+ const files = uniqueStrings(specializedScenario?.useBaseFlowFiles
4995
+ ? [...(baseFlow?.files ?? []), ...scenarioFiles]
4996
+ : scenarioFiles.length > 0
4997
+ ? scenarioFiles
4998
+ : (baseFlow?.files ?? [])).slice(0, 20);
4746
4999
  const coverage = baseFlow?.coverage ?? buildCoverageTargets("domain", files, plan.recommendedRunner.name);
4747
5000
  const runner = plan.recommendedRunner.name;
4748
5001
  const entrypoints = uniqueEntrypoints([
@@ -4794,6 +5047,15 @@ function specializedDomainScenarioDraft(scenario, baseFlow) {
4794
5047
  if (scenario.source !== "changed-file" || !baseFlow) {
4795
5048
  return undefined;
4796
5049
  }
5050
+ if (/through imports/i.test(baseFlow.reason) && /\sprimary journey$/i.test(scenario.title)) {
5051
+ const subject = baseFlow.title.replace(/\s+UI smoke flow$/i, "").trim();
5052
+ return {
5053
+ title: subject,
5054
+ reason: baseFlow.reason,
5055
+ steps: baseFlow.steps,
5056
+ useBaseFlowFiles: true,
5057
+ };
5058
+ }
4797
5059
  if (isApiContractFocusedFlow(baseFlow)) {
4798
5060
  const subject = scenario.title.replace(/\s+primary journey$/i, "");
4799
5061
  return {
@@ -4945,7 +5207,7 @@ function domainScenarioForFlow(flow) {
4945
5207
  return flow.domainScenario;
4946
5208
  }
4947
5209
  function draftStability(plan, flow) {
4948
- const needsSelector = flow.missingTestability.length > 0;
5210
+ const needsSelector = !isVerificationOnlyFlow(flow) && flow.missingTestability.length > 0;
4949
5211
  const needsSetup = plan.missingTestability.some((gap) => /No \.maestro|No Playwright config/i.test(gap));
4950
5212
  if (needsSelector && needsSetup) {
4951
5213
  return "needs-selector-and-setup";
@@ -4959,14 +5221,15 @@ function draftStability(plan, flow) {
4959
5221
  return "ready";
4960
5222
  }
4961
5223
  function draftExecutionBlockers(plan, flow, runner, validationSummary, selfCheck) {
4962
- const blockers = [...plan.executionProfile.blockers];
4963
- if (runner !== "manual" && flow.entrypoints.length === 0) {
5224
+ const verificationOnly = isVerificationOnlyFlow(flow);
5225
+ const blockers = verificationOnly ? [] : [...plan.executionProfile.blockers];
5226
+ if (!verificationOnly && runner !== "manual" && flow.entrypoints.length === 0) {
4964
5227
  blockers.push("No runnable route, screen, or command entrypoint was inferred for this flow.");
4965
5228
  }
4966
- if (flow.missingTestability.length > 0) {
5229
+ if (!verificationOnly && flow.missingTestability.length > 0) {
4967
5230
  blockers.push(flow.missingTestability[0]);
4968
5231
  }
4969
- if (flow.fixtureReadiness.status === "missing") {
5232
+ if (!verificationOnly && flow.fixtureReadiness.status === "missing") {
4970
5233
  blockers.push(flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason);
4971
5234
  }
4972
5235
  if (validationSummary.blockingGapCount > 0) {
@@ -4978,7 +5241,7 @@ function draftExecutionBlockers(plan, flow, runner, validationSummary, selfCheck
4978
5241
  return uniqueStrings(blockers).slice(0, 8);
4979
5242
  }
4980
5243
  function draftRunnableStatus(plan, flow, runner, validationSummary, executionBlockers, selfCheck) {
4981
- if (runner === "manual") {
5244
+ if (runner === "manual" || isVerificationOnlyFlow(flow)) {
4982
5245
  return "review-only";
4983
5246
  }
4984
5247
  if (selfCheck?.status === "fail") {
@@ -5981,19 +6244,39 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
5981
6244
  if (mockableEndpoints.length === 0) {
5982
6245
  return;
5983
6246
  }
6247
+ const insights = flow.fixtureReadiness.mockInsights ?? [];
6248
+ const shapeSources = [];
5984
6249
  lines.push("");
5985
6250
  lines.push(" const mockApiResponses = {");
5986
6251
  for (const endpoint of mockableEndpoints.slice(0, maxFilesPerFlow)) {
6252
+ const insight = insights.find((candidate) => specSafeSampleKeys(candidate).length > 0 && insightCoversEndpoint(candidate, endpoint)) ??
6253
+ insights.find((candidate) => specSafeSampleKeys(candidate).length > 0);
5987
6254
  lines.push(` "${quoteJs(playwrightMockRoutePattern(endpoint))}": {`);
5988
6255
  lines.push(" status: 200,");
5989
6256
  lines.push(" body: {");
5990
- lines.push(' ok: true,');
5991
- lines.push(' source: "qamap-draft",');
6257
+ if (insight) {
6258
+ if (!shapeSources.includes(insight.file)) {
6259
+ shapeSources.push(insight.file);
6260
+ }
6261
+ for (const key of specSafeSampleKeys(insight)) {
6262
+ const propertyName = isJsIdentifier(key) ? key : JSON.stringify(key);
6263
+ lines.push(` ${propertyName}: ${JSON.stringify(`qamap-${key}`)},`);
6264
+ }
6265
+ }
6266
+ else {
6267
+ lines.push(' ok: true,');
6268
+ lines.push(' source: "qamap-draft",');
6269
+ }
5992
6270
  lines.push(" },");
5993
6271
  lines.push(" },");
5994
6272
  }
5995
6273
  lines.push(" };");
5996
- lines.push(" // Replace sample responses with deterministic fixtures from the target domain before promoting this draft.");
6274
+ if (shapeSources.length > 0) {
6275
+ lines.push(` // Response shape keys reuse ${formatFileSummary(shapeSources)}; replace the sample values with deterministic fixture data before promoting this draft.`);
6276
+ }
6277
+ else {
6278
+ lines.push(" // Replace sample responses with deterministic fixtures from the target domain before promoting this draft.");
6279
+ }
5997
6280
  lines.push(" for (const [urlPattern, response] of Object.entries(mockApiResponses)) {");
5998
6281
  lines.push(" await page.route(urlPattern, async (route) => {");
5999
6282
  lines.push(" await route.fulfill({");
@@ -6004,6 +6287,11 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
6004
6287
  lines.push(" });");
6005
6288
  lines.push(" }");
6006
6289
  }
6290
+ // Drafts are pinned to contain no literal TODO marker, so a fixture key that
6291
+ // would introduce one disqualifies itself from spec interpolation.
6292
+ function specSafeSampleKeys(insight) {
6293
+ return insight.sampleKeys.filter((key) => !/todo/i.test(key));
6294
+ }
6007
6295
  function observedEndpointsForFlow(flow) {
6008
6296
  if (flow.fixtureReadiness.status === "not-needed" || flow.fixtureReadiness.apiEndpoints.length === 0) {
6009
6297
  return [];
@@ -6794,6 +7082,28 @@ function extractTextNodeSelectors(file, text) {
6794
7082
  for (const selector of extractAttributeSelectors(file, text, ["title"], "visible-text")) {
6795
7083
  selectors.push(selector);
6796
7084
  }
7085
+ selectors.push(...extractRenderedStateSelectors(file, text));
7086
+ return selectors;
7087
+ }
7088
+ function extractRenderedStateSelectors(file, text) {
7089
+ const selectors = [];
7090
+ const renderedNames = new Set([...text.matchAll(/>\s*\{\s*([A-Za-z_$][\w$]*)\s*\}\s*</g)].map((match) => match[1]));
7091
+ for (const stateName of renderedNames) {
7092
+ const stateDeclaration = new RegExp(`\\bconst\\s*\\[\\s*${escapeRegExp(stateName)}\\s*,\\s*([A-Za-z_$][\\w$]*)\\s*\\]\\s*=\\s*useState\\b`).exec(text);
7093
+ const setterName = stateDeclaration?.[1];
7094
+ if (!setterName) {
7095
+ continue;
7096
+ }
7097
+ const setterCallMatcher = new RegExp(`\\b${escapeRegExp(setterName)}\\s*\\(([^\\n;]{0,300})\\)`, "g");
7098
+ for (const call of text.matchAll(setterCallMatcher)) {
7099
+ for (const literal of call[1].matchAll(/"([^"]+)"|'([^']+)'|`([^`]+)`/g)) {
7100
+ const value = normalizeSelectorValue(literal[1] ?? literal[2] ?? literal[3]);
7101
+ if (value && isUsefulSelector(value)) {
7102
+ selectors.push({ kind: "visible-text", value, file });
7103
+ }
7104
+ }
7105
+ }
7106
+ }
6797
7107
  return selectors;
6798
7108
  }
6799
7109
  function extractRoleSelectorsFromText(file, text) {
@@ -7051,11 +7361,12 @@ function selectorRank(kind) {
7051
7361
  return 4;
7052
7362
  }
7053
7363
  function slugify(value) {
7054
- return value
7364
+ const slug = value
7055
7365
  .toLowerCase()
7056
- .replace(/[^a-z0-9]+/g, "-")
7366
+ .replace(/[^a-z0-9\uAC00-\uD7A3]+/g, "-")
7057
7367
  .replace(/^-+|-+$/g, "")
7058
7368
  .slice(0, 80);
7369
+ return slug || "draft";
7059
7370
  }
7060
7371
  function quoteYaml(value) {
7061
7372
  return JSON.stringify(value);