@ivorycanvas/qamap 0.3.5 → 0.4.1

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 (49) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +37 -23
  3. package/dist/agent-init.js +2 -2
  4. package/dist/agent-init.js.map +1 -1
  5. package/dist/behavior-intent.d.ts +6 -0
  6. package/dist/behavior-intent.js +183 -0
  7. package/dist/behavior-intent.js.map +1 -0
  8. package/dist/behavior-manifest.d.ts +6 -0
  9. package/dist/behavior-manifest.js +221 -0
  10. package/dist/behavior-manifest.js.map +1 -0
  11. package/dist/behavior.d.ts +151 -0
  12. package/dist/behavior.js +458 -0
  13. package/dist/behavior.js.map +1 -0
  14. package/dist/change-intent.d.ts +81 -0
  15. package/dist/change-intent.js +884 -0
  16. package/dist/change-intent.js.map +1 -0
  17. package/dist/cli.js +13 -8
  18. package/dist/cli.js.map +1 -1
  19. package/dist/e2e.d.ts +16 -1
  20. package/dist/e2e.js +290 -25
  21. package/dist/e2e.js.map +1 -1
  22. package/dist/index.d.ts +11 -3
  23. package/dist/index.js +5 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/qa.d.ts +1 -0
  26. package/dist/qa.js +293 -88
  27. package/dist/qa.js.map +1 -1
  28. package/dist/terminal.js +1 -1
  29. package/dist/terminal.js.map +1 -1
  30. package/dist/test-plan.d.ts +18 -0
  31. package/dist/test-plan.js +123 -13
  32. package/dist/test-plan.js.map +1 -1
  33. package/dist/version.d.ts +1 -1
  34. package/dist/version.js +1 -1
  35. package/docs/adoption.md +11 -10
  36. package/docs/agent-format.md +16 -10
  37. package/docs/agent-skill.md +8 -5
  38. package/docs/architecture.md +131 -0
  39. package/docs/benchmarking.md +23 -4
  40. package/docs/commands.md +13 -7
  41. package/docs/e2e-output-examples.md +29 -28
  42. package/docs/quickstart-demo.md +29 -17
  43. package/docs/release-validation.md +49 -6
  44. package/docs/releasing.md +26 -3
  45. package/docs/roadmap.md +20 -10
  46. package/package.json +4 -2
  47. package/schema/qamap-agent.schema.json +122 -3
  48. package/schema/qamap-behavior.schema.json +339 -0
  49. package/skills/qamap-pr-qa/SKILL.md +20 -14
package/dist/e2e.js CHANGED
@@ -1,6 +1,10 @@
1
1
  import { promises as fs } from "node:fs";
2
2
  import path from "node:path";
3
3
  import YAML from "yaml";
4
+ import { analyzeBehaviorGraph, createInferredFlowBehaviorAdapter } from "./behavior.js";
5
+ import { createChangeIntentBehaviorAdapter } from "./behavior-intent.js";
6
+ import { createManifestBehaviorAdapter } from "./behavior-manifest.js";
7
+ import { analyzeChangeIntents } from "./change-intent.js";
4
8
  import { buildDomainLanguageSummary } from "./domain-language.js";
5
9
  import { defaultDomainManifestPath, loadDomainManifest, matchDomains } from "./domains.js";
6
10
  import { analyzeFixtureSource, insightCoversEndpoint } from "./fixture-insight.js";
