@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.
Files changed (98) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/LICENSE +21 -0
  3. package/README.md +636 -0
  4. package/dist/cli.d.ts +2 -0
  5. package/dist/cli.js +879 -0
  6. package/dist/cli.js.map +1 -0
  7. package/dist/config.d.ts +5 -0
  8. package/dist/config.js +114 -0
  9. package/dist/config.js.map +1 -0
  10. package/dist/context.d.ts +1 -0
  11. package/dist/context.js +126 -0
  12. package/dist/context.js.map +1 -0
  13. package/dist/doctor.d.ts +32 -0
  14. package/dist/doctor.js +200 -0
  15. package/dist/doctor.js.map +1 -0
  16. package/dist/domain-language.d.ts +25 -0
  17. package/dist/domain-language.js +655 -0
  18. package/dist/domain-language.js.map +1 -0
  19. package/dist/domains.d.ts +33 -0
  20. package/dist/domains.js +258 -0
  21. package/dist/domains.js.map +1 -0
  22. package/dist/e2e.d.ts +326 -0
  23. package/dist/e2e.js +6869 -0
  24. package/dist/e2e.js.map +1 -0
  25. package/dist/eval.d.ts +40 -0
  26. package/dist/eval.js +374 -0
  27. package/dist/eval.js.map +1 -0
  28. package/dist/flows.d.ts +32 -0
  29. package/dist/flows.js +246 -0
  30. package/dist/flows.js.map +1 -0
  31. package/dist/fs.d.ts +7 -0
  32. package/dist/fs.js +132 -0
  33. package/dist/fs.js.map +1 -0
  34. package/dist/github.d.ts +34 -0
  35. package/dist/github.js +183 -0
  36. package/dist/github.js.map +1 -0
  37. package/dist/history.d.ts +173 -0
  38. package/dist/history.js +247 -0
  39. package/dist/history.js.map +1 -0
  40. package/dist/index.d.ts +35 -0
  41. package/dist/index.js +20 -0
  42. package/dist/index.js.map +1 -0
  43. package/dist/manifest-suggestions.d.ts +67 -0
  44. package/dist/manifest-suggestions.js +673 -0
  45. package/dist/manifest-suggestions.js.map +1 -0
  46. package/dist/manifest.d.ts +212 -0
  47. package/dist/manifest.js +1607 -0
  48. package/dist/manifest.js.map +1 -0
  49. package/dist/qa.d.ts +53 -0
  50. package/dist/qa.js +361 -0
  51. package/dist/qa.js.map +1 -0
  52. package/dist/report.d.ts +5 -0
  53. package/dist/report.js +164 -0
  54. package/dist/report.js.map +1 -0
  55. package/dist/review.d.ts +31 -0
  56. package/dist/review.js +383 -0
  57. package/dist/review.js.map +1 -0
  58. package/dist/scanner.d.ts +3 -0
  59. package/dist/scanner.js +797 -0
  60. package/dist/scanner.js.map +1 -0
  61. package/dist/severity.d.ts +3 -0
  62. package/dist/severity.js +9 -0
  63. package/dist/severity.js.map +1 -0
  64. package/dist/test-evidence.d.ts +39 -0
  65. package/dist/test-evidence.js +303 -0
  66. package/dist/test-evidence.js.map +1 -0
  67. package/dist/test-plan.d.ts +35 -0
  68. package/dist/test-plan.js +573 -0
  69. package/dist/test-plan.js.map +1 -0
  70. package/dist/types.d.ts +60 -0
  71. package/dist/types.js +2 -0
  72. package/dist/types.js.map +1 -0
  73. package/dist/verify.d.ts +27 -0
  74. package/dist/verify.js +244 -0
  75. package/dist/verify.js.map +1 -0
  76. package/dist/version.d.ts +2 -0
  77. package/dist/version.js +3 -0
  78. package/dist/version.js.map +1 -0
  79. package/docs/adoption.md +166 -0
  80. package/docs/agent-skill.md +94 -0
  81. package/docs/api-contracts.md +30 -0
  82. package/docs/assets/qamap-30s-demo.gif +0 -0
  83. package/docs/configuration.md +277 -0
  84. package/docs/e2e-output-examples.md +464 -0
  85. package/docs/ecosystem.md +41 -0
  86. package/docs/eval.md +52 -0
  87. package/docs/github-action.md +89 -0
  88. package/docs/manifest.md +350 -0
  89. package/docs/quickstart-demo.md +268 -0
  90. package/docs/release-validation.md +247 -0
  91. package/docs/releasing.md +112 -0
  92. package/docs/roadmap.md +67 -0
  93. package/docs/rules.md +28 -0
  94. package/docs/verify.md +44 -0
  95. package/package.json +80 -0
  96. package/schema/qamap-manifest.schema.json +323 -0
  97. package/schema/qamap.schema.json +58 -0
  98. package/skills/qamap-pr-qa/SKILL.md +81 -0
