@keber/qa-framework 1.12.0 → 1.13.1

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 (42) hide show
  1. package/CHANGELOG.md +359 -0
  2. package/README.md +41 -1
  3. package/docs/installation.md +11 -4
  4. package/docs/usage-with-agent.md +14 -9
  5. package/package.json +3 -4
  6. package/qa-framework.config.json +3 -2
  7. package/scripts/init.js +51 -10
  8. package/scripts/lib/claude-agents.js +278 -9
  9. package/scripts/lib/module-keys.js +54 -0
  10. package/scripts/sync-version.js +27 -0
  11. package/scripts/upgrade.js +16 -3
  12. package/scripts/validate.js +31 -6
  13. package/templates/agents/qa-informe-resultados.md +20 -18
  14. package/templates/agents/qa-investigador.md +143 -0
  15. package/templates/agents/qa-reporte-ejecucion.md +138 -0
  16. package/templates/qa-framework.instructions.md +1 -1
  17. package/templates/qa-framework.rules.md +1 -1
  18. package/docs/visuals/diag-01-ejecutivo-1.png +0 -0
  19. package/docs/visuals/diag-02-tactico-1.png +0 -0
  20. package/docs/visuals/diag-03-tecnico-1.png +0 -0
  21. package/docs/visuals/info-01-ejecutivo-1.png +0 -0
  22. package/docs/visuals/info-02-tactico-1.png +0 -0
  23. package/docs/visuals/info-03-tecnico-1.png +0 -0
  24. package/docs/visuals/pres-01-ejecutivo-1.png +0 -0
  25. package/docs/visuals/pres-02-tactico-1.png +0 -0
  26. package/docs/visuals/pres-03-tecnico-1.png +0 -0
  27. package/docs/visuals/qa-01-problema-1.png +0 -0
  28. package/docs/visuals/qa-02-que-es-1.png +0 -0
  29. package/docs/visuals/qa-03-pipeline-1.png +0 -0
  30. package/docs/visuals/qa-04-6-archivos-1.png +0 -0
  31. package/docs/visuals/qa-05-humano-1.png +0 -0
  32. package/docs/visuals/qa-06-agente-1.png +0 -0
  33. package/docs/visuals/qa-07-skills-arch-1.png +0 -0
  34. package/docs/visuals/qa-08-traceability-1.png +0 -0
  35. package/docs/visuals/qa-en-01-problem-1.png +0 -0
  36. package/docs/visuals/qa-en-02-what-is-1.png +0 -0
  37. package/docs/visuals/qa-en-03-pipeline-1.png +0 -0
  38. package/docs/visuals/qa-en-04-6files-1.png +0 -0
  39. package/docs/visuals/qa-en-05-human-1.png +0 -0
  40. package/docs/visuals/qa-en-06-agent-1.png +0 -0
  41. package/docs/visuals/qa-en-07-skills-arch-1.png +0 -0
  42. package/docs/visuals/qa-en-08-traceability-1.png +0 -0
package/scripts/init.js CHANGED
@@ -16,7 +16,7 @@ const fs = require('fs');
16
16
  const path = require('path');
17
17
 
18
18
  const { buildCommandContent, discoverSkillNames, commandFileName } = require('./lib/claude-commands');
19
- const { AGENT_NAMES, isSprintCycleEnabled, buildAgentContent, agentFileName } = require('./lib/claude-agents');
19
+ const { AGENT_NAMES, isSprintCycleEnabled, renderAllAgents } = require('./lib/claude-agents');
20
20
 
21
21
  // --- Parse args ---
22
22
  const args = process.argv.slice(2);
