@ivorycanvas/qamap 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/CHANGELOG.md +54 -0
  2. package/README.md +44 -578
  3. package/dist/agent-init.d.ts +16 -0
  4. package/dist/agent-init.js +110 -0
  5. package/dist/agent-init.js.map +1 -0
  6. package/dist/cli.js +17 -1
  7. package/dist/cli.js.map +1 -1
  8. package/dist/context.d.ts +1 -0
  9. package/dist/context.js +4 -0
  10. package/dist/context.js.map +1 -1
  11. package/dist/domain-language.js +48 -5
  12. package/dist/domain-language.js.map +1 -1
  13. package/dist/e2e.d.ts +4 -0
  14. package/dist/e2e.js +385 -74
  15. package/dist/e2e.js.map +1 -1
  16. package/dist/fixture-insight.d.ts +8 -0
  17. package/dist/fixture-insight.js +193 -0
  18. package/dist/fixture-insight.js.map +1 -0
  19. package/dist/fs.js +11 -0
  20. package/dist/fs.js.map +1 -1
  21. package/dist/index.d.ts +2 -1
  22. package/dist/index.js +2 -1
  23. package/dist/index.js.map +1 -1
  24. package/dist/manifest.d.ts +5 -0
  25. package/dist/manifest.js +615 -57
  26. package/dist/manifest.js.map +1 -1
  27. package/dist/qa.d.ts +4 -0
  28. package/dist/qa.js +125 -18
  29. package/dist/qa.js.map +1 -1
  30. package/dist/terminal.d.ts +2 -0
  31. package/dist/terminal.js +51 -0
  32. package/dist/terminal.js.map +1 -0
  33. package/dist/test-plan.js +56 -6
  34. package/dist/test-plan.js.map +1 -1
  35. package/dist/version.d.ts +1 -1
  36. package/dist/version.js +1 -1
  37. package/docs/adoption.md +62 -0
  38. package/docs/agent-format.md +52 -0
  39. package/docs/agent-skill.md +17 -1
  40. package/docs/benchmarking.md +65 -20
  41. package/docs/commands.md +242 -0
  42. package/docs/configuration.md +11 -0
  43. package/docs/e2e-output-examples.md +8 -4
  44. package/docs/guardrails.md +64 -0
  45. package/docs/manifest.md +7 -3
  46. package/docs/quickstart-demo.md +1 -1
  47. package/docs/release-validation.md +35 -34
  48. package/docs/releasing.md +13 -10
  49. package/docs/roadmap.md +9 -14
  50. package/package.json +4 -3
  51. package/schema/qamap-agent.schema.json +171 -0
  52. package/skills/qamap-pr-qa/SKILL.md +1 -0
package/dist/manifest.js CHANGED
@@ -32,7 +32,12 @@ export async function writeVerificationManifestBaseline(rootInput, options = {})
32
32
  if (!options.force && (await pathExists(outputPath))) {
33
33
  throw new Error(`Refusing to overwrite ${outputPath}. Pass --force to replace it.`);
34
34
  }
35
- const files = await collectProjectFiles(root, options.maxFiles ?? defaultMaxManifestFiles);
35
+ const maxFiles = options.maxFiles ?? defaultMaxManifestFiles;
36
+ // Probe one file past the cap so a repo with exactly maxFiles files is not
37
+ // reported as truncated.
38
+ const collected = await collectProjectFiles(root, maxFiles + 1);
39
+ const truncated = collected.length > maxFiles;
40
+ const files = truncated ? collected.slice(0, maxFiles) : collected;
36
41
  const manifest = buildVerificationManifestBaseline(root, manifestRoot, files);
37
42
  await fs.mkdir(path.dirname(outputPath), { recursive: true });
38
43
  await fs.writeFile(outputPath, formatVerificationManifestYaml(manifest), "utf8");
@@ -48,6 +53,11 @@ export async function writeVerificationManifestBaseline(rootInput, options = {})
48
53
  generatedAt: new Date().toISOString(),
49
54
  manifest,
50
55
  summary: summarizeManifest(manifest),
56
+ scan: {
57
+ files: files.length,
58
+ maxFiles,
59
+ truncated,
60
+ },
51
61
  };
52
62
  }
53
63
  export function matchVerificationManifest(manifest, changedFiles) {
@@ -134,7 +144,7 @@ export function changedFilesRelativeToManifestRoot(changedFiles, rootInput, mani
134
144
  }));
135
145
  }
