@ivorycanvas/qamap 0.3.0
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 +93 -0
- package/LICENSE +21 -0
- package/README.md +636 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +879 -0
- package/dist/cli.js.map +1 -0
- package/dist/config.d.ts +5 -0
- package/dist/config.js +114 -0
- package/dist/config.js.map +1 -0
- package/dist/context.d.ts +1 -0
- package/dist/context.js +126 -0
- package/dist/context.js.map +1 -0
- package/dist/doctor.d.ts +32 -0
- package/dist/doctor.js +200 -0
- package/dist/doctor.js.map +1 -0
- package/dist/domain-language.d.ts +25 -0
- package/dist/domain-language.js +655 -0
- package/dist/domain-language.js.map +1 -0
- package/dist/domains.d.ts +33 -0
- package/dist/domains.js +258 -0
- package/dist/domains.js.map +1 -0
- package/dist/e2e.d.ts +326 -0
- package/dist/e2e.js +6869 -0
- package/dist/e2e.js.map +1 -0
- package/dist/eval.d.ts +40 -0
- package/dist/eval.js +374 -0
- package/dist/eval.js.map +1 -0
- package/dist/flows.d.ts +32 -0
- package/dist/flows.js +246 -0
- package/dist/flows.js.map +1 -0
- package/dist/fs.d.ts +7 -0
- package/dist/fs.js +132 -0
- package/dist/fs.js.map +1 -0
- package/dist/github.d.ts +34 -0
- package/dist/github.js +183 -0
- package/dist/github.js.map +1 -0
- package/dist/history.d.ts +173 -0
- package/dist/history.js +247 -0
- package/dist/history.js.map +1 -0
- package/dist/index.d.ts +35 -0
- package/dist/index.js +20 -0
- package/dist/index.js.map +1 -0
- package/dist/manifest-suggestions.d.ts +67 -0
- package/dist/manifest-suggestions.js +673 -0
- package/dist/manifest-suggestions.js.map +1 -0
- package/dist/manifest.d.ts +212 -0
- package/dist/manifest.js +1607 -0
- package/dist/manifest.js.map +1 -0
- package/dist/qa.d.ts +53 -0
- package/dist/qa.js +361 -0
- package/dist/qa.js.map +1 -0
- package/dist/report.d.ts +5 -0
- package/dist/report.js +164 -0
- package/dist/report.js.map +1 -0
- package/dist/review.d.ts +31 -0
- package/dist/review.js +383 -0
- package/dist/review.js.map +1 -0
- package/dist/scanner.d.ts +3 -0
- package/dist/scanner.js +797 -0
- package/dist/scanner.js.map +1 -0
- package/dist/severity.d.ts +3 -0
- package/dist/severity.js +9 -0
- package/dist/severity.js.map +1 -0
- package/dist/test-evidence.d.ts +39 -0
- package/dist/test-evidence.js +303 -0
- package/dist/test-evidence.js.map +1 -0
- package/dist/test-plan.d.ts +35 -0
- package/dist/test-plan.js +573 -0
- package/dist/test-plan.js.map +1 -0
- package/dist/types.d.ts +60 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/verify.d.ts +27 -0
- package/dist/verify.js +244 -0
- package/dist/verify.js.map +1 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +3 -0
- package/dist/version.js.map +1 -0
- package/docs/adoption.md +166 -0
- package/docs/agent-skill.md +94 -0
- package/docs/api-contracts.md +30 -0
- package/docs/assets/qamap-30s-demo.gif +0 -0
- package/docs/configuration.md +277 -0
- package/docs/e2e-output-examples.md +464 -0
- package/docs/ecosystem.md +41 -0
- package/docs/eval.md +52 -0
- package/docs/github-action.md +89 -0
- package/docs/manifest.md +350 -0
- package/docs/quickstart-demo.md +268 -0
- package/docs/release-validation.md +247 -0
- package/docs/releasing.md +112 -0
- package/docs/roadmap.md +67 -0
- package/docs/rules.md +28 -0
- package/docs/verify.md +44 -0
- package/package.json +80 -0
- package/schema/qamap-manifest.schema.json +323 -0
- package/schema/qamap.schema.json +58 -0
- package/skills/qamap-pr-qa/SKILL.md +81 -0
|
@@ -0,0 +1,673 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import YAML from "yaml";
|
|
4
|
+
import { defaultDomainManifestPath } from "./domains.js";
|
|
5
|
+
import { generateE2ePlan } from "./e2e.js";
|
|
6
|
+
import { defaultFlowManifestPath } from "./flows.js";
|
|
7
|
+
import { pathExists, toPosixPath } from "./fs.js";
|
|
8
|
+
import { TOOL_NAME, VERSION } from "./version.js";
|
|
9
|
+
export async function generateDomainManifestSuggestion(rootInput, options = {}) {
|
|
10
|
+
const plan = await generateE2ePlan(rootInput, options);
|
|
11
|
+
const domains = buildSuggestedDomains(plan);
|
|
12
|
+
const promotionPlan = buildDomainPromotionPlan(domains);
|
|
13
|
+
return {
|
|
14
|
+
tool: {
|
|
15
|
+
name: TOOL_NAME,
|
|
16
|
+
version: VERSION,
|
|
17
|
+
},
|
|
18
|
+
kind: "domain-manifest-suggestion",
|
|
19
|
+
root: plan.root,
|
|
20
|
+
workspaceRoot: plan.workspaceRoot,
|
|
21
|
+
manifestRoot: plan.workspaceRoot ?? plan.root,
|
|
22
|
+
generatedAt: new Date().toISOString(),
|
|
23
|
+
base: plan.base,
|
|
24
|
+
head: plan.head,
|
|
25
|
+
includeWorkingTree: plan.includeWorkingTree,
|
|
26
|
+
changedFiles: plan.changedFiles.map((file) => manifestRelativeFile(plan, file.path)),
|
|
27
|
+
domains,
|
|
28
|
+
promotionPlan,
|
|
29
|
+
yaml: formatSuggestedDomainManifestYaml(domains),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
export async function generateFlowManifestSuggestion(rootInput, options = {}) {
|
|
33
|
+
const plan = await generateE2ePlan(rootInput, options);
|
|
34
|
+
const domains = buildSuggestedDomains(plan);
|
|
35
|
+
const flows = buildSuggestedFlows(plan, domains);
|
|
36
|
+
const promotionPlan = buildFlowPromotionPlan(flows);
|
|
37
|
+
return {
|
|
38
|
+
tool: {
|
|
39
|
+
name: TOOL_NAME,
|
|
40
|
+
version: VERSION,
|
|
41
|
+
},
|
|
42
|
+
kind: "flow-manifest-suggestion",
|
|
43
|
+
root: plan.root,
|
|
44
|
+
workspaceRoot: plan.workspaceRoot,
|
|
45
|
+
manifestRoot: plan.workspaceRoot ?? plan.root,
|
|
46
|
+
generatedAt: new Date().toISOString(),
|
|
47
|
+
base: plan.base,
|
|
48
|
+
head: plan.head,
|
|
49
|
+
includeWorkingTree: plan.includeWorkingTree,
|
|
50
|
+
changedFiles: plan.changedFiles.map((file) => manifestRelativeFile(plan, file.path)),
|
|
51
|
+
flows,
|
|
52
|
+
promotionPlan,
|
|
53
|
+
yaml: formatSuggestedFlowManifestYaml(flows),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
export function formatDomainManifestSuggestion(result, format) {
|
|
57
|
+
if (format === "json") {
|
|
58
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
59
|
+
}
|
|
60
|
+
if (format === "markdown") {
|
|
61
|
+
const lines = manifestSuggestionHeader("Domain Manifest Suggestion", result);
|
|
62
|
+
appendPromotionPlan(lines, result.promotionPlan);
|
|
63
|
+
lines.push("## Suggested Domains");
|
|
64
|
+
lines.push("");
|
|
65
|
+
if (result.domains.length === 0) {
|
|
66
|
+
lines.push("No durable domain candidates were detected from the changed files.");
|
|
67
|
+
lines.push("");
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
for (const domain of result.domains) {
|
|
71
|
+
lines.push(`- ${domain.name} \`${domain.id}\`: ${domain.files.join(", ")}`);
|
|
72
|
+
}
|
|
73
|
+
lines.push("");
|
|
74
|
+
}
|
|
75
|
+
lines.push("## YAML");
|
|
76
|
+
lines.push("");
|
|
77
|
+
lines.push("```yaml");
|
|
78
|
+
lines.push(result.yaml.trimEnd());
|
|
79
|
+
lines.push("```");
|
|
80
|
+
lines.push("");
|
|
81
|
+
return lines.join("\n");
|
|
82
|
+
}
|
|
83
|
+
return result.yaml;
|
|
84
|
+
}
|
|
85
|
+
export function formatFlowManifestSuggestion(result, format) {
|
|
86
|
+
if (format === "json") {
|
|
87
|
+
return `${JSON.stringify(result, null, 2)}\n`;
|
|
88
|
+
}
|
|
89
|
+
if (format === "markdown") {
|
|
90
|
+
const lines = manifestSuggestionHeader("Core Flow Manifest Suggestion", result);
|
|
91
|
+
appendPromotionPlan(lines, result.promotionPlan);
|
|
92
|
+
lines.push("## Suggested Flows");
|
|
93
|
+
lines.push("");
|
|
94
|
+
if (result.flows.length === 0) {
|
|
95
|
+
lines.push("No durable core flow candidates were detected from the changed files.");
|
|
96
|
+
lines.push("");
|
|
97
|
+
}
|
|
98
|
+
else {
|
|
99
|
+
for (const flow of result.flows) {
|
|
100
|
+
lines.push(`- ${flow.name} \`${flow.id}\` [${flow.priority}]: ${flow.files.join(", ")}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push("");
|
|
103
|
+
}
|
|
104
|
+
lines.push("## YAML");
|
|
105
|
+
lines.push("");
|
|
106
|
+
lines.push("```yaml");
|
|
107
|
+
lines.push(result.yaml.trimEnd());
|
|
108
|
+
lines.push("```");
|
|
109
|
+
lines.push("");
|
|
110
|
+
return lines.join("\n");
|
|
111
|
+
}
|
|
112
|
+
return result.yaml;
|
|
113
|
+
}
|
|
114
|
+
export async function writeSuggestedManifest(rootInput, fileName, content, force = false) {
|
|
115
|
+
const root = path.resolve(rootInput);
|
|
116
|
+
const outputPath = path.resolve(root, fileName);
|
|
117
|
+
if (!force && (await pathExists(outputPath))) {
|
|
118
|
+
throw new Error(`Refusing to overwrite ${outputPath}. Pass --force to replace it.`);
|
|
119
|
+
}
|
|
120
|
+
await fs.mkdir(path.dirname(outputPath), { recursive: true });
|
|
121
|
+
await fs.writeFile(outputPath, content, "utf8");
|
|
122
|
+
return outputPath;
|
|
123
|
+
}
|
|
124
|
+
function buildSuggestedDomains(plan) {
|
|
125
|
+
const domains = [];
|
|
126
|
+
const terms = plan.domainLanguage.terms.filter((term) => term.files.length > 0).slice(0, 8);
|
|
127
|
+
for (const term of terms) {
|
|
128
|
+
const files = uniqueStrings(term.files.map((file) => manifestRelativeFile(plan, file)).filter(isBehaviorFile));
|
|
129
|
+
const patterns = uniqueStrings(files.map(domainFilePattern)).slice(0, 4);
|
|
130
|
+
if (patterns.length === 0) {
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
const id = slugify(term.term);
|
|
134
|
+
const scenarios = scenariosForTerm(plan, term.term, files);
|
|
135
|
+
domains.push({
|
|
136
|
+
id,
|
|
137
|
+
name: term.term,
|
|
138
|
+
aliases: [],
|
|
139
|
+
files: patterns,
|
|
140
|
+
routes: routeHintsForFiles(plan, term.files).slice(0, 4),
|
|
141
|
+
tags: ["suggested"],
|
|
142
|
+
scenarios,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
if (domains.length === 0) {
|
|
146
|
+
for (const flow of plan.flows.slice(0, 4)) {
|
|
147
|
+
const subject = durableSubject(flow.title);
|
|
148
|
+
const files = uniqueStrings(flow.files.map((file) => manifestRelativeFile(plan, file)).filter(isBehaviorFile));
|
|
149
|
+
const patterns = uniqueStrings(files.map(domainFilePattern)).slice(0, 4);
|
|
150
|
+
if (!subject || patterns.length === 0) {
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
domains.push({
|
|
154
|
+
id: slugify(subject),
|
|
155
|
+
name: subject,
|
|
156
|
+
aliases: [],
|
|
157
|
+
files: patterns,
|
|
158
|
+
routes: routeHintsForFiles(plan, flow.files).slice(0, 4),
|
|
159
|
+
tags: ["suggested"],
|
|
160
|
+
scenarios: [
|
|
161
|
+
{
|
|
162
|
+
title: `${subject} primary journey`,
|
|
163
|
+
checks: checksForFlow(flow),
|
|
164
|
+
},
|
|
165
|
+
],
|
|
166
|
+
});
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return dedupeDomains(domains).slice(0, 8);
|
|
170
|
+
}
|
|
171
|
+
function buildSuggestedFlows(plan, domains) {
|
|
172
|
+
const flows = [];
|
|
173
|
+
const scenarios = plan.domainLanguage.scenarios.length > 0
|
|
174
|
+
? plan.domainLanguage.scenarios
|
|
175
|
+
: plan.flows.map((flow) => ({
|
|
176
|
+
title: flow.title,
|
|
177
|
+
intent: flow.reason,
|
|
178
|
+
checks: flow.steps,
|
|
179
|
+
files: flow.files,
|
|
180
|
+
source: "changed-file",
|
|
181
|
+
}));
|
|
182
|
+
for (const scenario of scenarios.slice(0, 8)) {
|
|
183
|
+
const baseFlow = bestFlowForFiles(plan.flows, scenario.files);
|
|
184
|
+
const localFiles = scenario.files.length > 0 ? scenario.files : (baseFlow?.files ?? []);
|
|
185
|
+
const files = uniqueStrings(localFiles.map((file) => manifestRelativeFile(plan, file)).filter(isBehaviorFile));
|
|
186
|
+
const patterns = uniqueStrings(files.map(domainFilePattern)).slice(0, 5);
|
|
187
|
+
if (patterns.length === 0) {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
const flowDomains = domainIdsForFiles(files, domains);
|
|
191
|
+
const checks = scenario.checks.length > 0 ? scenario.checks : baseFlow ? checksForFlow(baseFlow) : genericFlowChecks(scenario.title);
|
|
192
|
+
flows.push({
|
|
193
|
+
id: slugify(scenario.title),
|
|
194
|
+
name: scenario.title,
|
|
195
|
+
priority: priorityForFlow(scenario.title, baseFlow),
|
|
196
|
+
domains: flowDomains,
|
|
197
|
+
files: patterns,
|
|
198
|
+
routes: uniqueStrings([
|
|
199
|
+
...routesForScenario(scenario),
|
|
200
|
+
...routeHintsForFiles(plan, localFiles),
|
|
201
|
+
]).slice(0, 4),
|
|
202
|
+
tags: ["suggested"],
|
|
203
|
+
checks: checks.slice(0, 6),
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (flows.length === 0) {
|
|
207
|
+
for (const flow of plan.flows.slice(0, 4)) {
|
|
208
|
+
const files = uniqueStrings(flow.files.map((file) => manifestRelativeFile(plan, file)).filter(isBehaviorFile));
|
|
209
|
+
const patterns = uniqueStrings(files.map(domainFilePattern)).slice(0, 5);
|
|
210
|
+
if (patterns.length === 0) {
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
flows.push({
|
|
214
|
+
id: slugify(flow.title),
|
|
215
|
+
name: flow.title,
|
|
216
|
+
priority: priorityForFlow(flow.title, flow),
|
|
217
|
+
domains: domainIdsForFiles(files, domains),
|
|
218
|
+
files: patterns,
|
|
219
|
+
routes: routeHintsForFiles(plan, flow.files).slice(0, 4),
|
|
220
|
+
tags: ["suggested"],
|
|
221
|
+
checks: checksForFlow(flow),
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
return dedupeFlows(flows).slice(0, 8);
|
|
226
|
+
}
|
|
227
|
+
function buildDomainPromotionPlan(domains) {
|
|
228
|
+
const candidates = domains.map((domain) => {
|
|
229
|
+
const status = domainPromotionStatus(domain);
|
|
230
|
+
return {
|
|
231
|
+
id: domain.id,
|
|
232
|
+
name: domain.name,
|
|
233
|
+
status,
|
|
234
|
+
reason: domainPromotionReason(domain, status),
|
|
235
|
+
action: domainPromotionAction(status),
|
|
236
|
+
files: domain.files,
|
|
237
|
+
routes: domain.routes,
|
|
238
|
+
};
|
|
239
|
+
});
|
|
240
|
+
return buildPromotionPlan(candidates);
|
|
241
|
+
}
|
|
242
|
+
function buildFlowPromotionPlan(flows) {
|
|
243
|
+
const candidates = flows.map((flow) => {
|
|
244
|
+
const status = flowPromotionStatus(flow);
|
|
245
|
+
return {
|
|
246
|
+
id: flow.id,
|
|
247
|
+
name: flow.name,
|
|
248
|
+
status,
|
|
249
|
+
reason: flowPromotionReason(flow, status),
|
|
250
|
+
action: flowPromotionAction(status),
|
|
251
|
+
files: flow.files,
|
|
252
|
+
routes: flow.routes,
|
|
253
|
+
};
|
|
254
|
+
});
|
|
255
|
+
return buildPromotionPlan(candidates);
|
|
256
|
+
}
|
|
257
|
+
function buildPromotionPlan(candidates) {
|
|
258
|
+
const sortedCandidates = [...candidates].sort(comparePromotionCandidates);
|
|
259
|
+
const counts = {
|
|
260
|
+
commitCandidate: sortedCandidates.filter((candidate) => candidate.status === "commit-candidate").length,
|
|
261
|
+
needsReview: sortedCandidates.filter((candidate) => candidate.status === "needs-review").length,
|
|
262
|
+
lowSignal: sortedCandidates.filter((candidate) => candidate.status === "low-signal").length,
|
|
263
|
+
};
|
|
264
|
+
return {
|
|
265
|
+
summary: promotionSummary(counts),
|
|
266
|
+
candidates: sortedCandidates,
|
|
267
|
+
counts,
|
|
268
|
+
};
|
|
269
|
+
}
|
|
270
|
+
function domainPromotionStatus(domain) {
|
|
271
|
+
const hasScenarioChecks = domain.scenarios.some((scenario) => scenario.checks.length >= 3);
|
|
272
|
+
if (domain.routes.length > 0 && hasScenarioChecks) {
|
|
273
|
+
return "commit-candidate";
|
|
274
|
+
}
|
|
275
|
+
if (domain.files.length > 0 && domain.scenarios.length > 0) {
|
|
276
|
+
return "needs-review";
|
|
277
|
+
}
|
|
278
|
+
return "low-signal";
|
|
279
|
+
}
|
|
280
|
+
function flowPromotionStatus(flow) {
|
|
281
|
+
if ((flow.priority === "critical" || flow.routes.length > 0) && flow.domains.length > 0 && flow.checks.length >= 3) {
|
|
282
|
+
return "commit-candidate";
|
|
283
|
+
}
|
|
284
|
+
if (flow.files.length > 0 && flow.checks.length >= 2) {
|
|
285
|
+
return "needs-review";
|
|
286
|
+
}
|
|
287
|
+
return "low-signal";
|
|
288
|
+
}
|
|
289
|
+
function domainPromotionReason(domain, status) {
|
|
290
|
+
if (status === "commit-candidate") {
|
|
291
|
+
return "The candidate has route evidence plus scenario checks, so it is close to commit-ready if the name matches team language.";
|
|
292
|
+
}
|
|
293
|
+
if (status === "needs-review") {
|
|
294
|
+
return "The candidate has file and scenario evidence, but routes or stronger checks should be reviewed before committing.";
|
|
295
|
+
}
|
|
296
|
+
return "The candidate is mostly path-derived and should stay as an exploration note until the team confirms the language.";
|
|
297
|
+
}
|
|
298
|
+
function flowPromotionReason(flow, status) {
|
|
299
|
+
if (status === "commit-candidate") {
|
|
300
|
+
return "The candidate has domains, route or criticality evidence, and multiple checks, so it can be reviewed as team policy.";
|
|
301
|
+
}
|
|
302
|
+
if (status === "needs-review") {
|
|
303
|
+
return "The candidate has changed-file evidence and checks, but needs human confirmation of priority, domains, or routes.";
|
|
304
|
+
}
|
|
305
|
+
return "The candidate is low-signal and should not be committed until it is tied to a real user journey.";
|
|
306
|
+
}
|
|
307
|
+
function domainPromotionAction(status) {
|
|
308
|
+
if (status === "commit-candidate") {
|
|
309
|
+
return "Review the name with the team, remove the suggested tag if accepted, then commit it to .qamap/domains.yml.";
|
|
310
|
+
}
|
|
311
|
+
if (status === "needs-review") {
|
|
312
|
+
return "Add aliases, routes, or better scenario checks before committing this domain.";
|
|
313
|
+
}
|
|
314
|
+
return "Keep this out of shared policy until repeated PRs prove the term is durable.";
|
|
315
|
+
}
|
|
316
|
+
function flowPromotionAction(status) {
|
|
317
|
+
if (status === "commit-candidate") {
|
|
318
|
+
return "Confirm this is a durable journey, adjust priority if needed, then commit it to .qamap/flows.yml.";
|
|
319
|
+
}
|
|
320
|
+
if (status === "needs-review") {
|
|
321
|
+
return "Confirm owner, route, priority, and failure-path checks before committing this flow.";
|
|
322
|
+
}
|
|
323
|
+
return "Keep this as a temporary note until the journey is tied to a stable product flow.";
|
|
324
|
+
}
|
|
325
|
+
function promotionSummary(counts) {
|
|
326
|
+
if (counts.commitCandidate > 0) {
|
|
327
|
+
return `${counts.commitCandidate} candidate${counts.commitCandidate === 1 ? "" : "s"} look close enough to review for shared policy.`;
|
|
328
|
+
}
|
|
329
|
+
if (counts.needsReview > 0) {
|
|
330
|
+
return `${counts.needsReview} candidate${counts.needsReview === 1 ? "" : "s"} need human review before they should be committed.`;
|
|
331
|
+
}
|
|
332
|
+
return "No candidates are strong enough to commit as shared policy yet.";
|
|
333
|
+
}
|
|
334
|
+
function comparePromotionCandidates(left, right) {
|
|
335
|
+
const statusDiff = promotionStatusRank(left.status) - promotionStatusRank(right.status);
|
|
336
|
+
if (statusDiff !== 0) {
|
|
337
|
+
return statusDiff;
|
|
338
|
+
}
|
|
339
|
+
return left.name.localeCompare(right.name);
|
|
340
|
+
}
|
|
341
|
+
function promotionStatusRank(status) {
|
|
342
|
+
if (status === "commit-candidate") {
|
|
343
|
+
return 0;
|
|
344
|
+
}
|
|
345
|
+
if (status === "needs-review") {
|
|
346
|
+
return 1;
|
|
347
|
+
}
|
|
348
|
+
return 2;
|
|
349
|
+
}
|
|
350
|
+
function appendPromotionPlan(lines, plan) {
|
|
351
|
+
lines.push("## Promotion Plan");
|
|
352
|
+
lines.push("");
|
|
353
|
+
lines.push(plan.summary);
|
|
354
|
+
lines.push("");
|
|
355
|
+
lines.push(`Summary: ${plan.counts.commitCandidate} commit-candidate, ${plan.counts.needsReview} needs-review, ${plan.counts.lowSignal} low-signal.`);
|
|
356
|
+
lines.push("");
|
|
357
|
+
if (plan.candidates.length === 0) {
|
|
358
|
+
lines.push("No candidates were produced.");
|
|
359
|
+
lines.push("");
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
lines.push("| Status | Candidate | Reason | Action |");
|
|
363
|
+
lines.push("| --- | --- | --- | --- |");
|
|
364
|
+
for (const candidate of plan.candidates) {
|
|
365
|
+
lines.push(`| ${candidate.status} | ${escapeMarkdownTableCell(candidate.name)} \`${escapeMarkdownInline(candidate.id)}\` | ${escapeMarkdownTableCell(candidate.reason)} | ${escapeMarkdownTableCell(candidate.action)} |`);
|
|
366
|
+
}
|
|
367
|
+
lines.push("");
|
|
368
|
+
}
|
|
369
|
+
function manifestSuggestionHeader(title, result) {
|
|
370
|
+
const lines = [];
|
|
371
|
+
lines.push(`# QAMap ${title}`);
|
|
372
|
+
lines.push("");
|
|
373
|
+
lines.push(`- Root: \`${escapeMarkdownInline(result.root)}\``);
|
|
374
|
+
if (result.workspaceRoot) {
|
|
375
|
+
lines.push(`- Workspace root: \`${escapeMarkdownInline(result.workspaceRoot)}\``);
|
|
376
|
+
}
|
|
377
|
+
lines.push(`- Manifest root: \`${escapeMarkdownInline(result.manifestRoot)}\``);
|
|
378
|
+
lines.push(`- Base: \`${escapeMarkdownInline(result.base)}\``);
|
|
379
|
+
lines.push(`- Head: \`${escapeMarkdownInline(result.head)}\``);
|
|
380
|
+
if (result.includeWorkingTree) {
|
|
381
|
+
lines.push("- Includes working tree changes: yes");
|
|
382
|
+
}
|
|
383
|
+
lines.push(`- Changed files considered: ${result.changedFiles.length}`);
|
|
384
|
+
lines.push("");
|
|
385
|
+
return lines;
|
|
386
|
+
}
|
|
387
|
+
function formatSuggestedDomainManifestYaml(domains) {
|
|
388
|
+
return [
|
|
389
|
+
"# Suggested by QAMap. Review names, routes, and checks before committing.",
|
|
390
|
+
"# Commit this file as .qamap/domains.yml only when these words match team language.",
|
|
391
|
+
YAML.stringify({ domains }, { lineWidth: 0 }).trimEnd(),
|
|
392
|
+
"",
|
|
393
|
+
].join("\n");
|
|
394
|
+
}
|
|
395
|
+
function formatSuggestedFlowManifestYaml(flows) {
|
|
396
|
+
return [
|
|
397
|
+
"# Suggested by QAMap. Review priorities, routes, and checks before committing.",
|
|
398
|
+
"# Commit this file as .qamap/flows.yml only when these journeys are team-approved.",
|
|
399
|
+
YAML.stringify({ flows }, { lineWidth: 0 }).trimEnd(),
|
|
400
|
+
"",
|
|
401
|
+
].join("\n");
|
|
402
|
+
}
|
|
403
|
+
function scenariosForTerm(plan, term, manifestFiles) {
|
|
404
|
+
const normalizedTerm = normalizeToken(term);
|
|
405
|
+
const scenario = plan.domainLanguage.scenarios.find((item) => {
|
|
406
|
+
const normalizedTitle = normalizeToken(item.title);
|
|
407
|
+
return normalizedTitle === normalizedTerm || normalizedTitle.startsWith(`${normalizedTerm}-`) || normalizedTitle.includes(normalizedTerm);
|
|
408
|
+
});
|
|
409
|
+
if (scenario) {
|
|
410
|
+
return [
|
|
411
|
+
{
|
|
412
|
+
title: scenario.title,
|
|
413
|
+
checks: scenario.checks.length > 0 ? scenario.checks.slice(0, 6) : genericFlowChecks(scenario.title),
|
|
414
|
+
},
|
|
415
|
+
];
|
|
416
|
+
}
|
|
417
|
+
const localFiles = manifestFiles.map((file) => localRelativeFile(plan, file));
|
|
418
|
+
const flow = bestFlowForFiles(plan.flows, localFiles);
|
|
419
|
+
return [
|
|
420
|
+
{
|
|
421
|
+
title: `${term} primary journey`,
|
|
422
|
+
checks: flow ? checksForFlow(flow) : genericFlowChecks(term),
|
|
423
|
+
},
|
|
424
|
+
];
|
|
425
|
+
}
|
|
426
|
+
function routesForScenario(scenario) {
|
|
427
|
+
if (!scenario || typeof scenario !== "object" || !("routes" in scenario)) {
|
|
428
|
+
return [];
|
|
429
|
+
}
|
|
430
|
+
const routes = scenario.routes;
|
|
431
|
+
return Array.isArray(routes) ? routes.filter((route) => typeof route === "string") : [];
|
|
432
|
+
}
|
|
433
|
+
function checksForFlow(flow) {
|
|
434
|
+
const checks = uniqueStrings([
|
|
435
|
+
...flow.steps,
|
|
436
|
+
...flow.coverage.filter((target) => target.priority === "critical").flatMap((target) => target.checks),
|
|
437
|
+
]);
|
|
438
|
+
return checks.length > 0 ? checks.slice(0, 6) : genericFlowChecks(flow.title);
|
|
439
|
+
}
|
|
440
|
+
function genericFlowChecks(subject) {
|
|
441
|
+
return [
|
|
442
|
+
`Start from the normal entry point for ${subject}.`,
|
|
443
|
+
`Complete the main ${subject} action with realistic data.`,
|
|
444
|
+
`Confirm the visible result, saved state, navigation, or emitted event.`,
|
|
445
|
+
`Try one empty, blocked, rejected, or failed ${subject} path that a real user could hit.`,
|
|
446
|
+
];
|
|
447
|
+
}
|
|
448
|
+
function priorityForFlow(title, flow) {
|
|
449
|
+
const text = `${title}\n${flow?.files.join("\n") ?? ""}`.toLowerCase();
|
|
450
|
+
if (/(checkout|billing|payment|purchase|subscription|auth|login|signup|permission|security|settlement)/.test(text)) {
|
|
451
|
+
return "critical";
|
|
452
|
+
}
|
|
453
|
+
if (flow?.coverage.some((target) => target.priority === "critical")) {
|
|
454
|
+
return "recommended";
|
|
455
|
+
}
|
|
456
|
+
return "optional";
|
|
457
|
+
}
|
|
458
|
+
function routeHintsForFiles(plan, files) {
|
|
459
|
+
const routes = new Set();
|
|
460
|
+
const relatedFlows = plan.flows.filter((flow) => overlaps(flow.files, files));
|
|
461
|
+
for (const flow of relatedFlows) {
|
|
462
|
+
for (const entrypoint of flow.entrypoints) {
|
|
463
|
+
if (entrypoint.kind === "route") {
|
|
464
|
+
routes.add(entrypoint.value);
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
for (const file of files) {
|
|
469
|
+
const route = routeFromFile(file);
|
|
470
|
+
if (route) {
|
|
471
|
+
routes.add(route);
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
return [...routes].slice(0, 6);
|
|
475
|
+
}
|
|
476
|
+
function bestFlowForFiles(flows, files) {
|
|
477
|
+
let best;
|
|
478
|
+
for (const flow of flows) {
|
|
479
|
+
const score = overlapScore(flow.files, files);
|
|
480
|
+
if (!best || score > best.score) {
|
|
481
|
+
best = { flow, score };
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
return best && best.score > 0 ? best.flow : flows[0];
|
|
485
|
+
}
|
|
486
|
+
function domainIdsForFiles(files, domains) {
|
|
487
|
+
return domains
|
|
488
|
+
.filter((domain) => files.some((file) => domain.files.some((pattern) => fileMatchesPattern(file, pattern))) ||
|
|
489
|
+
files.some((file) => normalizeToken(file).includes(domain.id)))
|
|
490
|
+
.map((domain) => domain.id)
|
|
491
|
+
.slice(0, 4);
|
|
492
|
+
}
|
|
493
|
+
function dedupeDomains(domains) {
|
|
494
|
+
const seen = new Set();
|
|
495
|
+
const result = [];
|
|
496
|
+
for (const domain of domains) {
|
|
497
|
+
if (seen.has(domain.id)) {
|
|
498
|
+
continue;
|
|
499
|
+
}
|
|
500
|
+
seen.add(domain.id);
|
|
501
|
+
result.push(domain);
|
|
502
|
+
}
|
|
503
|
+
return result;
|
|
504
|
+
}
|
|
505
|
+
function dedupeFlows(flows) {
|
|
506
|
+
const seen = new Set();
|
|
507
|
+
const result = [];
|
|
508
|
+
for (const flow of flows) {
|
|
509
|
+
if (seen.has(flow.id)) {
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
seen.add(flow.id);
|
|
513
|
+
result.push(flow);
|
|
514
|
+
}
|
|
515
|
+
return result;
|
|
516
|
+
}
|
|
517
|
+
function domainFilePattern(file) {
|
|
518
|
+
const segments = file.split("/");
|
|
519
|
+
const anchors = ["features", "domains", "modules", "pages", "app", "routes", "screens", "services", "entities", "packages", "apps"];
|
|
520
|
+
for (const anchor of anchors) {
|
|
521
|
+
const index = segments.lastIndexOf(anchor);
|
|
522
|
+
if (index >= 0) {
|
|
523
|
+
const nextSegment = meaningfulSegmentAfter(segments, index);
|
|
524
|
+
if (nextSegment) {
|
|
525
|
+
const nextIndex = segments.indexOf(nextSegment, index + 1);
|
|
526
|
+
return `${segments.slice(0, nextIndex + 1).join("/")}/**`;
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
const directory = path.posix.dirname(file);
|
|
531
|
+
return directory === "." || directory === "src" ? file : `${directory}/**`;
|
|
532
|
+
}
|
|
533
|
+
function meaningfulSegmentAfter(segments, index) {
|
|
534
|
+
for (const segment of segments.slice(index + 1)) {
|
|
535
|
+
const normalized = normalizeToken(segment.replace(/\.[^.]+$/g, ""));
|
|
536
|
+
if (normalized && !ignoredPathSegments.has(normalized) && !/^\[.+\]$/.test(segment) && !/^\(.+\)$/.test(segment)) {
|
|
537
|
+
return segment;
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
return undefined;
|
|
541
|
+
}
|
|
542
|
+
function routeFromFile(file) {
|
|
543
|
+
const segments = file.split("/");
|
|
544
|
+
const rootIndex = Math.max(segments.lastIndexOf("app"), segments.lastIndexOf("pages"), segments.lastIndexOf("routes"));
|
|
545
|
+
if (rootIndex < 0) {
|
|
546
|
+
return undefined;
|
|
547
|
+
}
|
|
548
|
+
const routeSegments = segments.slice(rootIndex + 1).map(normalizeRouteSegment).filter(Boolean);
|
|
549
|
+
const visibleSegments = routeSegments.filter((segment) => !["index", "page", "route", "layout"].includes(segment));
|
|
550
|
+
return visibleSegments.length > 0 ? `/${visibleSegments.join("/")}` : "/";
|
|
551
|
+
}
|
|
552
|
+
function normalizeRouteSegment(segment) {
|
|
553
|
+
const stem = segment
|
|
554
|
+
.replace(/\.(?:d\.)?(?:[cm]?[jt]sx?|vue|svelte|mdx?)$/i, "")
|
|
555
|
+
.replace(/Page$/i, "");
|
|
556
|
+
const dynamic = stem.match(/^\[([^[\].]+)\]$/)?.[1];
|
|
557
|
+
if (dynamic) {
|
|
558
|
+
return `:${normalizeRouteParam(dynamic)}`;
|
|
559
|
+
}
|
|
560
|
+
if (/^\([^)]*\)$/.test(stem) || stem.startsWith("_")) {
|
|
561
|
+
return "";
|
|
562
|
+
}
|
|
563
|
+
return slugify(stem);
|
|
564
|
+
}
|
|
565
|
+
function normalizeRouteParam(value) {
|
|
566
|
+
const normalized = value.replace(/[^A-Za-z0-9_$]+/g, "");
|
|
567
|
+
return normalized || "param";
|
|
568
|
+
}
|
|
569
|
+
function overlaps(left, right) {
|
|
570
|
+
return overlapScore(left, right) > 0;
|
|
571
|
+
}
|
|
572
|
+
function overlapScore(left, right) {
|
|
573
|
+
let score = 0;
|
|
574
|
+
for (const leftFile of left) {
|
|
575
|
+
for (const rightFile of right) {
|
|
576
|
+
if (leftFile === rightFile || leftFile.endsWith(`/${rightFile}`) || rightFile.endsWith(`/${leftFile}`)) {
|
|
577
|
+
score += 3;
|
|
578
|
+
}
|
|
579
|
+
else if (path.posix.dirname(leftFile) === path.posix.dirname(rightFile)) {
|
|
580
|
+
score += 1;
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
}
|
|
584
|
+
return score;
|
|
585
|
+
}
|
|
586
|
+
function manifestRelativeFile(plan, file) {
|
|
587
|
+
if (!plan.workspaceRoot) {
|
|
588
|
+
return file;
|
|
589
|
+
}
|
|
590
|
+
const packagePrefix = toPosixPath(path.relative(plan.workspaceRoot, plan.root));
|
|
591
|
+
if (!packagePrefix || packagePrefix.startsWith("..") || path.isAbsolute(packagePrefix)) {
|
|
592
|
+
return file;
|
|
593
|
+
}
|
|
594
|
+
return toPosixPath(path.posix.join(packagePrefix, file));
|
|
595
|
+
}
|
|
596
|
+
function localRelativeFile(plan, manifestFile) {
|
|
597
|
+
if (!plan.workspaceRoot) {
|
|
598
|
+
return manifestFile;
|
|
599
|
+
}
|
|
600
|
+
const packagePrefix = toPosixPath(path.relative(plan.workspaceRoot, plan.root));
|
|
601
|
+
if (!packagePrefix || packagePrefix.startsWith("..") || path.isAbsolute(packagePrefix)) {
|
|
602
|
+
return manifestFile;
|
|
603
|
+
}
|
|
604
|
+
return manifestFile.startsWith(`${packagePrefix}/`) ? manifestFile.slice(packagePrefix.length + 1) : manifestFile;
|
|
605
|
+
}
|
|
606
|
+
function fileMatchesPattern(file, pattern) {
|
|
607
|
+
const prefix = pattern.endsWith("/**") ? pattern.slice(0, -3) : pattern;
|
|
608
|
+
return file === prefix || file.startsWith(`${prefix}/`);
|
|
609
|
+
}
|
|
610
|
+
function durableSubject(title) {
|
|
611
|
+
const cleaned = title
|
|
612
|
+
.replace(/\b(?:ui smoke flow|api contract smoke checklist|api contract smoke flow|state transition flow|workflow smoke checklist|workflow smoke flow|configuration verification checklist|configuration verification flow|content and theme smoke flow)\b/gi, "")
|
|
613
|
+
.trim();
|
|
614
|
+
return cleaned.length > 1 ? titleCase(cleaned) : undefined;
|
|
615
|
+
}
|
|
616
|
+
function isBehaviorFile(file) {
|
|
617
|
+
return (!/(?:^|\/)(?:__tests__|tests?|specs?|e2e)\//i.test(file) &&
|
|
618
|
+
!/(?:\.|-)(?:test|spec)\.[cm]?[jt]sx?$/i.test(file) &&
|
|
619
|
+
!/(?:^|\/)(?:docs?|\.github)\//i.test(file) &&
|
|
620
|
+
!/(?:^|\/)(?:README|CHANGELOG|LICENSE|CONTRIBUTING|CODE_OF_CONDUCT|SECURITY)\.md$/i.test(file) &&
|
|
621
|
+
!/(?:^|\/)(?:package|tsconfig|qamap\.config)\.json$/i.test(file));
|
|
622
|
+
}
|
|
623
|
+
function slugify(value) {
|
|
624
|
+
const slug = value
|
|
625
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1-$2")
|
|
626
|
+
.toLowerCase()
|
|
627
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
628
|
+
.replace(/^-+|-+$/g, "");
|
|
629
|
+
return slug || "suggested-flow";
|
|
630
|
+
}
|
|
631
|
+
function normalizeToken(value) {
|
|
632
|
+
return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
|
|
633
|
+
}
|
|
634
|
+
function titleCase(value) {
|
|
635
|
+
return value
|
|
636
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
637
|
+
.replace(/[-_]+/g, " ")
|
|
638
|
+
.replace(/[^a-zA-Z0-9 ]+/g, " ")
|
|
639
|
+
.trim()
|
|
640
|
+
.split(/\s+/)
|
|
641
|
+
.filter(Boolean)
|
|
642
|
+
.map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
|
|
643
|
+
.join(" ");
|
|
644
|
+
}
|
|
645
|
+
function uniqueStrings(values) {
|
|
646
|
+
return [...new Set(values.filter(Boolean))];
|
|
647
|
+
}
|
|
648
|
+
function escapeMarkdownInline(value) {
|
|
649
|
+
return value.replaceAll("`", "'");
|
|
650
|
+
}
|
|
651
|
+
function escapeMarkdownTableCell(value) {
|
|
652
|
+
return escapeMarkdownInline(value).replaceAll("|", "\\|").replace(/\r?\n/g, " ");
|
|
653
|
+
}
|
|
654
|
+
const ignoredPathSegments = new Set([
|
|
655
|
+
"api",
|
|
656
|
+
"app",
|
|
657
|
+
"apps",
|
|
658
|
+
"component",
|
|
659
|
+
"components",
|
|
660
|
+
"index",
|
|
661
|
+
"layout",
|
|
662
|
+
"page",
|
|
663
|
+
"pages",
|
|
664
|
+
"route",
|
|
665
|
+
"routes",
|
|
666
|
+
"screen",
|
|
667
|
+
"screens",
|
|
668
|
+
"src",
|
|
669
|
+
"ui",
|
|
670
|
+
]);
|
|
671
|
+
export const defaultSuggestedDomainManifestPath = defaultDomainManifestPath;
|
|
672
|
+
export const defaultSuggestedFlowManifestPath = defaultFlowManifestPath;
|
|
673
|
+
//# sourceMappingURL=manifest-suggestions.js.map
|