@keber/qa-framework 1.12.0 → 1.13.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.
@@ -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
  // -----------------------------------------------------------------------
@@ -8,6 +8,9 @@
8
8
  * qa-framework validate --config ./my-config.json
9
9
  *
10
10
  * Checks:
11
+ * 0. Config values read by the sprint agents: approvalThresholdPercent (0 < x < 100),
12
+ * decimalSeparator, defectIdPrefix and, when sprint agents and lanes are enabled,
13
+ * parallelLanes / mcpNamespace (only present-but-invalid values are reported)
11
14
  * 1. Required top-level folders exist
12
15
  * 2. All submodule folders under 01-specifications/ contain the 6 required spec files
13
16
  * 3. No plaintext credentials in spec files (basic scan)
@@ -20,19 +23,27 @@
20
23
 
21
24
  const fs = require('fs');
22
25
  const path = require('path');
26
+ const { moduleBareName, submoduleBareName } = require('./lib/module-keys');
23
27
 
24
28
  // --- Args ---
25
29
  const args = process.argv.slice(2);
26
30
  const strict = args.includes('--strict');
27
31
  const configFlag = args.indexOf('--config');
32
+ const { validateAgentConfig } = require('./lib/claude-agents');
33
+
34
+ // Same lookup order as init/upgrade: --config, then root, then qa/.
28
35
  const configPath = configFlag !== -1
29
36
  ? path.resolve(process.cwd(), args[configFlag + 1])
30
- : path.resolve(process.cwd(), 'qa-framework.config.json');
37
+ : [
38
+ path.resolve(process.cwd(), 'qa-framework.config.json'),
39
+ path.resolve(process.cwd(), 'qa', 'qa-framework.config.json'),
40
+ ].find((candidate) => fs.existsSync(candidate));
31
41
 
32
42
  // --- Load config (optional) ---
33
43
  let qaRoot = path.resolve(process.cwd(), 'qa');
34
- if (fs.existsSync(configPath)) {
35
- const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
44
+ let config = null;
45
+ if (configPath && fs.existsSync(configPath)) {
46
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
36
47
  qaRoot = path.resolve(process.cwd(), config.conventions?.qaRoot ?? 'qa');
37
48
  }
38
49
 
@@ -42,6 +53,15 @@ if (strict) console.log('[qa-framework/validate] Mode: STRICT');
42
53
  const errors = [];
43
54
  const warnings = [];
44
55
 
56
+ // -----------------------------------------------------------------------
57
+ // Check 0: Config values (only present-but-invalid values are reported)
58
+ // -----------------------------------------------------------------------
59
+ if (config) {
60
+ for (const message of validateAgentConfig(config)) {
61
+ errors.push(`Invalid config (${path.relative(process.cwd(), configPath)}): ${message}`);
62
+ }
63
+ }
64
+
45
65
  // -----------------------------------------------------------------------
46
66
  // Check 1: Required top-level folders
47
67
  // -----------------------------------------------------------------------
@@ -94,11 +114,16 @@ if (fs.existsSync(specsRoot)) {
94
114
  }
95
115
  }
96
116
 
97
- // Strict: automation spec must exist
117
+ // Strict: automation spec must exist.
118
+ // Spec folders are module-{name}/submodule-{name}; e2e/tests/ is keyed by the
119
+ // bare name, so the prefixes are stripped before deriving the automation path.
120
+ // Without this the warning points at a folder no project has ever created.
98
121
  if (strict) {
99
- const specTs = path.join(qaRoot, '07-automation', 'e2e', 'tests', moduleDir, `${subDir}.spec.ts`);
122
+ const moduleName = moduleBareName(moduleDir);
123
+ const subName = submoduleBareName(subDir);
124
+ const specTs = path.join(qaRoot, '07-automation', 'e2e', 'tests', moduleName, `${subName}.spec.ts`);
100
125
  if (!fs.existsSync(specTs)) {
101
- warnings.push(`[STRICT] No automation spec found: qa/07-automation/e2e/tests/${moduleDir}/${subDir}.spec.ts`);
126
+ warnings.push(`[STRICT] No automation spec found: qa/07-automation/e2e/tests/${moduleName}/${subName}.spec.ts`);
102
127
  }
103
128
  }
104
129
  }