136
146
  export function formatVerificationManifestInitResult(result) {
137
- return [
147
+ const lines = [
138
148
  `Wrote ${result.path}`,
139
149
  `Domains: ${result.summary.domains}`,
140
150
  `Flows: ${result.summary.flows}`,
@@ -143,8 +153,22 @@ export function formatVerificationManifestInitResult(result) {
143
153
  `Context sources: ${result.summary.contextSources}`,
144
154
  `Validation commands: ${result.summary.validationCommands}`,
145
155
  `Safety rules: ${result.summary.safetyRules}`,
146
- "Review and commit this file when the baseline should become team verification policy.",
147
- ].join("\n");
156
+ `Scanned files: ${result.scan.files}`,
157
+ ];
158
+ if (result.scan.truncated) {
159
+ const rerun = [
160
+ `qamap manifest init ${result.root}`,
161
+ result.workspaceRoot !== undefined && result.workspaceRoot !== result.root ? `--workspace-root ${result.workspaceRoot}` : "",
162
+ `--write ${result.path}`,
163
+ `--max-files ${result.scan.maxFiles * 4}`,
164
+ "--force",
165
+ ]
166
+ .filter(Boolean)
167
+ .join(" ");
168
+ lines.push(`Warning: the scan stopped at the ${result.scan.maxFiles}-file limit before reading the whole project, so domains and flows may be missing.`, `Rerun with a larger limit, for example: ${rerun}`);
169
+ }
170
+ lines.push("Review and commit this file when the baseline should become team verification policy.");
171
+ return lines.join("\n");
148
172
  }
149
173
  export async function validateVerificationManifest(rootInput, workspaceRootInput, manifestPathInput) {
150
174
  const root = path.resolve(rootInput);
@@ -360,15 +384,22 @@ export function formatVerificationManifestYaml(manifest) {
360
384
  }
361
385
  function buildVerificationManifestBaseline(root, manifestRoot, files) {
362
386
  const context = buildManifestContext(root, manifestRoot, files);
363
- const behaviorFiles = files
364
- .filter((file) => isBehaviorFile(file.path))
387
+ const manifestFiles = files
365
388
  .map((file) => ({
366
389
  ...file,
367
390
  path: toPosixPath(path.relative(manifestRoot, path.join(root, file.path))),
368
391
  }))
369
392
  .filter((file) => !file.path.startsWith("../"));
370
- const domains = buildBaselineDomains(behaviorFiles, context).slice(0, 12);
371
- const flows = buildBaselineFlows(behaviorFiles, domains, inferRunner(files), context).slice(0, 16);
393
+ const behaviorFiles = manifestFiles.filter((file) => isBehaviorFile(file.path));
394
+ const domains = mergeDomainsById(buildBaselineDomains(behaviorFiles, context), buildPythonAppDomains(files, manifestRoot, root)).slice(0, 12);
395
+ const runner = inferRunner(files);
396
+ const uiFlows = buildBaselineFlows(behaviorFiles, domains, runner, context);
397
+ const apiFlows = buildBaselineApiFlows(manifestFiles, domains, runner);
398
+ const flows = apiFlows.length === 0
399
+ ? uiFlows.slice(0, 16)
400
+ : uiFlows.length === 0
401
+ ? apiFlows.slice(0, 16)
402
+ : dedupeFlows([...uiFlows.slice(0, 12), ...apiFlows.slice(0, 4)]).slice(0, 16);
372
403
  return {
373
404
  $schema: verificationManifestSchemaUrl,
374
405
  version: 1,
@@ -388,7 +419,7 @@ function validateManifestMetadata(manifest, issues) {
388
419
  }
389
420
  function validateDomainDefinitions(manifest, issues) {
390
421
  if (manifest.domains.length === 0) {
391
- issues.push(issue("warning", `${manifest.path} > domains`, "No domains are declared.", "Run `qamap manifest init .` or add product domains manually."));
422
+ issues.push(issue("warning", `${manifest.path} > domains`, "No domains are declared.", "Add product domains manually, or rerun `qamap manifest init . --force` with a larger `--max-files` if the scan was truncated."));
392
423
  }
393
424
  const ids = new Set();
394
425
  for (const domain of manifest.domains) {
@@ -848,14 +879,16 @@ function buildManifestContext(root, manifestRoot, files) {
848
879
  validationCommands.push(...commands);
849
880
  safetyRules.push(...rules);
850
881
  }
851
- if (instructionFiles.length === 0 && validationCommands.length === 0 && safetyRules.length === 0) {
882
+ const projectCommands = extractProjectValidationCommands(files);
883
+ const allValidationCommands = dropRedundantCompoundCommands(uniqueStrings([...projectCommands, ...validationCommands])).slice(0, 12);
884
+ if (instructionFiles.length === 0 && allValidationCommands.length === 0 && safetyRules.length === 0) {
852
885
  return undefined;
853
886
  }
854
887
  return {
855
888
  instructionFiles: instructionFiles
856
889
  .sort((left, right) => contextKindRank(left.kind) - contextKindRank(right.kind) || left.path.localeCompare(right.path))
857
890
  .slice(0, 24),
858
- validationCommands: uniqueStrings(validationCommands).slice(0, 12),
891
+ validationCommands: allValidationCommands,
859
892
  safetyRules: uniqueStrings(safetyRules).slice(0, 12),
860
893
  source: {
861
894
  kind: "inferred",
@@ -890,20 +923,108 @@ function buildBaselineDomains(files, context) {
890
923
  }
891
924
  return [...grouped.entries()]
892
925
  .sort((left, right) => right[1].files.length - left[1].files.length)
893
- .map(([id, value]) => ({
894
- id,
895
- name: value.name,
896
- paths: domainPatterns(value.files).slice(0, 5),
897
- criticality: "medium",
898
- source: {
899
- kind: "inferred",
900
- confidence: value.files.length > 1 || value.from.some((item) => item.endsWith("-context")) ? "medium" : "low",
901
- from: uniqueStrings(value.from).slice(0, 4),
902
- },
903
- }));
926
+ .map(([id, value]) => {
927
+ const paths = dropSubsumedPatterns(domainPatterns(value.files)).slice(0, 5);
928
+ return {
929
+ id,
930
+ name: value.name,
931
+ paths,
932
+ criticality: domainCriticality(id, paths),
933
+ source: {
934
+ kind: "inferred",
935
+ confidence: (value.files.length > 1 || value.from.some((item) => item.endsWith("-context")) ? "medium" : "low"),
936
+ from: uniqueStrings(value.from).slice(0, 4),
937
+ },
938
+ };
939
+ });
940
+ }
941
+ // Same-id domains from different inference passes (JS structure vs Django
942
+ // structure) must merge, not duplicate: duplicate ids fail manifest validate.
943
+ function mergeDomainsById(...groups) {
944
+ const byId = new Map();
945
+ for (const domain of groups.flat()) {
946
+ const existing = byId.get(domain.id);
947
+ if (!existing) {
948
+ byId.set(domain.id, domain);
949
+ continue;
950
+ }
951
+ existing.paths = dropSubsumedPatterns(uniqueStrings([...existing.paths, ...domain.paths])).slice(0, 5);
952
+ existing.criticality = existing.criticality === "high" || domain.criticality === "high" ? "high" : existing.criticality;
953
+ existing.source = {
954
+ ...existing.source,
955
+ confidence: existing.source.confidence === "medium" || domain.source.confidence === "medium" ? "medium" : existing.source.confidence,
956
+ from: uniqueStrings([...existing.source.from, ...domain.source.from]).slice(0, 4),
957
+ };
958
+ }
959
+ return [...byId.values()];
960
+ }
961
+ // A child glob adds nothing when a parent glob in the same list already
962
+ // covers it; the survivors read as curated when they are not.
963
+ function dropSubsumedPatterns(patterns) {
964
+ return patterns.filter((pattern) => !patterns.some((other) => other !== pattern && other.endsWith("/**") && pattern.startsWith(other.slice(0, -2))));
965
+ }
966
+ // Revenue- and identity-bearing areas deserve a stronger default than a flat
967
+ // "medium"; internal design/tooling packages deserve a weaker one.
968
+ function domainCriticality(id, paths) {
969
+ const haystack = `${id} ${paths.join(" ")}`.toLowerCase();
970
+ if (/payment|billing|checkout|subscription|purchase|order|cart|auth|login|signup|credit|account/.test(haystack)) {
971
+ return "high";
972
+ }
973
+ // "design-tokens", not bare "tokens": an auth/API-token domain must never
974
+ // be downgraded to low criticality.
975
+ if (/icons?|design-tokens|design-system|storybook|figma|tooling/.test(haystack)) {
976
+ return "low";
977
+ }
978
+ return "medium";
979
+ }
980
+ // Django-style backends: an app directory with several framework marker files
981
+ // is a product domain even though no JS behavior file ever names it.
982
+ function buildPythonAppDomains(files, manifestRoot, root) {
983
+ const markers = new Map();
984
+ for (const file of files) {
985
+ const relative = toPosixPath(path.relative(manifestRoot, path.join(root, file.path)));
986
+ if (relative.startsWith("../")) {
987
+ continue;
988
+ }
989
+ // Only actual Python files count: Rails app/models/user.rb and Vue
990
+ // client/views/Home.vue must not fabricate Django evidence.
991
+ if (!relative.endsWith(".py")) {
992
+ continue;
993
+ }
994
+ const match = relative.match(/^((?:[\w.-]+\/)*)([\w-]+)\/(models|views|urls|serializers|forms|admin|apps|tasks)(?:\/|\.py$)/);
995
+ if (!match) {
996
+ continue;
997
+ }
998
+ const appPath = `${match[1]}${match[2]}`;
999
+ const id = slugify(match[2]);
1000
+ if (!id || structuralDomainIds.has(id)) {
1001
+ continue;
1002
+ }
1003
+ const existing = markers.get(id) ?? { appPaths: new Set(), found: new Set(), name: match[2] };
1004
+ existing.appPaths.add(appPath);
1005
+ existing.found.add(match[3]);
1006
+ markers.set(id, existing);
1007
+ }
1008
+ return [...markers.entries()]
1009
+ .filter(([, value]) => value.found.size >= 2)
1010
+ .sort((left, right) => right[1].found.size - left[1].found.size || left[0].localeCompare(right[0]))
1011
+ .map(([id, value]) => {
1012
+ const paths = [...value.appPaths].sort().map((appPath) => `${appPath}/**`).slice(0, 5);
1013
+ return {
1014
+ id,
1015
+ name: titleCase(value.name),
1016
+ paths,
1017
+ criticality: domainCriticality(id, paths),
1018
+ source: {
1019
+ kind: "inferred",
1020
+ confidence: "medium",
1021
+ from: [...value.found].slice(0, 4).map((marker) => `django-${marker}`),
1022
+ },
1023
+ };
1024
+ });
904
1025
  }
905
1026
  function buildBaselineFlows(files, domains, runner, context) {
906
- const flows = [];
1027
+ const scored = [];
907
1028
  for (const file of files) {
908
1029
  const route = routeFromFile(file.path);
909
1030
  const component = componentNameFromFile(file.path);
@@ -911,43 +1032,246 @@ function buildBaselineFlows(files, domains, runner, context) {
911
1032
  continue;
912
1033
  }
913
1034
  const domain = bestDomainForFile(domains, file.path);
914
- const inferredSubject = route ? titleCase(route.replace(/^\/+/, "").replace(/[:/]+/g, " ")) : component ?? "Changed UI";
1035
+ const inferredSubject = route ? titleCase(route.replace(/^\/+/, "").replace(/[:/]+/g, " ")) || "Home" : component ?? "Changed UI";
915
1036
  const subject = contextFlowSubjectForTerms(context, [inferredSubject, domain?.id, domain?.name].filter(Boolean), inferredSubject);
916
1037
  const id = slugify([domain?.id, subject].filter(Boolean).join(" "));
917
1038
  const contextEvidence = contextEvidenceForTerms(context, [subject, domain?.name, domain?.id].filter(Boolean));
1039
+ const symbol = route
1040
+ ? undefined
1041
+ : (exportedSymbolFromText(file.text, path.basename(file.path).replace(/\.[^.]+$/, "")) ?? undefined);
918
1042
  const anchors = [
919
1043
  {
920
1044
  kind: route ? "route" : "component",
921
1045
  path: file.path,
922
1046
  route,
923
- symbol: route ? undefined : component,
1047
+ symbol,
924
1048
  source: "inferred",
925
1049
  confidence: route ? "high" : "medium",
926
1050
  },
927
1051
  ];
928
- flows.push({
929
- id,
930
- domain: domain?.id,
931
- name: subject,
932
- entry: route ? { route, source: "inferred" } : undefined,
933
- runner,
934
- anchors,
935
- checks: checksForFlow(subject, file.text),
936
- source: {
937
- kind: "inferred",
938
- confidence: route || contextEvidence.length > 0 ? "medium" : "low",
939
- from: uniqueStrings([route ? "route-file" : "component-file", ...contextEvidence]),
1052
+ scored.push({
1053
+ score: flowCandidateScore(file.path, route, subject),
1054
+ flow: {
1055
+ id,
1056
+ domain: domain?.id,
1057
+ name: subject,
1058
+ entry: route ? { route, source: "inferred" } : undefined,
1059
+ runner,
1060
+ anchors,
1061
+ checks: checksForFlow(subject, file.text),
1062
+ source: {
1063
+ kind: "inferred",
1064
+ confidence: route || contextEvidence.length > 0 ? "medium" : "low",
1065
+ from: uniqueStrings([route ? "route-file" : "component-file", ...contextEvidence]),
1066
+ },
940
1067
  },
941
1068
  });
942
1069
  }
943
- return dedupeFlows(flows);
1070
+ scored.sort((left, right) => right.score - left.score);
1071
+ return interleaveFlowsByDomain(dedupeFlows(scored.map((entry) => entry.flow)));
1072
+ }
1073
+ function buildBaselineApiFlows(files, domains, runner) {
1074
+ const serviceProject = runner === "manual" && hasApiServiceSignal(files);
1075
+ const grouped = new Map();
1076
+ for (const file of files) {
1077
+ if (!isApiBehaviorFile(file.path, runner, serviceProject)) {
1078
+ continue;
1079
+ }
1080
+ const domain = bestDomainForFile(domains, file.path);
1081
+ const subject = domain?.name ?? apiSubjectFromFile(file.path);
1082
+ const id = slugify(`${domain?.id ?? subject} api contract`);
1083
+ if (!id) {
1084
+ continue;
1085
+ }
1086
+ const symbol = /\.[cm]?[jt]sx?$/.test(file.path) && file.text
1087
+ ? (exportedSymbolFromText(file.text, path.basename(file.path).replace(/\.[^.]+$/, "")) ?? undefined)
1088
+ : undefined;
1089
+ const anchor = {
1090
+ kind: "api",
1091
+ path: file.path,
1092
+ symbol,
1093
+ source: "inferred",
1094
+ confidence: symbol ? "high" : "medium",
1095
+ };
1096
+ const existing = grouped.get(id);
1097
+ if (existing) {
1098
+ if (!existing.anchors.some((candidate) => candidate.path === anchor.path)) {
1099
+ existing.anchors.push(anchor);
1100
+ }
1101
+ continue;
1102
+ }
1103
+ grouped.set(id, {
1104
+ domain,
1105
+ name: `${subject} API contract`,
1106
+ anchors: [anchor],
1107
+ });
1108
+ }
1109
+ return [...grouped.entries()]
1110
+ .map(([id, value]) => ({
1111
+ id,
1112
+ domain: value.domain?.id,
1113
+ name: value.name,
1114
+ runner: "manual",
1115
+ anchors: value.anchors.slice(0, 6),
1116
+ checks: [
1117
+ {
1118
+ id: "response-contract",
1119
+ title: `Return the expected ${value.name.replace(/ API contract$/, "")} response contract`,
1120
+ type: "contract",
1121
+ },
1122
+ {
1123
+ id: "invalid-request",
1124
+ title: "Reject invalid requests without partial side effects",
1125
+ type: "failure",
1126
+ },
1127
+ ],
1128
+ source: {
1129
+ kind: "inferred",
1130
+ confidence: "medium",
1131
+ from: ["api-route-file"],
1132
+ },
1133
+ }))
1134
+ .sort((left, right) => right.anchors.length - left.anchors.length || left.id.localeCompare(right.id));
1135
+ }
1136
+ function isApiBehaviorFile(file, runner, serviceProject) {
1137
+ const normalized = toPosixPath(file);
1138
+ if (/(?:^|\/)(?:tests?|__tests__|e2e|dist|build|out|\.output|__generated__)(?:\/|$)/i.test(normalized)) {
1139
+ return false;
1140
+ }
1141
+ if (runner !== "manual") {
1142
+ return false;
1143
+ }
1144
+ return (/(?:^|\/)(?:api|routes?|controllers?|handlers?)\/.+\.(?:[cm]?[jt]s|py|go|rs|java|kt|cs|rb)$/i.test(normalized) ||
1145
+ /(?:^|\/)(?:api|routes?|controllers?|handlers?|views)(?:\/[^/]+|[_-][^/]+)?\.py$/i.test(normalized) ||
1146
+ /(?:^|\/)(?:urls|views|api|controllers?|handlers?)\.py$/i.test(normalized) ||
1147
+ (serviceProject && isNamedServiceModule(normalized)));
1148
+ }
1149
+ function isNamedServiceModule(file) {
1150
+ return (/(?:^|\/)src\/(?:.*\/)?(?:domains?|features?|modules?|services?)\/.+\.(?:[cm]?[jt]s|py|go|rs|java|kt|cs|rb)$/i.test(file) ||
1151
+ /(?:^|\/)[^/]*(?:controller|endpoint|gateway|handler|repository|resolver|router|service|use-?case)[^/]*\.(?:[cm]?[jt]s|py|go|rs|java|kt|cs|rb)$/i.test(file));
1152
+ }
1153
+ function hasApiServiceSignal(files) {
1154
+ const serverDependencies = new Set([
1155
+ "@hapi/hapi",
1156
+ "@nestjs/core",
1157
+ "express",
1158
+ "fastify",
1159
+ "hono",
1160
+ "koa",
1161
+ "restify",
1162
+ ]);
1163
+ for (const file of files) {
1164
+ if (!file.text) {
1165
+ continue;
1166
+ }
1167
+ const basename = path.basename(file.path).toLowerCase();
1168
+ if (basename === "package.json") {
1169
+ try {
1170
+ const parsed = JSON.parse(file.text);
1171
+ const dependencies = { ...parsed.dependencies, ...parsed.devDependencies };
1172
+ if ([...serverDependencies].some((dependency) => dependencies[dependency] !== undefined)) {
1173
+ return true;
1174
+ }
1175
+ }
1176
+ catch {
1177
+ continue;
1178
+ }
1179
+ }
1180
+ if (["requirements.txt", "pyproject.toml", "pipfile"].includes(basename) &&
1181
+ /\b(?:django|fastapi|flask|litestar|sanic|starlette)\b/i.test(file.text)) {
1182
+ return true;
1183
+ }
1184
+ if (basename === "go.mod" && /\b(?:gin-gonic|gofiber|labstack\/echo)\b/i.test(file.text)) {
1185
+ return true;
1186
+ }
1187
+ if (basename === "cargo.toml" && /\b(?:actix-web|axum|rocket)\b/i.test(file.text)) {
1188
+ return true;
1189
+ }
1190
+ }
1191
+ return false;
1192
+ }
1193
+ function apiSubjectFromFile(file) {
1194
+ const segments = toPosixPath(file).split("/").filter(Boolean);
1195
+ const basename = (segments.at(-1) ?? "api").replace(/\.[^.]+$/, "");
1196
+ const generic = /^(?:api|controller|controllers|handler|handlers|index|route|routes|urls|view|views)$/i;
1197
+ const candidate = generic.test(basename) ? (segments.at(-2) ?? "api") : basename;
1198
+ return titleCase(candidate) || "API";
1199
+ }
1200
+ const productSignalMatcher = /login|signin|sign-in|signup|sign-up|register|auth|password|payment|pay\b|checkout|billing|subscription|purchase|order|cart|onboarding|settings|account|profile|search|upload|home/i;
1201
+ // Rank flow candidates by product signal so the flow cap keeps core journeys
1202
+ // instead of whichever files sort first alphabetically.
1203
+ function flowCandidateScore(file, route, subject) {
1204
+ let score = 0;
1205
+ if (route) {
1206
+ score += 40;
1207
+ // Shallow routes tend to be core journeys; deep admin leaves are not.
1208
+ score += Math.max(0, 10 - (route.split("/").filter(Boolean).length - 1) * 3);
1209
+ }
1210
+ if (productSignalMatcher.test(`${file} ${subject}`)) {
1211
+ score += 25;
1212
+ }
1213
+ return score;
1214
+ }
1215
+ // Round-robin across domains (in best-candidate order) so a single large area
1216
+ // cannot occupy every slot under the flow cap.
1217
+ function interleaveFlowsByDomain(flows) {
1218
+ const groups = new Map();
1219
+ for (const flow of flows) {
1220
+ const key = flow.domain ?? "";
1221
+ const existing = groups.get(key) ?? [];
1222
+ existing.push(flow);
1223
+ groups.set(key, existing);
1224
+ }
1225
+ const result = [];
1226
+ let picked = true;
1227
+ while (picked) {
1228
+ picked = false;
1229
+ for (const group of groups.values()) {
1230
+ const next = group.shift();
1231
+ if (next) {
1232
+ result.push(next);
1233
+ picked = true;
1234
+ }
1235
+ }
1236
+ }
1237
+ return result;
1238
+ }
1239
+ // The real exported identifier, so anchor symbols are greppable code names
1240
+ // rather than humanized guesses; absent when it cannot be resolved. The
1241
+ // default export wins; otherwise only a named export that matches the file's
1242
+ // basename qualifies — the first export in the file is often an unrelated
1243
+ // constant.
1244
+ function exportedSymbolFromText(text, basename) {
1245
+ if (!text) {
1246
+ return undefined;
1247
+ }
1248
+ const defaultMatch = text.match(/export\s+default\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/) ??
1249
+ text.match(/export\s+default\s+class\s+([A-Za-z_$][\w$]*)/) ??
1250
+ // Bare or wrapped identifier defaults: `export default PaymentForm;`,
1251
+ // `export default memo(PaymentForm)`.
1252
+ text.match(/export\s+default\s+(?:(?!function\b|class\b|async\b|new\b|await\b|typeof\b)[A-Za-z_$][\w$]*\s*\(\s*)*(?!function\b|class\b|async\b|new\b|await\b|typeof\b)([A-Z][\w$]*)/);
1253
+ if (defaultMatch) {
1254
+ return defaultMatch[1];
1255
+ }
1256
+ const normalizedBasename = basename.replace(/[-_.]/g, "").toLowerCase();
1257
+ const named = [...text.matchAll(/export\s+(?:async\s+)?(?:function|const|class)\s+([A-Za-z_$][\w$]*)/g)]
1258
+ .map((match) => match[1])
1259
+ .find((identifier) => identifier.replace(/[-_$]/g, "").toLowerCase() === normalizedBasename);
1260
+ if (named) {
1261
+ return named;
1262
+ }
1263
+ return text.match(/\bname:\s*["']([\w-]+)["']/)?.[1];
944
1264
  }
945
1265
  function checksForFlow(subject, text) {
1266
+ // A stable test id observed in the file makes the check concrete instead of
1267
+ // a name-substituted template.
1268
+ const observedTestId = text?.match(/data-testid=["']([\w:-]+)["']/)?.[1];
946
1269
  const checks = [
947
1270
  {
948
1271
  id: "happy-path",
949
1272
  title: `${subject} happy path works`,
950
1273
  type: "success",
1274
+ ...(observedTestId ? { selector: `[data-testid="${observedTestId}"]` } : {}),
951
1275
  },
952
1276
  ];
953
1277
  if (text && /\b(?:fetch|axios|graphql|mutation|query|api|request)\b/i.test(text)) {
@@ -1055,10 +1379,17 @@ function classifyInstructionRoles(file, text, kind, commands, rules) {
1055
1379
  }
1056
1380
  function extractValidationCommands(text) {
1057
1381
  const commands = [];
1382
+ let inCodeFence = false;
1058
1383
  for (const line of text.split(/\r?\n/)) {
1384
+ if (/^\s*(?:```|~~~)/.test(line)) {
1385
+ inCodeFence = !inCodeFence;
1386
+ continue;
1387
+ }
1059
1388
  const candidates = [...line.matchAll(/`([^`\n]+)`/g)].map((match) => match[1]);
1060
1389
  const stripped = cleanMarkdownLine(line);
1061
- if (/^(?:pnpm|npm|yarn|bun|npx|node|pytest|go|cargo|maestro|playwright|gradle|mvn|\.\/gradlew)\b/i.test(stripped)) {
1390
+ // A bare line only counts as a command inside a fenced code block; in
1391
+ // prose, a sentence that merely starts with a tool name is not a command.
1392
+ if (inCodeFence && /^(?:pnpm|npm|yarn|bun|npx|node|pytest|go|cargo|maestro|playwright|gradle|mvn|\.\/gradlew)\b/i.test(stripped)) {
1062
1393
  candidates.push(stripped);
1063
1394
  }
1064
1395
  for (const candidate of candidates) {
@@ -1070,9 +1401,83 @@ function extractValidationCommands(text) {
1070
1401
  }
1071
1402
  return uniqueStrings(commands).slice(0, 12);
1072
1403
  }
1404
+ // Ground-truth validation commands read from project config rather than doc
1405
+ // prose: package.json scripts and pytest markers. These rank ahead of
1406
+ // doc-extracted commands because they cannot drift from what actually runs.
1407
+ function extractProjectValidationCommands(files) {
1408
+ const commands = [];
1409
+ const packageJson = files.find((file) => file.path === "package.json");
1410
+ if (packageJson?.text !== undefined) {
1411
+ const packageManager = files.some((file) => file.path === "pnpm-lock.yaml")
1412
+ ? "pnpm"
1413
+ : files.some((file) => file.path === "yarn.lock")
1414
+ ? "yarn"
1415
+ : files.some((file) => file.path === "bun.lockb" || file.path === "bun.lock")
1416
+ ? "bun"
1417
+ : "npm";
1418
+ try {
1419
+ const parsed = JSON.parse(packageJson.text);
1420
+ for (const [name, script] of Object.entries(parsed.scripts ?? {})) {
1421
+ if (typeof script !== "string") {
1422
+ continue;
1423
+ }
1424
+ // Verification-shaped scripts only. Bare "build" qualifies, but build
1425
+ // variants (build:android-apk) are packaging, not verification.
1426
+ if (!/^(?:test|lint|typecheck|type-check|check-types?|check|e2e|coverage|verify|format:check)(?:[:.][\w:.-]+)?$/.test(name) && name !== "build") {
1427
+ continue;
1428
+ }
1429
+ // Name segments that mutate, block, or open a UI disqualify the
1430
+ // script — matched per segment so test:server or e2e:device survive.
1431
+ const nameSegments = name.split(/[:._-]/);
1432
+ if (nameSegments.some((segment) => /^(?:fix|fixes|watch|dev|debug|start|serve|open|update|record|snapshot|snapshots|inspect|ui)$/i.test(segment))) {
1433
+ continue;
1434
+ }
1435
+ // The script body is the ground truth for blocking/mutating behavior,
1436
+ // and npm-init's placeholder test script is not a verification run.
1437
+ if (/--inspect|--watch\b|--ui\b|--update-?snapshots?|(?:^|\s)-u\b|\bopen\b|no test specified/i.test(script)) {
1438
+ continue;
1439
+ }
1440
+ commands.push(`${packageManager} run ${name}`);
1441
+ }
1442
+ }
1443
+ catch {
1444
+ // Unreadable package.json: fall back to doc-extracted commands only.
1445
+ }
1446
+ }
1447
+ const hasPytestConfig = files.some((file) => file.path === "pytest.ini" ||
1448
+ file.path === "conftest.py" ||
1449
+ file.path === "tests/conftest.py" ||
1450
+ (file.path === "pyproject.toml" && (file.text?.includes("[tool.pytest") ?? false)) ||
1451
+ (file.path === "setup.cfg" && (file.text?.includes("[tool:pytest]") ?? false)));
1452
+ if (hasPytestConfig) {
1453
+ commands.push("pytest");
1454
+ }
1455
+ return uniqueStrings(commands).slice(0, 12);
1456
+ }
1457
+ // "a && b" adds nothing when a and b are already listed individually.
1458
+ function dropRedundantCompoundCommands(commands) {
1459
+ const seen = new Set(commands);
1460
+ return commands.filter((command) => {
1461
+ const parts = command.split(/\s*&&\s*/);
1462
+ if (parts.length < 2) {
1463
+ return true;
1464
+ }
1465
+ return !parts.every((part) => seen.has(part.trim()));
1466
+ });
1467
+ }
1073
1468
  function extractSafetyRules(text) {
1074
1469
  const rules = [];
1470
+ let inCodeFence = false;
1075
1471
  for (const line of text.split(/\r?\n/)) {
1472
+ if (/^\s*(?:```|~~~)/.test(line)) {
1473
+ inCodeFence = !inCodeFence;
1474
+ continue;
1475
+ }
1476
+ // Code blocks hold examples (imports, CI YAML, mermaid diagrams), never
1477
+ // the team's prose rules.
1478
+ if (inCodeFence) {
1479
+ continue;
1480
+ }
1076
1481
  const cleaned = redactSensitiveText(cleanMarkdownLine(line));
1077
1482
  if (cleaned.length < 8 || cleaned.length > 220) {
1078
1483
  continue;
@@ -1080,7 +1485,14 @@ function extractSafetyRules(text) {
1080
1485
  if (/\bdo not (?:belong|require|show)\b/i.test(cleaned)) {
1081
1486
  continue;
1082
1487
  }
1083
- if (/(?:do not|don't|never|must not|read-only|\/tmp|token|secret|credential|절대|금지|하지 말|하면 안|커밋|푸시|PR 생성)/i.test(cleaned)) {
1488
+ // Structural fragments are never rules: mermaid edges, YAML keys and
1489
+ // template expressions, code statements, markup.
1490
+ if (/-->|\$\{\{|^[A-Za-z0-9_."'-]+:\s|^(?:import|export|from|const|let|var|function|class|return|if|for|uses:|run:|with:|<)\b/.test(cleaned)) {
1491
+ continue;
1492
+ }
1493
+ // Only prohibition/obligation language qualifies; topic words alone
1494
+ // (commit, push, token) pulled in list items and diagram labels.
1495
+ if (/(?:do not|don't|never|must not|read-only|절대|금지|하지 ?마|하지 ?말|하지 않|하면 안|해서는 안)/i.test(cleaned)) {
1084
1496
  rules.push(cleaned);
1085
1497
  }
1086
1498
  }
@@ -1091,6 +1503,12 @@ function normalizeCommand(value) {
1091
1503
  if (!command || command.length > 140 || /(?:publish|login|token|secret|password|rm\s+-rf)/i.test(command)) {
1092
1504
  return undefined;
1093
1505
  }
1506
+ // Commas, parentheses, and Hangul mark prose fragments, not shell commands;
1507
+ // == marks dependency version pins; a bare version number or --version
1508
+ // probe is not a verification run.
1509
+ if (/[,()]|==|[ᄀ-ᇿ㄰-㆏가-힣]|--version\b|^\S+\s+v?\d+(?:\.\d+)+\s*$/.test(command)) {
1510
+ return undefined;
1511
+ }
1094
1512
  return command.replace(/\s+/g, " ");
1095
1513
  }
1096
1514
  function isValidationCommand(command) {
@@ -1098,7 +1516,8 @@ function isValidationCommand(command) {
1098
1516
  /^(?:npx\s+)?playwright\s+test\b/i.test(command) ||
1099
1517
  /^maestro\s+test\b/i.test(command) ||
1100
1518
  /^node\s+--test\b/i.test(command) ||
1101
- /^pytest\b/i.test(command) ||
1519
+ // "pytest" alone or with arguments — not "pytest.ini" or "pytest-django".
1520
+ /^pytest(?:\s|$)/i.test(command) ||
1102
1521
  /^go\s+test\b/i.test(command) ||
1103
1522
  /^cargo\s+test\b/i.test(command) ||
1104
1523
  /^(?:gradle|\.\/gradlew)\s+(?:test|check)\b/i.test(command) ||
@@ -1106,8 +1525,9 @@ function isValidationCommand(command) {
1106
1525
  }
1107
1526
  function cleanMarkdownLine(value) {
1108
1527
  return value
1109
- .replace(/^\s{0,3}(?:[-*]|\d+\.)\s+/, "")
1110
1528
  .replace(/^\s{0,3}#+\s*/, "")
1529
+ .replace(/^\s{0,3}(?:[-*]|\d+\.)\s+/, "")
1530
+ .replace(/\s*\{#[\w-]+\}\s*$/, "")
1111
1531
  .trim();
1112
1532
  }
1113
1533
  function redactSensitiveText(value) {
@@ -1196,11 +1616,36 @@ function contextKindRank(kind) {
1196
1616
  return ranks[kind];
1197
1617
  }
1198
1618
  function inferRunner(files) {
1199
- const packageText = files.find((file) => file.path === "package.json")?.text ?? "";
1200
- if (/\b(?:expo|react-native)\b/i.test(packageText) || files.some((file) => /(?:^|\/)app\.json$/.test(file.path))) {
1619
+ // Decide from dependency KEYS across every collected package.json, not raw
1620
+ // text: the word "react" in a description or an eslint-plugin-react entry
1621
+ // is not a web app. app.json only counts as mobile evidence when it has a
1622
+ // top-level "expo" key.
1623
+ const dependencies = {};
1624
+ let hasExpoAppJson = false;
1625
+ for (const file of files) {
1626
+ if (file.text === undefined) {
1627
+ continue;
1628
+ }
1629
+ const basename = path.basename(file.path);
1630
+ try {
1631
+ if (basename === "package.json") {
1632
+ const parsed = JSON.parse(file.text);
1633
+ Object.assign(dependencies, parsed.dependencies, parsed.devDependencies);
1634
+ }
1635
+ else if (basename === "app.json") {
1636
+ const parsed = JSON.parse(file.text);
1637
+ hasExpoAppJson ||= parsed.expo !== undefined;
1638
+ }
1639
+ }
1640
+ catch {
1641
+ // Unreadable JSON: contributes no signal.
1642
+ }
1643
+ }
1644
+ if (dependencies["expo"] !== undefined || dependencies["react-native"] !== undefined || hasExpoAppJson || files.some((file) => file.path.startsWith(".maestro/"))) {
1201
1645
  return "maestro";
1202
1646
  }
1203
- if (/\b(?:next|react|vite|vue|nuxt|svelte|remix|astro|angular|playwright)\b/i.test(packageText)) {
1647
+ const webDependencies = ["next", "react", "react-dom", "vue", "nuxt", "svelte", "astro", "vite", "@remix-run/react", "@angular/core", "@playwright/test", "playwright"];
1648
+ if (webDependencies.some((name) => dependencies[name] !== undefined) || files.some((file) => /(?:^|\/)playwright\.config\.[cm]?[jt]s$/.test(file.path))) {
1204
1649
  return "playwright";
1205
1650
  }
1206
1651
  return "manual";
@@ -1209,10 +1654,23 @@ function routeFromFile(file) {
1209
1654
  const normalized = toPosixPath(file);
1210
1655
  const pagesMatch = normalized.match(/(?:^|\/)(?:src\/)?pages\/(.+)\.(?:[cm]?[jt]sx?|vue|svelte)$/);
1211
1656
  if (pagesMatch && !pagesMatch[1].startsWith("_")) {
1212
- return normalizeRouteSegments(pagesMatch[1].replace(/\/index$/, ""));
1657
+ // pages/api/** are HTTP handlers, not navigable UI routes.
1658
+ if (/^api(?:\/|$)/.test(pagesMatch[1])) {
1659
+ return undefined;
1660
+ }
1661
+ return normalizeRouteSegments(pagesMatch[1].replace(/^index$|\/index$/, ""));
1213
1662
  }
1214
- const appMatch = normalized.match(/(?:^|\/)(?:src\/)?app\/(.+)\/(?:page|route)\.(?:[cm]?[jt]sx?)$/);
1663
+ const appRootMatch = normalized.match(/(?:^|\/)(?:src\/)?app\/page\.(?:[cm]?[jt]sx?)$/);
1664
+ if (appRootMatch) {
1665
+ return "/";
1666
+ }
1667
+ // Only page.* files are navigable; route.* files are HTTP handlers even
1668
+ // outside app/api (App Router allows them anywhere).
1669
+ const appMatch = normalized.match(/(?:^|\/)(?:src\/)?app\/(.+)\/page\.(?:[cm]?[jt]sx?)$/);
1215
1670
  if (appMatch) {
1671
+ if (/^api(?:\/|$)/.test(appMatch[1])) {
1672
+ return undefined;
1673
+ }
1216
1674
  const withoutGroups = appMatch[1]
1217
1675
  .split("/")
1218
1676
  .filter((segment) => !/^\(.+\)$/.test(segment))
@@ -1225,13 +1683,59 @@ function normalizeRouteSegments(value) {
1225
1683
  const route = value
1226
1684
  .split("/")
1227
1685
  .filter(Boolean)
1228
- .map((segment) => segment.replace(/^\[(\.\.\.)?(.+)\]$/, ":$2"))
1686
+ .map((segment) => segment
1687
+ .replace(/^\[(\.\.\.)?(.+)\]$/, ":$2")
1688
+ // Nuxt-style dynamic segments: _orderId -> :orderId, so entry
1689
+ // routes read as navigable specs rather than file paths.
1690
+ .replace(/^_(.+)$/, ":$1"))
1229
1691
  .join("/");
1230
1692
  return `/${route || ""}`;
1231
1693
  }
1694
+ // Component basenames that are UI plumbing rather than product behavior; a
1695
+ // flow named after them tests a primitive, not a user journey. Only terms
1696
+ // that are generic in ANY codebase belong here — a feature-shaped word
1697
+ // (Story, Logger) may be someone's real product surface.
1698
+ const genericComponentPrefixes = new Set([
1699
+ "app",
1700
+ "base",
1701
+ "common",
1702
+ "default",
1703
+ "error",
1704
+ "generic",
1705
+ "index",
1706
+ "loading",
1707
+ "main",
1708
+ "mock",
1709
+ "root",
1710
+ "sample",
1711
+ "shared",
1712
+ "test",
1713
+ "ui",
1714
+ ]);
1715
+ // Suffixes that are structural nouns: bare "Modal.tsx" or "Page.tsx" is a
1716
+ // primitive, while a bare product action like "Checkout.tsx" is a flow.
1717
+ const structuralComponentSuffixMatcher = /^(?:page|screen|view|modal|form|flow)$/i;
1232
1718
  function componentNameFromFile(file) {
1233
- const basename = path.basename(file).replace(/\.[^.]+$/, "");
1234
- if (!/(?:page|screen|view|modal|form|flow|checkout|submit|complete)$/i.test(basename)) {
1719
+ const normalized = toPosixPath(file);
1720
+ // Icon sets, mocks, and generic component buckets never hold product flows.
1721
+ if (/(?:^|\/)(?:icons?|__mocks__|\.storybook|storybook|constants|hooks)\//i.test(normalized)) {
1722
+ return undefined;
1723
+ }
1724
+ if (/\/components\/(?:shared|common|ui|base|lib)\//i.test(normalized)) {
1725
+ return undefined;
1726
+ }
1727
+ const basename = path.basename(normalized).replace(/\.[^.]+$/, "");
1728
+ const suffixMatch = basename.match(/(page|screen|view|modal|form|flow|checkout|submit|complete|success|confirmation)$/i);
1729
+ if (!suffixMatch) {
1730
+ return undefined;
1731
+ }
1732
+ const prefix = basename.slice(0, basename.length - suffixMatch[1].length).replace(/[-_.]+$/, "");
1733
+ // A bare structural noun (Modal, Page) is a primitive; a bare product
1734
+ // action (Checkout, Submit, Complete) is a real funnel step.
1735
+ if (prefix === "") {
1736
+ return structuralComponentSuffixMatcher.test(basename) ? undefined : titleCase(basename);
1737
+ }
1738
+ if (genericComponentPrefixes.has(prefix.toLowerCase())) {
1235
1739
  return undefined;
1236
1740
  }
1237
1741
  return titleCase(basename);
@@ -1244,19 +1748,72 @@ function domainCandidateFromPath(file) {
1244
1748
  }
1245
1749
  const routeIndex = segments.findIndex((segment) => ["app", "pages", "screens"].includes(segment));
1246
1750
  if (routeIndex >= 0) {
1247
- const segment = segments
1248
- .slice(routeIndex + 1)
1249
- .find((item) => item && !item.startsWith("_") && !item.startsWith("+") && !/^\(.+\)$/.test(item));
1250
- if (segment) {
1251
- return domainCandidate(segment, segments[routeIndex]);
1751
+ // Walk past structural DIRECTORY segments (navigations, providers,
1752
+ // layout) toward the first product-shaped one instead of giving up on
1753
+ // the first miss but never descend into the file basename, or every
1754
+ // colocated components/hooks/utils file mints its own garbage domain.
1755
+ for (const segment of segments.slice(routeIndex + 1, -1)) {
1756
+ if (!segment || segment.startsWith("_") || segment.startsWith("+") || /^\(.+\)$/.test(segment)) {
1757
+ continue;
1758
+ }
1759
+ // pages/api and app/api are HTTP handler trees, not product areas.
1760
+ if (segment === "api") {
1761
+ return undefined;
1762
+ }
1763
+ const candidate = domainCandidate(segment, segments[routeIndex]);
1764
+ if (candidate) {
1765
+ return candidate;
1766
+ }
1767
+ }
1768
+ // The basename may only speak for itself when the file sits directly
1769
+ // under the route dir (app/SampleChatPage.tsx), not when a
1770
+ // structural directory owns the subtree. Router-convention names
1771
+ // (_layout, +not-found, (group)) never qualify.
1772
+ if (segments.length === routeIndex + 2) {
1773
+ const basename = segments[routeIndex + 1];
1774
+ if (basename && !basename.startsWith("_") && !basename.startsWith("+") && !/^\(.+\)$/.test(basename)) {
1775
+ return domainCandidate(basename, segments[routeIndex]);
1776
+ }
1252
1777
  }
1253
1778
  }
1254
1779
  return undefined;
1255
1780
  }
1781
+ // Architecture-layer directory names: they organize code, not the product.
1782
+ const structuralDomainIds = new Set([
1783
+ "api",
1784
+ "assets",
1785
+ "common",
1786
+ "components",
1787
+ "config",
1788
+ "constants",
1789
+ "contexts",
1790
+ "core",
1791
+ "helpers",
1792
+ "hooks",
1793
+ "i18n",
1794
+ "index",
1795
+ "layout",
1796
+ "layouts",
1797
+ "lib",
1798
+ "locales",
1799
+ "navigation",
1800
+ "navigations",
1801
+ "page",
1802
+ "providers",
1803
+ "shared",
1804
+ "src",
1805
+ "state",
1806
+ "styles",
1807
+ "theme",
1808
+ "themes",
1809
+ "types",
1810
+ "ui",
1811
+ "utils",
1812
+ ]);
1256
1813
  function domainCandidate(value, from) {
1257
1814
  const clean = value.replace(/\.[^.]+$/, "").replace(/^\[(.+)\]$/, "$1");
1258
1815
  const id = slugify(clean);
1259
- if (!id || ["api", "components", "hooks", "lib", "shared", "src", "utils"].includes(id)) {
1816
+ if (!id || structuralDomainIds.has(id)) {
1260
1817
  return undefined;
1261
1818
  }
1262
1819
  return { id, name: titleCase(clean), from };
@@ -1313,7 +1870,8 @@ function matchesPathPattern(file, pattern) {
1313
1870
  return regex.test(normalizedFile);
1314
1871
  }
1315
1872
  function isBehaviorFile(file) {
1316
- return /\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(file) && !/(?:^|\/)(?:tests?|__tests__|e2e|dist|build)\//i.test(file);
1873
+ return (/\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(file) &&
1874
+ !/(?:^|\/)(?:tests?|__tests__|e2e|dist|build|out|\.output|storybook-static|__generated__)\//i.test(file));
1317
1875
  }
1318
1876
  function normalizeVerificationManifest(value, manifestPath) {
1319
1877
  const record = asRecord(value, `QAMap manifest must be an object: ${manifestPath}`);