@ivorycanvas/qamap 0.3.2 → 0.3.4

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 +56 -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 +121 -12
  12. package/dist/domain-language.js.map +1 -1
  13. package/dist/e2e.d.ts +4 -0
  14. package/dist/e2e.js +424 -85
  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 +478 -54
  26. package/dist/manifest.js.map +1 -1
  27. package/dist/qa.d.ts +4 -0
  28. package/dist/qa.js +177 -14
  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 +60 -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 +3 -3
  46. package/docs/quickstart-demo.md +1 -1
  47. package/docs/release-validation.md +31 -4
  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 +2 -1
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;
@@ -114,6 +131,12 @@ export async function generateE2ePlan(rootInput, options = {}) {
114
131
  export async function generateE2eDraft(rootInput, options = {}) {
115
132
  const root = path.resolve(rootInput);
116
133
  const plan = await generateE2ePlan(root, options);
134
+ const addedDiffText = await collectAddedDiffText(root, {
135
+ base: plan.base,
136
+ head: plan.head,
137
+ workspaceRoot: plan.workspaceRoot,
138
+ includeWorkingTree: options.includeWorkingTree,
139
+ });
117
140
  const runner = plan.recommendedRunner.name;
118
141
  const outputDirectory = path.resolve(root, options.output ?? defaultDraftOutputDirectory(runner));
119
142
  const draftLimit = options.maxDrafts && options.maxDrafts > 0 ? Math.floor(options.maxDrafts) : undefined;
@@ -130,7 +153,7 @@ export async function generateE2eDraft(rootInput, options = {}) {
130
153
  const promotionGuidance = buildDraftPromotionGuidance(flow);
131
154
  const fileAlreadyExists = await exists(filePath);
132
155
  const shouldSkip = fileAlreadyExists && !options.force && !dryRun;
133
- const content = shouldSkip ? ((await readTextIfExists(filePath)) ?? "") : draftContentForFlow(plan, flow, runner);
156
+ const content = shouldSkip ? ((await readTextIfExists(filePath)) ?? "") : draftContentForFlow(plan, flow, runner, addedDiffText);
134
157
  const todoCount = countTodos(content);
135
158
  const selfCheck = evaluateDraftSelfCheck(plan, flow, runner, content, todoCount);
136
159
  const actionItems = buildDraftActionItems(plan, flow, runner, validationSummary, promotionGuidance, selfCheck);
@@ -453,6 +476,7 @@ function buildE2eValidationMatrix(flows, coreFlows) {
453
476
  }
454
477
  function buildE2eBootstrapPlan(input) {
455
478
  const steps = [];
479
+ const verificationOnly = input.flows.length > 0 && input.flows.every(isVerificationOnlyFlow);
456
480
  const runnerGap = input.missingTestability.find((gap) => /No \.maestro|No Playwright config/i.test(gap));
457
481
  const draftCommand = `qamap e2e draft . --base ${input.base} --head ${input.head}`;
458
482
  const planHistoryCommand = `qamap e2e plan . --base ${input.base} --head ${input.head} --record-history`;
@@ -465,7 +489,10 @@ function buildE2eBootstrapPlan(input) {
465
489
  ? `${concreteTargets.length} changed workspace target${concreteTargets.length === 1 ? "" : "s"} have clearer app or service signals than the workspace root.`
466
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)));
467
491
  }
468
- 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") {
469
496
  steps.push(bootstrapStep("runner", "required", manualBootstrapTitle(input.projectType), manualBootstrapReason(input.projectType), manualBootstrapAction(input.projectType), [], []));
470
497
  }
471
498
  else if (runnerGap) {
@@ -481,27 +508,30 @@ function buildE2eBootstrapPlan(input) {
481
508
  else {
482
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.", [], []));
483
510
  }
484
- if (input.recommendedRunner.name !== "manual" && executionProfileBlockers.length > 0) {
511
+ if (!verificationOnly && input.recommendedRunner.name !== "manual" && executionProfileBlockers.length > 0) {
485
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));
486
513
  }
487
- 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) {
488
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)));
489
519
  }
490
520
  else {
491
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.", [], []));
492
522
  }
493
- if (!input.domainManifestPath && input.domainLanguage.terms.length > 0) {
523
+ if (!verificationOnly && !input.domainManifestPath && input.domainLanguage.terms.length > 0) {
494
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)));
495
525
  }
496
- else if (input.domainManifestPath || input.domains.length > 0) {
526
+ else if (!verificationOnly && (input.domainManifestPath || input.domains.length > 0)) {
497
527
  steps.push(bootstrapStep("domain-language", "ready", "Domain language has shared policy evidence", input.domainManifestPath
498
528
  ? `Domain manifest found at ${input.domainManifestPath}.`
499
529
  : "Matched domain definitions were found.", "Keep committed domain language aligned with the names used in generated E2E drafts.", [], []));
500
530
  }
501
- if (!input.coreFlowManifestPath) {
531
+ if (!verificationOnly && !input.coreFlowManifestPath) {
502
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)));
503
533
  }
504
- else {
534
+ else if (!verificationOnly) {
505
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
506
536
  ? `${input.coreFlows.length} declared core flow${input.coreFlows.length === 1 ? "" : "s"} matched the changed files.`
507
537
  : `Core flow manifest found at ${input.coreFlowManifestPath}, but this change did not match a declared flow.`, input.coreFlows.length > 0
@@ -521,24 +551,30 @@ function buildE2eBootstrapPlan(input) {
521
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)));
522
552
  }
523
553
  const nonRunnerTestabilityGaps = input.missingTestability.filter((gap) => !/No \.maestro|No Playwright config/i.test(gap));
524
- if (nonRunnerTestabilityGaps.length > 0) {
554
+ if (!verificationOnly && nonRunnerTestabilityGaps.length > 0) {
525
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)));
526
556
  }
527
- 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)) {
528
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)));
529
559
  }
