@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/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);
@@ -367,7 +391,7 @@ function buildVerificationManifestBaseline(root, manifestRoot, files) {
367
391
  path: toPosixPath(path.relative(manifestRoot, path.join(root, file.path))),
368
392
  }))
369
393
  .filter((file) => !file.path.startsWith("../"));
370
- const domains = buildBaselineDomains(behaviorFiles, context).slice(0, 12);
394
+ const domains = mergeDomainsById(buildBaselineDomains(behaviorFiles, context), buildPythonAppDomains(files, manifestRoot, root)).slice(0, 12);
371
395
  const flows = buildBaselineFlows(behaviorFiles, domains, inferRunner(files), context).slice(0, 16);
372
396
  return {
373
397
  $schema: verificationManifestSchemaUrl,
@@ -388,7 +412,7 @@ function validateManifestMetadata(manifest, issues) {
388
412
  }
389
413
  function validateDomainDefinitions(manifest, issues) {
390
414
  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."));
415
+ 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
416
  }
393
417
  const ids = new Set();
394
418
  for (const domain of manifest.domains) {
@@ -848,14 +872,16 @@ function buildManifestContext(root, manifestRoot, files) {
848
872
  validationCommands.push(...commands);
849
873
  safetyRules.push(...rules);
850
874
  }
851
- if (instructionFiles.length === 0 && validationCommands.length === 0 && safetyRules.length === 0) {
875
+ const projectCommands = extractProjectValidationCommands(files);
876
+ const allValidationCommands = dropRedundantCompoundCommands(uniqueStrings([...projectCommands, ...validationCommands])).slice(0, 12);
877
+ if (instructionFiles.length === 0 && allValidationCommands.length === 0 && safetyRules.length === 0) {
852
878
  return undefined;
853
879
  }
854
880
  return {
855
881
  instructionFiles: instructionFiles
856
882
  .sort((left, right) => contextKindRank(left.kind) - contextKindRank(right.kind) || left.path.localeCompare(right.path))
857
883
  .slice(0, 24),
858
- validationCommands: uniqueStrings(validationCommands).slice(0, 12),
884
+ validationCommands: allValidationCommands,
859
885
  safetyRules: uniqueStrings(safetyRules).slice(0, 12),
860
886
  source: {
861
887
  kind: "inferred",
@@ -890,20 +916,108 @@ function buildBaselineDomains(files, context) {
890
916
  }
891
917
  return [...grouped.entries()]
892
918
  .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
- }));
919
+ .map(([id, value]) => {
920
+ const paths = dropSubsumedPatterns(domainPatterns(value.files)).slice(0, 5);
921
+ return {
922
+ id,
923
+ name: value.name,
924
+ paths,
925
+ criticality: domainCriticality(id, paths),
926
+ source: {
927
+ kind: "inferred",
928
+ confidence: (value.files.length > 1 || value.from.some((item) => item.endsWith("-context")) ? "medium" : "low"),
929
+ from: uniqueStrings(value.from).slice(0, 4),
930
+ },
931
+ };
932
+ });
933
+ }
934
+ // Same-id domains from different inference passes (JS structure vs Django
935
+ // structure) must merge, not duplicate: duplicate ids fail manifest validate.
936
+ function mergeDomainsById(...groups) {
937
+ const byId = new Map();
938
+ for (const domain of groups.flat()) {
939
+ const existing = byId.get(domain.id);
940
+ if (!existing) {
941
+ byId.set(domain.id, domain);
942
+ continue;
943
+ }
944
+ existing.paths = dropSubsumedPatterns(uniqueStrings([...existing.paths, ...domain.paths])).slice(0, 5);
945
+ existing.criticality = existing.criticality === "high" || domain.criticality === "high" ? "high" : existing.criticality;
946
+ existing.source = {
947
+ ...existing.source,
948
+ confidence: existing.source.confidence === "medium" || domain.source.confidence === "medium" ? "medium" : existing.source.confidence,
949
+ from: uniqueStrings([...existing.source.from, ...domain.source.from]).slice(0, 4),
950
+ };
951
+ }
952
+ return [...byId.values()];
953
+ }
954
+ // A child glob adds nothing when a parent glob in the same list already
955
+ // covers it; the survivors read as curated when they are not.
956
+ function dropSubsumedPatterns(patterns) {
957
+ return patterns.filter((pattern) => !patterns.some((other) => other !== pattern && other.endsWith("/**") && pattern.startsWith(other.slice(0, -2))));
958
+ }
959
+ // Revenue- and identity-bearing areas deserve a stronger default than a flat
960
+ // "medium"; internal design/tooling packages deserve a weaker one.
961
+ function domainCriticality(id, paths) {
962
+ const haystack = `${id} ${paths.join(" ")}`.toLowerCase();
963
+ if (/payment|billing|checkout|subscription|purchase|order|cart|auth|login|signup|credit|account/.test(haystack)) {
964
+ return "high";
965
+ }
966
+ // "design-tokens", not bare "tokens": an auth/API-token domain must never
967
+ // be downgraded to low criticality.
968
+ if (/icons?|design-tokens|design-system|storybook|figma|tooling/.test(haystack)) {
969
+ return "low";
970
+ }
971
+ return "medium";
972
+ }
973
+ // Django-style backends: an app directory with several framework marker files
974
+ // is a product domain even though no JS behavior file ever names it.
975
+ function buildPythonAppDomains(files, manifestRoot, root) {
976
+ const markers = new Map();
977
+ for (const file of files) {
978
+ const relative = toPosixPath(path.relative(manifestRoot, path.join(root, file.path)));
979
+ if (relative.startsWith("../")) {
980
+ continue;
981
+ }
982
+ // Only actual Python files count: Rails app/models/user.rb and Vue
983
+ // client/views/Home.vue must not fabricate Django evidence.
984
+ if (!relative.endsWith(".py")) {
985
+ continue;
986
+ }
987
+ const match = relative.match(/^((?:[\w.-]+\/)*)([\w-]+)\/(models|views|urls|serializers|forms|admin|apps|tasks)(?:\/|\.py$)/);
988
+ if (!match) {
989
+ continue;
990
+ }
991
+ const appPath = `${match[1]}${match[2]}`;
992
+ const id = slugify(match[2]);
993
+ if (!id || structuralDomainIds.has(id)) {
994
+ continue;
995
+ }
996
+ const existing = markers.get(id) ?? { appPaths: new Set(), found: new Set(), name: match[2] };
997
+ existing.appPaths.add(appPath);
998
+ existing.found.add(match[3]);
999
+ markers.set(id, existing);
1000
+ }
1001
+ return [...markers.entries()]
1002
+ .filter(([, value]) => value.found.size >= 2)
1003
+ .sort((left, right) => right[1].found.size - left[1].found.size || left[0].localeCompare(right[0]))
1004
+ .map(([id, value]) => {
1005
+ const paths = [...value.appPaths].sort().map((appPath) => `${appPath}/**`).slice(0, 5);
1006
+ return {
1007
+ id,
1008
+ name: titleCase(value.name),
1009
+ paths,
1010
+ criticality: domainCriticality(id, paths),
1011
+ source: {
1012
+ kind: "inferred",
1013
+ confidence: "medium",
1014
+ from: [...value.found].slice(0, 4).map((marker) => `django-${marker}`),
1015
+ },
1016
+ };
1017
+ });
904
1018
  }
905
1019
  function buildBaselineFlows(files, domains, runner, context) {
906
- const flows = [];
1020
+ const scored = [];
907
1021
  for (const file of files) {
908
1022
  const route = routeFromFile(file.path);
909
1023
  const component = componentNameFromFile(file.path);
@@ -911,43 +1025,119 @@ function buildBaselineFlows(files, domains, runner, context) {
911
1025
  continue;
912
1026
  }
913
1027
  const domain = bestDomainForFile(domains, file.path);
914
- const inferredSubject = route ? titleCase(route.replace(/^\/+/, "").replace(/[:/]+/g, " ")) : component ?? "Changed UI";
1028
+ const inferredSubject = route ? titleCase(route.replace(/^\/+/, "").replace(/[:/]+/g, " ")) || "Home" : component ?? "Changed UI";
915
1029
  const subject = contextFlowSubjectForTerms(context, [inferredSubject, domain?.id, domain?.name].filter(Boolean), inferredSubject);
916
1030
  const id = slugify([domain?.id, subject].filter(Boolean).join(" "));
917
1031
  const contextEvidence = contextEvidenceForTerms(context, [subject, domain?.name, domain?.id].filter(Boolean));
1032
+ const symbol = route
1033
+ ? undefined
1034
+ : (exportedSymbolFromText(file.text, path.basename(file.path).replace(/\.[^.]+$/, "")) ?? undefined);
918
1035
  const anchors = [
919
1036
  {
920
1037
  kind: route ? "route" : "component",
921
1038
  path: file.path,
922
1039
  route,
923
- symbol: route ? undefined : component,
1040
+ symbol,
924
1041
  source: "inferred",
925
1042
  confidence: route ? "high" : "medium",
926
1043
  },
927
1044
  ];
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]),
1045
+ scored.push({
1046
+ score: flowCandidateScore(file.path, route, subject),
1047
+ flow: {
1048
+ id,
1049
+ domain: domain?.id,
1050
+ name: subject,
1051
+ entry: route ? { route, source: "inferred" } : undefined,
1052
+ runner,
1053
+ anchors,
1054
+ checks: checksForFlow(subject, file.text),
1055
+ source: {
1056
+ kind: "inferred",
1057
+ confidence: route || contextEvidence.length > 0 ? "medium" : "low",
1058
+ from: uniqueStrings([route ? "route-file" : "component-file", ...contextEvidence]),
1059
+ },
940
1060
  },
941
1061
  });
942
1062
  }
