@ivorycanvas/qamap 0.3.3 → 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.
- package/CHANGELOG.md +38 -0
- package/README.md +44 -578
- package/dist/agent-init.d.ts +16 -0
- package/dist/agent-init.js +110 -0
- package/dist/agent-init.js.map +1 -0
- package/dist/cli.js +17 -1
- package/dist/cli.js.map +1 -1
- package/dist/context.d.ts +1 -0
- package/dist/context.js +4 -0
- package/dist/context.js.map +1 -1
- package/dist/domain-language.js +48 -5
- package/dist/domain-language.js.map +1 -1
- package/dist/e2e.d.ts +4 -0
- package/dist/e2e.js +342 -73
- package/dist/e2e.js.map +1 -1
- package/dist/fixture-insight.d.ts +8 -0
- package/dist/fixture-insight.js +193 -0
- package/dist/fixture-insight.js.map +1 -0
- package/dist/fs.js +11 -0
- package/dist/fs.js.map +1 -1
- package/dist/index.d.ts +2 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts +5 -0
- package/dist/manifest.js +478 -54
- package/dist/manifest.js.map +1 -1
- package/dist/qa.d.ts +4 -0
- package/dist/qa.js +125 -18
- package/dist/qa.js.map +1 -1
- package/dist/terminal.d.ts +2 -0
- package/dist/terminal.js +51 -0
- package/dist/terminal.js.map +1 -0
- package/dist/test-plan.js +56 -6
- package/dist/test-plan.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/adoption.md +62 -0
- package/docs/agent-format.md +52 -0
- package/docs/agent-skill.md +17 -1
- package/docs/benchmarking.md +60 -20
- package/docs/commands.md +242 -0
- package/docs/configuration.md +11 -0
- package/docs/e2e-output-examples.md +8 -4
- package/docs/guardrails.md +64 -0
- package/docs/manifest.md +3 -3
- package/docs/quickstart-demo.md +1 -1
- package/docs/release-validation.md +18 -4
- package/docs/releasing.md +13 -10
- package/docs/roadmap.md +9 -14
- package/package.json +4 -3
- package/schema/qamap-agent.schema.json +171 -0
- 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
|
|
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
|
-
|
|
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
|
-
|
|
147
|
-
]
|
|
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.", "
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
|
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
|
|
1040
|
+
symbol,
|
|
924
1041
|
source: "inferred",
|
|
925
1042
|
confidence: route ? "high" : "medium",
|
|
926
1043
|
},
|
|
927
1044
|
];
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
1234
|
-
|
|
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
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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 ||
|
|
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) &&
|
|
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}`);
|