530
560
  if (input.validationMatrix.summary.missing > 0) {
531
561
  steps.push(bootstrapStep("validation", "required", "Close missing validation matrix rows", input.validationMatrix.summary.missing === 1
532
562
  ? "1 validation row is missing evidence."
533
- : `${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)));
534
566
  }
535
567
  else if (input.validationMatrix.summary.partial > 0) {
536
568
  steps.push(bootstrapStep("validation", "recommended", "Strengthen partial validation evidence", input.validationMatrix.summary.partial === 1
537
569
  ? "1 validation row is only partial."
538
- : `${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)));
539
573
  }
540
574
  else if (input.validationMatrix.rows.length > 0) {
541
- 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.", [], []));
542
578
  }
543
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], []));
544
580
  if (input.suggestedCommands.length === 0) {
@@ -551,7 +587,7 @@ function buildE2eBootstrapPlan(input) {
551
587
  ready: sortedSteps.filter((step) => step.status === "ready").length,
552
588
  };
553
589
  return {
554
- summary: bootstrapSummary(counts, sortedSteps),
590
+ summary: bootstrapSummary(counts, sortedSteps, verificationOnly),
555
591
  steps: sortedSteps,
556
592
  counts,
557
593
  };
@@ -655,9 +691,12 @@ function bootstrapCategoryRank(category) {
655
691
  ];
656
692
  return order.indexOf(category);
657
693
  }
658
- function bootstrapSummary(counts, steps) {
694
+ function bootstrapSummary(counts, steps, verificationOnly = false) {
659
695
  const firstRequired = steps.find((step) => step.status === "required");
660
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
+ }
661
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}.`;
662
701
  }
663
702
  const firstRecommended = steps.find((step) => step.status === "recommended");
@@ -916,6 +955,7 @@ function buildDraftPromotionGuidance(flow) {
916
955
  }
917
956
  function buildDraftActionItems(plan, flow, runner, validationSummary, promotionGuidance, selfCheck) {
918
957
  const items = [];
958
+ const verificationOnly = isVerificationOnlyFlow(flow);
919
959
  const runnerGap = runnerSetupGap(plan, runner);
920
960
  if (runnerGap) {
921
961
  const setupCommand = plan.runnerSetup.setupCommand ? ` Run \`${plan.runnerSetup.setupCommand}\` after accepting this runner setup.` : "";
@@ -925,7 +965,7 @@ function buildDraftActionItems(plan, flow, runner, validationSummary, promotionG
925
965
  items.push(draftActionItem("runner", "required", `Configure ${formatRunnerName(runner)} execution`, setupDetail));
926
966
  }
927
967
  const executionBlockers = remainingExecutionProfileBlockers(plan.executionProfile.blockers, runnerGap);
928
- if (runner !== "manual" && executionBlockers.length > 0) {
968
+ if (!verificationOnly && runner !== "manual" && executionBlockers.length > 0) {
929
969
  items.push(draftActionItem("runner", "required", "Complete execution profile", executionBlockers.slice(0, 3).join(" ")));
930
970
  }
931
971
  const route = primaryRouteEntrypoint(flow);
@@ -936,34 +976,42 @@ function buildDraftActionItems(plan, flow, runner, validationSummary, promotionG
936
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}.`));
937
977
  }
938
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
+ : "";
939
983
  if (flow.fixtureReadiness.status === "missing") {
940
- 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));
941
985
  }
942
986
  else if (flow.fixtureReadiness.status === "partial") {
943
- 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));
944
988
  }
945
- if (flow.missingTestability.length > 0) {
989
+ if (!verificationOnly && flow.missingTestability.length > 0) {
946
990
  items.push(draftActionItem("selector", "required", "Replace weak or missing selectors", flow.missingTestability[0]));
947
991
  }
948
- else if (flow.selectors.length === 0 && runner !== "manual") {
992
+ else if (!verificationOnly && flow.selectors.length === 0 && runner !== "manual") {
949
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."));
950
994
  }
951
- if (flow.steps.length > 0 && draftNeedsAssertionWork(selfCheck)) {
995
+ if (!verificationOnly && flow.steps.length > 0 && draftNeedsAssertionWork(selfCheck)) {
952
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.`));
953
997
  }
954
- if (selfCheck?.status === "fail") {
998
+ if (!verificationOnly && selfCheck?.status === "fail") {
955
999
  items.push(draftActionItem("validation", "required", "Resolve generated draft self-check", selfCheck.blockers[0] ?? selfCheck.summary));
956
1000
  }
957
- else if (selfCheck?.status === "warning") {
1001
+ else if (!verificationOnly && selfCheck?.status === "warning") {
958
1002
  items.push(draftActionItem("validation", "recommended", "Review generated draft self-check", selfCheck.summary));
959
1003
  }
960
1004
  if (validationSummary.blockingGapCount > 0) {
961
- 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.`));
962
1008
  }
963
1009
  else if (validationSummary.gapCount > 0) {
964
- 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.`));
965
1013
  }
966
- if (promotionGuidance.status !== "commit-candidate") {
1014
+ if (!verificationOnly && promotionGuidance.status !== "commit-candidate") {
967
1015
  items.push(draftActionItem("manifest", "recommended", "Promote durable product language", promotionGuidance.action));
968
1016
  }
969
1017
  return uniqueDraftActionItems(items).slice(0, 8);
@@ -1171,8 +1219,8 @@ function draftReadinessRecommendation(level, blockers) {
1171
1219
  : "Close required action items before treating the drafts as regression evidence.";
1172
1220
  }
1173
1221
  return blocker
1174
- ? `Do not treat these drafts as runnable yet. Start with: ${withTerminalPeriod(blocker)}`
1175
- : "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.";
1176
1224
  }
1177
1225
  function withTerminalPeriod(value) {
1178
1226
  return /[.!?)]$/.test(value.trim()) ? value.trim() : `${value.trim()}.`;
