@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.
- package/CHANGELOG.md +54 -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 +385 -74
- 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 +615 -57
- 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 +65 -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 +7 -3
- package/docs/quickstart-demo.md +1 -1
- package/docs/release-validation.md +35 -34
- 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);
|
|
@@ -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
|
|
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
|
|
371
|
-
const
|
|
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.", "
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
901
|
-
|
|
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
|
|
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
|
|
1047
|
+
symbol,
|
|
924
1048
|
source: "inferred",
|
|
925
1049
|
confidence: route ? "high" : "medium",
|
|
926
1050
|
},
|
|
927
1051
|
];
|
|
928
|
-
|
|
929
|
-
|
|
930
|
-
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
936
|
-
|
|
937
|
-
|
|
938
|
-
|
|
939
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
1200
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1234
|
-
|
|
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
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
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 ||
|
|
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) &&
|
|
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}`);
|