@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.
- package/CHANGELOG.md +340 -0
- package/README.md +41 -1
- package/docs/installation.md +11 -4
- package/docs/usage-with-agent.md +14 -9
- package/package.json +2 -2
- 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
|
@@ -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
|
// -----------------------------------------------------------------------
|
package/scripts/validate.js
CHANGED
|
@@ -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
|
-
:
|
|
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
|
-
|
|
35
|
-
|
|
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
|
|
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/${
|
|
126
|
+
warnings.push(`[STRICT] No automation spec found: qa/07-automation/e2e/tests/${moduleName}/${subName}.spec.ts`);
|
|
102
127
|
}
|
|
103
128
|
}
|
|
104
129
|
}
|