@@ -1354,6 +1402,10 @@ function inferFlowSuccessSignal(flow) {
1354
1402
  if (/\bconfiguration verification\b/i.test(flow.title)) {
1355
1403
  return "the affected build or runtime variant starts cleanly and handles fallback values";
1356
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
+ }
1357
1409
  const verificationStep = flow.steps.find((step) => isAssertionStep(step));
1358
1410
  if (verificationStep) {
1359
1411
  return stripTerminalPunctuation(verificationStep);
@@ -1364,32 +1416,36 @@ function inferFlowSuccessSignal(flow) {
1364
1416
  }
1365
1417
  return "the changed journey reaches a visible, stable success state";
1366
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
+ }
1367
1423
  function inferFlowReviewQuestion(flow, successSignal) {
1368
1424
  if (isApiContractFocusedFlow(flow)) {
1369
- 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}?`;
1370
1426
  }
1371
1427
  if (isDesignTokenFocusedFlow(flow)) {
1372
- 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}?`;
1373
1429
  }
1374
1430
  if (isCatalogFocusedFlow(flow)) {
1375
- 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}?`;
1376
1432
  }
1377
1433
  if (isTestEvidenceFocusedFlow(flow)) {
1378
- 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}?`;
1379
1435
  }
1380
1436
  if (isDocumentationFocusedFlow(flow)) {
1381
- 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}?`;
1382
1438
  }
1383
1439
  if (isGeneratedArtifactFocusedFlow(flow)) {
1384
- 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}?`;
1385
1441
  }
1386
1442
  if (isCliCommandFocusedFlow(flow)) {
1387
- 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}?`;
1388
1444
  }
1389
1445
  if (/\bconfiguration verification\b/i.test(flow.title)) {
1390
- 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}?`;
1391
1447
  }
1392
- 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}?`;
1393
1449
  }
1394
1450
  function isApiContractFocusedFlow(flow) {
1395
1451
  if (isEvidenceVerificationFocusedFlow(flow)) {
@@ -1786,7 +1842,7 @@ export function formatMarkdownE2eDraft(result) {
1786
1842
  lines.push("");
1787
1843
  lines.push("## Draft Readiness Summary");
1788
1844
  lines.push("");
1789
- 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)}`);
1790
1846
  lines.push(`Summary: ${result.actionSummary.required} required action${result.actionSummary.required === 1 ? "" : "s"}, ${result.actionSummary.recommended} recommended action${result.actionSummary.recommended === 1 ? "" : "s"}.`);
1791
1847
  lines.push(`- Runnable candidates: ${result.readinessSummary.runnableCandidates}`);
1792
1848
  lines.push(`- Near-runnable files: ${result.readinessSummary.nearRunnable}`);
@@ -1900,7 +1956,7 @@ export function formatMarkdownE2eSetup(result) {
1900
1956
  lines.push(`- Output directory: \`${escapeMarkdownInline(result.draftOutputDirectory)}\``);
1901
1957
  }
1902
1958
  if (result.draftReadinessSummary) {
1903
- lines.push(`- Readiness: ${result.draftReadinessSummary.level} (${result.draftReadinessSummary.score}/100)`);
1959
+ lines.push(`- Stage: ${formatDraftReadinessStage(result.draftReadinessSummary)}`);
1904
1960
  }