@@ -9,7 +13,7 @@ import { buildReverseImportIndex, expandChangedFilesWithImporters, findImporting
9
13
  import { loadCoreFlowManifest, matchCoreFlows } from "./flows.js";
10
14
  import { loadVerificationManifest, matchVerificationManifest } from "./manifest.js";
11
15
  import { collectTestSuiteInventory, evaluateFlowCoverageEvidence, summarizeTestSuiteInventory, } from "./test-evidence.js";
12
- import { collectAddedDiffText, generateTestPlan } from "./test-plan.js";
16
+ import { addedDiffTextFromEvidence, collectAddedDiffEvidence, collectAddedDiffText, generateTestPlan, } from "./test-plan.js";
13
17
  import { TOOL_NAME, VERSION } from "./version.js";
14
18
  // Human reports render readiness as a stage on a fixed journey instead of a
15
19
  // verdict, so a first run reads as "you are at the start", not "you failed".
@@ -59,15 +63,43 @@ export async function generateE2ePlan(rootInput, options = {}) {
59
63
  const domains = matchDomains(domainManifest, matchableChangedFiles);
60
64
  const verificationManifest = await loadVerificationManifest(coreFlowRoot, { manifestPath: options.manifestPath });
61
65
  const verificationManifestMatches = matchVerificationManifest(verificationManifest, matchableChangedFiles);
62
- const addedDiffText = await collectAddedDiffText(root, {
66
+ const addedDiffEvidence = await collectAddedDiffEvidence(root, {
63
67
  base: testPlan.base,
64
68
  head: testPlan.head,
65
69
  workspaceRoot: testPlan.workspaceRoot,
66
70
  includeWorkingTree: options.includeWorkingTree,
67
71
  });
72
+ const addedDiffText = addedDiffTextFromEvidence(addedDiffEvidence);
73
+ const changeAnalysis = await analyzeChangeIntents(root, {
74
+ base: testPlan.base,
75
+ head: testPlan.head,
76
+ workspaceRoot: testPlan.workspaceRoot,
77
+ includeWorkingTree: options.includeWorkingTree,
78
+ changedFiles: testPlan.changedFiles,
79
+ addedDiffText,
80
+ addedDiffEvidence,
81
+ });
68
82
  const domainLanguage = await buildDomainLanguageSummary(root, testPlan.changedFiles, coreFlows, domains, addedDiffText);
69
83
  const workspaceTargets = await buildWorkspaceTargets(root, testPlan);
70
- const flows = await buildFlows(root, testPlan.changedFiles, recommendedRunner.name, project.type, testSuiteInventory, domainLanguage, addedDiffText);
84
+ const flows = await buildFlows(root, testPlan.changedFiles, recommendedRunner.name, project.type, testSuiteInventory, domainLanguage, addedDiffText, changeAnalysis);
85
+ const behaviorGraph = await analyzeBehaviorGraph({
86
+ root: testPlan.root,
87
+ workspaceRoot: testPlan.workspaceRoot,
88
+ base: testPlan.base,
89
+ head: testPlan.head,
90
+ projectType: project.type,
91
+ surface: behaviorSurfaceForProject(project.type),
92
+ runner: recommendedRunner.name,
93
+ changedFiles: testPlan.changedFiles.map((file) => ({
94
+ path: file.path,
95
+ status: file.status,
96
+ previousPath: file.previousPath,
97
+ })),
98
+ }, [
99
+ createInferredFlowBehaviorAdapter({ flows: flows.map(toInferredBehaviorFlow) }),
100
+ createChangeIntentBehaviorAdapter({ analysis: changeAnalysis }),
101
+ createManifestBehaviorAdapter({ matches: verificationManifestMatches }),
102
+ ]);
71
103
  const testSuite = summarizeTestSuiteInventory(testSuiteInventory);
72
104
  const missingTestability = uniqueStrings([
73
105
  ...flows.flatMap((flow) => flow.missingTestability),
@@ -118,16 +150,50 @@ export async function generateE2ePlan(rootInput, options = {}) {
118
150
  verificationManifestPath: verificationManifest.path,
119
151
  verificationManifestMatches,
120
152
  domainLanguage,
153
+ changeAnalysis,
121
154
  changedFiles: testPlan.changedFiles,
122
155
  suggestedCommands: testPlan.suggestedCommands,
123
156
  workspaceTargets,
124
157
  flows,
158
+ behaviorGraph,
125
159
  validationMatrix,
126
160
  bootstrap,
127
161
  missingTestability,
128
162
  setupNotes,
129
163
  };
130
164
  }
165
+ function behaviorSurfaceForProject(projectType) {
166
+ if (projectType === "web") {
167
+ return "web";
168
+ }
169
+ if (projectType === "expo-react-native" || projectType === "react-native") {
170
+ return "mobile";
171
+ }
172
+ if (projectType === "api-service") {
173
+ return "api";
174
+ }
175
+ if (projectType === "cli") {
176
+ return "cli";
177
+ }
178
+ if (projectType === "design-tokens" || projectType === "data-catalog") {
179
+ return "artifact";
180
+ }
181
+ return "unknown";
182
+ }
183
+ function toInferredBehaviorFlow(flow) {
184
+ return {
185
+ kind: flow.kind ?? "changed-file",
186
+ title: flow.title,
187
+ reason: flow.reason,
188
+ files: flow.files,
189
+ steps: flow.steps,
190
+ entrypoints: flow.entrypoints,
191
+ selectors: flow.selectors,
192
+ coverage: flow.coverage,
193
+ fixtureStatus: flow.fixtureReadiness.status,
194
+ fixtureFiles: flow.fixtureReadiness.mockInsights?.map((insight) => insight.file) ?? [],
195
+ };
196
+ }
131
197
  export async function generateE2eDraft(rootInput, options = {}) {
132
198
  const root = path.resolve(rootInput);
133
199
  const plan = await generateE2ePlan(root, options);
@@ -1025,6 +1091,10 @@ function draftFileDetails(flow) {
1025
1091
  setupHints: flow.setupHints.map((hint) => `${hint.title}: ${hint.detail}`).slice(0, 6),
1026
1092
  coverageTargets: flow.coverage.map((target) => `${target.priority}: ${target.title}`).slice(0, 7),
1027
1093
  manifestUpdatePath: flow.manifestMatch?.updatePath,
1094
+ intentId: flow.intentId,
1095
+ intentConfidence: flow.intentConfidence,
1096
+ lifecycle: flow.lifecycle,
1097
+ qaScenarios: flow.qaScenarios,
1028
1098
  };
1029
1099
  }
1030
1100
  function formatEntrypointHint(entrypoint) {
@@ -1227,6 +1297,35 @@ function withTerminalPeriod(value) {
1227
1297
  }
1228
1298
  function buildFlowLanguageBrief(flow) {
1229
1299
  const actor = inferFlowActor(flow);
1300
+ if (flow.intentId && flow.lifecycle && flow.lifecycle.length > 0) {
1301
+ const trigger = flow.lifecycle.find((stage) => stage.kind === "trigger")?.label ?? `Start the changed ${flow.title} behavior.`;
1302
+ const outcomes = flow.lifecycle
1303
+ .filter((stage) => stage.kind === "observable-outcome")
1304
+ .map((stage) => stripTerminalPunctuation(stage.label));
1305
+ const effects = flow.lifecycle
1306
+ .filter((stage) => stage.kind === "side-effect")
1307
+ .map((stage) => stripTerminalPunctuation(stage.label));
1308
+ const lifecycleSuccessSignal = outcomes.length > 0
1309
+ ? outcomes.slice(0, 2).join("; ")
1310
+ : effects.length > 0
1311
+ ? `the intended side effect completes: ${effects.slice(0, 2).join("; ")}`
1312
+ : "the observable result matches the commit intent";
1313
+ const repositorySuccessSignal = inferFlowSuccessSignal(flow);
1314
+ const successSignal = repositorySuccessSignal === "the changed journey reaches a visible, stable success state"
1315
+ ? lifecycleSuccessSignal
1316
+ : repositorySuccessSignal;
1317
+ const scenarioEdges = (flow.qaScenarios ?? [])
1318
+ .filter((scenario) => scenario.kind !== "primary")
1319
+ .flatMap((scenario) => [scenario.title, ...scenario.edgeCases]);
1320
+ return {
1321
+ actor,
1322
+ trigger,
1323
+ goal: `Complete the intended behavior: ${flow.title}.`,
1324
+ successSignal,
1325
+ reviewQuestion: `Does ${flow.title} follow the inferred lifecycle and produce this outcome: ${successSignal}?`,
1326
+ edgeCases: uniqueStrings(scenarioEdges).slice(0, 6),
1327
+ };
1328
+ }
1230
1329
  const trigger = inferFlowTrigger(flow);
1231
1330
  const goal = inferFlowGoal(flow);
1232
1331
  const successSignal = inferFlowSuccessSignal(flow);
@@ -1417,8 +1516,8 @@ function inferFlowSuccessSignal(flow) {
1417
1516
  return "the changed journey reaches a visible, stable success state";
1418
1517
  }
1419
1518
  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);
1519
+ return /\b(?:confirmed|saved|scheduled|refreshed|succeeded|success|completed|created|updated|deleted|sent|approved|accepted)\b/i.test(value) ||
1520
+ /(?:완료|성공|예약(?:됨|됐|되)|저장(?:됨|됐|되)|등록(?:됨|됐|되)|제출(?:됨|됐|되)|승인(?:됨|됐|되))/.test(value);
1422
1521
  }
1423
1522
  function inferFlowReviewQuestion(flow, successSignal) {
1424
1523
  if (isApiContractFocusedFlow(flow)) {
@@ -1519,7 +1618,7 @@ export function formatMarkdownE2ePlan(result) {
1519
1618
  lines.push("- Includes working tree changes: yes");
1520
1619
  }
1521
1620
  lines.push(`- Project: ${formatProjectType(result.project.type)}`);
1522
- lines.push(`- Recommended runner: ${formatRunnerName(result.recommendedRunner.name)}`);
1621
+ lines.push(`- Automation adapter: ${formatRunnerName(result.recommendedRunner.name)}`);
1523
1622
  lines.push(`- Test suite: ${result.testSuite.hasTestSuite ? `${result.testSuite.testFileCount} test file${result.testSuite.testFileCount === 1 ? "" : "s"}` : "not detected"}`);
1524
1623
  if (result.testSuite.frameworkSignals.length > 0) {
1525
1624
  lines.push(`- Test frameworks: ${result.testSuite.frameworkSignals.join(", ")}`);
@@ -1544,7 +1643,10 @@ export function formatMarkdownE2ePlan(result) {
1544
1643
  lines.push(`- Local history: \`${escapeMarkdownInline(result.localHistory.path)}\``);
1545
1644
  }
1546
1645
  lines.push("");
1547
- lines.push("## Recommendation");
1646
+ appendChangeIntentMarkdown(lines, result.changeAnalysis);
1647
+ lines.push("## Automation Adapter");
1648
+ lines.push("");
1649
+ lines.push("QAMap selects an output adapter only after deriving runner-independent change intent and QA scenarios.");
1548
1650
  lines.push("");
1549
1651
  lines.push(result.recommendedRunner.reason);
1550
1652
  if (result.project.evidence.length > 0) {
@@ -1788,6 +1890,53 @@ export function formatMarkdownE2ePlan(result) {
1788
1890
  }
1789
1891
  return lines.join("\n");
1790
1892
  }
1893
+ function appendChangeIntentMarkdown(lines, analysis) {
1894
+ lines.push("## Change Intent And Behavior Lifecycle");
1895
+ lines.push("");
1896
+ if (analysis.intents.length === 0) {
1897
+ lines.push("No evidence-backed change intent was inferred. QAMap keeps heuristic flow suggestions review-only.");
1898
+ for (const diagnostic of analysis.diagnostics.slice(0, 3)) {
1899
+ lines.push(`- ${escapeMarkdownInline(diagnostic)}`);
1900
+ }
1901
+ lines.push("");
1902
+ return;
1903
+ }
1904
+ for (const intent of analysis.intents.slice(0, 3)) {
1905
+ lines.push(`### ${escapeMarkdownInline(intent.title)}`);
1906
+ lines.push("");
1907
+ lines.push(`- Confidence: ${intent.confidence}${intent.reviewRequired ? "; human review required" : ""}`);
1908
+ lines.push(`- Summary: ${escapeMarkdownInline(intent.summary)}`);
1909
+ if (intent.commits.length > 0) {
1910
+ lines.push("- Commit evidence:");
1911
+ for (const commit of intent.commits.slice(0, 6)) {
1912
+ lines.push(` - \`${commit.sha.slice(0, 12)}\` ${escapeMarkdownInline(commit.subject)}`);
1913
+ }
1914
+ }
1915
+ if (intent.files.length > 0) {
1916
+ lines.push(`- Source scope: ${intent.files.slice(0, 6).map((file) => `\`${escapeMarkdownInline(file)}\``).join(", ")}`);
1917
+ }
1918
+ lines.push("");
1919
+ lines.push("Behavior lifecycle:");
1920
+ intent.lifecycle.slice(0, 12).forEach((stage, index) => {
1921
+ lines.push(`${index + 1}. **${stage.kind}**: ${escapeMarkdownInline(stage.label)} [${stage.confidence}]`);
1922
+ });
1923
+ lines.push("");
1924
+ lines.push("Required QA scenarios:");
1925
+ for (const scenario of intent.scenarios.slice(0, 4)) {
1926
+ lines.push(`- **${scenario.priority} / ${scenario.kind}**: ${escapeMarkdownInline(scenario.title)}`);
1927
+ for (const step of scenario.steps.slice(0, 3)) {
1928
+ lines.push(` - Step: ${escapeMarkdownInline(step)}`);
1929
+ }
1930
+ for (const assertion of scenario.assertions.slice(0, 3)) {
1931
+ lines.push(` - Assert: ${escapeMarkdownInline(assertion)}`);
1932
+ }
1933
+ if (scenario.edgeCases.length > 0) {
1934
+ lines.push(` - Boundaries: ${scenario.edgeCases.slice(0, 4).map(escapeMarkdownInline).join(", ")}`);
1935
+ }
1936
+ }
1937
+ lines.push("");
1938
+ }
1939
+ }
1791
1940
  function appendVerificationManifestMatchesMarkdown(lines, matches) {
1792
1941
  if (matches.length === 0) {
1793
1942
  return;
@@ -1833,7 +1982,7 @@ export function formatMarkdownE2eDraft(result) {
1833
1982
  lines.push("# QAMap E2E Draft");
1834
1983
  lines.push("");
1835
1984
  lines.push(`- Root: \`${escapeMarkdownInline(result.root)}\``);
1836
- lines.push(`- Runner: ${formatRunnerName(result.runner)}`);
1985
+ lines.push(`- Automation adapter: ${formatRunnerName(result.runner)}`);
1837
1986
  lines.push(`- Output directory: \`${escapeMarkdownInline(result.outputDirectory)}\``);
1838
1987
  if (result.dryRun) {
1839
1988
  lines.push("- Mode: dry run (no files were written)");
@@ -1863,6 +2012,7 @@ export function formatMarkdownE2eDraft(result) {
1863
2012
  lines.push(`- Action categories: ${formatDraftActionKindSummary(result.actionSummary.byKind)}`);
1864
2013
  }
1865
2014
  lines.push("");
2015
+ appendChangeIntentMarkdown(lines, result.plan.changeAnalysis);
1866
2016
  appendVerificationManifestMatchesMarkdown(lines, result.plan.verificationManifestMatches);
1867
2017
  lines.push("## Files");
1868
2018
  lines.push("");
@@ -2111,8 +2261,9 @@ async function detectProjectProfile(root, workspaceRoot) {
2111
2261
  "admin.py",
2112
2262
  ]);
2113
2263
  const projectFilePaths = (await collectProjectFiles(root, 2000)).map((file) => file.path);
2114
- const hasDesignTokenProject = projectFilePaths.some(isDesignTokenFile);
2115
- const hasDataCatalogProject = projectFilePaths.some(isCatalogDataFile);
2264
+ const projectProfileArtifactFiles = projectFilePaths.filter((file) => !isTestLikeFile(file));
2265
+ const hasDesignTokenProject = projectProfileArtifactFiles.some(isDesignTokenFile);
2266
+ const hasDataCatalogProject = projectProfileArtifactFiles.some(isCatalogDataFile);
2116
2267
  const hasCliBin = packageJsonHasCliBin(packageJson);
2117
2268
  if (hasExpoDependency) {
2118
2269
  evidence.push("package.json dependency: expo");
@@ -2171,15 +2322,15 @@ async function detectProjectProfile(root, workspaceRoot) {
2171
2322
  if (apiServiceDependency || hasApiServiceConfig || hasDjangoEntrypoint || hasPythonServiceDependency || hasPythonServiceModule) {
2172
2323
  return { type: "api-service", evidence };
2173
2324
  }
2325
+ if (hasCliBin) {
2326
+ return { type: "cli", evidence };
2327
+ }
2174
2328
  if (hasDesignTokenProject) {
2175
2329
  return { type: "design-tokens", evidence };
2176
2330
  }
2177
2331
  if (hasDataCatalogProject) {
2178
2332
  return { type: "data-catalog", evidence };
2179
2333
  }
2180
- if (hasCliBin) {
2181
- return { type: "cli", evidence };
2182
- }
2183
2334
  return {
2184
2335
  type: "unknown",
2185
2336
  evidence,
@@ -3057,11 +3208,11 @@ function toCoreFlowChangedFiles(changedFiles, scopedRoot, coreFlowRoot) {
3057
3208
  previousPath: file.previousPath ? toPosixPath(path.join(relativeRoot, file.previousPath)) : undefined,
3058
3209
  }));
3059
3210
  }
3060
- async function buildFlows(root, changedFiles, runner, projectType, testSuiteInventory, domainLanguage, addedDiffText = {}) {
3211
+ async function buildFlows(root, changedFiles, runner, projectType, testSuiteInventory, domainLanguage, addedDiffText = {}, changeAnalysis) {
3061
3212
  const files = changedFiles.map((file) => file.path);
3062
3213
  const importImpacts = await collectImportImpacts(root, files);
3063
3214
  const fixtureContext = await collectFixtureReadinessContext(root, files);
3064
- const flowResults = await Promise.all(buildFlowCandidates(files, runner, projectType, domainLanguage, importImpacts).map((candidate) => buildFlow(root, runner, candidate, testSuiteInventory, fixtureContext, addedDiffText)));
3215
+ const flowResults = await Promise.all(buildFlowCandidates(files, runner, projectType, domainLanguage, importImpacts, changeAnalysis).map((candidate) => buildFlow(root, runner, candidate, testSuiteInventory, fixtureContext, addedDiffText)));
3065
3216
  const flows = flowResults.filter((flow) => Boolean(flow));
3066
3217
  return dedupeFlows(flows).slice(0, 4);
3067
3218
  }
@@ -3106,7 +3257,7 @@ async function collectImportImpacts(root, changedFiles) {
3106
3257
  function describeImportChain(impact) {
3107
3258
  return impact.chain.join(" -> ");
3108
3259
  }
3109
- function buildFlowCandidates(files, runner, projectType, domainLanguage, importImpacts = []) {
3260
+ function buildFlowCandidates(files, runner, projectType, domainLanguage, importImpacts = [], changeAnalysis) {
3110
3261
  const lowSignalCandidate = importImpacts.length === 0 ? buildLowSignalChangeCandidate(files) : undefined;
3111
3262
  if (lowSignalCandidate) {
3112
3263
  return [lowSignalCandidate];
@@ -3311,7 +3462,58 @@ function buildFlowCandidates(files, runner, projectType, domainLanguage, importI
3311
3462
  ],
3312
3463
  });
3313
3464
  }
3314
- return candidates;
3465
+ return prioritizeChangeIntentCandidates(changeAnalysis, candidates, projectType);
3466
+ }
3467
+ function prioritizeChangeIntentCandidates(analysis, heuristicCandidates, projectType) {
3468
+ const intentCandidates = (analysis?.intents ?? [])
3469
+ .filter((intent) => intent.confidence !== "low" && intent.files.length > 0)
3470
+ .map((intent) => {
3471
+ const primaryScenario = intent.scenarios.find((scenario) => scenario.kind === "primary") ?? intent.scenarios[0];
3472
+ const steps = uniqueStrings([
3473
+ ...(primaryScenario?.steps ?? intent.lifecycle.map((stage) => stage.label)),
3474
+ ...(primaryScenario?.assertions ?? []),
3475
+ ]);
3476
+ return {
3477
+ kind: intentFlowKind(intent, projectType),
3478
+ title: intent.title,
3479
+ reason: `Commit and diff evidence support this ${intent.confidence}-confidence change intent. ${intent.summary}`,
3480
+ files: intent.files,
3481
+ steps,
3482
+ coverage: intent.scenarios.map((scenario) => ({
3483
+ title: scenario.title,
3484
+ priority: scenario.priority,
3485
+ reason: scenario.rationale,
3486
+ checks: uniqueStrings([...scenario.assertions, ...scenario.edgeCases.map((edgeCase) => `Exercise ${lowercaseFirst(edgeCase)}.`)]),
3487
+ })),
3488
+ intentId: intent.id,
3489
+ intentConfidence: intent.confidence,
3490
+ intentEvidence: intent.evidence,
3491
+ lifecycle: intent.lifecycle,
3492
+ qaScenarios: intent.scenarios,
3493
+ };
3494
+ });
3495
+ if (intentCandidates.length === 0) {
3496
+ return heuristicCandidates;
3497
+ }
3498
+ const intentFiles = new Set(intentCandidates.flatMap((candidate) => candidate.files));
3499
+ const nonOverlapping = heuristicCandidates.filter((candidate) => candidate.files.every((file) => !intentFiles.has(file)) || isVerificationOnlyKind(candidate.kind));
3500
+ return [...intentCandidates, ...nonOverlapping].slice(0, 4);
3501
+ }
3502
+ function intentFlowKind(intent, projectType) {
3503
+ const searchable = `${intent.title} ${intent.keywords.join(" ")} ${intent.lifecycle.map((stage) => stage.label).join(" ")}`.toLowerCase();
3504
+ if (projectType === "cli") {
3505
+ return "command";
3506
+ }
3507
+ if (/\b(?:endpoint|request|response|api|contract)\b/.test(searchable) && projectType === "api-service") {
3508
+ return "api";
3509
+ }
3510
+ if (/\b(?:state|store|persist|sync|toggle|cache|session|notification|reminder)\b/.test(searchable)) {
3511
+ return "state";
3512
+ }
3513
+ if (intent.lifecycle.some((stage) => stage.kind === "observable-outcome") && projectType !== "api-service") {
3514
+ return "ui";
3515
+ }
3516
+ return "domain";
3315
3517
  }
3316
3518
  function cliCommandChecklistTitle(subject) {
3317
3519
  if (/^cli$/i.test(subject.trim())) {
@@ -3387,13 +3589,14 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
3387
3589
  if (files.length === 0) {
3388
3590
  return undefined;
3389
3591
  }
3390
- const coverage = buildCoverageTargets(candidate.kind, files, runner);
3592
+ const coverage = candidate.coverage ?? buildCoverageTargets(candidate.kind, files, runner);
3391
3593
  const setupHints = await inferFlowSetupHints(root, files, candidate.kind);
3392
3594
  const interactionEvidenceApplies = !isVerificationOnlyKind(candidate.kind);
3393
3595
  const selectors = interactionEvidenceApplies
3394
3596
  ? await inferFlowSelectors(root, files, runner, addedDiffText)
3395
3597
  : [];
3396
3598
  const flow = {
3599
+ kind: candidate.kind,
3397
3600
  title: candidate.title,
3398
3601
  reason: candidate.reason,
3399
3602
  files,
@@ -3405,6 +3608,11 @@ async function buildFlow(root, runner, candidate, testSuiteInventory, fixtureCon
3405
3608
  fixtureReadiness: await inferFlowFixtureReadiness(root, files, candidate.kind, setupHints, fixtureContext),
3406
3609
  selectors,
3407
3610
  missingTestability: interactionEvidenceApplies ? await findFlowTestabilityGaps(root, files, runner) : [],
3611
+ intentId: candidate.intentId,
3612
+ intentConfidence: candidate.intentConfidence,
3613
+ intentEvidence: candidate.intentEvidence,
3614
+ lifecycle: candidate.lifecycle,
3615
+ qaScenarios: candidate.qaScenarios,
3408
3616
  };
3409
3617
  return {
3410
3618
  ...flow,
@@ -4101,11 +4309,11 @@ function normalizeRouteSegment(segment) {
4101
4309
  if (!stem || /^\([^)]*\)$/.test(stem) || stem.startsWith("_") || stem.startsWith("@")) {
4102
4310
  return undefined;
4103
4311
  }
4104
- const routeStem = stem.replace(/^\((?:\.{1,3})\)/, "");
4312
+ const routeStem = stem.replace(/^\+/, "").replace(/^\((?:\.{1,3})\)/, "");
4105
4313
  if (!routeStem) {
4106
4314
  return undefined;
4107
4315
  }
4108
- if (/^(?:index|page|route|layout|template|loading|error|not-found|404|500)$/i.test(routeStem)) {
4316
+ if (/^(?:index|page|route|server|layout|template|loading|error|not-found|404|500)$/i.test(routeStem)) {
4109
4317
  return undefined;
4110
4318
  }
4111
4319
  const dynamic = dynamicRouteSegmentName(routeStem);
@@ -4713,7 +4921,7 @@ async function buildDraftFlows(plan) {
4713
4921
  if (manifestFlows.length === 0 && scenarioFlows.length === 0) {
4714
4922
  return baseFlows.map((flow) => ({
4715
4923
  ...flow,
4716
- draftSource: "heuristic",
4924
+ draftSource: flow.intentId ? "change-intent" : "heuristic",
4717
4925
  }));
4718
4926
  }
4719
4927
  const combined = prioritizeImportantBaseDraftFlow(dedupeDraftFlowsByOutputPath(dedupeFlows([...manifestFlows, ...scenarioFlows, ...baseFlows]), plan.recommendedRunner.name), baseFlows).slice(0, 4);
@@ -4747,6 +4955,9 @@ function shouldUseDomainScenariosForDraft(plan) {
4747
4955
  if (isLowSignalVerificationOnlyChange(files) || isConfigurationOnlyChange(files)) {
4748
4956
  return false;
4749
4957
  }
4958
+ if (plan.changeAnalysis.intents.some((intent) => intent.confidence !== "low")) {
4959
+ return false;
4960
+ }
4750
4961
  return !plan.flows.every(isEvidenceVerificationFocusedFlow);
4751
4962
  }
4752
4963
  function isEvidenceVerificationFocusedFlow(flow) {
@@ -4805,9 +5016,10 @@ function uniqueManifestCheckMatches(matches) {
4805
5016
  function preferredDraftSource(left, right) {
4806
5017
  const ranks = {
4807
5018
  "verification-manifest": 0,
4808
- "core-flow": 1,
4809
- "domain-language": 2,
4810
- heuristic: 3,
5019
+ "change-intent": 1,
5020
+ "core-flow": 2,
5021
+ "domain-language": 3,
5022
+ heuristic: 4,
4811
5023
  };
4812
5024
  if (!left) {
4813
5025
  return right;
@@ -4884,6 +5096,7 @@ async function buildManifestDraftFlow(plan, match, checkMatches, baseFlows) {
4884
5096
  ...(await inferFlowSetupHints(plan.root, files, "domain")),
4885
5097
  ]);
4886
5098
  const flow = {
5099
+ kind: baseFlow?.kind ?? "domain",
4887
5100
  title: match.name,
4888
5101
  reason: match.reason,
4889
5102
  files,
@@ -5020,6 +5233,7 @@ async function buildDomainScenarioDraftFlow(plan, scenario, baseFlows) {
5020
5233
  checks: refinedSteps,
5021
5234
  };
5022
5235
  const flow = {
5236
+ kind: baseFlow?.kind ?? "domain",
5023
5237
  title,
5024
5238
  reason,
5025
5239
  files,
@@ -5201,7 +5415,7 @@ function fileOverlapScore(leftFiles, rightFiles) {
5201
5415
  }
5202
5416
  function draftFlowSource(flow) {
5203
5417
  const draftFlow = flow;
5204
- return draftFlow.draftSource ?? "heuristic";
5418
+ return draftFlow.draftSource ?? (flow.intentId ? "change-intent" : "heuristic");
5205
5419
  }
5206
5420
  function domainScenarioForFlow(flow) {
5207
5421
  return flow.domainScenario;
@@ -5343,6 +5557,7 @@ function buildFallbackFlow(plan) {
5343
5557
  const fallback = fallbackFlowDefinition(plan.project.type);
5344
5558
  const coverage = buildCoverageTargets(fallback.kind, [], plan.recommendedRunner.name);
5345
5559
  const flow = {
5560
+ kind: fallback.kind,
5346
5561
  title: fallback.title,
5347
5562
  reason: fallback.reason,
5348
5563
  files: [],
@@ -5503,6 +5718,7 @@ function buildMaestroDraft(plan, flow) {
5503
5718
  lines.push(`# Base: ${plan.base}`);
5504
5719
  lines.push(`# Head: ${plan.head}`);
5505
5720
  appendDraftBriefComments(lines, flow, "maestro", "#");
5721
+ appendIntentDraftComments(lines, flow, "#");
5506
5722
  appendExecutionProfileComments(lines, plan.executionProfile, "#");
5507
5723
  appendRunnerSetupProposalComments(lines, plan.runnerSetup, "#");
5508
5724
  lines.push("# Replace ${APP_ID} with the app id or export APP_ID before running Maestro.");
@@ -5593,6 +5809,7 @@ function buildPlaywrightDraft(plan, flow, addedDiffText = {}) {
5593
5809
  lines.push(`// Intent: ${scenario.intent}`);
5594
5810
  }
5595
5811
  appendDraftBriefComments(lines, flow, "playwright", "//");
5812
+ appendIntentDraftComments(lines, flow, "//");
5596
5813
  appendExecutionProfileComments(lines, plan.executionProfile, "//");
5597
5814
  appendRunnerSetupProposalComments(lines, plan.runnerSetup, "//");
5598
5815
  lines.push("");
@@ -5707,6 +5924,7 @@ function buildManualDraft(plan, flow) {
5707
5924
  lines.push(`- Head: \`${plan.head}\``);
5708
5925
  lines.push("");
5709
5926
  appendManualDraftBrief(lines, flow, "manual");
5927
+ appendManualIntentDraft(lines, flow);
5710
5928
  appendManualExecutionProfile(lines, plan.executionProfile);
5711
5929
  appendManualRunnerSetupProposal(lines, plan.runnerSetup);
5712
5930
  lines.push("");
@@ -6025,6 +6243,53 @@ function appendDraftBriefComments(lines, flow, runner, commentPrefix) {
6025
6243
  lines.push(`${commentPrefix} - ${input}`);
6026
6244
  }
6027
6245
  }
6246
+ function appendIntentDraftComments(lines, flow, commentPrefix) {
6247
+ if (!flow.intentId || !flow.lifecycle || !flow.qaScenarios) {
6248
+ return;
6249
+ }
6250
+ lines.push("");
6251
+ lines.push(`${commentPrefix} Change intent evidence:`);
6252
+ lines.push(`${commentPrefix} - Intent id: ${flow.intentId}`);
6253
+ lines.push(`${commentPrefix} - Confidence: ${flow.intentConfidence ?? "low"}`);
6254
+ for (const evidence of (flow.intentEvidence ?? []).filter((item) => item.kind === "commit").slice(0, 5)) {
6255
+ lines.push(`${commentPrefix} - Commit: ${evidence.value}`);
6256
+ }
6257
+ lines.push(`${commentPrefix} - Behavior lifecycle:`);
6258
+ for (const stage of flow.lifecycle.slice(0, 10)) {
6259
+ lines.push(`${commentPrefix} - ${stage.kind}: ${stage.label}`);
6260
+ }
6261
+ lines.push(`${commentPrefix} - Runner-independent QA scenarios:`);
6262
+ for (const scenario of flow.qaScenarios.slice(0, 4)) {
6263
+ lines.push(`${commentPrefix} - [${scenario.priority}] ${scenario.kind}: ${scenario.title}`);
6264
+ for (const assertion of scenario.assertions.slice(0, 2)) {
6265
+ lines.push(`${commentPrefix} - Assert: ${assertion}`);
6266
+ }
6267
+ }
6268
+ }
6269
+ function appendManualIntentDraft(lines, flow) {
6270
+ if (!flow.intentId || !flow.lifecycle || !flow.qaScenarios) {
6271
+ return;
6272
+ }
6273
+ lines.push("");
6274
+ lines.push("## Change Intent Evidence");
6275
+ lines.push("");
6276
+ lines.push(`- Intent id: \`${flow.intentId}\``);
6277
+ lines.push(`- Confidence: ${flow.intentConfidence ?? "low"}`);
6278
+ for (const evidence of (flow.intentEvidence ?? []).filter((item) => item.kind === "commit").slice(0, 5)) {
6279
+ lines.push(`- Commit: ${evidence.value}`);
6280
+ }
6281
+ lines.push("- Behavior lifecycle:");
6282
+ for (const stage of flow.lifecycle.slice(0, 10)) {
6283
+ lines.push(` - ${stage.kind}: ${stage.label}`);
6284
+ }
6285
+ lines.push("- Runner-independent QA scenarios:");
6286
+ for (const scenario of flow.qaScenarios.slice(0, 4)) {
6287
+ lines.push(` - [${scenario.priority}] ${scenario.kind}: ${scenario.title}`);
6288
+ for (const assertion of scenario.assertions.slice(0, 2)) {
6289
+ lines.push(` - Assert: ${assertion}`);
6290
+ }
6291
+ }
6292
+ }
6028
6293
  function appendExecutionProfileComments(lines, profile, commentPrefix) {
6029
6294
  lines.push("");
6030
6295
  lines.push(`${commentPrefix} Execution profile:`);