@@ -195,13 +195,26 @@ const SPEC_FILES = [
195
195
 
196
196
  const templateDir = path.resolve(__dirname, '..', 'templates', 'specification');
197
197
 
198
+ // Spec folders carry the module-/submodule- prefix; automation folders and TC ids
199
+ // use the bare key. See scripts/lib/module-keys.js for why the two differ.
200
+ const {
201
+ moduleSpecDirName,
202
+ submoduleSpecDirName,
203
+ moduleBareName,
204
+ submoduleBareName,
205
+ } = require('./lib/module-keys');
206
+
198
207
  for (const mod of modules) {
199
208
  const moduleKey = mod.key ?? mod.name.toLowerCase().replace(/\s+/g, '-');
209
+ const moduleDirName = moduleSpecDirName(moduleKey);
210
+ const moduleName = moduleBareName(moduleKey);
200
211
  const submodules = mod.submodules ?? [];
201
212
 
202
213
  for (const sub of submodules) {
203
214
  const subKey = sub.key ?? sub.name.toLowerCase().replace(/\s+/g, '-');
204
- const subDir = path.join(qaRoot, '01-specifications', moduleKey, subKey);
215
+ const subDirName = submoduleSpecDirName(subKey);
216
+ const subName = submoduleBareName(subKey);
217
+ const subDir = path.join(qaRoot, '01-specifications', moduleDirName, subDirName);
205
218
  fs.mkdirSync(subDir, { recursive: true });
206
219
 
207
220
  for (const specFile of SPEC_FILES) {
@@ -213,8 +226,8 @@ for (const mod of modules) {
213
226
  content = content
214
227
  .replace(/\{\{MODULE_NAME\}\}/g, mod.name)
215
228
  .replace(/\{\{SUBMODULE_NAME\}\}/g, sub.name)
216
- .replace(/\{\{MODULE\}\}/g, moduleKey.toUpperCase())
217
- .replace(/\{\{SUB\}\}/g, subKey.toUpperCase());
229
+ .replace(/\{\{MODULE\}\}/g, moduleName.toUpperCase())
230
+ .replace(/\{\{SUB\}\}/g, subName.toUpperCase());
218
231
  fs.writeFileSync(dest, content, 'utf8');
219
232
  } else {
220
233
  fs.writeFileSync(dest, `# ${specFile}\n\n> Auto-generated placeholder\n`, 'utf8');
@@ -226,11 +239,11 @@ for (const mod of modules) {
226
239
  }
227
240
 
228
241
  // Create automation spec stub in e2e/tests/{module}/
229
- const e2eDir = path.join(qaRoot, '07-automation', 'e2e', 'tests', moduleKey);
242
+ const e2eDir = path.join(qaRoot, '07-automation', 'e2e', 'tests', moduleName);
230
243
  fs.mkdirSync(e2eDir, { recursive: true });
231
- const specTs = path.join(e2eDir, `${subKey}.spec.ts`);
244
+ const specTs = path.join(e2eDir, `${subName}.spec.ts`);
232
245
  if (!fs.existsSync(specTs)) {
233
- fs.writeFileSync(specTs, specScaffold(mod.name, sub.name, moduleKey, subKey), 'utf8');
246
+ fs.writeFileSync(specTs, specScaffold(mod.name, sub.name, moduleName, subName), 'utf8');
234
247
  console.log(` [created] ${path.relative(cwd, specTs)}`);
235
248
  }
236
249
  }
@@ -347,12 +360,19 @@ writeIfMissing(claudeRulesPath, claudeRulesContent);
347
360
  // --- .claude/agents/qa-*.md - optional ANALISIS/PLAN sprint-cycle mode (Claude Code only) ---
348
361
  // Gated by integrations.azureDevOps.sprintCycle.enabled. No .github/ equivalent: Claude
349
362
  // Code subagents with per-agent tools/model frontmatter have no Copilot counterpart.
363
+ // Each agent is rendered independently: an invalid lane config fails qa-investigador
364
+ // only, the other agents are still written, and init exits 1 at the end.
365
+ const agentFailures = [];
350
366
  if (isSprintCycleEnabled(config)) {
351
367
  const templatesDir = path.resolve(__dirname, '..', 'templates');
352
368
  const claudeAgentsDest = path.join(cwd, '.claude', 'agents');
353
369
  fs.mkdirSync(claudeAgentsDest, { recursive: true });
354
- for (const agentName of AGENT_NAMES) {
355
- writeIfMissing(path.join(claudeAgentsDest, agentFileName(agentName)), buildAgentContent(agentName, templatesDir, config));
370
+ for (const agent of renderAllAgents(templatesDir, config)) {
371
+ if (agent.error) {
372
+ agentFailures.push(agent);
373
+ continue;
374
+ }
375
+ writeIfMissing(path.join(claudeAgentsDest, agent.fileName), agent.content);
356
376
  }
357
377
  }
358
378
 
@@ -414,7 +434,11 @@ console.log(' .github/instructions/ qa-framework.instructions.md');
414
434
  console.log(' .claude/commands/ qa-*.md (Claude Code slash commands)');
415
435
  console.log(' .claude/rules/ qa-framework.md (Claude Code always-loaded rules)');
416
436
  if (isSprintCycleEnabled(config)) {
417
- console.log(' .claude/agents/ qa-analisis/qa-plan/qa-asesoria/qa-informe-resultados.md (ANALISIS/PLAN mode)');
437
+ const failedAgents = new Set(agentFailures.map((a) => a.agentName));
438
+ const generatedAgents = AGENT_NAMES.filter((name) => !failedAgents.has(name));
439
+ if (generatedAgents.length) {
440
+ console.log(` .claude/agents/ ${generatedAgents.join('/')}.md (ANALISIS/PLAN mode)`);
441
+ }
418
442
  }
419
443
  console.log('');
420
444
  console.log(' Optional integrations:');
@@ -438,6 +462,23 @@ console.log('');
438
462
  console.log(' Next: edit qa/qa-framework.config.json, then read qa/AGENT-NEXT-STEPS.md');
439
463
  console.log('');
440
464
 
465
+ if (agentFailures.length) {
466
+ if (skipIfExists) {
467
+ // Postinstall mode: a non-zero exit would make `npm install` of this package fail,
468
+ // which blocks the user from ever reaching the fix. Report loudly, exit 0.
469
+ for (const { agentName, fileName, error } of agentFailures) {
470
+ console.error(`[qa-framework/init] WARNING: agent ${agentName} (.claude/agents/${fileName}) was not generated. ${error.message}`);
471
+ }
472
+ console.error('[qa-framework/init] WARNING: fix qa-framework.config.json (run "npx qa-framework validate"), then run "npx qa-framework upgrade" to generate the missing agent.');
473
+ } else {
474
+ for (const { fileName, error } of agentFailures) {
475
+ console.error(`[qa-framework/init] ERROR: .claude/agents/${fileName} was not generated. ${error.message}`);
476
+ }
477
+ console.error('[qa-framework/init] Fix qa-framework.config.json (run "qa-framework validate") and run init or upgrade again.');
478
+ process.exitCode = 1;
479
+ }
480
+ }
481
+
441
482
  // -----------------------------------------------------------------------
442
483
  // Helpers
443
484
  // -----------------------------------------------------------------------
@@ -7,22 +7,71 @@
7
7
  * qa-framework.config.json. Claude Code only - subagents with per-agent tools/model
8
8
  * frontmatter have no GitHub Copilot equivalent, so no .github/ counterpart is generated.
9
9
  *
10
- * Placeholders are resolved from config.integrations.azureDevOps.sprintCycle, falling back
11
- * to neutral defaults when a field (or the whole config section) is absent.
10
+ * Placeholders are resolved from the project config, falling back to neutral defaults
11
+ * when a field (or the whole config section) is absent.
12
+ *
13
+ * Template syntax:
14
+ * {{NAME}} placeholder, replaced in a single pass (values are
15
+ * inserted verbatim and never re-expanded)
16
+ * {{#if FLAG}}...{{/if}} kept only when FLAG is true (not nestable)
17
+ * {{#unless FLAG}}...{{/unless}} kept only when FLAG is false (not nestable)
18
+ *
19
+ * Any {{...}} token left after rendering is a template bug and throws.
20
+ *
21
+ * buildAgentContent() throws per agent, so callers can generate the agents that are
22
+ * valid, report the one that is not, and exit non-zero.
12
23
  */
13
24
 
14
25
  const fs = require('fs');
15
26
  const path = require('path');
16
27
 
17
- const AGENT_NAMES = ['qa-analisis', 'qa-plan', 'qa-asesoria', 'qa-informe-resultados'];
28
+ const { laneTableFromConfig, DEFAULT_LANE_TABLE } = require('../../templates/automation-scaffold/scripts/lane-config');
29
+
30
+ const AGENT_NAMES = [
31
+ 'qa-analisis',
32
+ 'qa-plan',
33
+ 'qa-asesoria',
34
+ 'qa-informe-resultados',
35
+ 'qa-reporte-ejecucion',
36
+ 'qa-investigador',
37
+ ];
18
38
 
19
39
  const DEFAULT_SPRINT_CYCLE = {
20
40
  sprintDurationDays: 8,
21
41
  manualTestingTimeboxDays: 2,
22
42
  dateFormat: 'dd-mm-aaaa',
23
43
  timezone: 'America/Santiago',
44
+ approvalThresholdPercent: 85,
24
45
  };
25
46
 
47
+ const DEFAULT_TC_ID_PATTERN = 'TC-{MODULE}-{SUBMODULE}-{NUM}';
48
+ const NOT_CONFIGURED = '(no configurado: ver qa/qa-framework.config.json)';
49
+
50
+ const MCP_NAMESPACE_PATTERN = /^mcp__[A-Za-z0-9_-]+__$/;
51
+ const DEFECT_ID_PREFIX_PATTERN = /^[A-Z0-9-]+$/;
52
+ const DECIMAL_SEPARATORS = [',', '.'];
53
+
54
+ // Playwright MCP tools granted to qa-investigador per lane namespace, in this order.
55
+ const BROWSER_TOOLS = [
56
+ 'browser_navigate',
57
+ 'browser_snapshot',
58
+ 'browser_click',
59
+ 'browser_type',
60
+ 'browser_fill_form',
61
+ 'browser_select_option',
62
+ 'browser_hover',
63
+ 'browser_press_key',
64
+ 'browser_wait_for',
65
+ 'browser_evaluate',
66
+ 'browser_network_requests',
67
+ 'browser_console_messages',
68
+ 'browser_take_screenshot',
69
+ 'browser_find',
70
+ 'browser_close',
71
+ ];
72
+
73
+ const INVESTIGATOR_BASE_TOOLS = ['Read', 'Write', 'Edit', 'Grep', 'Glob', 'Bash'];
74
+
26
75
  const LOCALE_LANGUAGE_LABELS = {
27
76
  es: 'espanol',
28
77
  'es-cl': 'espanol de Chile',
@@ -31,6 +80,10 @@ const LOCALE_LANGUAGE_LABELS = {
31
80
  en: 'English',
32
81
  };
33
82
 
83
+ // Tokens whose value depends on the lane table. They are resolved only for templates
84
+ // that use them, so an invalid lane config fails qa-investigador and nothing else.
85
+ const LANE_TOKENS = ['{{INVESTIGATOR_TOOLS}}', '{{LANE_MCP_TABLE}}', 'HAS_MCP_LANES'];
86
+
34
87
  function isSprintCycleEnabled(config) {
35
88
  return Boolean(config?.integrations?.azureDevOps?.sprintCycle?.enabled);
36
89
  }
@@ -40,9 +93,98 @@ function resolveLocaleLanguageLabel(locale) {
40
93
  return LOCALE_LANGUAGE_LABELS[locale.toLowerCase()] ?? locale;
41
94
  }
42
95
 
96
+ function isValidApprovalThreshold(value) {
97
+ return typeof value === 'number' && Number.isFinite(value) && value > 0 && value < 100;
98
+ }
99
+
100
+ function resolveApprovalThresholdPercent(sprintCycle) {
101
+ const value = sprintCycle.approvalThresholdPercent;
102
+ // Invalid values are reported by `qa-framework validate`; generation keeps the default.
103
+ return isValidApprovalThreshold(value) ? value : DEFAULT_SPRINT_CYCLE.approvalThresholdPercent;
104
+ }
105
+
106
+ function formatRatio(percent) {
107
+ // Rounded to strip binary float noise (e.g. 57 / 100), always with a dot.
108
+ return String(Number((percent / 100).toFixed(6)));
109
+ }
110
+
111
+ function resolveDecimalSeparator(config) {
112
+ const explicit = config?.conventions?.decimalSeparator;
113
+ if (DECIMAL_SEPARATORS.includes(explicit)) return explicit;
114
+ const locale = config?.conventions?.locale ?? config?.conventions?.language;
115
+ if (!locale) return ',';
116
+ return String(locale).toLowerCase().startsWith('es') ? ',' : '.';
117
+ }
118
+
119
+ function resolveDefectIdPrefix(config) {
120
+ const explicit = config?.conventions?.defectIdPrefix;
121
+ if (typeof explicit === 'string' && DEFECT_ID_PREFIX_PATTERN.test(explicit)) return explicit;
122
+ return config?.project?.name ? config.project.name.toUpperCase().replace(/[^A-Z0-9]/g, '-') : 'PROJ';
123
+ }
124
+
125
+ function resolveProjectField(value) {
126
+ if (typeof value !== 'string' || value.trim() === '' || value.includes('{{')) return NOT_CONFIGURED;
127
+ return value;
128
+ }
129
+
130
+ /**
131
+ * MCP-capable lanes in config order: [{ id, mcpNamespace }].
132
+ *
133
+ * Validation is delegated to laneTableFromConfig (the same reader the automation
134
+ * scaffold uses). Order comes from the config array, not from the table object,
135
+ * because numeric-like lane ids would otherwise be reordered by the JS engine.
136
+ * Throws on an invalid lane block or an mcpNamespace that is not a tool prefix.
137
+ */
138
+ function resolveMcpLanes(config) {
139
+ const table = laneTableFromConfig(config);
140
+ if (table === DEFAULT_LANE_TABLE) return [];
141
+
142
+ const lanes = [];
143
+ for (const [index, entry] of config.parallelLanes.lanes.entries()) {
144
+ const lane = table[entry.id];
145
+ if (!lane || lane.mcpNamespace === null) continue;
146
+ if (!MCP_NAMESPACE_PATTERN.test(lane.mcpNamespace)) {
147
+ throw new Error(
148
+ `parallelLanes.lanes[${index}].mcpNamespace must match ${MCP_NAMESPACE_PATTERN} ` +
149
+ `(e.g. "mcp__playwright-qauser__"). Got: ${JSON.stringify(lane.mcpNamespace)}`
150
+ );
151
+ }
152
+ lanes.push({ id: entry.id, mcpNamespace: lane.mcpNamespace });
153
+ }
154
+ return lanes;
155
+ }
156
+
157
+ function buildLanePlaceholders(config) {
158
+ const mcpLanes = resolveMcpLanes(config);
159
+
160
+ const tools = [...INVESTIGATOR_BASE_TOOLS];
161
+ for (const { mcpNamespace } of mcpLanes) {
162
+ for (const tool of BROWSER_TOOLS) tools.push(`${mcpNamespace}${tool}`);
163
+ }
164
+
165
+ const tableLines = mcpLanes.length
166
+ ? [
167
+ '| Lane | Servidor MCP | Prefijo de herramientas |',
168
+ '| --- | --- | --- |',
169
+ ...mcpLanes.map(({ id, mcpNamespace }) =>
170
+ `| ${id} | ${mcpNamespace.replace(/^mcp__/, '').replace(/__$/, '')} | \`${mcpNamespace}\` |`
171
+ ),
172
+ ]
173
+ : [];
174
+
175
+ return {
176
+ placeholders: {
177
+ '{{INVESTIGATOR_TOOLS}}': tools.join(', '),
178
+ '{{LANE_MCP_TABLE}}': tableLines.join('\n'),
179
+ },
180
+ flags: { HAS_MCP_LANES: mcpLanes.length > 0 },
181
+ };
182
+ }
183
+
43
184
  function buildPlaceholders(config) {
44
185
  const sprintCycle = { ...DEFAULT_SPRINT_CYCLE, ...(config?.integrations?.azureDevOps?.sprintCycle ?? {}) };
45
186
  const project = config?.project ?? {};
187
+ const approvalPercent = resolveApprovalThresholdPercent(sprintCycle);
46
188
 
47
189
  return {
48
190
  '{{PROJECT_NAME}}': project.name ?? '{{PROJECT_NAME}}',
@@ -52,18 +194,138 @@ function buildPlaceholders(config) {
52
194
  '{{DATE_FORMAT}}': sprintCycle.dateFormat,
53
195
  '{{TIMEZONE}}': sprintCycle.timezone,
54
196
  '{{LOCALE_LANGUAGE_LABEL}}': resolveLocaleLanguageLabel(config?.conventions?.locale ?? config?.conventions?.language),
55
- '{{DEFECT_ID_PREFIX}}': config?.project?.name ? config.project.name.toUpperCase().replace(/[^A-Z0-9]/g, '-') : 'PROJ',
197
+ '{{DEFECT_ID_PREFIX}}': resolveDefectIdPrefix(config),
198
+ '{{APPROVAL_THRESHOLD_PERCENT}}': String(approvalPercent).replace('.', resolveDecimalSeparator(config)),
199
+ '{{APPROVAL_THRESHOLD_RATIO}}': formatRatio(approvalPercent),
200
+ '{{DECIMAL_SEPARATOR}}': resolveDecimalSeparator(config),
201
+ '{{TC_ID_PATTERN}}': config?.conventions?.testCaseIdPattern || DEFAULT_TC_ID_PATTERN,
202
+ '{{QA_BASE_URL}}': resolveProjectField(project.qaBaseUrl),
203
+ '{{LOGIN_PATH}}': resolveProjectField(project.loginPath),
204
+ '{{TECH_STACK}}': resolveProjectField(project.techStack),
56
205
  };
57
206
  }
58
207
 
208
+ /**
209
+ * Renders conditional blocks, then placeholders, then guards against leftovers.
210
+ *
211
+ * A block whose opening and closing tags sit on their own lines is removed together
212
+ * with those lines, so dropping a block never leaves an extra blank line behind.
213
+ */
214
+ function renderTemplate(template, placeholders, flags, agentName) {
215
+ let content = template;
216
+
217
+ for (const [keyword, keepWhen] of [['if', true], ['unless', false]]) {
218
+ const ownLine = new RegExp(
219
+ `^[ \\t]*\\{\\{#${keyword} ([A-Z0-9_]+)\\}\\}[ \\t]*\\r?\\n([\\s\\S]*?)^[ \\t]*\\{\\{\\/${keyword}\\}\\}[ \\t]*(?:\\r?\\n|$)`,
220
+ 'gm'
221
+ );
222
+ const inline = new RegExp(`\\{\\{#${keyword} ([A-Z0-9_]+)\\}\\}([\\s\\S]*?)\\{\\{\\/${keyword}\\}\\}`, 'g');
223
+ const choose = (match, flag, body) => (Boolean(flags[flag]) === keepWhen ? body : '');
224
+ content = content.replace(ownLine, choose).replace(inline, choose);
225
+ }
226
+
227
+ const leftovers = new Set();
228
+ content = content.replace(/\{\{[#/]?[A-Za-z0-9_ ]+\}\}/g, (token) => {
229
+ if (Object.prototype.hasOwnProperty.call(placeholders, token)) return placeholders[token];
230
+ leftovers.add(token);
231
+ return token;
232
+ });
233
+
234
+ if (leftovers.size) {
235
+ throw new Error(
236
+ `Template for agent ${agentName} has unresolved tokens: ${[...leftovers].join(', ')}`
237
+ );
238
+ }
239
+ return content;
240
+ }
241
+
59
242
  function buildAgentContent(agentName, templatesDir, config) {
60
243
  const templatePath = path.join(templatesDir, 'agents', `${agentName}.md`);
61
- let content = fs.readFileSync(templatePath, 'utf8');
62
- const placeholders = buildPlaceholders(config);
63
- for (const [placeholder, value] of Object.entries(placeholders)) {
64
- content = content.split(placeholder).join(value);
244
+ try {
245
+ const template = fs.readFileSync(templatePath, 'utf8');
246
+ const placeholders = buildPlaceholders(config);
247
+ let flags = {};
248
+ if (LANE_TOKENS.some((token) => template.includes(token))) {
249
+ const lane = buildLanePlaceholders(config);
250
+ Object.assign(placeholders, lane.placeholders);
251
+ flags = lane.flags;
252
+ }
253
+ return renderTemplate(template, placeholders, flags, agentName);
254
+ } catch (err) {
255
+ if (err.message.startsWith(`Cannot generate agent ${agentName}`)) throw err;
256
+ throw new Error(`Cannot generate agent ${agentName}: ${err.message.replace(/^\[lane-config\] /, '')}`);
65
257
  }
66
- return content;
258
+ }
259
+
260
+ /**
261
+ * Renders every agent, never letting one failure stop the others.
262
+ * Returns [{ agentName, fileName, content }] or [{ agentName, fileName, error }].
263
+ */
264
+ function renderAllAgents(templatesDir, config) {
265
+ return AGENT_NAMES.map((agentName) => {
266
+ const fileName = agentFileName(agentName);
267
+ try {
268
+ return { agentName, fileName, content: buildAgentContent(agentName, templatesDir, config) };
269
+ } catch (error) {
270
+ return { agentName, fileName, error };
271
+ }
272
+ });
273
+ }
274
+
275
+ /**
276
+ * Present-but-invalid values of the config keys the agents consume.
277
+ * Absent keys are never reported. Returns an array of messages.
278
+ */
279
+ function validateAgentConfig(config) {
280
+ const errors = [];
281
+ const conventions = config?.conventions ?? {};
282
+ const sprintCycle = config?.integrations?.azureDevOps?.sprintCycle ?? {};
283
+
284
+ if ('approvalThresholdPercent' in sprintCycle && !isValidApprovalThreshold(sprintCycle.approvalThresholdPercent)) {
285
+ errors.push(
286
+ 'integrations.azureDevOps.sprintCycle.approvalThresholdPercent must be a number greater than 0 and less than 100. ' +
287
+ `Got: ${JSON.stringify(sprintCycle.approvalThresholdPercent)}`
288
+ );
289
+ }
290
+ if ('decimalSeparator' in conventions && !DECIMAL_SEPARATORS.includes(conventions.decimalSeparator)) {
291
+ errors.push(`conventions.decimalSeparator must be "," or ".". Got: ${JSON.stringify(conventions.decimalSeparator)}`);
292
+ }
293
+ if (
294
+ 'defectIdPrefix' in conventions &&
295
+ (typeof conventions.defectIdPrefix !== 'string' || !DEFECT_ID_PREFIX_PATTERN.test(conventions.defectIdPrefix))
296
+ ) {
297
+ errors.push(
298
+ `conventions.defectIdPrefix must match ${DEFECT_ID_PREFIX_PATTERN} (uppercase letters, digits, "-"). ` +
299
+ `Got: ${JSON.stringify(conventions.defectIdPrefix)}`
300
+ );
301
+ }
302
+
303
+ // Lanes are only checked when the generator actually consumes them: sprint agents on
304
+ // and a lane block that laneTableFromConfig does not short-circuit (enabled !== false).
305
+ // A disabled block, or a project without sprint agents, never fails validate here.
306
+ const block = config?.parallelLanes;
307
+ const lanesInUse =
308
+ isSprintCycleEnabled(config) && block !== undefined && block !== null && block.enabled !== false;
309
+ if (!lanesInUse) return errors;
310
+
311
+ const lanes = block.lanes;
312
+ if (Array.isArray(lanes)) {
313
+ lanes.forEach((lane, index) => {
314
+ const ns = lane && typeof lane === 'object' ? lane.mcpNamespace : undefined;
315
+ if (ns !== undefined && ns !== null && (typeof ns !== 'string' || !MCP_NAMESPACE_PATTERN.test(ns))) {
316
+ errors.push(
317
+ `parallelLanes.lanes[${index}].mcpNamespace must match ${MCP_NAMESPACE_PATTERN} or be null. Got: ${JSON.stringify(ns)}`
318
+ );
319
+ }
320
+ });
321
+ }
322
+ try {
323
+ laneTableFromConfig(config);
324
+ } catch (err) {
325
+ errors.push(`parallelLanes: ${err.message.replace(/^\[lane-config\] /, '')}`);
326
+ }
327
+
328
+ return errors;
67
329
  }
68
330
 
69
331
  function agentFileName(agentName) {
@@ -72,7 +334,14 @@ function agentFileName(agentName) {
72
334
 
73
335
  module.exports = {
74
336
  AGENT_NAMES,
337
+ BROWSER_TOOLS,
338
+ DEFAULT_SPRINT_CYCLE,
75
339
  isSprintCycleEnabled,
340
+ buildPlaceholders,
341
+ resolveMcpLanes,
342
+ renderTemplate,
76
343
  buildAgentContent,
344
+ renderAllAgents,
345
+ validateAgentConfig,
77
346
  agentFileName,
78
347
  };
@@ -0,0 +1,54 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * scripts/lib/module-keys.js - the one place that knows how a module or submodule
5
+ * key maps to a folder name.
6
+ *
7
+ * Two layouts coexist on purpose, and they are NOT the same string:
8
+ *
9
+ * specs qa/01-specifications/module-{name}/submodule-{name}/
10
+ * automation qa/07-automation/e2e/tests/{name}/{name}.spec.ts
11
+ *
12
+ * The prefixed form is what every skill under skills/, docs/folder-structure-guide.md
13
+ * and the "specPath" field of qa-framework.config.json declare. The bare form is what
14
+ * "automationPath" declares and what the live consuming projects actually contain.
15
+ * init.js used to write the bare form in both places, so a freshly scaffolded project
16
+ * violated the spec-path convention its own agent instructions impose.
17
+ *
18
+ * Both helpers are idempotent: a config that already spells the key as
19
+ * "module-suppliers" must not become "module-module-suppliers", and a key written
20
+ * bare must not lose a leading segment that merely looks like a prefix.
21
+ */
22
+
23
+ /** Prefix `key` with `prefix-` unless it already carries it. */
24
+ function withPrefix(prefix, key) {
25
+ const marker = `${prefix}-`;
26
+ return key.startsWith(marker) ? key : marker + key;
27
+ }
28
+
29
+ /** Strip a leading `prefix-` from `key` if present. */
30
+ function withoutPrefix(prefix, key) {
31
+ const marker = `${prefix}-`;
32
+ return key.startsWith(marker) ? key.slice(marker.length) : key;
33
+ }
34
+
35
+ /** Folder name for a module's spec tree: module-{name}. */
36
+ const moduleSpecDirName = key => withPrefix('module', key);
37
+
38
+ /** Folder name for a submodule's spec tree: submodule-{name}. */
39
+ const submoduleSpecDirName = key => withPrefix('submodule', key);
40
+
41
+ /** Bare module name used by automation paths and TC ids. */
42
+ const moduleBareName = key => withoutPrefix('module', key);
43
+
44
+ /** Bare submodule name used by automation paths and TC ids. */
45
+ const submoduleBareName = key => withoutPrefix('submodule', key);
46
+
47
+ module.exports = {
48
+ withPrefix,
49
+ withoutPrefix,
50
+ moduleSpecDirName,
51
+ submoduleSpecDirName,
52
+ moduleBareName,
53
+ submoduleBareName,
54
+ };
@@ -13,6 +13,15 @@ const targets = [
13
13
  path.join(rootDir, '.github', 'copilot-instructions.md'),
14
14
  ];
15
15
 
16
+ // qa-framework.config.json is published and is the template init.js copies into every
17
+ // consuming project, where its frameworkVersion is injected into {{VERSION}} of the
18
+ // generated agent instructions. Left out of this sync it silently drifted to 1.10.0
19
+ // while the package shipped 1.12.0, so every fresh install told its agent the wrong
20
+ // version. It is patched by field, not by the loose vX.Y.Z pattern used for prose.
21
+ const jsonTargets = [
22
+ { file: path.join(rootDir, 'qa-framework.config.json'), field: 'frameworkVersion' },
23
+ ];
24
+
16
25
  // templates/qa-framework.instructions.md and templates/qa-framework.rules.md contain a
17
26
  // literal {{VERSION}} placeholder substituted by init.js/upgrade.js in the consumer
18
27
  // project at install/upgrade time. That placeholder is NOT a hardcoded version string
@@ -20,6 +29,24 @@ const targets = [
20
29
 
21
30
  const versionPattern = /v\d+\.\d+\.\d+/;
22
31
 
32
+ for (const { file: filePath, field } of jsonTargets) {
33
+ const original = fs.readFileSync(filePath, 'utf8');
34
+ const fieldPattern = new RegExp(
35
+ `("${field}"\\s*:\\s*")\\d+\\.\\d+\\.\\d+(")`
36
+ );
37
+
38
+ if (!fieldPattern.test(original)) {
39
+ throw new Error(`sync-version: no "${field}" version field in ${filePath}`);
40
+ }
41
+
42
+ const updated = original.replace(fieldPattern, `$1${version}$2`);
43
+
44
+ if (updated !== original) {
45
+ fs.writeFileSync(filePath, updated, 'utf8');
46
+ console.log(`sync-version: updated ${path.relative(rootDir, filePath)} ${field} -> ${version}`);
47
+ }
48
+ }
49
+
23
50
  for (const filePath of targets) {
24
51
  const original = fs.readFileSync(filePath, 'utf8');
25
52
 
@@ -77,7 +77,7 @@ const fs = require('fs');
77
77
  const path = require('path');
78
78
 
79
79
  const { buildCommandContent, discoverSkillNames, commandFileName } = require('./lib/claude-commands');
80
- const { AGENT_NAMES, isSprintCycleEnabled, buildAgentContent, agentFileName } = require('./lib/claude-agents');
80
+ const { isSprintCycleEnabled, renderAllAgents } = require('./lib/claude-agents');
81
81
 
82
82
  const args = process.argv.slice(2);
83
83
  const dryRun = args.includes('--dry-run');
@@ -152,6 +152,7 @@ const SHIPPED_SCAFFOLD_HASHES = {
152
152
  const updated = [];
153
153
  const skipped = [];
154
154
  const warnings = [];
155
+ const errors = [];
155
156
 
156
157
  console.log(`\n[qa-framework/upgrade] ${dryRun ? '(dry-run) ' : ''}Upgrading framework-owned files`);
157
158
  console.log(`[qa-framework/upgrade] Project root: ${cwd}\n`);
@@ -197,8 +198,14 @@ forceWrite(claudeRulesPath, claudeRulesContent);
197
198
  if (isSprintCycleEnabled(config)) {
198
199
  const templatesDir = path.resolve(__dirname, '..', 'templates');
199
200
  const claudeAgentsDest = path.join(cwd, '.claude', 'agents');
200
- for (const agentName of AGENT_NAMES) {
201
- forceWrite(path.join(claudeAgentsDest, agentFileName(agentName)), buildAgentContent(agentName, templatesDir, config));
201
+ // Rendered per agent: one invalid agent (e.g. qa-investigador with a bad lane config)
202
+ // is reported and left untouched on disk, the others are still upgraded.
203
+ for (const agent of renderAllAgents(templatesDir, config)) {
204
+ if (agent.error) {
205
+ errors.push(`.claude/agents/${agent.fileName} was not generated. ${agent.error.message}`);
206
+ continue;
207
+ }
208
+ forceWrite(path.join(claudeAgentsDest, agent.fileName), agent.content);
202
209
  }
203
210
  }
204
211
 
@@ -704,6 +711,12 @@ if (warnings.length) {
704
711
  for (const w of warnings) console.log(` ⚠️ ${w}`);
705
712
  }
706
713
 
714
+ if (errors.length) {
715
+ for (const e of errors) console.error(`[qa-framework/upgrade] ERROR: ${e}`);
716
+ console.error('[qa-framework/upgrade] Fix qa-framework.config.json (run "qa-framework validate") and run upgrade again.');
717
+ process.exitCode = 1;
718
+ }
719
+
707
720
  console.log('\n[qa-framework/upgrade] Done.\n');
708
721
 
709
722
  // -----------------------------------------------------------------------