1905
1961
  for (const file of result.draftFiles) {
1906
1962
  const details = [
@@ -2215,6 +2271,21 @@ function recommendRunner(project) {
2215
2271
  reason: "No clear app platform was detected, so start with a manual smoke checklist before choosing a runnable E2E framework.",
2216
2272
  };
2217
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
+ }
2218
2289
  function overrideRunner(project, runner) {
2219
2290
  if (runner === "maestro") {
2220
2291
  return {
@@ -3043,7 +3114,13 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
3043
3114
  const behaviorFiles = files.filter((file) => !isTestLikeFile(file));
3044
3115
  const candidateFiles = behaviorFiles.length > 0 ? behaviorFiles : files;
3045
3116
  const impactSurfaceFiles = importImpacts.map((impact) => impact.surface).filter((surface) => !candidateFiles.includes(surface));
3046
- 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)]);
3047
3124
  const apiFiles = candidateFiles.filter(isApiLikeFile);
3048
3125
  const apiServiceSourceFiles = projectType === "api-service"
3049
3126
  ? candidateFiles.filter((file) => !apiFiles.includes(file) && !isConfigLikeFile(file) && isServiceSourceFile(file))
@@ -3061,7 +3138,8 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
3061
3138
  const domainFiles = candidateFiles.filter(isDomainOwnedFile);
3062
3139
  const candidates = [];
3063
3140
  if (uiFiles.length > 0) {
3064
- const subject = summarizeFlowSubject(uiFiles, "Changed", domainLanguage);
3141
+ const subjectFiles = impactSurfaceFiles.length > 0 ? impactSurfaceFiles : uiFiles;
3142
+ const subject = summarizeFlowSubject(subjectFiles, "Changed", domainLanguage);
3065
3143
  const impactReason = importImpacts.length > 0
3066
3144
  ? ` Changed shared files reach these surfaces through imports: ${importImpacts.slice(0, 3).map(describeImportChain).join("; ")}.`
3067
3145
  : "";
@@ -3180,7 +3258,9 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
3180
3258
  if (configFiles.length > 0) {
3181
3259
  const subject = isReleaseMetadataOnlyChange(configFiles)
3182
3260
  ? "Release metadata"
3183
- : summarizeFlowSubject(configFiles, "Changed", domainLanguage);
3261
+ : (projectType === "expo-react-native" || projectType === "react-native") && configFiles.some(isMobileNativeConfigFile)
3262
+ ? "Mobile build"
3263
+ : summarizeFlowSubject(configFiles, "Changed", domainLanguage);
3184
3264
  candidates.push({
3185
3265
  kind: "config",
3186
3266
  title: `${subject} configuration verification ${runner === "manual" ? "checklist" : "flow"}`,
@@ -3309,7 +3389,10 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
3309
3389
  }
3310
3390
  const coverage = buildCoverageTargets(candidate.kind, files, runner);
3311
3391
  const setupHints = await inferFlowSetupHints(root, files, candidate.kind);
3312
- 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
+ : [];
3313
3396
  const flow = {
3314
3397
  title: candidate.title,
3315
3398
  reason: candidate.reason,
@@ -3317,17 +3400,20 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
3317
3400
  steps: refineStepsForInferredSelectors(candidate.steps, selectors),
3318
3401
  coverage,
3319
3402
  coverageEvidence: evaluateFlowCoverageEvidence({ title: candidate.title, files, coverage }, testSuiteInventory),
3320
- entrypoints: await inferFlowEntrypoints(root, files, runner),
3403
+ entrypoints: interactionEvidenceApplies ? await inferFlowEntrypoints(root, files, runner) : [],
3321
3404
  setupHints,
3322
3405
  fixtureReadiness: await inferFlowFixtureReadiness(root, files, candidate.kind, setupHints, fixtureContext),
3323
3406
  selectors,
3324
- missingTestability: await findFlowTestabilityGaps(root, files, runner),
3407
+ missingTestability: interactionEvidenceApplies ? await findFlowTestabilityGaps(root, files, runner) : [],
3325
3408
  };
3326
3409
  return {
3327
3410
  ...flow,
3328
3411
  languageBrief: buildFlowLanguageBrief(flow),
3329
3412
  };
3330
3413
  }
3414
+ function isVerificationOnlyKind(kind) {
3415
+ return kind === "config" || kind === "test-evidence" || kind === "documentation" || kind === "generated-artifact";
3416
+ }
3331
3417
  function preferDiffAdded(selectors, predicate) {
3332
3418
  return selectors.find((selector) => selector.addedInDiff && predicate(selector)) ?? selectors.find(predicate);
3333
3419
  }
@@ -3381,7 +3467,11 @@ function selectorStepLabel(selector) {
3381
3467
  return raw.length > 0 ? `"${raw}"` : `the ${selector.kind} control`;
3382
3468
  }
3383
3469
  function actionVerbForSelector(selector) {
3384
- 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)) {
3385
3475
  return "Submit";
3386
3476
  }
3387
3477
  return "Activate";
@@ -3458,12 +3548,38 @@ function setupHint(kind, title, detail, files, confidence) {
3458
3548
  confidence,
3459
3549
  };
3460
3550
  }
3551
+ const maxAnalyzedMockFiles = 24;
3461
3552
  async function collectFixtureReadinessContext(root, changedFiles) {
3462
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
+ }
3463
3578
  return {
3464
3579
  changedBackendFiles: changedFiles.filter(isBackendImplementationFile).slice(0, maxFilesPerFlow),
3465
- changedMockFiles: changedFiles.filter(isMockOrFixtureFile).slice(0, maxFilesPerFlow),
3466
- 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,
3467
3583
  };
3468
3584
  }
3469
3585
  async function inferFlowFixtureReadiness(root, files, kind, setupHints, context) {
@@ -3535,6 +3651,7 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
3535
3651
  const fallbackProjectMockSignals = projectMockSignals.length > 0 ? projectMockSignals : context.projectMockFiles;
3536
3652
  const mockSignals = uniqueStrings([...changedMockSignals, ...fallbackProjectMockSignals]).slice(0, maxFilesPerFlow);
3537
3653
  if (changedMockSignals.length > 0) {
3654
+ const changedInsights = insightsForMockSignals(changedMockSignals, context);
3538
3655
  return {
3539
3656
  status: "ready",
3540
3657
  reason: "This branch includes mock or fixture evidence for an API-dependent flow.",
@@ -3543,13 +3660,15 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
3543
3660
  backendSignals,
3544
3661
  mockSignals,
3545
3662
  nextActions: [
3546
- `Keep changed fixture/mock evidence aligned with this flow: ${formatFileSummary(changedMockSignals)}.`,
3663
+ `Keep changed fixture/mock evidence aligned with this flow: ${describeFixtureEvidence(changedMockSignals, changedInsights)}.`,
3547
3664
  "Keep deterministic success, empty, unauthorized, and failure fixture cases aligned with the changed flow.",
3548
3665
  ],
3666
+ ...(changedInsights.length > 0 ? { mockInsights: changedInsights } : {}),
3549
3667
  };
3550
3668
  }
3551
3669
  if (context.projectMockFiles.length > 0) {
3552
3670
  const reusableMockSignals = mockSignals.length > 0 ? mockSignals : context.projectMockFiles;
3671
+ const reusableInsights = insightsForMockSignals(reusableMockSignals, context);
3553
3672
  return {
3554
3673
  status: "partial",
3555
3674
  reason: "Mock or fixture infrastructure exists, but this branch does not add flow-specific fixture evidence.",
@@ -3558,9 +3677,10 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
3558
3677
  backendSignals,
3559
3678
  mockSignals,
3560
3679
  nextActions: [
3561
- `Reuse or extend existing fixture/mock evidence for this flow: ${formatFileSummary(reusableMockSignals)}.`,
3680
+ reuseFixtureAction(reusableMockSignals, reusableInsights, apiEndpoints),
3562
3681
  "Cover the primary success response and one empty, rejected, or server-error response.",
3563
3682
  ],
3683
+ ...(reusableInsights.length > 0 ? { mockInsights: reusableInsights } : {}),
3564
3684
  };
3565
3685
  }
3566
3686
  if (backendSignals.length > 0) {
@@ -3572,7 +3692,9 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
3572
3692
  backendSignals,
3573
3693
  mockSignals,
3574
3694
  nextActions: [
3575
- "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.",
3576
3698
  "Seed realistic response data for success and failure paths.",
3577
3699
  ],
3578
3700
  };
@@ -3585,11 +3707,59 @@ async function inferFlowFixtureReadiness(root, files, kind, setupHints, context)
3585
3707
  backendSignals,
3586
3708
  mockSignals,
3587
3709
  nextActions: [
3588
- "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.",
3589
3713
  "Include success plus one empty, unauthorized, rejected, timeout, or server-error response.",
3590
3714
  ],
3591
3715
  };