@@ -0,0 +1,1607 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import YAML from "yaml";
4
+ import { collectProjectFiles, pathExists, toPosixPath } from "./fs.js";
5
+ import { generateTestPlan } from "./test-plan.js";
6
+ import { TOOL_NAME, VERSION } from "./version.js";
7
+ export const defaultVerificationManifestPath = ".qamap/manifest.yaml";
8
+ export const verificationManifestSchemaUrl = "https://raw.githubusercontent.com/IvoryCanvas/qamap/main/schema/qamap-manifest.schema.json";
9
+ const manifestCandidates = [
10
+ ".qamap/manifest.yaml",
11
+ ".qamap/manifest.yml",
12
+ ".qamap/manifest.json",
13
+ ];
14
+ const defaultMaxManifestFiles = 2500;
15
+ export async function loadVerificationManifest(rootInput, options = {}) {
16
+ const root = path.resolve(rootInput);
17
+ const manifestPath = options.manifestPath ? path.resolve(options.manifestPath) : await findVerificationManifestPath(root);
18
+ if (!manifestPath) {
19
+ return { version: 1, domains: [], flows: [] };
20
+ }
21
+ const raw = await fs.readFile(manifestPath, "utf8");
22
+ const parsed = parseVerificationManifest(raw, manifestPath);
23
+ return {
24
+ path: displayManifestPath(root, manifestPath),
25
+ ...normalizeVerificationManifest(parsed, manifestPath),
26
+ };
27
+ }
28
+ export async function writeVerificationManifestBaseline(rootInput, options = {}) {
29
+ const root = path.resolve(rootInput);
30
+ const manifestRoot = path.resolve(options.workspaceRoot ?? rootInput);
31
+ const outputPath = path.resolve(manifestRoot, options.write ?? defaultVerificationManifestPath);
32
+ if (!options.force && (await pathExists(outputPath))) {
33
+ throw new Error(`Refusing to overwrite ${outputPath}. Pass --force to replace it.`);
34
+ }
35
+ const files = await collectProjectFiles(root, options.maxFiles ?? defaultMaxManifestFiles);
36
+ const manifest = buildVerificationManifestBaseline(root, manifestRoot, files);
37
+ await fs.mkdir(path.dirname(outputPath), { recursive: true });
38
+ await fs.writeFile(outputPath, formatVerificationManifestYaml(manifest), "utf8");
39
+ return {
40
+ tool: {
41
+ name: TOOL_NAME,
42
+ version: VERSION,
43
+ },
44
+ root,
45
+ workspaceRoot: options.workspaceRoot ? manifestRoot : undefined,
46
+ manifestRoot,
47
+ path: outputPath,
48
+ generatedAt: new Date().toISOString(),
49
+ manifest,
50
+ summary: summarizeManifest(manifest),
51
+ };
52
+ }
53
+ export function matchVerificationManifest(manifest, changedFiles) {
54
+ if (!manifest.path || changedFiles.length === 0) {
55
+ return [];
56
+ }
57
+ const filePaths = changedFiles.map((file) => file.path);
58
+ const matches = [];
59
+ for (const domain of manifest.domains) {
60
+ const matchedFiles = filePaths.filter((file) => domain.paths.some((pattern) => matchesPathPattern(file, pattern)));
61
+ if (matchedFiles.length === 0) {
62
+ continue;
63
+ }
64
+ matches.push({
65
+ kind: "domain",
66
+ id: domain.id,
67
+ name: domain.name,
68
+ manifestPath: `${manifest.path} > domains.${domain.id}.paths`,
69
+ updatePath: `${manifest.path} > domains.${domain.id}.paths`,
70
+ reason: `Changed files match the manifest ${domain.name} domain paths.`,
71
+ evidenceSources: domain.source.from,
72
+ nextActions: domainNextActions(domain),
73
+ repairHints: domainRepairHints(manifest.path, domain),
74
+ matchedFiles: matchedFiles.slice(0, 12),
75
+ confidence: domain.source.confidence,
76
+ criticality: domain.criticality,
77
+ });
78
+ }
79
+ for (const flow of manifest.flows) {
80
+ const anchorMatches = matchFlowAnchors(flow, filePaths);
81
+ if (anchorMatches.length === 0) {
82
+ continue;
83
+ }
84
+ matches.push({
85
+ kind: "flow",
86
+ id: flow.id,
87
+ name: flow.name,
88
+ manifestPath: `${manifest.path} > flows.${flow.id}.anchors`,
89
+ updatePath: `${manifest.path} > flows.${flow.id}.anchors`,
90
+ reason: `Changed files match anchors for the ${flow.name} flow.`,
91
+ evidenceSources: flow.source.from,
92
+ nextActions: flowNextActions(flow),
93
+ repairHints: flowRepairHints(manifest.path, flow),
94
+ matchedFiles: anchorMatches.slice(0, 12),
95
+ confidence: flow.source.confidence,
96
+ runner: flow.runner,
97
+ entryRoute: flow.entry?.route,
98
+ checks: flow.checks.map((check) => check.title),
99
+ });
100
+ for (const check of flow.checks.slice(0, 4)) {
101
+ matches.push({
102
+ kind: "check",
103
+ id: `${flow.id}.${check.id}`,
104
+ name: check.title,
105
+ manifestPath: `${manifest.path} > flows.${flow.id}.checks.${check.id}`,
106
+ updatePath: `${manifest.path} > flows.${flow.id}.checks`,
107
+ reason: `The ${flow.name} flow declares this ${check.type} verification check.`,
108
+ evidenceSources: flow.source.from,
109
+ nextActions: checkNextActions(flow, check),
110
+ repairHints: checkRepairHints(manifest.path, flow, check),
111
+ matchedFiles: anchorMatches.slice(0, 12),
112
+ confidence: flow.source.confidence,
113
+ runner: flow.runner,
114
+ entryRoute: flow.entry?.route,
115
+ checks: [check.title],
116
+ checkType: check.type,
117
+ checkSelector: check.selector,
118
+ checkValue: check.value,
119
+ checkSteps: check.steps,
120
+ });
121
+ }
122
+ }
123
+ return dedupeManifestMatches(matches).slice(0, 18);
124
+ }
125
+ export function changedFilesRelativeToManifestRoot(changedFiles, rootInput, manifestRootInput) {
126
+ const root = path.resolve(rootInput);
127
+ const manifestRoot = path.resolve(manifestRootInput);
128
+ if (root === manifestRoot) {
129
+ return changedFiles;
130
+ }
131
+ return changedFiles.map((file) => ({
132
+ ...file,
133
+ path: toPosixPath(path.relative(manifestRoot, path.join(root, file.path))),
134
+ }));
135
+ }
136
+ export function formatVerificationManifestInitResult(result) {
137
+ return [
138
+ `Wrote ${result.path}`,
139
+ `Domains: ${result.summary.domains}`,
140
+ `Flows: ${result.summary.flows}`,
141
+ `Anchors: ${result.summary.anchors}`,
142
+ `Checks: ${result.summary.checks}`,
143
+ `Context sources: ${result.summary.contextSources}`,
144
+ `Validation commands: ${result.summary.validationCommands}`,
145
+ `Safety rules: ${result.summary.safetyRules}`,
146
+ "Review and commit this file when the baseline should become team verification policy.",
147
+ ].join("\n");
148
+ }
149
+ export async function validateVerificationManifest(rootInput, workspaceRootInput, manifestPathInput) {
150
+ const root = path.resolve(rootInput);
151
+ const manifestRoot = path.resolve(workspaceRootInput ?? rootInput);
152
+ const issues = [];
153
+ let manifest;
154
+ try {
155
+ manifest = await loadVerificationManifest(manifestRoot, { manifestPath: manifestPathInput });
156
+ }
157
+ catch (error) {
158
+ const message = error instanceof Error ? error.message : String(error);
159
+ issues.push(issue("error", defaultVerificationManifestPath, message, "Fix the manifest syntax or schema, then run `qamap manifest validate` again."));
160
+ return validationResult(root, workspaceRootInput ? manifestRoot : undefined, undefined, "invalid", issues);
161
+ }
162
+ if (!manifest.path) {
163
+ issues.push(issue("error", defaultVerificationManifestPath, "No verification manifest was found.", "Run `qamap manifest init .` to create a baseline."));
164
+ return validationResult(root, workspaceRootInput ? manifestRoot : undefined, undefined, "missing", issues);
165
+ }
166
+ validateDomainDefinitions(manifest, issues);
167
+ validateManifestMetadata(manifest, issues);
168
+ await validateManifestContext(manifest, manifestRoot, issues);
169
+ await validateFlowDefinitions(manifest, manifestRoot, issues);
170
+ const status = issues.some((item) => item.severity === "error")
171
+ ? "invalid"
172
+ : issues.some((item) => item.severity === "warning")
173
+ ? "needs-work"
174
+ : "valid";
175
+ return validationResult(root, workspaceRootInput ? manifestRoot : undefined, manifest.path, status, issues);
176
+ }
177
+ export async function explainVerificationManifest(rootInput, options = {}) {
178
+ const testPlan = await generateTestPlan(rootInput, options);
179
+ const manifestRoot = testPlan.workspaceRoot ?? testPlan.root;
180
+ const manifest = await loadVerificationManifest(manifestRoot, { manifestPath: options.manifestPath });
181
+ const manifestChangedFiles = changedFilesRelativeToManifestRoot(testPlan.changedFiles, testPlan.root, manifestRoot);
182
+ return {
183
+ tool: {
184
+ name: TOOL_NAME,
185
+ version: VERSION,
186
+ },
187
+ root: testPlan.root,
188
+ workspaceRoot: testPlan.workspaceRoot,
189
+ manifestPath: manifest.path,
190
+ generatedAt: new Date().toISOString(),
191
+ base: testPlan.base,
192
+ head: testPlan.head,
193
+ includeWorkingTree: testPlan.includeWorkingTree,
194
+ changedFiles: testPlan.changedFiles,
195
+ matches: matchVerificationManifest(manifest, manifestChangedFiles),
196
+ };
197
+ }
198
+ export async function analyzeVerificationManifestContext(rootInput, options = {}) {
199
+ const root = path.resolve(rootInput);
200
+ const manifestRoot = path.resolve(options.workspaceRoot ?? rootInput);
201
+ const manifest = await loadVerificationManifest(manifestRoot);
202
+ const files = await collectProjectFiles(root, options.maxFiles ?? defaultMaxManifestFiles);
203
+ const scannedContext = buildManifestContext(root, manifestRoot, files);
204
+ const context = manifest.context ?? scannedContext;
205
+ const roleSummary = summarizeContextRoles(context);
206
+ const diagnostics = await buildContextDiagnostics(manifest, context, scannedContext, manifestRoot);
207
+ return {
208
+ tool: {
209
+ name: TOOL_NAME,
210
+ version: VERSION,
211
+ },
212
+ root,
213
+ workspaceRoot: options.workspaceRoot ? manifestRoot : undefined,
214
+ manifestRoot,
215
+ manifestPath: manifest.path,
216
+ generatedAt: new Date().toISOString(),
217
+ context,
218
+ roleSummary,
219
+ diagnostics,
220
+ summary: {
221
+ contextSources: context?.instructionFiles.length ?? 0,
222
+ roles: roleSummary.length,
223
+ validationCommands: context?.validationCommands.length ?? 0,
224
+ safetyRules: context?.safetyRules.length ?? 0,
225
+ diagnostics: diagnostics.length,
226
+ },
227
+ };
228
+ }
229
+ export function formatVerificationManifestValidationResult(result, format) {
230
+ if (format === "json") {
231
+ return `${JSON.stringify(result, null, 2)}\n`;
232
+ }
233
+ const lines = [];
234
+ if (format === "markdown") {
235
+ lines.push("# QAMap Manifest Validate", "");
236
+ lines.push(`- Root: \`${escapeMarkdownInline(result.root)}\``);
237
+ if (result.workspaceRoot) {
238
+ lines.push(`- Workspace root: \`${escapeMarkdownInline(result.workspaceRoot)}\``);
239
+ }
240
+ lines.push(`- Status: ${result.status}`);
241
+ lines.push(`- Manifest: ${result.manifestPath ? `\`${escapeMarkdownInline(result.manifestPath)}\`` : "not found"}`);
242
+ lines.push(`- Issues: ${result.summary.errors} errors, ${result.summary.warnings} warnings, ${result.summary.info} info`);
243
+ lines.push("");
244
+ if (result.issues.length > 0) {
245
+ lines.push("## Issues", "");
246
+ for (const item of result.issues) {
247
+ lines.push(`- [${item.severity}] \`${escapeMarkdownInline(item.path)}\`: ${escapeMarkdownInline(item.message)}`);
248
+ lines.push(` - Fix: ${escapeMarkdownInline(item.recommendation)}`);
249
+ }
250
+ lines.push("");
251
+ }
252
+ return lines.join("\n");
253
+ }
254
+ lines.push("QAMap Manifest Validate");
255
+ lines.push(`Root: ${result.root}`);
256
+ if (result.workspaceRoot) {
257
+ lines.push(`Workspace root: ${result.workspaceRoot}`);
258
+ }
259
+ lines.push(`Status: ${result.status}`);
260
+ lines.push(`Manifest: ${result.manifestPath ?? "not found"}`);
261
+ lines.push(`Issues: ${result.summary.errors} errors, ${result.summary.warnings} warnings, ${result.summary.info} info`);
262
+ for (const item of result.issues) {
263
+ lines.push(`- [${item.severity}] ${item.path}: ${item.message}`);
264
+ lines.push(` Fix: ${item.recommendation}`);
265
+ }
266
+ return `${lines.join("\n")}\n`;
267
+ }
268
+ export function formatVerificationManifestExplainResult(result, format) {
269
+ if (format === "json") {
270
+ return `${JSON.stringify(result, null, 2)}\n`;
271
+ }
272
+ const lines = [];
273
+ if (format === "markdown") {
274
+ lines.push("# QAMap Manifest Explain", "");
275
+ lines.push(`- Root: \`${escapeMarkdownInline(result.root)}\``);
276
+ if (result.workspaceRoot) {
277
+ lines.push(`- Workspace root: \`${escapeMarkdownInline(result.workspaceRoot)}\``);
278
+ }
279
+ lines.push(`- Base: \`${escapeMarkdownInline(result.base)}\``);
280
+ lines.push(`- Head: \`${escapeMarkdownInline(result.head)}\``);
281
+ lines.push(`- Manifest: ${result.manifestPath ? `\`${escapeMarkdownInline(result.manifestPath)}\`` : "not found"}`);
282
+ lines.push(`- Changed files: ${result.changedFiles.length}`);
283
+ lines.push(`- Matches: ${result.matches.length}`);
284
+ lines.push("");
285
+ appendExplainMatches(lines, result.matches, "markdown");
286
+ return lines.join("\n");
287
+ }
288
+ lines.push("QAMap Manifest Explain");
289
+ lines.push(`Root: ${result.root}`);
290
+ if (result.workspaceRoot) {
291
+ lines.push(`Workspace root: ${result.workspaceRoot}`);
292
+ }
293
+ lines.push(`Base: ${result.base}`);
294
+ lines.push(`Head: ${result.head}`);
295
+ lines.push(`Manifest: ${result.manifestPath ?? "not found"}`);
296
+ lines.push(`Changed files: ${result.changedFiles.length}`);
297
+ lines.push(`Matches: ${result.matches.length}`);
298
+ appendExplainMatches(lines, result.matches, "text");
299
+ return `${lines.join("\n")}\n`;
300
+ }
301
+ export function formatVerificationManifestContextResult(result, format) {
302
+ if (format === "json") {
303
+ return `${JSON.stringify(result, null, 2)}\n`;
304
+ }
305
+ const context = result.context;
306
+ const lines = [];
307
+ if (format === "markdown") {
308
+ lines.push("# QAMap Manifest Context", "");
309
+ lines.push(`- Root: \`${escapeMarkdownInline(result.root)}\``);
310
+ if (result.workspaceRoot) {
311
+ lines.push(`- Workspace root: \`${escapeMarkdownInline(result.workspaceRoot)}\``);
312
+ }
313
+ lines.push(`- Manifest root: \`${escapeMarkdownInline(result.manifestRoot)}\``);
314
+ lines.push(`- Manifest: ${result.manifestPath ? `\`${escapeMarkdownInline(result.manifestPath)}\`` : "not found"}`);
315
+ lines.push(`- Context sources: ${result.summary.contextSources}`);
316
+ lines.push(`- Roles: ${result.summary.roles}`);
317
+ lines.push(`- Validation commands: ${result.summary.validationCommands}`);
318
+ lines.push(`- Safety rules: ${result.summary.safetyRules}`);
319
+ lines.push(`- Diagnostics: ${result.summary.diagnostics}`);
320
+ lines.push("");
321
+ if (!context) {
322
+ lines.push("No repo-local context sources were found.", "");
323
+ appendContextDiagnostics(lines, result.diagnostics, "markdown");
324
+ return lines.join("\n");
325
+ }
326
+ appendContextRoleSummary(lines, result.roleSummary, "markdown");
327
+ appendContextSources(lines, context.instructionFiles, "markdown");
328
+ appendContextValues(lines, "Validation Commands", context.validationCommands, "markdown");
329
+ appendContextValues(lines, "Safety Rules", context.safetyRules, "markdown");
330
+ appendContextDiagnostics(lines, result.diagnostics, "markdown");
331
+ return lines.join("\n");
332
+ }
333
+ lines.push("QAMap Manifest Context");
334
+ lines.push(`Root: ${result.root}`);
335
+ if (result.workspaceRoot) {
336
+ lines.push(`Workspace root: ${result.workspaceRoot}`);
337
+ }
338
+ lines.push(`Manifest root: ${result.manifestRoot}`);
339
+ lines.push(`Manifest: ${result.manifestPath ?? "not found"}`);
340
+ lines.push(`Context sources: ${result.summary.contextSources}`);
341
+ lines.push(`Roles: ${result.summary.roles}`);
342
+ lines.push(`Validation commands: ${result.summary.validationCommands}`);
343
+ lines.push(`Safety rules: ${result.summary.safetyRules}`);
344
+ lines.push(`Diagnostics: ${result.summary.diagnostics}`);
345
+ lines.push("");
346
+ if (!context) {
347
+ lines.push("No repo-local context sources were found.");
348
+ appendContextDiagnostics(lines, result.diagnostics, "text");
349
+ return `${lines.join("\n")}\n`;
350
+ }
351
+ appendContextRoleSummary(lines, result.roleSummary, "text");
352
+ appendContextSources(lines, context.instructionFiles, "text");
353
+ appendContextValues(lines, "Validation Commands", context.validationCommands, "text");
354
+ appendContextValues(lines, "Safety Rules", context.safetyRules, "text");
355
+ appendContextDiagnostics(lines, result.diagnostics, "text");
356
+ return `${lines.join("\n")}\n`;
357
+ }
358
+ export function formatVerificationManifestYaml(manifest) {
359
+ return `${YAML.stringify(manifest, { lineWidth: 100 }).trimEnd()}\n`;
360
+ }
361
+ function buildVerificationManifestBaseline(root, manifestRoot, files) {
362
+ const context = buildManifestContext(root, manifestRoot, files);
363
+ const behaviorFiles = files
364
+ .filter((file) => isBehaviorFile(file.path))
365
+ .map((file) => ({
366
+ ...file,
367
+ path: toPosixPath(path.relative(manifestRoot, path.join(root, file.path))),
368
+ }))
369
+ .filter((file) => !file.path.startsWith("../"));
370
+ const domains = buildBaselineDomains(behaviorFiles, context).slice(0, 12);
371
+ const flows = buildBaselineFlows(behaviorFiles, domains, inferRunner(files), context).slice(0, 16);
372
+ return {
373
+ $schema: verificationManifestSchemaUrl,
374
+ version: 1,
375
+ ...(context ? { context } : {}),
376
+ domains,
377
+ flows,
378
+ };
379
+ }
380
+ function validateManifestMetadata(manifest, issues) {
381
+ if (!manifest.$schema) {
382
+ issues.push(issue("info", `${manifest.path} > $schema`, "Manifest does not declare the QAMap manifest JSON schema.", "Add the `$schema` field generated by `qamap manifest init .` for editor validation."));
383
+ return;
384
+ }
385
+ if (!/schema\/qamap-manifest\.schema\.json$/i.test(manifest.$schema)) {
386
+ issues.push(issue("warning", `${manifest.path} > $schema`, "Manifest points at an unknown schema URL.", "Use the official QAMap manifest schema URL or remove the field if the team intentionally manages validation elsewhere."));
387
+ }
388
+ }
389
+ function validateDomainDefinitions(manifest, issues) {
390
+ if (manifest.domains.length === 0) {
391
+ issues.push(issue("warning", `${manifest.path} > domains`, "No domains are declared.", "Run `qamap manifest init .` or add product domains manually."));
392
+ }
393
+ const ids = new Set();
394
+ for (const domain of manifest.domains) {
395
+ const basePath = `${manifest.path} > domains.${domain.id}`;
396
+ if (ids.has(domain.id)) {
397
+ issues.push(issue("error", basePath, `Duplicate domain id '${domain.id}'.`, "Give each domain a stable unique id."));
398
+ }
399
+ ids.add(domain.id);
400
+ if (domain.paths.length === 0) {
401
+ issues.push(issue("error", `${basePath}.paths`, "Domain has no path patterns.", "Add at least one path pattern so PR changes can map to this domain."));
402
+ }
403
+ if (domain.source.kind === "inferred" && domain.source.confidence === "low") {
404
+ issues.push(issue("info", `${basePath}.source`, "Domain is inferred with low confidence.", "Review the domain name and paths, then mark the source as declared if the team accepts it."));
405
+ }
406
+ }
407
+ }
408
+ async function validateManifestContext(manifest, manifestRoot, issues) {
409
+ if (!manifest.context) {
410
+ return;
411
+ }
412
+ const contextPath = `${manifest.path} > context`;
413
+ if (manifest.context.source.kind === "inferred") {
414
+ issues.push(issue("info", `${contextPath}.source`, "Instruction and runbook context was inferred from repository documents.", "Treat this context as advisory until a human confirms which rules are product verification policy."));
415
+ }
416
+ if (manifest.context.instructionFiles.some((file) => file.confidence === "low")) {
417
+ issues.push(issue("info", `${contextPath}.instructionFiles`, "Some instruction-derived context has low confidence.", "Use it as a hint for manifest refinement, not as product truth, until the team reviews it."));
418
+ }
419
+ const seen = new Set();
420
+ for (const [index, file] of manifest.context.instructionFiles.entries()) {
421
+ const filePath = `${contextPath}.instructionFiles[${index}]`;
422
+ if (seen.has(file.path)) {
423
+ issues.push(issue("warning", filePath, `Duplicate context source '${file.path}'.`, "Keep one context entry per source file."));
424
+ }
425
+ seen.add(file.path);
426
+ if (!(await pathExists(path.join(manifestRoot, file.path)))) {
427
+ issues.push(issue("warning", `${filePath}.path`, `Context source '${file.path}' was not found.`, "Remove stale context sources or regenerate the manifest baseline."));
428
+ }
429
+ }
430
+ }
431
+ async function validateFlowDefinitions(manifest, manifestRoot, issues) {
432
+ if (manifest.flows.length === 0) {
433
+ issues.push(issue("warning", `${manifest.path} > flows`, "No flows are declared.", "Add at least one flow when the repo has user-facing or contract-critical behavior."));
434
+ }
435
+ const domainIds = new Set(manifest.domains.map((domain) => domain.id));
436
+ const flowIds = new Set();
437
+ for (const flow of manifest.flows) {
438
+ const basePath = `${manifest.path} > flows.${flow.id}`;
439
+ if (flowIds.has(flow.id)) {
440
+ issues.push(issue("error", basePath, `Duplicate flow id '${flow.id}'.`, "Give each flow a stable unique id."));
441
+ }
442
+ flowIds.add(flow.id);
443
+ if (flow.domain && !domainIds.has(flow.domain)) {
444
+ issues.push(issue("error", `${basePath}.domain`, `Flow references unknown domain '${flow.domain}'.`, "Use an existing domain id or add the missing domain."));
445
+ }
446
+ if (flow.anchors.length === 0) {
447
+ issues.push(issue("error", `${basePath}.anchors`, "Flow has no anchors.", "Add route, component, file, API, or test anchors so PR changes can match this flow."));
448
+ }
449
+ if (flow.checks.length === 0) {
450
+ issues.push(issue("warning", `${basePath}.checks`, "Flow has no verification checks.", "Add success, failure, edge, contract, or visual checks to shape generated drafts."));
451
+ }
452
+ const checkIds = new Set();
453
+ for (const [index, check] of flow.checks.entries()) {
454
+ const checkPath = `${basePath}.checks[${index}]`;
455
+ if (checkIds.has(check.id)) {
456
+ issues.push(issue("error", `${checkPath}.id`, `Duplicate check id '${check.id}'.`, "Give each check in a flow a stable unique id so generated evidence can map back to one requirement."));
457
+ }
458
+ checkIds.add(check.id);
459
+ if (!check.title.trim()) {
460
+ issues.push(issue("error", `${checkPath}.title`, "Check title is empty.", "Write the behavior the draft must prove, such as `Submit content URL successfully`."));
461
+ }
462
+ }
463
+ const anchorKeys = new Set();
464
+ for (const [index, anchor] of flow.anchors.entries()) {
465
+ const anchorPath = `${basePath}.anchors[${index}]`;
466
+ const anchorKey = `${anchor.kind}:${anchor.path ?? ""}:${anchor.route ?? ""}:${anchor.symbol ?? ""}`;
467
+ if (anchorKeys.has(anchorKey)) {
468
+ issues.push(issue("warning", anchorPath, "Duplicate anchor detected.", "Keep only one anchor per route, path, or symbol so recommendations stay easy to explain."));
469
+ }
470
+ anchorKeys.add(anchorKey);
471
+ if (!anchor.path && !anchor.route && !anchor.symbol) {
472
+ issues.push(issue("error", anchorPath, "Anchor has no path, route, or symbol.", "Add a matchable path, route, or symbol."));
473
+ }
474
+ if (anchor.path && !anchor.path.includes("*") && !(await pathExists(path.join(manifestRoot, anchor.path)))) {
475
+ issues.push(issue("warning", `${anchorPath}.path`, `Anchor path '${anchor.path}' was not found.`, "Confirm the path is relative to the manifest root or replace it with a glob pattern."));
476
+ }
477
+ if (anchor.route && !anchor.route.startsWith("/")) {
478
+ issues.push(issue("warning", `${anchorPath}.route`, `Route '${anchor.route}' does not start with '/'.`, "Use absolute route hints such as `/checkout`."));
479
+ }
480
+ }
481
+ if (flow.source.kind === "inferred" && flow.source.confidence === "low") {
482
+ issues.push(issue("info", `${basePath}.source`, "Flow is inferred with low confidence.", "Review the flow name, anchors, and checks before relying on it as team policy."));
483
+ }
484
+ }
485
+ }
486
+ function validationResult(root, workspaceRoot, manifestPath, status, issues) {
487
+ return {
488
+ tool: {
489
+ name: TOOL_NAME,
490
+ version: VERSION,
491
+ },
492
+ root,
493
+ workspaceRoot,
494
+ manifestPath,
495
+ generatedAt: new Date().toISOString(),
496
+ status,
497
+ summary: {
498
+ errors: issues.filter((item) => item.severity === "error").length,
499
+ warnings: issues.filter((item) => item.severity === "warning").length,
500
+ info: issues.filter((item) => item.severity === "info").length,
501
+ },
502
+ issues,
503
+ };
504
+ }
505
+ function issue(severity, itemPath, message, recommendation) {
506
+ return {
507
+ severity,
508
+ path: itemPath,
509
+ message,
510
+ recommendation,
511
+ };
512
+ }
513
+ function appendExplainMatches(lines, matches, format) {
514
+ if (matches.length === 0) {
515
+ lines.push(format === "markdown" ? "No manifest matches were found for the changed files." : "No manifest matches were found for the changed files.");
516
+ return;
517
+ }
518
+ if (format === "markdown") {
519
+ lines.push("## Matches", "");
520
+ for (const match of matches) {
521
+ lines.push(`### ${escapeMarkdownInline(match.name)} \`${escapeMarkdownInline(match.id)}\``);
522
+ lines.push("");
523
+ lines.push(`- Kind: ${match.kind}`);
524
+ lines.push(`- Confidence: ${match.confidence}`);
525
+ if (match.criticality) {
526
+ lines.push(`- Criticality: ${match.criticality}`);
527
+ }
528
+ if (match.entryRoute) {
529
+ lines.push(`- Entry route: \`${escapeMarkdownInline(match.entryRoute)}\``);
530
+ }
531
+ lines.push(`- Why this was recommended: ${escapeMarkdownInline(match.reason)}`);
532
+ if (match.evidenceSources.length > 0) {
533
+ lines.push(`- Evidence sources: ${match.evidenceSources.map(escapeMarkdownInline).join(", ")}`);
534
+ }
535
+ lines.push(`- Manifest evidence: \`${escapeMarkdownInline(match.manifestPath)}\``);
536
+ lines.push(`- If this is wrong: update \`${escapeMarkdownInline(match.updatePath)}\``);
537
+ appendGuidanceList(lines, "Next actions", match.nextActions, "markdown");
538
+ appendGuidanceList(lines, "Repair hints", match.repairHints, "markdown");
539
+ if (match.checks && match.checks.length > 0) {
540
+ lines.push("- Checks:");
541
+ for (const check of match.checks) {
542
+ lines.push(` - ${escapeMarkdownInline(check)}`);
543
+ }
544
+ }
545
+ if (match.matchedFiles.length > 0) {
546
+ lines.push("- Matched files:");
547
+ for (const file of match.matchedFiles) {
548
+ lines.push(` - \`${escapeMarkdownInline(file)}\``);
549
+ }
550
+ }
551
+ lines.push("");
552
+ }
553
+ return;
554
+ }
555
+ lines.push("");
556
+ lines.push("Matches:");
557
+ for (const match of matches) {
558
+ lines.push(`- ${match.name} (${match.kind}, ${match.confidence})`);
559
+ lines.push(` Why: ${match.reason}`);
560
+ if (match.evidenceSources.length > 0) {
561
+ lines.push(` Evidence sources: ${match.evidenceSources.join(", ")}`);
562
+ }
563
+ lines.push(` Evidence: ${match.manifestPath}`);
564
+ lines.push(` If wrong: update ${match.updatePath}`);
565
+ appendGuidanceList(lines, "Next actions", match.nextActions, "text");
566
+ appendGuidanceList(lines, "Repair hints", match.repairHints, "text");
567
+ if (match.checks && match.checks.length > 0) {
568
+ lines.push(` Checks: ${match.checks.join("; ")}`);
569
+ }
570
+ }
571
+ }
572
+ function domainNextActions(domain) {
573
+ return [
574
+ `Confirm this PR really affects the ${domain.name} domain before spending time on unrelated broad smoke tests.`,
575
+ domain.criticality === "high"
576
+ ? "Require explicit validation evidence because this domain is marked high criticality."
577
+ : `Use the matched domain as the boundary for focused verification instead of testing the whole repository.`,
578
+ ];
579
+ }
580
+ function domainRepairHints(manifestPath, domain) {
581
+ return [
582
+ `If unrelated files keep matching, narrow ${manifestPath} > domains.${domain.id}.paths.`,
583
+ `If this is a real product area, mark ${manifestPath} > domains.${domain.id}.source as declared after team review.`,
584
+ ];
585
+ }
586
+ function flowNextActions(flow) {
587
+ const actions = [
588
+ `Draft or review E2E coverage for the ${flow.name} flow, not just the changed file.`,
589
+ ];
590
+ if (flow.entry?.route) {
591
+ actions.push(`Start the draft from the manifest entry route ${flow.entry.route}.`);
592
+ }
593
+ if (flow.runner) {
594
+ actions.push(`Prefer the manifest runner ${flow.runner} unless the local project setup says otherwise.`);
595
+ }
596
+ if (flow.checks.length > 0) {
597
+ actions.push(`Cover the declared checks: ${flow.checks.slice(0, 3).map((check) => check.title).join("; ")}.`);
598
+ }
599
+ return actions;
600
+ }
601
+ function flowRepairHints(manifestPath, flow) {
602
+ const hints = [
603
+ `If these files do not belong to this flow, update ${manifestPath} > flows.${flow.id}.anchors.`,
604
+ `If the generated test starts from the wrong screen, update ${manifestPath} > flows.${flow.id}.entry.route.`,
605
+ ];
606
+ if (flow.checks.length === 0) {
607
+ hints.push(`Add concrete success, failure, or edge checks under ${manifestPath} > flows.${flow.id}.checks.`);
608
+ }
609
+ else {
610
+ hints.push(`If the recommended assertions feel vague, rewrite ${manifestPath} > flows.${flow.id}.checks in team language.`);
611
+ }
612
+ return hints;
613
+ }
614
+ function checkNextActions(flow, check) {
615
+ return [
616
+ `Add or review one assertion for this ${check.type} case: ${check.title}.`,
617
+ `Keep the assertion tied to the ${flow.name} flow so the draft proves product behavior rather than implementation detail.`,
618
+ ];
619
+ }
620
+ function checkRepairHints(manifestPath, flow, check) {
621
+ return [
622
+ `If this is no longer required, remove or rename ${manifestPath} > flows.${flow.id}.checks.${check.id}.`,
623
+ `If the behavior is still required but hard to automate, add fixture, selector, or setup notes near ${manifestPath} > flows.${flow.id}.checks.`,
624
+ ];
625
+ }
626
+ function appendGuidanceList(lines, title, values, format) {
627
+ if (values.length === 0) {
628
+ return;
629
+ }
630
+ if (format === "markdown") {
631
+ lines.push(`- ${title}:`);
632
+ for (const value of values.slice(0, 4)) {
633
+ lines.push(` - ${escapeMarkdownInline(value)}`);
634
+ }
635
+ return;
636
+ }
637
+ lines.push(` ${title}:`);
638
+ for (const value of values.slice(0, 4)) {
639
+ lines.push(` - ${value}`);
640
+ }
641
+ }
642
+ function appendContextRoleSummary(lines, roleSummary, format) {
643
+ if (format === "markdown") {
644
+ lines.push("## Role Summary", "");
645
+ if (roleSummary.length === 0) {
646
+ lines.push("No context roles were classified.", "");
647
+ return;
648
+ }
649
+ for (const item of roleSummary) {
650
+ lines.push(`- \`${escapeMarkdownInline(item.role)}\`: ${item.sources
651
+ .map((source) => `\`${escapeMarkdownInline(source)}\``)
652
+ .join(", ")}`);
653
+ }
654
+ lines.push("");
655
+ return;
656
+ }
657
+ lines.push("Role Summary");
658
+ if (roleSummary.length === 0) {
659
+ lines.push("- No context roles were classified.");
660
+ lines.push("");
661
+ return;
662
+ }
663
+ for (const item of roleSummary) {
664
+ lines.push(`- ${item.role}: ${item.sources.join(", ")}`);
665
+ }
666
+ lines.push("");
667
+ }
668
+ function appendContextSources(lines, instructionFiles, format) {
669
+ if (format === "markdown") {
670
+ lines.push("## Context Sources", "");
671
+ if (instructionFiles.length === 0) {
672
+ lines.push("No instruction, runbook, ADR, goal, or context files were captured.", "");
673
+ return;
674
+ }
675
+ for (const file of instructionFiles) {
676
+ lines.push(`### \`${escapeMarkdownInline(file.path)}\``);
677
+ lines.push(`- Kind: \`${escapeMarkdownInline(file.kind)}\``);
678
+ lines.push(`- Confidence: \`${escapeMarkdownInline(file.confidence)}\``);
679
+ lines.push(`- Roles: ${file.roles.length > 0
680
+ ? file.roles.map((role) => `\`${escapeMarkdownInline(role)}\``).join(", ")
681
+ : "none"}`);
682
+ lines.push(`- Signals: ${file.signals.length > 0
683
+ ? file.signals.map((signal) => `\`${escapeMarkdownInline(signal)}\``).join(", ")
684
+ : "none"}`);
685
+ lines.push("");
686
+ }
687
+ return;
688
+ }
689
+ lines.push("Context Sources");
690
+ if (instructionFiles.length === 0) {
691
+ lines.push("- No instruction, runbook, ADR, goal, or context files were captured.");
692
+ lines.push("");
693
+ return;
694
+ }
695
+ for (const file of instructionFiles) {
696
+ lines.push(`- ${file.path}`);
697
+ lines.push(` Kind: ${file.kind}`);
698
+ lines.push(` Confidence: ${file.confidence}`);
699
+ lines.push(` Roles: ${file.roles.length > 0 ? file.roles.join(", ") : "none"}`);
700
+ lines.push(` Signals: ${file.signals.length > 0 ? file.signals.join(", ") : "none"}`);
701
+ }
702
+ lines.push("");
703
+ }
704
+ function appendContextValues(lines, title, values, format) {
705
+ if (format === "markdown") {
706
+ lines.push(`## ${title}`, "");
707
+ if (values.length === 0) {
708
+ lines.push("None detected.", "");
709
+ return;
710
+ }
711
+ for (const value of values) {
712
+ lines.push(`- \`${escapeMarkdownInline(value)}\``);
713
+ }
714
+ lines.push("");
715
+ return;
716
+ }
717
+ lines.push(title);
718
+ if (values.length === 0) {
719
+ lines.push("- None detected.");
720
+ lines.push("");
721
+ return;
722
+ }
723
+ for (const value of values) {
724
+ lines.push(`- ${value}`);
725
+ }
726
+ lines.push("");
727
+ }
728
+ function appendContextDiagnostics(lines, diagnostics, format) {
729
+ if (format === "markdown") {
730
+ lines.push("## Diagnostics", "");
731
+ if (diagnostics.length === 0) {
732
+ lines.push("No context diagnostics.", "");
733
+ return;
734
+ }
735
+ for (const item of diagnostics) {
736
+ lines.push(`- [${item.severity}] \`${escapeMarkdownInline(item.path)}\`: ${escapeMarkdownInline(item.message)}`);
737
+ lines.push(` - Fix: ${escapeMarkdownInline(item.recommendation)}`);
738
+ }
739
+ lines.push("");
740
+ return;
741
+ }
742
+ lines.push("Diagnostics");
743
+ if (diagnostics.length === 0) {
744
+ lines.push("- No context diagnostics.");
745
+ lines.push("");
746
+ return;
747
+ }
748
+ for (const item of diagnostics) {
749
+ lines.push(`- [${item.severity}] ${item.path}: ${item.message}`);
750
+ lines.push(` Fix: ${item.recommendation}`);
751
+ }
752
+ lines.push("");
753
+ }
754
+ async function buildContextDiagnostics(manifest, context, scannedContext, manifestRoot) {
755
+ const issues = [];
756
+ const manifestPath = manifest.path ?? defaultVerificationManifestPath;
757
+ if (!context || context.instructionFiles.length === 0) {
758
+ issues.push(issue("warning", `${manifestPath} > context`, "No repo-local context sources were found.", "Add CONTEXT.md, ADRs, QA runbooks, or local agent instruction docs, then run `qamap manifest init .` from the default branch."));
759
+ return issues;
760
+ }
761
+ if (!manifest.path) {
762
+ issues.push(issue("info", defaultVerificationManifestPath, "No verification manifest was found; this context report is a read-only preview.", "Run `qamap manifest init .` from the default branch when this baseline should become team verification policy."));
763
+ }
764
+ if (manifest.path && !manifest.context && scannedContext) {
765
+ issues.push(issue("warning", `${manifest.path} > context`, "The manifest has no context section, but repo-local context files were detected.", "Regenerate the manifest or manually add reviewed `context.instructionFiles` entries."));
766
+ }
767
+ if (contextHasRole(context, "verification-rubric") && manifestCheckCount(manifest) === 0) {
768
+ issues.push(issue("warning", `${manifestPath} > flows`, "Verification rubric context exists, but no manifest checks are declared.", "Add flow checks or regenerate the baseline so rubric knowledge can shape E2E drafts."));
769
+ }
770
+ if (contextHasRole(context, "test-runner") && context.validationCommands.length === 0) {
771
+ issues.push(issue("warning", `${manifestPath} > context.validationCommands`, "Test runner context exists, but no validation commands were inferred.", "Add explicit commands such as `pnpm test` to the context docs or manifest config."));
772
+ }
773
+ if (contextHasRole(context, "safety-policy") && context.safetyRules.length === 0) {
774
+ issues.push(issue("warning", `${manifestPath} > context.safetyRules`, "Safety policy context exists, but no safety rules were extracted.", "Make no-write, no-publish, no-token, or approval rules explicit in short imperative sentences."));
775
+ }
776
+ for (const file of context.instructionFiles) {
777
+ const sourcePath = path.resolve(manifestRoot, file.path);
778
+ if (!(await pathExists(sourcePath))) {
779
+ issues.push(issue("warning", `${manifestPath} > context.instructionFiles.${file.path}`, "Context source no longer exists on disk.", "Remove this source from the manifest or update the path after moving the document."));
780
+ continue;
781
+ }
782
+ if (file.roles.length === 0) {
783
+ issues.push(issue("info", `${manifestPath} > context.instructionFiles.${file.path}.roles`, "Context source has no classified role.", "Add clearer headings or keep this source as advisory-only context."));
784
+ }
785
+ if (file.roles.length > 5) {
786
+ issues.push(issue("info", `${manifestPath} > context.instructionFiles.${file.path}.roles`, "Context source matches many roles and may be broad or noisy.", "Split broad docs or move important QA rules into focused runbooks."));
787
+ }
788
+ }
789
+ return issues;
790
+ }
791
+ function summarizeContextRoles(context) {
792
+ if (!context) {
793
+ return [];
794
+ }
795
+ const sourcesByRole = new Map();
796
+ for (const file of context.instructionFiles) {
797
+ for (const role of file.roles) {
798
+ sourcesByRole.set(role, [...(sourcesByRole.get(role) ?? []), file.path]);
799
+ }
800
+ }
801
+ return [...sourcesByRole.entries()]
802
+ .map(([role, sources]) => ({
803
+ role,
804
+ sources: uniqueStrings(sources).sort((left, right) => left.localeCompare(right)),
805
+ }))
806
+ .sort((left, right) => left.role.localeCompare(right.role));
807
+ }
808
+ function contextHasRole(context, role) {
809
+ return context?.instructionFiles.some((file) => file.roles.includes(role)) ?? false;
810
+ }
811
+ function manifestCheckCount(manifest) {
812
+ return manifest.flows.reduce((total, flow) => total + flow.checks.length, 0);
813
+ }
814
+ function buildManifestContext(root, manifestRoot, files) {
815
+ const instructionFiles = [];
816
+ const validationCommands = [];
817
+ const safetyRules = [];
818
+ for (const file of files) {
819
+ if (!file.text) {
820
+ continue;
821
+ }
822
+ const kind = instructionKindForFile(file.path);
823
+ if (!kind) {
824
+ continue;
825
+ }
826
+ const manifestPath = toPosixPath(path.relative(manifestRoot, path.join(root, file.path)));
827
+ if (manifestPath.startsWith("../")) {
828
+ continue;
829
+ }
830
+ const commands = extractValidationCommands(file.text);
831
+ const rules = extractSafetyRules(file.text);
832
+ const roles = classifyInstructionRoles(file.path, file.text, kind, commands, rules);
833
+ const signals = [
834
+ ...commands.map(() => "validation-command"),
835
+ ...rules.map(() => "safety-rule"),
836
+ ...roles.map((role) => `role:${role}`),
837
+ kind === "adr" ? "architecture-decision" : "",
838
+ kind === "goal" ? "goal-document" : "",
839
+ kind === "context" ? "domain-language" : "",
840
+ ].filter(Boolean);
841
+ instructionFiles.push({
842
+ path: manifestPath,
843
+ kind,
844
+ confidence: instructionConfidence(kind, signals),
845
+ roles,
846
+ signals: uniqueStrings(signals).slice(0, 6),
847
+ });
848
+ validationCommands.push(...commands);
849
+ safetyRules.push(...rules);
850
+ }
851
+ if (instructionFiles.length === 0 && validationCommands.length === 0 && safetyRules.length === 0) {
852
+ return undefined;
853
+ }
854
+ return {
855
+ instructionFiles: instructionFiles
856
+ .sort((left, right) => contextKindRank(left.kind) - contextKindRank(right.kind) || left.path.localeCompare(right.path))
857
+ .slice(0, 24),
858
+ validationCommands: uniqueStrings(validationCommands).slice(0, 12),
859
+ safetyRules: uniqueStrings(safetyRules).slice(0, 12),
860
+ source: {
861
+ kind: "inferred",
862
+ confidence: instructionFiles.some((file) => file.confidence === "medium") ? "medium" : "low",
863
+ from: uniqueStrings([
864
+ ...instructionFiles.map((file) => contextSourceLabel(file.kind)),
865
+ ...instructionFiles.flatMap((file) => file.roles.map(contextRoleLabel)),
866
+ ]).slice(0, 10),
867
+ },
868
+ };
869
+ }
870
+ function buildBaselineDomains(files, context) {
871
+ const grouped = new Map();
872
+ for (const file of files) {
873
+ const candidate = domainCandidateFromPath(file.path);
874
+ if (!candidate) {
875
+ continue;
876
+ }
877
+ const existing = grouped.get(candidate.id);
878
+ const contextEvidence = contextEvidenceForTerms(context, [candidate.id, candidate.name]);
879
+ if (existing) {
880
+ existing.files.push(file.path);
881
+ existing.from.push(candidate.from);
882
+ existing.from.push(...contextEvidence);
883
+ continue;
884
+ }
885
+ grouped.set(candidate.id, {
886
+ name: candidate.name,
887
+ files: [file.path],
888
+ from: [candidate.from, ...contextEvidence],
889
+ });
890
+ }
891
+ return [...grouped.entries()]
892
+ .sort((left, right) => right[1].files.length - left[1].files.length)
893
+ .map(([id, value]) => ({
894
+ id,
895
+ name: value.name,
896
+ paths: domainPatterns(value.files).slice(0, 5),
897
+ criticality: "medium",
898
+ source: {
899
+ kind: "inferred",
900
+ confidence: value.files.length > 1 || value.from.some((item) => item.endsWith("-context")) ? "medium" : "low",
901
+ from: uniqueStrings(value.from).slice(0, 4),
902
+ },
903
+ }));
904
+ }
905
+ function buildBaselineFlows(files, domains, runner, context) {
906
+ const flows = [];
907
+ for (const file of files) {
908
+ const route = routeFromFile(file.path);
909
+ const component = componentNameFromFile(file.path);
910
+ if (!route && !component) {
911
+ continue;
912
+ }
913
+ const domain = bestDomainForFile(domains, file.path);
914
+ const inferredSubject = route ? titleCase(route.replace(/^\/+/, "").replace(/[:/]+/g, " ")) : component ?? "Changed UI";
915
+ const subject = contextFlowSubjectForTerms(context, [inferredSubject, domain?.id, domain?.name].filter(Boolean), inferredSubject);
916
+ const id = slugify([domain?.id, subject].filter(Boolean).join(" "));
917
+ const contextEvidence = contextEvidenceForTerms(context, [subject, domain?.name, domain?.id].filter(Boolean));
918
+ const anchors = [
919
+ {
920
+ kind: route ? "route" : "component",
921
+ path: file.path,
922
+ route,
923
+ symbol: route ? undefined : component,
924
+ source: "inferred",
925
+ confidence: route ? "high" : "medium",
926
+ },
927
+ ];
928
+ flows.push({
929
+ id,
930
+ domain: domain?.id,
931
+ name: subject,
932
+ entry: route ? { route, source: "inferred" } : undefined,
933
+ runner,
934
+ anchors,
935
+ checks: checksForFlow(subject, file.text),
936
+ source: {
937
+ kind: "inferred",
938
+ confidence: route || contextEvidence.length > 0 ? "medium" : "low",
939
+ from: uniqueStrings([route ? "route-file" : "component-file", ...contextEvidence]),
940
+ },
941
+ });
942
+ }
943
+ return dedupeFlows(flows);
944
+ }
945
+ function checksForFlow(subject, text) {
946
+ const checks = [
947
+ {
948
+ id: "happy-path",
949
+ title: `${subject} happy path works`,
950
+ type: "success",
951
+ },
952
+ ];
953
+ if (text && /\b(?:fetch|axios|graphql|mutation|query|api|request)\b/i.test(text)) {
954
+ checks.push({
955
+ id: "api-success-fixture",
956
+ title: `${subject} uses deterministic success fixture data`,
957
+ type: "contract",
958
+ }, {
959
+ id: "api-failure-fixture",
960
+ title: `${subject} handles failed, empty, or unauthorized responses`,
961
+ type: "failure",
962
+ });
963
+ }
964
+ else {
965
+ checks.push({
966
+ id: "visible-result",
967
+ title: `${subject} shows the expected visible result`,
968
+ type: "success",
969
+ });
970
+ }
971
+ return checks;
972
+ }
973
+ function instructionKindForFile(file) {
974
+ const normalized = toPosixPath(file);
975
+ const lower = normalized.toLowerCase();
976
+ const basename = path.basename(normalized);
977
+ if (basename === "CONTEXT.md" || basename === "CONTEXT-MAP.md") {
978
+ return "context";
979
+ }
980
+ if (/^(?:docs\/)?adrs?\//i.test(normalized) || /\/adrs?\//i.test(normalized)) {
981
+ return "adr";
982
+ }
983
+ if (/^(?:\.?goals|goals)\//i.test(normalized) || /\/goals\//i.test(normalized)) {
984
+ return "goal";
985
+ }
986
+ if (basename === "AGENTS.md" ||
987
+ basename === "CLAUDE.md" ||
988
+ basename === "GEMINI.md" ||
989
+ /^(?:\.codex|\.claude|\.agent-core|\.github\/instructions)\//i.test(normalized)) {
990
+ return "agent-instruction";
991
+ }
992
+ if (/docs\/.*(?:qa|quality).*\.md$/i.test(lower)) {
993
+ return "qa-runbook";
994
+ }
995
+ if (/docs\/.*(?:test|e2e|playwright|maestro).*\.md$/i.test(lower)) {
996
+ return "test-runbook";
997
+ }
998
+ if (/docs\/.*(?:release|deploy|publish).*\.md$/i.test(lower)) {
999
+ return "release-runbook";
1000
+ }
1001
+ if (/docs\/.*runbook.*\.md$/i.test(lower)) {
1002
+ return "runbook";
1003
+ }
1004
+ return undefined;
1005
+ }
1006
+ function instructionConfidence(kind, signals) {
1007
+ if (kind === "agent-instruction") {
1008
+ return signals.length > 0 ? "medium" : "low";
1009
+ }
1010
+ return "medium";
1011
+ }
1012
+ function classifyInstructionRoles(file, text, kind, commands, rules) {
1013
+ const normalized = toPosixPath(file).toLowerCase();
1014
+ const roles = [];
1015
+ if (kind === "context" ||
1016
+ kind === "adr" ||
1017
+ kind === "goal" ||
1018
+ /(?:product|domain|business|customer|user flow|journey|scenario|feature|screen|route|제품|도메인|사용자|고객|플로우|시나리오)/i.test(text)) {
1019
+ roles.push("domain-context");
1020
+ }
1021
+ if (kind === "qa-runbook" ||
1022
+ kind === "test-runbook" ||
1023
+ commands.length > 0 ||
1024
+ /(?:verify|verification|qa|test|e2e|playwright|maestro|assert|coverage|evidence|fixture|selector|acceptance criteria|rubric|검증|테스트|근거|기준|커버리지)/i.test(text)) {
1025
+ roles.push("verification-rubric");
1026
+ }
1027
+ if (commands.length > 0 ||
1028
+ kind === "test-runbook" ||
1029
+ /(?:playwright|maestro|jest|vitest|node --test|pytest|go test|cargo test|runner|test command|검증 명령|테스트 명령)/i.test(text)) {
1030
+ roles.push("test-runner");
1031
+ }
1032
+ if (rules.length > 0 ||
1033
+ /(?:do not (?:commit|push|merge|publish|print|expose|write)|never (?:create|commit|push|merge|publish|print|expose|write)|must not|read-only|secret|credential|guardrail|forbid|절대|금지|하지 말|하면 안|토큰|비밀|권한)/i.test(text)) {
1034
+ roles.push("safety-policy");
1035
+ }
1036
+ if (kind === "release-runbook" ||
1037
+ /(?:release|deploy|publish|changelog)/i.test(normalized) ||
1038
+ /(?:\b(?:publish|deploy|tag)\b|npm publish|version bump|릴리즈|배포|버전)/i.test(text)) {
1039
+ roles.push("release-policy");
1040
+ }
1041
+ if (kind === "goal" ||
1042
+ /(?:lifecycle|workflow|process|goal|adr|review|iterate|iteration|loop|handoff|plan|implement|decision|작업 흐름|라이프사이클|목표|리뷰|반복|절차|계획|결정)/i.test(text)) {
1043
+ roles.push("workflow-lifecycle");
1044
+ }
1045
+ if (/(?:^|\/)skills?\//.test(normalized) ||
1046
+ (/(?:^|\n)name:\s*[\w-]+/i.test(text) && /(?:^|\n)description:\s+/i.test(text)) ||
1047
+ /\bskill\b/i.test(text)) {
1048
+ roles.push("agent-skill");
1049
+ }
1050
+ if (/(?:^|\/)(?:\.agent-core|\.github\/instructions|\.codex|\.claude)(?:\/|$)/.test(normalized) ||
1051
+ (kind === "agent-instruction" && /(?:harness|mcp|hook|settings|agent config|agent instruction|에이전트|하네스)/i.test(text))) {
1052
+ roles.push("harness-config");
1053
+ }
1054
+ return uniqueStrings(roles);
1055
+ }
1056
+ function extractValidationCommands(text) {
1057
+ const commands = [];
1058
+ for (const line of text.split(/\r?\n/)) {
1059
+ const candidates = [...line.matchAll(/`([^`\n]+)`/g)].map((match) => match[1]);
1060
+ const stripped = cleanMarkdownLine(line);
1061
+ if (/^(?:pnpm|npm|yarn|bun|npx|node|pytest|go|cargo|maestro|playwright|gradle|mvn|\.\/gradlew)\b/i.test(stripped)) {
1062
+ candidates.push(stripped);
1063
+ }
1064
+ for (const candidate of candidates) {
1065
+ const command = normalizeCommand(candidate);
1066
+ if (command && isValidationCommand(command)) {
1067
+ commands.push(command);
1068
+ }
1069
+ }
1070
+ }
1071
+ return uniqueStrings(commands).slice(0, 12);
1072
+ }
1073
+ function extractSafetyRules(text) {
1074
+ const rules = [];
1075
+ for (const line of text.split(/\r?\n/)) {
1076
+ const cleaned = redactSensitiveText(cleanMarkdownLine(line));
1077
+ if (cleaned.length < 8 || cleaned.length > 220) {
1078
+ continue;
1079
+ }
1080
+ if (/\bdo not (?:belong|require|show)\b/i.test(cleaned)) {
1081
+ continue;
1082
+ }
1083
+ if (/(?:do not|don't|never|must not|read-only|\/tmp|token|secret|credential|절대|금지|하지 말|하면 안|커밋|푸시|PR 생성)/i.test(cleaned)) {
1084
+ rules.push(cleaned);
1085
+ }
1086
+ }
1087
+ return uniqueStrings(rules).slice(0, 12);
1088
+ }
1089
+ function normalizeCommand(value) {
1090
+ const command = redactSensitiveText(value.trim().replace(/^\$\s*/, "").replace(/^>\s*/, ""));
1091
+ if (!command || command.length > 140 || /(?:publish|login|token|secret|password|rm\s+-rf)/i.test(command)) {
1092
+ return undefined;
1093
+ }
1094
+ return command.replace(/\s+/g, " ");
1095
+ }
1096
+ function isValidationCommand(command) {
1097
+ return (/^(?:pnpm|npm|yarn|bun)\s+(?:run\s+)?(?:test|test:[\w:-]+|e2e|lint|typecheck|check|build|verify|coverage)\b/i.test(command) ||
1098
+ /^(?:npx\s+)?playwright\s+test\b/i.test(command) ||
1099
+ /^maestro\s+test\b/i.test(command) ||
1100
+ /^node\s+--test\b/i.test(command) ||
1101
+ /^pytest\b/i.test(command) ||
1102
+ /^go\s+test\b/i.test(command) ||
1103
+ /^cargo\s+test\b/i.test(command) ||
1104
+ /^(?:gradle|\.\/gradlew)\s+(?:test|check)\b/i.test(command) ||
1105
+ /^mvn\s+test\b/i.test(command));
1106
+ }
1107
+ function cleanMarkdownLine(value) {
1108
+ return value
1109
+ .replace(/^\s{0,3}(?:[-*]|\d+\.)\s+/, "")
1110
+ .replace(/^\s{0,3}#+\s*/, "")
1111
+ .trim();
1112
+ }
1113
+ function redactSensitiveText(value) {
1114
+ return value
1115
+ .replace(/\bnpm_[A-Za-z0-9]{12,}\b/g, "[redacted-token]")
1116
+ .replace(/\b(TOKEN|SECRET|PASSWORD|API_KEY|AUTH_TOKEN)=\S+/gi, "$1=[redacted]")
1117
+ .replace(/_authToken=\S+/gi, "_authToken=[redacted]");
1118
+ }
1119
+ function contextEvidenceForTerms(context, terms) {
1120
+ if (!context) {
1121
+ return [];
1122
+ }
1123
+ const normalizedTerms = uniqueStrings(terms
1124
+ .flatMap((term) => [term, slugify(term), term.replace(/\s+/g, "-")])
1125
+ .map((term) => term.toLowerCase())
1126
+ .filter((term) => term.length >= 3));
1127
+ if (normalizedTerms.length === 0) {
1128
+ return [];
1129
+ }
1130
+ const evidence = context.instructionFiles
1131
+ .filter((file) => {
1132
+ const filePath = file.path.toLowerCase();
1133
+ return normalizedTerms.some((term) => filePath.includes(term));
1134
+ })
1135
+ .map((file) => contextSourceLabel(file.kind));
1136
+ return uniqueStrings(evidence);
1137
+ }
1138
+ function contextFlowSubjectForTerms(context, terms, fallback) {
1139
+ if (!context) {
1140
+ return fallback;
1141
+ }
1142
+ const normalizedTerms = uniqueStrings(terms
1143
+ .flatMap((term) => [term, slugify(term), term.replace(/\s+/g, "-")])
1144
+ .map((term) => term.toLowerCase())
1145
+ .filter((term) => term.length >= 3));
1146
+ if (normalizedTerms.length === 0) {
1147
+ return fallback;
1148
+ }
1149
+ const candidates = context.instructionFiles
1150
+ .filter((file) => file.roles.includes("domain-context") || file.roles.includes("verification-rubric"))
1151
+ .map((file) => contextFlowSubjectCandidate(file.path, normalizedTerms))
1152
+ .filter(Boolean);
1153
+ const best = candidates
1154
+ .filter((candidate) => slugify(candidate) !== slugify(fallback))
1155
+ .sort((left, right) => right.length - left.length)[0];
1156
+ return best ?? fallback;
1157
+ }
1158
+ function contextFlowSubjectCandidate(filePath, normalizedTerms) {
1159
+ const basename = path.basename(toPosixPath(filePath)).replace(/\.[^.]+$/, "");
1160
+ const slug = slugify(basename);
1161
+ if (!slug || !normalizedTerms.some((term) => slug.includes(term))) {
1162
+ return undefined;
1163
+ }
1164
+ const cleanSlug = slug
1165
+ .split("-")
1166
+ .filter((part) => !["adr", "context", "decision", "doc", "docs", "goal", "note", "notes", "runbook", "spec"].includes(part))
1167
+ .join("-");
1168
+ if (!cleanSlug || cleanSlug.split("-").length < 2) {
1169
+ return undefined;
1170
+ }
1171
+ return titleCase(cleanSlug);
1172
+ }
1173
+ function contextSourceLabel(kind) {
1174
+ if (kind === "agent-instruction") {
1175
+ return "agent-instruction-context";
1176
+ }
1177
+ if (kind === "context") {
1178
+ return "context-document-context";
1179
+ }
1180
+ return `${kind}-context`;
1181
+ }
1182
+ function contextRoleLabel(role) {
1183
+ return `${role}-context`;
1184
+ }
1185
+ function contextKindRank(kind) {
1186
+ const ranks = {
1187
+ context: 0,
1188
+ adr: 1,
1189
+ goal: 2,
1190
+ "qa-runbook": 3,
1191
+ "test-runbook": 4,
1192
+ "release-runbook": 5,
1193
+ runbook: 6,
1194
+ "agent-instruction": 7,
1195
+ };
1196
+ return ranks[kind];
1197
+ }
1198
+ function inferRunner(files) {
1199
+ const packageText = files.find((file) => file.path === "package.json")?.text ?? "";
1200
+ if (/\b(?:expo|react-native)\b/i.test(packageText) || files.some((file) => /(?:^|\/)app\.json$/.test(file.path))) {
1201
+ return "maestro";
1202
+ }
1203
+ if (/\b(?:next|react|vite|vue|nuxt|svelte|remix|astro|angular|playwright)\b/i.test(packageText)) {
1204
+ return "playwright";
1205
+ }
1206
+ return "manual";
1207
+ }
1208
+ function routeFromFile(file) {
1209
+ const normalized = toPosixPath(file);
1210
+ const pagesMatch = normalized.match(/(?:^|\/)(?:src\/)?pages\/(.+)\.(?:[cm]?[jt]sx?|vue|svelte)$/);
1211
+ if (pagesMatch && !pagesMatch[1].startsWith("_")) {
1212
+ return normalizeRouteSegments(pagesMatch[1].replace(/\/index$/, ""));
1213
+ }
1214
+ const appMatch = normalized.match(/(?:^|\/)(?:src\/)?app\/(.+)\/(?:page|route)\.(?:[cm]?[jt]sx?)$/);
1215
+ if (appMatch) {
1216
+ const withoutGroups = appMatch[1]
1217
+ .split("/")
1218
+ .filter((segment) => !/^\(.+\)$/.test(segment))
1219
+ .join("/");
1220
+ return normalizeRouteSegments(withoutGroups);
1221
+ }
1222
+ return undefined;
1223
+ }
1224
+ function normalizeRouteSegments(value) {
1225
+ const route = value
1226
+ .split("/")
1227
+ .filter(Boolean)
1228
+ .map((segment) => segment.replace(/^\[(\.\.\.)?(.+)\]$/, ":$2"))
1229
+ .join("/");
1230
+ return `/${route || ""}`;
1231
+ }
1232
+ function componentNameFromFile(file) {
1233
+ const basename = path.basename(file).replace(/\.[^.]+$/, "");
1234
+ if (!/(?:page|screen|view|modal|form|flow|checkout|submit|complete)$/i.test(basename)) {
1235
+ return undefined;
1236
+ }
1237
+ return titleCase(basename);
1238
+ }
1239
+ function domainCandidateFromPath(file) {
1240
+ const segments = file.split("/").filter(Boolean);
1241
+ const keyedIndex = segments.findIndex((segment) => ["apps", "domains", "entities", "features", "modules", "packages", "services"].includes(segment));
1242
+ if (keyedIndex >= 0 && segments[keyedIndex + 1]) {
1243
+ return domainCandidate(segments[keyedIndex + 1], segments[keyedIndex]);
1244
+ }
1245
+ const routeIndex = segments.findIndex((segment) => ["app", "pages", "screens"].includes(segment));
1246
+ if (routeIndex >= 0) {
1247
+ const segment = segments
1248
+ .slice(routeIndex + 1)
1249
+ .find((item) => item && !item.startsWith("_") && !item.startsWith("+") && !/^\(.+\)$/.test(item));
1250
+ if (segment) {
1251
+ return domainCandidate(segment, segments[routeIndex]);
1252
+ }
1253
+ }
1254
+ return undefined;
1255
+ }
1256
+ function domainCandidate(value, from) {
1257
+ const clean = value.replace(/\.[^.]+$/, "").replace(/^\[(.+)\]$/, "$1");
1258
+ const id = slugify(clean);
1259
+ if (!id || ["api", "components", "hooks", "lib", "shared", "src", "utils"].includes(id)) {
1260
+ return undefined;
1261
+ }
1262
+ return { id, name: titleCase(clean), from };
1263
+ }
1264
+ function bestDomainForFile(domains, file) {
1265
+ return domains.find((domain) => domain.paths.some((pattern) => matchesPathPattern(file, pattern)));
1266
+ }
1267
+ function domainPatterns(files) {
1268
+ return uniqueStrings(files.map(domainPatternForFile).filter(Boolean));
1269
+ }
1270
+ function domainPatternForFile(file) {
1271
+ const segments = file.split("/").filter(Boolean);
1272
+ const basename = segments.at(-1);
1273
+ const dir = segments.slice(0, -1).join("/");
1274
+ if (!basename || !dir) {
1275
+ return undefined;
1276
+ }
1277
+ const owningDir = segments.at(-2);
1278
+ if (owningDir && ["app", "pages", "screens"].includes(owningDir)) {
1279
+ return file;
1280
+ }
1281
+ return `${dir}/**`;
1282
+ }
1283
+ function matchFlowAnchors(flow, files) {
1284
+ const matched = new Set();
1285
+ for (const file of files) {
1286
+ for (const anchor of flow.anchors) {
1287
+ if (anchor.path && matchesPathPattern(file, anchor.path)) {
1288
+ matched.add(file);
1289
+ }
1290
+ if (anchor.route) {
1291
+ const routeSignal = normalizePathForMatch(anchor.route).replace(/^\//, "");
1292
+ if (routeSignal && normalizePathForMatch(file).includes(routeSignal)) {
1293
+ matched.add(file);
1294
+ }
1295
+ }
1296
+ }
1297
+ }
1298
+ return [...matched];
1299
+ }
1300
+ function matchesPathPattern(file, pattern) {
1301
+ const normalizedFile = normalizePathForMatch(file);
1302
+ const normalizedPattern = normalizePathForMatch(pattern);
1303
+ if (!normalizedPattern) {
1304
+ return false;
1305
+ }
1306
+ if (!normalizedPattern.includes("*")) {
1307
+ return normalizedFile === normalizedPattern || normalizedFile.startsWith(`${normalizedPattern}/`);
1308
+ }
1309
+ const regex = new RegExp(`^${normalizedPattern
1310
+ .split("**")
1311
+ .map((part) => part.split("*").map(escapeRegex).join("[^/]*"))
1312
+ .join(".*")}$`);
1313
+ return regex.test(normalizedFile);
1314
+ }
1315
+ function isBehaviorFile(file) {
1316
+ return /\.(?:[cm]?[jt]sx?|vue|svelte)$/.test(file) && !/(?:^|\/)(?:tests?|__tests__|e2e|dist|build)\//i.test(file);
1317
+ }
1318
+ function normalizeVerificationManifest(value, manifestPath) {
1319
+ const record = asRecord(value, `QAMap manifest must be an object: ${manifestPath}`);
1320
+ const version = record.version;
1321
+ if (version !== 1) {
1322
+ throw new Error(`QAMap manifest version must be 1: ${manifestPath}`);
1323
+ }
1324
+ const schema = readOptionalString(record, "$schema");
1325
+ const domains = Array.isArray(record.domains)
1326
+ ? record.domains.map((domain, index) => normalizeDomain(domain, manifestPath, index))
1327
+ : [];
1328
+ const flows = Array.isArray(record.flows)
1329
+ ? record.flows.map((flow, index) => normalizeFlow(flow, manifestPath, index))
1330
+ : [];
1331
+ const context = normalizeContext(record.context, manifestPath);
1332
+ return { $schema: schema, version: 1, ...(context ? { context } : {}), domains, flows };
1333
+ }
1334
+ function normalizeContext(value, manifestPath) {
1335
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1336
+ return undefined;
1337
+ }
1338
+ const record = value;
1339
+ const instructionFiles = Array.isArray(record.instructionFiles)
1340
+ ? record.instructionFiles.map((item, index) => normalizeInstructionFile(item, manifestPath, index))
1341
+ : [];
1342
+ const validationCommands = readStringArray(record, "validationCommands");
1343
+ const safetyRules = readStringArray(record, "safetyRules");
1344
+ return {
1345
+ instructionFiles,
1346
+ validationCommands,
1347
+ safetyRules,
1348
+ source: readSource(record.source, "context", manifestPath, 0),
1349
+ };
1350
+ }
1351
+ function normalizeInstructionFile(value, manifestPath, index) {
1352
+ const record = asRecord(value, `QAMap manifest context file at index ${index} must be an object: ${manifestPath}`);
1353
+ return {
1354
+ path: readRequiredString(record, "path", manifestPath, index),
1355
+ kind: readInstructionKind(readOptionalString(record, "kind") ?? "agent-instruction", manifestPath, index),
1356
+ confidence: readConfidence(readOptionalString(record, "confidence") ?? "low", manifestPath, index),
1357
+ roles: readStringArray(record, "roles").map((role) => readInstructionRole(role, manifestPath, index)),
1358
+ signals: readStringArray(record, "signals"),
1359
+ };
1360
+ }
1361
+ function normalizeDomain(value, manifestPath, index) {
1362
+ const record = asRecord(value, `QAMap manifest domain at index ${index} must be an object: ${manifestPath}`);
1363
+ const id = readRequiredString(record, "id", manifestPath, index);
1364
+ return {
1365
+ id,
1366
+ name: readOptionalString(record, "name") ?? id,
1367
+ paths: readStringArray(record, "paths"),
1368
+ criticality: readCriticality(readOptionalString(record, "criticality") ?? "medium", manifestPath, index),
1369
+ source: readSource(record.source, "domain", manifestPath, index),
1370
+ };
1371
+ }
1372
+ function normalizeFlow(value, manifestPath, index) {
1373
+ const record = asRecord(value, `QAMap manifest flow at index ${index} must be an object: ${manifestPath}`);
1374
+ const id = readRequiredString(record, "id", manifestPath, index);
1375
+ return {
1376
+ id,
1377
+ domain: readOptionalString(record, "domain"),
1378
+ name: readOptionalString(record, "name") ?? id,
1379
+ entry: normalizeEntry(record.entry),
1380
+ runner: readRunner(readOptionalString(record, "runner")),
1381
+ anchors: Array.isArray(record.anchors)
1382
+ ? record.anchors.map((anchor, anchorIndex) => normalizeAnchor(anchor, manifestPath, index, anchorIndex))
1383
+ : [],
1384
+ checks: Array.isArray(record.checks)
1385
+ ? record.checks.map((check, checkIndex) => normalizeCheck(check, manifestPath, index, checkIndex))
1386
+ : [],
1387
+ source: readSource(record.source, "flow", manifestPath, index),
1388
+ };
1389
+ }
1390
+ function normalizeEntry(value) {
1391
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1392
+ return undefined;
1393
+ }
1394
+ const record = value;
1395
+ return {
1396
+ route: readOptionalString(record, "route"),
1397
+ source: readSourceKind(readOptionalString(record, "source") ?? "declared"),
1398
+ };
1399
+ }
1400
+ function normalizeAnchor(value, manifestPath, flowIndex, anchorIndex) {
1401
+ const record = asRecord(value, `QAMap manifest anchor at flow ${flowIndex}, index ${anchorIndex} must be an object: ${manifestPath}`);
1402
+ return {
1403
+ kind: readAnchorKind(readOptionalString(record, "kind") ?? "file", manifestPath, anchorIndex),
1404
+ path: readOptionalString(record, "path"),
1405
+ route: readOptionalString(record, "route"),
1406
+ symbol: readOptionalString(record, "symbol"),
1407
+ source: readSourceKind(readOptionalString(record, "source") ?? "declared"),
1408
+ confidence: readConfidence(readOptionalString(record, "confidence") ?? "medium", manifestPath, anchorIndex),
1409
+ };
1410
+ }
1411
+ function normalizeCheck(value, manifestPath, flowIndex, checkIndex) {
1412
+ const record = asRecord(value, `QAMap manifest check at flow ${flowIndex}, index ${checkIndex} must be an object: ${manifestPath}`);
1413
+ return {
1414
+ id: readRequiredString(record, "id", manifestPath, checkIndex),
1415
+ title: readOptionalString(record, "title") ?? readRequiredString(record, "id", manifestPath, checkIndex),
1416
+ type: readCheckType(readOptionalString(record, "type") ?? "success", manifestPath, checkIndex),
1417
+ selector: readOptionalString(record, "selector"),
1418
+ value: readOptionalString(record, "value"),
1419
+ steps: readStringArray(record, "steps"),
1420
+ };
1421
+ }
1422
+ function readSource(value, label, manifestPath, index) {
1423
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1424
+ return {
1425
+ kind: "declared",
1426
+ confidence: "medium",
1427
+ from: [label],
1428
+ };
1429
+ }
1430
+ const record = value;
1431
+ return {
1432
+ kind: readSourceKind(readOptionalString(record, "kind") ?? "declared"),
1433
+ confidence: readConfidence(readOptionalString(record, "confidence") ?? "medium", manifestPath, index),
1434
+ from: readStringArray(record, "from"),
1435
+ };
1436
+ }
1437
+ async function findVerificationManifestPath(root) {
1438
+ for (const candidate of manifestCandidates) {
1439
+ const absolutePath = path.join(root, candidate);
1440
+ if (await pathExists(absolutePath)) {
1441
+ return absolutePath;
1442
+ }
1443
+ }
1444
+ return undefined;
1445
+ }
1446
+ function displayManifestPath(root, manifestPath) {
1447
+ const relative = path.relative(root, manifestPath);
1448
+ if (!relative.startsWith("..") && !path.isAbsolute(relative)) {
1449
+ return toPosixPath(relative);
1450
+ }
1451
+ return toPosixPath(manifestPath);
1452
+ }
1453
+ function parseVerificationManifest(raw, manifestPath) {
1454
+ try {
1455
+ if (/\.json$/i.test(manifestPath)) {
1456
+ return JSON.parse(raw);
1457
+ }
1458
+ return YAML.parse(raw);
1459
+ }
1460
+ catch (error) {
1461
+ const message = error instanceof Error ? error.message : String(error);
1462
+ throw new Error(`Could not parse QAMap manifest at ${manifestPath}: ${message}`);
1463
+ }
1464
+ }
1465
+ function summarizeManifest(manifest) {
1466
+ return {
1467
+ domains: manifest.domains.length,
1468
+ flows: manifest.flows.length,
1469
+ anchors: manifest.flows.reduce((total, flow) => total + flow.anchors.length, 0),
1470
+ checks: manifest.flows.reduce((total, flow) => total + flow.checks.length, 0),
1471
+ contextSources: manifest.context?.instructionFiles.length ?? 0,
1472
+ validationCommands: manifest.context?.validationCommands.length ?? 0,
1473
+ safetyRules: manifest.context?.safetyRules.length ?? 0,
1474
+ };
1475
+ }
1476
+ function dedupeFlows(flows) {
1477
+ const seen = new Map();
1478
+ for (const flow of flows) {
1479
+ if (!seen.has(flow.id)) {
1480
+ seen.set(flow.id, flow);
1481
+ }
1482
+ }
1483
+ return [...seen.values()];
1484
+ }
1485
+ function dedupeManifestMatches(matches) {
1486
+ const seen = new Map();
1487
+ for (const match of matches) {
1488
+ const key = `${match.kind}:${match.id}:${match.manifestPath}`;
1489
+ if (!seen.has(key)) {
1490
+ seen.set(key, match);
1491
+ }
1492
+ }
1493
+ return [...seen.values()];
1494
+ }
1495
+ function normalizePathForMatch(value) {
1496
+ return toPosixPath(value).replace(/^\.\/+/, "").replace(/\/+$/g, "").toLowerCase();
1497
+ }
1498
+ function slugify(value) {
1499
+ return value.trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "");
1500
+ }
1501
+ function titleCase(value) {
1502
+ return value
1503
+ .replace(/\.[^.]+$/, "")
1504
+ .replace(/([a-z0-9])([A-Z])/g, "$1 $2")
1505
+ .split(/[^a-zA-Z0-9:]+/)
1506
+ .filter(Boolean)
1507
+ .map((part) => {
1508
+ if (part.startsWith(":")) {
1509
+ return part;
1510
+ }
1511
+ return part[0].toUpperCase() + part.slice(1);
1512
+ })
1513
+ .join(" ");
1514
+ }
1515
+ function uniqueStrings(values) {
1516
+ return [...new Set(values.filter(Boolean))];
1517
+ }
1518
+ function escapeRegex(value) {
1519
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1520
+ }
1521
+ function escapeMarkdownInline(value) {
1522
+ return value.replaceAll("`", "'");
1523
+ }
1524
+ function asRecord(value, message) {
1525
+ if (!value || typeof value !== "object" || Array.isArray(value)) {
1526
+ throw new Error(message);
1527
+ }
1528
+ return value;
1529
+ }
1530
+ function readRequiredString(record, key, manifestPath, index) {
1531
+ const value = readOptionalString(record, key);
1532
+ if (!value) {
1533
+ throw new Error(`QAMap manifest entry at index ${index} is missing ${key}: ${manifestPath}`);
1534
+ }
1535
+ return value;
1536
+ }
1537
+ function readOptionalString(record, key) {
1538
+ const value = record[key];
1539
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
1540
+ }
1541
+ function readStringArray(record, key) {
1542
+ const value = record[key];
1543
+ if (!Array.isArray(value)) {
1544
+ return [];
1545
+ }
1546
+ return value.filter((item) => typeof item === "string" && item.trim().length > 0);
1547
+ }
1548
+ function readCriticality(value, manifestPath, index) {
1549
+ if (value === "low" || value === "medium" || value === "high") {
1550
+ return value;
1551
+ }
1552
+ throw new Error(`QAMap manifest criticality at index ${index} must be low, medium, or high: ${manifestPath}`);
1553
+ }
1554
+ function readConfidence(value, manifestPath, index) {
1555
+ if (value === "low" || value === "medium" || value === "high") {
1556
+ return value;
1557
+ }
1558
+ throw new Error(`QAMap manifest confidence at index ${index} must be low, medium, or high: ${manifestPath}`);
1559
+ }
1560
+ function readSourceKind(value) {
1561
+ return value === "inferred" ? "inferred" : "declared";
1562
+ }
1563
+ function readRunner(value) {
1564
+ if (value === "manual" || value === "maestro" || value === "playwright") {
1565
+ return value;
1566
+ }
1567
+ return undefined;
1568
+ }
1569
+ function readAnchorKind(value, manifestPath, index) {
1570
+ if (value === "api" || value === "component" || value === "file" || value === "route" || value === "test") {
1571
+ return value;
1572
+ }
1573
+ throw new Error(`QAMap manifest anchor kind at index ${index} is invalid: ${manifestPath}`);
1574
+ }
1575
+ function readCheckType(value, manifestPath, index) {
1576
+ if (value === "contract" || value === "edge" || value === "failure" || value === "success" || value === "visual") {
1577
+ return value;
1578
+ }
1579
+ throw new Error(`QAMap manifest check type at index ${index} is invalid: ${manifestPath}`);
1580
+ }
1581
+ function readInstructionKind(value, manifestPath, index) {
1582
+ if (value === "adr" ||
1583
+ value === "agent-instruction" ||
1584
+ value === "context" ||
1585
+ value === "goal" ||
1586
+ value === "qa-runbook" ||
1587
+ value === "release-runbook" ||
1588
+ value === "runbook" ||
1589
+ value === "test-runbook") {
1590
+ return value;
1591
+ }
1592
+ throw new Error(`QAMap manifest context kind at index ${index} is invalid: ${manifestPath}`);
1593
+ }
1594
+ function readInstructionRole(value, manifestPath, index) {
1595
+ if (value === "agent-skill" ||
1596
+ value === "domain-context" ||
1597
+ value === "harness-config" ||
1598
+ value === "release-policy" ||
1599
+ value === "safety-policy" ||
1600
+ value === "test-runner" ||
1601
+ value === "verification-rubric" ||
1602
+ value === "workflow-lifecycle") {
1603
+ return value;
1604
+ }
1605
+ throw new Error(`QAMap manifest context role at index ${index} is invalid: ${manifestPath}`);
1606
+ }
1607
+ //# sourceMappingURL=manifest.js.map