@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.
- package/CHANGELOG.md +359 -0
- package/README.md +41 -1
- package/docs/installation.md +11 -4
- package/docs/usage-with-agent.md +14 -9
- package/package.json +3 -4
- package/qa-framework.config.json +3 -2
- package/scripts/init.js +51 -10
- package/scripts/lib/claude-agents.js +278 -9
- package/scripts/lib/module-keys.js +54 -0
- package/scripts/sync-version.js +27 -0
- package/scripts/upgrade.js +16 -3
- package/scripts/validate.js +31 -6
- package/templates/agents/qa-informe-resultados.md +20 -18
- package/templates/agents/qa-investigador.md +143 -0
- package/templates/agents/qa-reporte-ejecucion.md +138 -0
- package/templates/qa-framework.instructions.md +1 -1
- package/templates/qa-framework.rules.md +1 -1
- package/docs/visuals/diag-01-ejecutivo-1.png +0 -0
- package/docs/visuals/diag-02-tactico-1.png +0 -0
- package/docs/visuals/diag-03-tecnico-1.png +0 -0
- package/docs/visuals/info-01-ejecutivo-1.png +0 -0
- package/docs/visuals/info-02-tactico-1.png +0 -0
- package/docs/visuals/info-03-tecnico-1.png +0 -0
- package/docs/visuals/pres-01-ejecutivo-1.png +0 -0
- package/docs/visuals/pres-02-tactico-1.png +0 -0
- package/docs/visuals/pres-03-tecnico-1.png +0 -0
- package/docs/visuals/qa-01-problema-1.png +0 -0
- package/docs/visuals/qa-02-que-es-1.png +0 -0
- package/docs/visuals/qa-03-pipeline-1.png +0 -0
- package/docs/visuals/qa-04-6-archivos-1.png +0 -0
- package/docs/visuals/qa-05-humano-1.png +0 -0
- package/docs/visuals/qa-06-agente-1.png +0 -0
- package/docs/visuals/qa-07-skills-arch-1.png +0 -0
- package/docs/visuals/qa-08-traceability-1.png +0 -0
- package/docs/visuals/qa-en-01-problem-1.png +0 -0
- package/docs/visuals/qa-en-02-what-is-1.png +0 -0
- package/docs/visuals/qa-en-03-pipeline-1.png +0 -0
- package/docs/visuals/qa-en-04-6files-1.png +0 -0
- package/docs/visuals/qa-en-05-human-1.png +0 -0
- package/docs/visuals/qa-en-06-agent-1.png +0 -0
- package/docs/visuals/qa-en-07-skills-arch-1.png +0 -0
- 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,
|
|
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
|
|
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,
|
|
217
|
-
.replace(/\{\{SUB\}\}/g,
|
|
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',
|
|
242
|
+
const e2eDir = path.join(qaRoot, '07-automation', 'e2e', 'tests', moduleName);
|
|
230
243
|
fs.mkdirSync(e2eDir, { recursive: true });
|
|
231
|
-
const specTs = path.join(e2eDir, `${
|
|
244
|
+
const specTs = path.join(e2eDir, `${subName}.spec.ts`);
|
|
232
245
|
if (!fs.existsSync(specTs)) {
|
|
233
|
-
fs.writeFileSync(specTs, specScaffold(mod.name, sub.name,
|
|
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
|
|
355
|
-
|
|
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
|
-
|
|
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
|
|
11
|
-
*
|
|
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
|
|
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
|
|
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
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
|
|
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
|
+
};
|
package/scripts/sync-version.js
CHANGED
|
@@ -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
|
|
package/scripts/upgrade.js
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
201
|
-
|
|
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
|
// -----------------------------------------------------------------------
|