3592
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
+ }
3593
3763
  async function findApiDependencySignals(root, files, kind, setupHints) {
3594
3764
  const signals = new Set();
3595
3765
  if (kind === "api") {
@@ -3652,7 +3822,7 @@ function normalizeApiEndpointHint(value) {
3652
3822
  if (!/(?:\/api(?:\/|$)|\/graphql(?:\/|$)|\/trpc(?:\/|$))/i.test(endpoint)) {
3653
3823
  return undefined;
3654
3824
  }
3655
- if (/[\s<>{}]/.test(endpoint)) {
3825
+ if (/[\s<>{}`]/.test(endpoint)) {
3656
3826
  return undefined;
3657
3827
  }
3658
3828
  return endpoint;
@@ -3697,10 +3867,40 @@ function isMockOrFixtureFile(file) {
3697
3867
  const stem = basename.replace(/\.[^.]+$/g, "");
3698
3868
  const extension = path.extname(basename).toLowerCase();
3699
3869
  const canUseBroadFilenameMatch = fixtureEvidenceSourceExtensions.has(extension);
3700
- return /(?:^|\/)(?:__mocks__|mocks?|fixtures?|factories|seeds?|test-data|testData|msw|mirage)\//i.test(file) ||
3701
- /(?:mock|fixture|factory|seed|handler|msw)\.[cm]?[jt]sx?$/i.test(basename) ||
3702
- (canUseBroadFilenameMatch && /(?:mock|fixture|factory|seed|msw|mirage)/i.test(stem));
3703
- }
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
+ ]);
3704
3904
  function isFixtureEvidenceIgnoredPath(file) {
3705
3905
  return /(?:^|\/)(?:node_modules|vendor|vendors|Pods|build|dist|coverage|\.next|\.nuxt|\.expo|\.turbo|\.yarn\/cache)\//i.test(file);
3706
3906
  }
@@ -4074,7 +4274,7 @@ async function buildSetupNotes(root, runner, project) {
4074
4274
  return ["Choose an E2E runner after documenting the primary user-facing entry point for this project."];
4075
4275
  }
4076
4276
  function isUserFacingFile(file) {
4077
- if (isApiRouteFile(file)) {
4277
+ if (isApiRouteFile(file) || isConfigLikeFile(file) || isTestLikeFile(file)) {
4078
4278
  return false;
4079
4279
  }
4080
4280
  return (/(?:^|\/)(app|pages|routes|screens|components|ui|navigation)\//i.test(file) ||
@@ -4130,7 +4330,13 @@ function isCatalogDataFile(file) {
4130
4330
  /(?:^|\/)(?:catalog|taxonomy)-site\/index\.html$/i.test(file);
4131
4331
  }
4132
4332
  function isConfigLikeFile(file) {
4133
- 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);
4134
4340
  }
4135
4341
  function isReleaseMetadataFile(file) {
4136
4342
  return /(?:^|\/)(?:CHANGELOG|RELEASES?|release-notes?|\.release-please-manifest)\.(?:md|json)$/i.test(file) ||
@@ -4163,7 +4369,11 @@ function isTestLikeFile(file) {
4163
4369
  /(?:^|\/)test_[^/]+\.py$/i.test(file) ||
4164
4370
  /(?:^|\/)[^/]+_test\.(?:py|go)$/i.test(file) ||
4165
4371
  /(?:^|\/)[^/]+(?:Test|Tests|Spec)\.(?:java|kt|cs|swift)$/i.test(file) ||
4166
- /(?:^|\/)[^/]+_(?: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);
4167
4377
  }
4168
4378
  function isDocumentationFile(file) {
4169
4379
  if (isReleaseMetadataFile(file)) {
@@ -4534,7 +4744,7 @@ function isImportantBaseDraftFlow(flow) {
4534
4744
  }
4535
4745
  function shouldUseDomainScenariosForDraft(plan) {
4536
4746
  const files = plan.changedFiles.map((file) => file.path);
4537
- if (isLowSignalVerificationOnlyChange(files)) {
4747
+ if (isLowSignalVerificationOnlyChange(files) || isConfigurationOnlyChange(files)) {
4538
4748
  return false;
4539
4749
  }
4540
4750
  return !plan.flows.every(isEvidenceVerificationFocusedFlow);
@@ -4542,6 +4752,9 @@ function shouldUseDomainScenariosForDraft(plan) {
4542
4752
  function isEvidenceVerificationFocusedFlow(flow) {
4543
4753
  return isTestEvidenceFocusedFlow(flow) || isDocumentationFocusedFlow(flow) || isGeneratedArtifactFocusedFlow(flow);
4544
4754
  }
4755
+ function isVerificationOnlyFlow(flow) {
4756
+ return isEvidenceVerificationFocusedFlow(flow) || /\bconfiguration verification\b/i.test(flow.title);
4757
+ }
4545
4758
  function dedupeDraftFlowsByOutputPath(flows, runner) {
4546
4759
  const flowsByOutput = new Map();
4547
4760
  for (const flow of flows) {
@@ -4736,7 +4949,11 @@ async function buildDomainScenarioDraftFlow(plan, scenario, baseFlows) {
4736
4949
  const reason = specializedScenario?.reason ?? scenario.intent;
4737
4950
  const steps = specializedScenario?.steps ?? (scenario.checks.length > 0 ? scenario.checks : (baseFlow?.steps ?? []));
4738
4951
  const scenarioFiles = normalizeScenarioFilesForRoot(plan, scenario.files);
4739
- const files = uniqueStrings(scenarioFiles.length > 0 ? scenarioFiles : (baseFlow?.files ?? [])).slice(0, 20);
4952
+ const files = uniqueStrings(specializedScenario?.useBaseFlowFiles
4953
+ ? [...(baseFlow?.files ?? []), ...scenarioFiles]
4954
+ : scenarioFiles.length > 0
4955
+ ? scenarioFiles
4956
+ : (baseFlow?.files ?? [])).slice(0, 20);
4740
4957
  const coverage = baseFlow?.coverage ?? buildCoverageTargets("domain", files, plan.recommendedRunner.name);
4741
4958
  const runner = plan.recommendedRunner.name;
4742
4959
  const entrypoints = uniqueEntrypoints([
@@ -4788,6 +5005,15 @@ function specializedDomainScenarioDraft(scenario, baseFlow) {
4788
5005
  if (scenario.source !== "changed-file" || !baseFlow) {
4789
5006
  return undefined;
4790
5007
  }
5008
+ if (/through imports/i.test(baseFlow.reason) && /\sprimary journey$/i.test(scenario.title)) {
5009
+ const subject = baseFlow.title.replace(/\s+UI smoke flow$/i, "").trim();
5010
+ return {
5011
+ title: subject,
5012
+ reason: baseFlow.reason,
5013
+ steps: baseFlow.steps,
5014
+ useBaseFlowFiles: true,
5015
+ };
5016
+ }
4791
5017
  if (isApiContractFocusedFlow(baseFlow)) {
4792
5018
  const subject = scenario.title.replace(/\s+primary journey$/i, "");
4793
5019
  return {
@@ -4939,7 +5165,7 @@ function domainScenarioForFlow(flow) {
4939
5165
  return flow.domainScenario;
4940
5166
  }
4941
5167
  function draftStability(plan, flow) {
4942
- const needsSelector = flow.missingTestability.length > 0;
5168
+ const needsSelector = !isVerificationOnlyFlow(flow) && flow.missingTestability.length > 0;
4943
5169
  const needsSetup = plan.missingTestability.some((gap) => /No \.maestro|No Playwright config/i.test(gap));
4944
5170
  if (needsSelector && needsSetup) {
4945
5171
  return "needs-selector-and-setup";
@@ -4953,14 +5179,15 @@ function draftStability(plan, flow) {
4953
5179
  return "ready";
4954
5180
  }
4955
5181
  function draftExecutionBlockers(plan, flow, runner, validationSummary, selfCheck) {
4956
- const blockers = [...plan.executionProfile.blockers];
4957
- if (runner !== "manual" && flow.entrypoints.length === 0) {
5182
+ const verificationOnly = isVerificationOnlyFlow(flow);
5183
+ const blockers = verificationOnly ? [] : [...plan.executionProfile.blockers];
5184
+ if (!verificationOnly && runner !== "manual" && flow.entrypoints.length === 0) {
4958
5185
  blockers.push("No runnable route, screen, or command entrypoint was inferred for this flow.");
4959
5186
  }
4960
- if (flow.missingTestability.length > 0) {
5187
+ if (!verificationOnly && flow.missingTestability.length > 0) {
4961
5188
  blockers.push(flow.missingTestability[0]);
4962
5189
  }
4963
- if (flow.fixtureReadiness.status === "missing") {
5190
+ if (!verificationOnly && flow.fixtureReadiness.status === "missing") {
4964
5191
  blockers.push(flow.fixtureReadiness.nextActions[0] ?? flow.fixtureReadiness.reason);
4965
5192
  }
4966
5193
  if (validationSummary.blockingGapCount > 0) {
@@ -4972,7 +5199,7 @@ function draftExecutionBlockers(plan, flow, runner, validationSummary, selfCheck
4972
5199
  return uniqueStrings(blockers).slice(0, 8);
4973
5200
  }
4974
5201
  function draftRunnableStatus(plan, flow, runner, validationSummary, executionBlockers, selfCheck) {
4975
- if (runner === "manual") {
5202
+ if (runner === "manual" || isVerificationOnlyFlow(flow)) {
4976
5203
  return "review-only";
4977
5204
  }
4978
5205
  if (selfCheck?.status === "fail") {
@@ -5212,12 +5439,12 @@ function draftExtension(runner) {
5212
5439
  }
5213
5440
  return ".md";
5214
5441
  }
5215
- function draftContentForFlow(plan, flow, runner) {
5442
+ function draftContentForFlow(plan, flow, runner, addedDiffText = {}) {
5216
5443
  if (runner === "maestro") {
5217
5444
  return buildMaestroDraft(plan, flow);
5218
5445
  }
5219
5446
  if (runner === "playwright") {
5220
- return buildPlaywrightDraft(plan, flow);
5447
+ return buildPlaywrightDraft(plan, flow, addedDiffText);
5221
5448
  }
5222
5449
  return buildManualDraft(plan, flow);
5223
5450
  }
@@ -5310,7 +5537,7 @@ function maestroCommandForStep(step, selectors) {
5310
5537
  value: maestroSelectorValue(selector),
5311
5538
  };
5312
5539
  }
5313
- function buildPlaywrightDraft(plan, flow) {
5540
+ function buildPlaywrightDraft(plan, flow, addedDiffText = {}) {
5314
5541
  const testName = flow.title.replaceAll('"', "'");
5315
5542
  const selectorQueue = [...flow.selectors];
5316
5543
  const scenario = domainScenarioForFlow(flow);
@@ -5367,6 +5594,7 @@ function buildPlaywrightDraft(plan, flow) {
5367
5594
  : playwrightFallbackActionForStep(step));
5368
5595
  appendPlaywrightTestStep(lines, step, body);
5369
5596
  }
5597
+ appendObservedResponseAssertion(lines, flow, addedDiffText);
5370
5598
  appendDomainScenarioComments(lines, flow, " //");
5371
5599
  appendPlaywrightCoverageComments(lines, flow);
5372
5600
  lines.push("});");
@@ -5964,12 +6192,7 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
5964
6192
  if (flow.fixtureReadiness.status === "not-needed" || flow.fixtureReadiness.apiEndpoints.length === 0) {
5965
6193
  return;
5966
6194
  }
5967
- const changedEndpointHints = uniqueStrings(flow.fixtureReadiness.backendSignals.flatMap(apiEndpointFromBackendFile));
5968
- const observedEndpoints = changedEndpointHints.length > 0
5969
- ? changedEndpointHints
5970
- : flow.fixtureReadiness.backendSignals.length > 0
5971
- ? flow.fixtureReadiness.apiEndpoints
5972
- : [];
6195
+ const observedEndpoints = observedEndpointsForFlow(flow);
5973
6196
  if (observedEndpoints.length > 0) {
5974
6197
  appendPlaywrightApiObservationScaffold(lines, observedEndpoints);
5975
6198
  }
@@ -5979,19 +6202,39 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
5979
6202
  if (mockableEndpoints.length === 0) {
5980
6203
  return;
5981
6204
  }
6205
+ const insights = flow.fixtureReadiness.mockInsights ?? [];
6206
+ const shapeSources = [];
5982
6207
  lines.push("");
5983
6208
  lines.push(" const mockApiResponses = {");
5984
6209
  for (const endpoint of mockableEndpoints.slice(0, maxFilesPerFlow)) {
6210
+ const insight = insights.find((candidate) => specSafeSampleKeys(candidate).length > 0 && insightCoversEndpoint(candidate, endpoint)) ??
6211
+ insights.find((candidate) => specSafeSampleKeys(candidate).length > 0);
5985
6212
  lines.push(` "${quoteJs(playwrightMockRoutePattern(endpoint))}": {`);
5986
6213
  lines.push(" status: 200,");
5987
6214
  lines.push(" body: {");
5988
- lines.push(' ok: true,');
5989
- lines.push(' source: "qamap-draft",');
6215
+ if (insight) {
6216
+ if (!shapeSources.includes(insight.file)) {
6217
+ shapeSources.push(insight.file);
6218
+ }
6219
+ for (const key of specSafeSampleKeys(insight)) {
6220
+ const propertyName = isJsIdentifier(key) ? key : JSON.stringify(key);
6221
+ lines.push(` ${propertyName}: ${JSON.stringify(`qamap-${key}`)},`);
6222
+ }
6223
+ }
6224
+ else {
6225
+ lines.push(' ok: true,');
6226
+ lines.push(' source: "qamap-draft",');
6227
+ }
5990
6228
  lines.push(" },");
5991
6229
  lines.push(" },");
5992
6230
  }
5993
6231
  lines.push(" };");
5994
- lines.push(" // Replace sample responses with deterministic fixtures from the target domain before promoting this draft.");
6232
+ if (shapeSources.length > 0) {
6233
+ lines.push(` // Response shape keys reuse ${formatFileSummary(shapeSources)}; replace the sample values with deterministic fixture data before promoting this draft.`);
6234
+ }
6235
+ else {
6236
+ lines.push(" // Replace sample responses with deterministic fixtures from the target domain before promoting this draft.");
6237
+ }
5995
6238
  lines.push(" for (const [urlPattern, response] of Object.entries(mockApiResponses)) {");
5996
6239
  lines.push(" await page.route(urlPattern, async (route) => {");
5997
6240
  lines.push(" await route.fulfill({");
@@ -6002,6 +6245,71 @@ function appendPlaywrightMockRouteScaffold(lines, flow) {
6002
6245
  lines.push(" });");
6003
6246
  lines.push(" }");
6004
6247
  }
6248
+ // Drafts are pinned to contain no literal TODO marker, so a fixture key that
6249
+ // would introduce one disqualifies itself from spec interpolation.
6250
+ function specSafeSampleKeys(insight) {
6251
+ return insight.sampleKeys.filter((key) => !/todo/i.test(key));
6252
+ }
6253
+ function observedEndpointsForFlow(flow) {
6254
+ if (flow.fixtureReadiness.status === "not-needed" || flow.fixtureReadiness.apiEndpoints.length === 0) {
6255
+ return [];
6256
+ }
6257
+ const changedEndpointHints = uniqueStrings(flow.fixtureReadiness.backendSignals.flatMap(apiEndpointFromBackendFile));
6258
+ if (changedEndpointHints.length > 0) {
6259
+ return changedEndpointHints;
6260
+ }
6261
+ return flow.fixtureReadiness.backendSignals.length > 0 ? flow.fixtureReadiness.apiEndpoints : [];
6262
+ }
6263
+ function collectChangedHandlerEvidence(flow, addedDiffText) {
6264
+ const statuses = [];
6265
+ const responseKeys = [];
6266
+ for (const file of flow.fixtureReadiness.backendSignals) {
6267
+ const addedText = addedDiffText[file];
6268
+ if (!addedText) {
6269
+ continue;
6270
+ }
6271
+ for (const match of addedText.matchAll(/(?:status[:(]\s*|\.status\(\s*)(\d{3})\b/g)) {
6272
+ const status = Number(match[1]);
6273
+ if (status >= 100 && status < 600) {
6274
+ statuses.push(status);
6275
+ }
6276
+ }
6277
+ for (const match of addedText.matchAll(/(?:NextResponse|Response|res)\.json\(\s*\{([^}]{1,200})\}/g)) {
6278
+ for (const keyMatch of match[1].matchAll(/(?:^|[,{])\s*([A-Za-z_$][\w$]*)\s*[:,}]/g)) {
6279
+ responseKeys.push(keyMatch[1]);
6280
+ }
6281
+ }
6282
+ }
6283
+ const uniqueStatuses = [...new Set(statuses)];
6284
+ return {
6285
+ statuses: uniqueStatuses,
6286
+ successOnly: uniqueStatuses.length > 0 && uniqueStatuses.every((status) => status < 400),
6287
+ responseKeys: [...new Set(responseKeys)].slice(0, 6),
6288
+ };
6289
+ }
6290
+ function appendObservedResponseAssertion(lines, flow, addedDiffText) {
6291
+ if (observedEndpointsForFlow(flow).length === 0) {
6292
+ return;
6293
+ }
6294
+ const evidence = collectChangedHandlerEvidence(flow, addedDiffText);
6295
+ const statusCeiling = evidence.successOnly ? 400 : 500;
6296
+ lines.push("");
6297
+ if (evidence.successOnly) {
6298
+ lines.push(` // The added handler code only shows success statuses (${evidence.statuses.join(", ")}), so any 4xx/5xx here is unexpected.`);
6299
+ }
6300
+ else {
6301
+ lines.push(" // Changed-endpoint check: the journey must not surface server errors from the code under test.");
6302
+ }
6303
+ if (evidence.responseKeys.length > 0) {
6304
+ lines.push(` // Response shape hint from the changed handler: { ${evidence.responseKeys.join(", ")} } — assert on these fields when promoting this draft.`);
6305
+ }
6306
+ lines.push(" for (const response of observedChangedApiResponses) {");
6307
+ lines.push(` expect(response.status, \`unexpected status from \${response.url}\`).toBeLessThan(${statusCeiling});`);
6308
+ lines.push(" }");
6309
+ lines.push(" if (observedChangedApiResponses.length === 0) {");
6310
+ lines.push(' console.warn("Changed endpoints were not exercised by this draft; extend the steps above to cover them.");');
6311
+ lines.push(" }");
6312
+ }
6005
6313
  function appendPlaywrightApiObservationScaffold(lines, endpoints) {
6006
6314
  lines.push("");
6007
6315
  lines.push(" const changedApiEndpointPatterns = [");
@@ -6703,7 +7011,7 @@ function withoutSelectorValueDuplicates(selectors, existing) {
6703
7011
  function extractAttributeSelectors(file, text, attributes, kind) {
6704
7012
  const selectors = [];
6705
7013
  for (const attribute of attributes) {
6706
- const matcher = new RegExp(`${escapeRegExp(attribute)}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{\\s*["'\`]([^"'\`{}]+)["'\`]\\s*\\})`, "g");
7014
+ const matcher = new RegExp(`(?<![:@.\\w-])${escapeRegExp(attribute)}\\s*=\\s*(?:"([^"]+)"|'([^']+)'|\\{\\s*["'\`]([^"'\`{}]+)["'\`]\\s*\\})`, "g");
6707
7015
  for (const match of text.matchAll(matcher)) {
6708
7016
  const value = normalizeSelectorValue(match[1] ?? match[2] ?? match[3]);
6709
7017
  if (value) {
@@ -6732,6 +7040,28 @@ function extractTextNodeSelectors(file, text) {
6732
7040
  for (const selector of extractAttributeSelectors(file, text, ["title"], "visible-text")) {
6733
7041
  selectors.push(selector);
6734
7042
  }
7043
+ selectors.push(...extractRenderedStateSelectors(file, text));
7044
+ return selectors;
7045
+ }
7046
+ function extractRenderedStateSelectors(file, text) {
7047
+ const selectors = [];
7048
+ const renderedNames = new Set([...text.matchAll(/>\s*\{\s*([A-Za-z_$][\w$]*)\s*\}\s*</g)].map((match) => match[1]));
7049
+ for (const stateName of renderedNames) {
7050
+ const stateDeclaration = new RegExp(`\\bconst\\s*\\[\\s*${escapeRegExp(stateName)}\\s*,\\s*([A-Za-z_$][\\w$]*)\\s*\\]\\s*=\\s*useState\\b`).exec(text);
7051
+ const setterName = stateDeclaration?.[1];
7052
+ if (!setterName) {
7053
+ continue;
7054
+ }
7055
+ const setterCallMatcher = new RegExp(`\\b${escapeRegExp(setterName)}\\s*\\(([^\\n;]{0,300})\\)`, "g");
7056
+ for (const call of text.matchAll(setterCallMatcher)) {
7057
+ for (const literal of call[1].matchAll(/"([^"]+)"|'([^']+)'|`([^`]+)`/g)) {
7058
+ const value = normalizeSelectorValue(literal[1] ?? literal[2] ?? literal[3]);
7059
+ if (value && isUsefulSelector(value)) {
7060
+ selectors.push({ kind: "visible-text", value, file });
7061
+ }
7062
+ }
7063
+ }
7064
+ }
6735
7065
  return selectors;
6736
7066
  }
6737
7067
  function extractRoleSelectorsFromText(file, text) {
@@ -6761,7 +7091,15 @@ function normalizeSelectorValue(value) {
6761
7091
  return normalized || undefined;
6762
7092
  }
6763
7093
  function isUsefulSelector(value) {
6764
- return value.length >= 2 && value.length <= 80 && !/[{}()[\]=>]/.test(value);
7094
+ if (value.length < 2 || value.length > 80 || /[{}()[\]=>]/.test(value)) {
7095
+ return false;
7096
+ }
7097
+ // Dotted tokens without spaces are almost always i18n keys or property paths,
7098
+ // never rendered UI text, so a locator built from them can never match.
7099
+ if (/^[\w$-]+(?:\.[\w$-]+)+$/.test(value)) {
7100
+ return false;
7101
+ }
7102
+ return true;
6765
7103
  }
6766
7104
  async function readPackageJson(root) {
6767
7105
  try {
@@ -6981,11 +7319,12 @@ function selectorRank(kind) {
6981
7319
  return 4;
6982
7320
  }
6983
7321
  function slugify(value) {
6984
- return value
7322
+ const slug = value
6985
7323
  .toLowerCase()
6986
- .replace(/[^a-z0-9]+/g, "-")
7324
+ .replace(/[^a-z0-9\uAC00-\uD7A3]+/g, "-")
6987
7325
  .replace(/^-+|-+$/g, "")
6988
7326
  .slice(0, 80);
7327
+ return slug || "draft";
6989
7328
  }
6990
7329
  function quoteYaml(value) {
6991
7330
  return JSON.stringify(value);