943
- return dedupeFlows(flows);
1063
+ scored.sort((left, right) => right.score - left.score);
1064
+ return interleaveFlowsByDomain(dedupeFlows(scored.map((entry) => entry.flow)));
1065
+ }
1066
+ 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;
1067
+ // Rank flow candidates by product signal so the flow cap keeps core journeys
1068
+ // instead of whichever files sort first alphabetically.
1069
+ function flowCandidateScore(file, route, subject) {
1070
+ let score = 0;
1071
+ if (route) {
1072
+ score += 40;
1073
+ // Shallow routes tend to be core journeys; deep admin leaves are not.
1074
+ score += Math.max(0, 10 - (route.split("/").filter(Boolean).length - 1) * 3);
1075
+ }
1076
+ if (productSignalMatcher.test(`${file} ${subject}`)) {
1077
+ score += 25;
1078
+ }
1079
+ return score;
1080
+ }
1081
+ // Round-robin across domains (in best-candidate order) so a single large area
1082
+ // cannot occupy every slot under the flow cap.
1083
+ function interleaveFlowsByDomain(flows) {
1084
+ const groups = new Map();
1085
+ for (const flow of flows) {
1086
+ const key = flow.domain ?? "";
1087
+ const existing = groups.get(key) ?? [];
1088
+ existing.push(flow);
1089
+ groups.set(key, existing);
1090
+ }
1091
+ const result = [];
1092
+ let picked = true;
1093
+ while (picked) {
1094
+ picked = false;
1095
+ for (const group of groups.values()) {
1096
+ const next = group.shift();
1097
+ if (next) {
1098
+ result.push(next);
1099
+ picked = true;
1100
+ }
1101
+ }
1102
+ }
1103
+ return result;
1104
+ }
1105
+ // The real exported identifier, so anchor symbols are greppable code names
1106
+ // rather than humanized guesses; absent when it cannot be resolved. The
1107
+ // default export wins; otherwise only a named export that matches the file's
1108
+ // basename qualifies — the first export in the file is often an unrelated
1109
+ // constant.
1110
+ function exportedSymbolFromText(text, basename) {
1111
+ if (!text) {
1112
+ return undefined;
1113
+ }
1114
+ const defaultMatch = text.match(/export\s+default\s+(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/) ??
1115
+ text.match(/export\s+default\s+class\s+([A-Za-z_$][\w$]*)/) ??
1116
+ // Bare or wrapped identifier defaults: `export default PaymentForm;`,
1117
+ // `export default memo(PaymentForm)`.
1118
+ 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$]*)/);
1119
+ if (defaultMatch) {
1120
+ return defaultMatch[1];
1121
+ }
1122
+ const normalizedBasename = basename.replace(/[-_.]/g, "").toLowerCase();
1123
+ const named = [...text.matchAll(/export\s+(?:async\s+)?(?:function|const|class)\s+([A-Za-z_$][\w$]*)/g)]
1124
+ .map((match) => match[1])
1125
+ .find((identifier) => identifier.replace(/[-_$]/g, "").toLowerCase() === normalizedBasename);
1126
+ if (named) {
1127
+ return named;
1128
+ }
1129
+ return text.match(/\bname:\s*["']([\w-]+)["']/)?.[1];
944
1130
  }
945
1131
  function checksForFlow(subject, text) {
1132
+ // A stable test id observed in the file makes the check concrete instead of
1133
+ // a name-substituted template.
1134
+ const observedTestId = text?.match(/data-testid=["']([\w:-]+)["']/)?.[1];
946
1135
  const checks = [
947
1136
  {
948
1137
  id: "happy-path",
949
1138
  title: `${subject} happy path works`,
950
1139
  type: "success",
1140
+ ...(observedTestId ? { selector: `[data-testid="${observedTestId}"]` } : {}),
951
1141
  },
952
1142
  ];
953
1143
  if (text && /\b(?:fetch|axios|graphql|mutation|query|api|request)\b/i.test(text)) {
@@ -1055,10 +1245,17 @@ function classifyInstructionRoles(file, text, kind, commands, rules) {
1055
1245
  }
1056
1246
  function extractValidationCommands(text) {
1057
1247
  const commands = [];
1248
+ let inCodeFence = false;
1058
1249
  for (const line of text.split(/\r?\n/)) {
1250
+ if (/^\s*(?:```|~~~)/.test(line)) {
1251
+ inCodeFence = !inCodeFence;
1252
+ continue;
1253
+ }
1059
1254
  const candidates = [...line.matchAll(/`([^`\n]+)`/g)].map((match) => match[1]);
1060
1255
  const stripped = cleanMarkdownLine(line);
1061
- if (/^(?:pnpm|npm|yarn|bun|npx|node|pytest|go|cargo|maestro|playwright|gradle|mvn|\.\/gradlew)\b/i.test(stripped)) {
1256
+ // A bare line only counts as a command inside a fenced code block; in
1257
+ // prose, a sentence that merely starts with a tool name is not a command.
1258
+ if (inCodeFence && /^(?:pnpm|npm|yarn|bun|npx|node|pytest|go|cargo|maestro|playwright|gradle|mvn|\.\/gradlew)\b/i.test(stripped)) {
1062
1259
  candidates.push(stripped);
1063
1260
  }
1064
1261
  for (const candidate of candidates) {
@@ -1070,9 +1267,83 @@ function extractValidationCommands(text) {
1070
1267
  }
1071
1268
  return uniqueStrings(commands).slice(0, 12);
1072
1269
  }
1270
+ // Ground-truth validation commands read from project config rather than doc
1271
+ // prose: package.json scripts and pytest markers. These rank ahead of
1272
+ // doc-extracted commands because they cannot drift from what actually runs.
1273
+ function extractProjectValidationCommands(files) {
1274
+ const commands = [];
1275
+ const packageJson = files.find((file) => file.path === "package.json");
1276
+ if (packageJson?.text !== undefined) {
1277
+ const packageManager = files.some((file) => file.path === "pnpm-lock.yaml")
1278
+ ? "pnpm"
1279
+ : files.some((file) => file.path === "yarn.lock")
1280
+ ? "yarn"
1281
+ : files.some((file) => file.path === "bun.lockb" || file.path === "bun.lock")
1282
+ ? "bun"
1283
+ : "npm";
1284
+ try {
1285
+ const parsed = JSON.parse(packageJson.text);
1286
+ for (const [name, script] of Object.entries(parsed.scripts ?? {})) {
1287
+ if (typeof script !== "string") {
1288
+ continue;
1289
+ }
1290
+ // Verification-shaped scripts only. Bare "build" qualifies, but build
1291
+ // variants (build:android-apk) are packaging, not verification.
1292
+ if (!/^(?:test|lint|typecheck|type-check|check-types?|check|e2e|coverage|verify|format:check)(?:[:.][\w:.-]+)?$/.test(name) && name !== "build") {
1293
+ continue;
1294
+ }
1295
+ // Name segments that mutate, block, or open a UI disqualify the
1296
+ // script — matched per segment so test:server or e2e:device survive.
1297
+ const nameSegments = name.split(/[:._-]/);
1298
+ if (nameSegments.some((segment) => /^(?:fix|fixes|watch|dev|debug|start|serve|open|update|record|snapshot|snapshots|inspect|ui)$/i.test(segment))) {
1299
+ continue;
1300
+ }
1301
+ // The script body is the ground truth for blocking/mutating behavior,
1302
+ // and npm-init's placeholder test script is not a verification run.
1303
+ if (/--inspect|--watch\b|--ui\b|--update-?snapshots?|(?:^|\s)-u\b|\bopen\b|no test specified/i.test(script)) {
1304
+ continue;
1305
+ }
1306
+ commands.push(`${packageManager} run ${name}`);
1307
+ }
1308
+ }
1309
+ catch {
1310
+ // Unreadable package.json: fall back to doc-extracted commands only.
1311
+ }
1312
+ }
1313
+ const hasPytestConfig = files.some((file) => file.path === "pytest.ini" ||
1314
+ file.path === "conftest.py" ||
1315
+ file.path === "tests/conftest.py" ||
1316
+ (file.path === "pyproject.toml" && (file.text?.includes("[tool.pytest") ?? false)) ||
1317
+ (file.path === "setup.cfg" && (file.text?.includes("[tool:pytest]") ?? false)));
1318
+ if (hasPytestConfig) {
1319
+ commands.push("pytest");
1320
+ }
1321
+ return uniqueStrings(commands).slice(0, 12);
1322
+ }
1323
+ // "a && b" adds nothing when a and b are already listed individually.
1324
+ function dropRedundantCompoundCommands(commands) {
1325
+ const seen = new Set(commands);
1326
+ return commands.filter((command) => {
1327
+ const parts = command.split(/\s*&&\s*/);
1328
+ if (parts.length < 2) {
1329
+ return true;
1330
+ }
1331
+ return !parts.every((part) => seen.has(part.trim()));
1332
+ });
1333
+ }
1073
1334
  function extractSafetyRules(text) {
1074
1335
  const rules = [];
1336
+ let inCodeFence = false;
1075
1337
  for (const line of text.split(/\r?\n/)) {
1338
+ if (/^\s*(?:```|~~~)/.test(line)) {
1339
+ inCodeFence = !inCodeFence;
1340
+ continue;
1341
+ }
1342
+ // Code blocks hold examples (imports, CI YAML, mermaid diagrams), never
1343
+ // the team's prose rules.
1344
+ if (inCodeFence) {
1345
+ continue;
1346
+ }
1076
1347
  const cleaned = redactSensitiveText(cleanMarkdownLine(line));
1077
1348
  if (cleaned.length < 8 || cleaned.length > 220) {
1078
1349
  continue;
@@ -1080,7 +1351,14 @@ function extractSafetyRules(text) {
1080
1351
  if (/\bdo not (?:belong|require|show)\b/i.test(cleaned)) {
1081
1352
  continue;
1082
1353
  }
1083
- if (/(?:do not|don't|never|must not|read-only|\/tmp|token|secret|credential|절대|금지|하지 말|하면 안|커밋|푸시|PR 생성)/i.test(cleaned)) {
1354
+ // Structural fragments are never rules: mermaid edges, YAML keys and
1355
+ // template expressions, code statements, markup.
1356
+ if (/-->|\$\{\{|^[A-Za-z0-9_."'-]+:\s|^(?:import|export|from|const|let|var|function|class|return|if|for|uses:|run:|with:|<)\b/.test(cleaned)) {
1357
+ continue;
1358
+ }
1359
+ // Only prohibition/obligation language qualifies; topic words alone
1360
+ // (commit, push, token) pulled in list items and diagram labels.
1361
+ if (/(?:do not|don't|never|must not|read-only|절대|금지|하지 ?마|하지 ?말|하지 않|하면 안|해서는 안)/i.test(cleaned)) {
1084
1362
  rules.push(cleaned);
1085
1363
  }
1086
1364
  }
@@ -1091,6 +1369,12 @@ function normalizeCommand(value) {
1091
1369
  if (!command || command.length > 140 || /(?:publish|login|token|secret|password|rm\s+-rf)/i.test(command)) {
1092
1370
  return undefined;
1093
1371
  }
1372
+ // Commas, parentheses, and Hangul mark prose fragments, not shell commands;
1373
+ // == marks dependency version pins; a bare version number or --version
1374
+ // probe is not a verification run.
1375
+ if (/[,()]|==|[ᄀ-ᇿ㄰-㆏가-힣]|--version\b|^\S+\s+v?\d+(?:\.\d+)+\s*$/.test(command)) {
1376
+ return undefined;
1377
+ }
1094
1378
  return command.replace(/\s+/g, " ");
1095
1379
  }
1096
1380
  function isValidationCommand(command) {
@@ -1098,7 +1382,8 @@ function isValidationCommand(command) {
1098
1382
  /^(?:npx\s+)?playwright\s+test\b/i.test(command) ||
1099
1383
  /^maestro\s+test\b/i.test(command) ||
1100
1384
  /^node\s+--test\b/i.test(command) ||
1101
- /^pytest\b/i.test(command) ||
1385
+ // "pytest" alone or with arguments — not "pytest.ini" or "pytest-django".
1386
+ /^pytest(?:\s|$)/i.test(command) ||
1102
1387
  /^go\s+test\b/i.test(command) ||
1103
1388
  /^cargo\s+test\b/i.test(command) ||
1104
1389
  /^(?:gradle|\.\/gradlew)\s+(?:test|check)\b/i.test(command) ||
@@ -1106,8 +1391,9 @@ function isValidationCommand(command) {
1106
1391
  }
1107
1392
  function cleanMarkdownLine(value) {
1108
1393
  return value
1109
- .replace(/^\s{0,3}(?:[-*]|\d+\.)\s+/, "")
1110
1394
  .replace(/^\s{0,3}#+\s*/, "")
1395
+ .replace(/^\s{0,3}(?:[-*]|\d+\.)\s+/, "")
1396
+ .replace(/\s*\{#[\w-]+\}\s*$/, "")
1111
1397
  .trim();
1112
1398
  }
1113
1399
  function redactSensitiveText(value) {
@@ -1196,11 +1482,36 @@ function contextKindRank(kind) {
1196
1482
  return ranks[kind];
1197
1483
  }
1198
1484
  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))) {
1485
+ // Decide from dependency KEYS across every collected package.json, not raw
1486
+ // text: the word "react" in a description or an eslint-plugin-react entry
1487
+ // is not a web app. app.json only counts as mobile evidence when it has a
1488
+ // top-level "expo" key.
1489
+ const dependencies = {};
1490
+ let hasExpoAppJson = false;
1491
+ for (const file of files) {
1492
+ if (file.text === undefined) {
1493
+ continue;
1494
+ }
1495
+ const basename = path.basename(file.path);
1496
+ try {
1497
+ if (basename === "package.json") {
1498
+ const parsed = JSON.parse(file.text);
1499
+ Object.assign(dependencies, parsed.dependencies, parsed.devDependencies);
1500
+ }
1501
+ else if (basename === "app.json") {
1502
+ const parsed = JSON.parse(file.text);
1503
+ hasExpoAppJson ||= parsed.expo !== undefined;
1504
+ }
1505
+ }
1506
+ catch {
1507
+ // Unreadable JSON: contributes no signal.
1508
+ }
1509
+ }
1510
+ if (dependencies["expo"] !== undefined || dependencies["react-native"] !== undefined || hasExpoAppJson || files.some((file) => file.path.startsWith(".maestro/"))) {
1201
1511
  return "maestro";
1202
1512
  }
1203
- if (/\b(?:next|react|vite|vue|nuxt|svelte|remix|astro|angular|playwright)\b/i.test(packageText)) {
1513
+ const webDependencies = ["next", "react", "react-dom", "vue", "nuxt", "svelte", "astro", "vite", "@remix-run/react", "@angular/core", "@playwright/test", "playwright"];
1514
+ if (webDependencies.some((name) => dependencies[name] !== undefined) || files.some((file) => /(?:^|\/)playwright\.config\.[cm]?[jt]s$/.test(file.path))) {
1204
1515
  return "playwright";
1205
1516
  }
1206
1517
  return "manual";
@@ -1209,10 +1520,23 @@ function routeFromFile(file) {
1209
1520
  const normalized = toPosixPath(file);
1210
1521
  const pagesMatch = normalized.match(/(?:^|\/)(?:src\/)?pages\/(.+)\.(?:[cm]?[jt]sx?|vue|svelte)$/);
1211
1522
  if (pagesMatch && !pagesMatch[1].startsWith("_")) {
1212
- return normalizeRouteSegments(pagesMatch[1].replace(/\/index$/, ""));
1523
+ // pages/api/** are HTTP handlers, not navigable UI routes.
1524
+ if (/^api(?:\/|$)/.test(pagesMatch[1])) {
1525
+ return undefined;
1526
+ }
1527
+ return normalizeRouteSegments(pagesMatch[1].replace(/^index$|\/index$/, ""));
1528
+ }
1529
+ const appRootMatch = normalized.match(/(?:^|\/)(?:src\/)?app\/page\.(?:[cm]?[jt]sx?)$/);
1530
+ if (appRootMatch) {
1531
+ return "/";
1213
1532
  }
1214
- const appMatch = normalized.match(/(?:^|\/)(?:src\/)?app\/(.+)\/(?:page|route)\.(?:[cm]?[jt]sx?)$/);
1533
+ // Only page.* files are navigable; route.* files are HTTP handlers even
1534
+ // outside app/api (App Router allows them anywhere).
1535
+ const appMatch = normalized.match(/(?:^|\/)(?:src\/)?app\/(.+)\/page\.(?:[cm]?[jt]sx?)$/);
1215
1536
  if (appMatch) {
1537
+ if (/^api(?:\/|$)/.test(appMatch[1])) {
1538
+ return undefined;
1539
+ }
1216
1540
  const withoutGroups = appMatch[1]
1217
1541
  .split("/")
1218
1542
  .filter((segment) => !/^\(.+\)$/.test(segment))
@@ -1225,13 +1549,59 @@ function normalizeRouteSegments(value) {
1225
1549
  const route = value
1226
1550
  .split("/")
1227
1551
  .filter(Boolean)
1228
- .map((segment) => segment.replace(/^\[(\.\.\.)?(.+)\]$/, ":$2"))
1552
+ .map((segment) => segment
1553
+ .replace(/^\[(\.\.\.)?(.+)\]$/, ":$2")
1554
+ // Nuxt-style dynamic segments: _orderId -> :orderId, so entry
1555
+ // routes read as navigable specs rather than file paths.
1556
+ .replace(/^_(.+)$/, ":$1"))
1229
1557
  .join("/");
1230
1558
  return `/${route || ""}`;
1231
1559
  }
1560
+ // Component basenames that are UI plumbing rather than product behavior; a
1561
+ // flow named after them tests a primitive, not a user journey. Only terms
1562
+ // that are generic in ANY codebase belong here — a feature-shaped word
1563
+ // (Story, Logger) may be someone's real product surface.
1564
+ const genericComponentPrefixes = new Set([
1565
+ "app",
1566
+ "base",
1567
+ "common",
1568
+ "default",
1569
+ "error",
1570
+ "generic",
1571
+ "index",
1572
+ "loading",
1573
+ "main",
1574
+ "mock",
1575
+ "root",
1576
+ "sample",
1577
+ "shared",
1578
+ "test",
1579
+ "ui",
1580
+ ]);
1581
+ // Suffixes that are structural nouns: bare "Modal.tsx" or "Page.tsx" is a
1582
+ // primitive, while a bare product action like "Checkout.tsx" is a flow.
1583
+ const structuralComponentSuffixMatcher = /^(?:page|screen|view|modal|form|flow)$/i;
1232
1584
  function componentNameFromFile(file) {
1233
- const basename = path.basename(file).replace(/\.[^.]+$/, "");
1234
- if (!/(?:page|screen|view|modal|form|flow|checkout|submit|complete)$/i.test(basename)) {
1585
+ const normalized = toPosixPath(file);
1586
+ // Icon sets, mocks, and generic component buckets never hold product flows.
1587
+ if (/(?:^|\/)(?:icons?|__mocks__|\.storybook|storybook|constants|hooks)\//i.test(normalized)) {
1588
+ return undefined;
1589
+ }
1590
+ if (/\/components\/(?:shared|common|ui|base|lib)\//i.test(normalized)) {
1591
+ return undefined;
1592
+ }
1593
+ const basename = path.basename(normalized).replace(/\.[^.]+$/, "");
1594
+ const suffixMatch = basename.match(/(page|screen|view|modal|form|flow|checkout|submit|complete|success|confirmation)$/i);
1595
+ if (!suffixMatch) {
1596
+ return undefined;
1597
+ }
1598
+ const prefix = basename.slice(0, basename.length - suffixMatch[1].length).replace(/[-_.]+$/, "");
1599
+ // A bare structural noun (Modal, Page) is a primitive; a bare product
1600
+ // action (Checkout, Submit, Complete) is a real funnel step.
1601
+ if (prefix === "") {
1602
+ return structuralComponentSuffixMatcher.test(basename) ? undefined : titleCase(basename);
1603
+ }
1604
+ if (genericComponentPrefixes.has(prefix.toLowerCase())) {
1235
1605
  return undefined;
1236
1606
  }
1237
1607
  return titleCase(basename);
@@ -1244,19 +1614,72 @@ function domainCandidateFromPath(file) {
1244
1614
  }
1245
1615
  const routeIndex = segments.findIndex((segment) => ["app", "pages", "screens"].includes(segment));
1246
1616
  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]);
1617
+ // Walk past structural DIRECTORY segments (navigations, providers,
1618
+ // layout) toward the first product-shaped one instead of giving up on
1619
+ // the first miss but never descend into the file basename, or every
1620
+ // colocated components/hooks/utils file mints its own garbage domain.
1621
+ for (const segment of segments.slice(routeIndex + 1, -1)) {
1622
+ if (!segment || segment.startsWith("_") || segment.startsWith("+") || /^\(.+\)$/.test(segment)) {
1623
+ continue;
1624
+ }
1625
+ // pages/api and app/api are HTTP handler trees, not product areas.
1626
+ if (segment === "api") {
1627
+ return undefined;
1628
+ }
1629
+ const candidate = domainCandidate(segment, segments[routeIndex]);
1630
+ if (candidate) {
1631
+ return candidate;
1632
+ }
1633
+ }
1634
+ // The basename may only speak for itself when the file sits directly
1635
+ // under the route dir (app/SentimentChatPage.tsx), not when a
1636
+ // structural directory owns the subtree. Router-convention names
1637
+ // (_layout, +not-found, (group)) never qualify.
1638
+ if (segments.length === routeIndex + 2) {
1639
+ const basename = segments[routeIndex + 1];
1640
+ if (basename && !basename.startsWith("_") && !basename.startsWith("+") && !/^\(.+\)$/.test(basename)) {
1641
+ return domainCandidate(basename, segments[routeIndex]);
1642
+ }
1252
1643
  }
1253
1644
  }
1254
1645
  return undefined;
1255
1646
  }
1647
+ // Architecture-layer directory names: they organize code, not the product.
1648
+ const structuralDomainIds = new Set([
1649
+ "api",
1650
+ "assets",
1651
+ "common",
1652
+ "components",
1653
+ "config",
1654
+ "constants",
1655
+ "contexts",
1656
+ "core",
1657
+ "helpers",
1658
+ "hooks",
1659
+ "i18n",
1660
+ "index",
1661
+ "layout",
1662
+ "layouts",
1663
+ "lib",
1664
+ "locales",
1665
+ "navigation",
1666
+ "navigations",
1667
+ "page",
1668
+ "providers",
1669
+ "shared",
1670
+ "src",
1671
+ "state",
1672
+ "styles",
1673
+ "theme",
1674
+ "themes",
1675
+ "types",
1676
+ "ui",
1677
+ "utils",
1678
+ ]);
1256
1679
  function domainCandidate(value, from) {
1257
1680
  const clean = value.replace(/\.[^.]+$/, "").replace(/^\[(.+)\]$/, "$1");
1258
1681
  const id = slugify(clean);
1259
- if (!id || ["api", "components", "hooks", "lib", "shared", "src", "utils"].includes(id)) {
1682
+ if (!id || structuralDomainIds.has(id)) {
1260
1683
  return undefined;
1261
1684
  }
1262
1685
  return { id, name: titleCase(clean), from };
@@ -1313,7 +1736,8 @@ function matchesPathPattern(file, pattern) {
1313
1736
  return regex.test(normalizedFile);
1314
1737
  }
1315
1738
  function isBehaviorFile(file) {
1316
- return /\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(file) && !/(?:^|\/)(?:tests?|__tests__|e2e|dist|build)\//i.test(file);
1739
+ return (/\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(file) &&
1740
+ !/(?:^|\/)(?:tests?|__tests__|e2e|dist|build|out|\.output|storybook-static|__generated__)\//i.test(file));
1317
1741
  }
1318
1742
  function normalizeVerificationManifest(value, manifestPath) {
1319
1743
  const record = asRecord(value, `QAMap manifest must be an object: ${manifestPath}`);