qa-flowkit 0.4.0-alpha.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/.qa-ai/adapters/aider/.aider/README.md +25 -0
- package/.qa-ai/adapters/aider/.aider.conf.yml +6 -0
- package/.qa-ai/adapters/claude/agents/qa-workflow-orchestrator.md +18 -0
- package/.qa-ai/adapters/claude/commands/qa-add-tests.md +42 -0
- package/.qa-ai/adapters/claude/commands/qa-automation-plan.md +43 -0
- package/.qa-ai/adapters/claude/commands/qa-clean.md +42 -0
- package/.qa-ai/adapters/claude/commands/qa-config.md +51 -0
- package/.qa-ai/adapters/claude/commands/qa-coverage.md +46 -0
- package/.qa-ai/adapters/claude/commands/qa-doctor.md +11 -0
- package/.qa-ai/adapters/claude/commands/qa-full-flow.md +59 -0
- package/.qa-ai/adapters/claude/commands/qa-gate.md +36 -0
- package/.qa-ai/adapters/claude/commands/qa-help.md +30 -0
- package/.qa-ai/adapters/claude/commands/qa-init.md +70 -0
- package/.qa-ai/adapters/claude/commands/qa-status.md +56 -0
- package/.qa-ai/adapters/claude/commands/qa-update-tests.md +47 -0
- package/.qa-ai/adapters/claude/commands/qa-validate-features.md +36 -0
- package/.qa-ai/adapters/cline/.cline/README.md +25 -0
- package/.qa-ai/adapters/cline/.clinerules +9 -0
- package/.qa-ai/adapters/codex/README.md +44 -0
- package/.qa-ai/adapters/codex/prompts/implement-project.md +15 -0
- package/.qa-ai/adapters/continue/README.md +26 -0
- package/.qa-ai/adapters/continue/checks/qa-feature-conventions.md +15 -0
- package/.qa-ai/adapters/gemini/GEMINI.md +40 -0
- package/.qa-ai/adapters/generic/AGENTS.md +100 -0
- package/.qa-ai/adapters/goose/recipes/qa-flowkit.yaml +20 -0
- package/.qa-ai/adapters/opencode/README.md +57 -0
- package/.qa-ai/adapters/opencode/agents/qa-workflow.md +18 -0
- package/.qa-ai/adapters/opencode/commands/qa-add-tests.md +42 -0
- package/.qa-ai/adapters/opencode/commands/qa-automation-plan.md +43 -0
- package/.qa-ai/adapters/opencode/commands/qa-clean.md +42 -0
- package/.qa-ai/adapters/opencode/commands/qa-config.md +51 -0
- package/.qa-ai/adapters/opencode/commands/qa-coverage.md +46 -0
- package/.qa-ai/adapters/opencode/commands/qa-doctor.md +13 -0
- package/.qa-ai/adapters/opencode/commands/qa-full-flow.md +59 -0
- package/.qa-ai/adapters/opencode/commands/qa-gate.md +36 -0
- package/.qa-ai/adapters/opencode/commands/qa-help.md +30 -0
- package/.qa-ai/adapters/opencode/commands/qa-init.md +70 -0
- package/.qa-ai/adapters/opencode/commands/qa-status.md +56 -0
- package/.qa-ai/adapters/opencode/commands/qa-update-tests.md +47 -0
- package/.qa-ai/adapters/opencode/commands/qa-validate-features.md +36 -0
- package/.qa-ai/agents/README.md +39 -0
- package/.qa-ai/agents/api-testing-agent.md +73 -0
- package/.qa-ai/agents/automation-feasibility-agent.md +128 -0
- package/.qa-ai/agents/gherkin-test-design-agent.md +110 -0
- package/.qa-ai/agents/jira-task-agent.md +92 -0
- package/.qa-ai/agents/pr-agent.md +101 -0
- package/.qa-ai/agents/qa-context-intake-agent.md +75 -0
- package/.qa-ai/agents/qa-workflow-orchestrator.md +113 -0
- package/.qa-ai/agents/release-gate-agent.md +50 -0
- package/.qa-ai/agents/requirements-intake-agent.md +79 -0
- package/.qa-ai/agents/requirements-normalization-agent.md +80 -0
- package/.qa-ai/agents/specialists/available/appium.md +59 -0
- package/.qa-ai/agents/specialists/available/cypress.md +68 -0
- package/.qa-ai/agents/specialists/available/generic-test-design.md +117 -0
- package/.qa-ai/agents/specialists/available/jira.md +108 -0
- package/.qa-ai/agents/specialists/available/karate.md +97 -0
- package/.qa-ai/agents/specialists/available/playwright-api.md +87 -0
- package/.qa-ai/agents/specialists/available/playwright-ui.md +87 -0
- package/.qa-ai/agents/specialists/available/postman.md +108 -0
- package/.qa-ai/agents/specialists/available/rest-assured.md +103 -0
- package/.qa-ai/agents/specialists/available/selenium.md +91 -0
- package/.qa-ai/agents/specialists/available/testrail.md +85 -0
- package/.qa-ai/agents/specialists/available/webdriverio.md +81 -0
- package/.qa-ai/agents/test-design-system-agent.md +33 -0
- package/.qa-ai/agents/testrail-coverage-agent.md +84 -0
- package/.qa-ai/agents/testrail-sync-agent.md +96 -0
- package/.qa-ai/agents/webdriverio-implementation-agent.md +84 -0
- package/.qa-ai/presets/manual-only.yaml +65 -0
- package/.qa-ai/presets/selenium-jest-browserstack.yaml +72 -0
- package/.qa-ai/presets/webdriverio-playwright-api.yaml +85 -0
- package/.qa-ai/rules/api-testing.rules.md +7 -0
- package/.qa-ai/rules/approval.rules.md +8 -0
- package/.qa-ai/rules/automation.rules.md +7 -0
- package/.qa-ai/rules/gherkin.rules.md +12 -0
- package/.qa-ai/rules/testrail.rules.md +10 -0
- package/.qa-ai/rules/webdriverio.rules.md +9 -0
- package/.qa-ai/scripts/bootstrap-agent-adapters.mjs +127 -0
- package/.qa-ai/scripts/clean.mjs +243 -0
- package/.qa-ai/scripts/config.mjs +202 -0
- package/.qa-ai/scripts/doctor.mjs +383 -0
- package/.qa-ai/scripts/init.mjs +447 -0
- package/.qa-ai/scripts/lib/markdown-table.mjs +76 -0
- package/.qa-ai/scripts/lib/project-config.mjs +184 -0
- package/.qa-ai/scripts/lib/qa-next-steps.mjs +578 -0
- package/.qa-ai/scripts/lib/release-gate.mjs +66 -0
- package/.qa-ai/scripts/lib/test-design.mjs +92 -0
- package/.qa-ai/scripts/lib/test-management-mapping.mjs +73 -0
- package/.qa-ai/scripts/lib/utils.mjs +331 -0
- package/.qa-ai/scripts/qa-help.mjs +44 -0
- package/.qa-ai/scripts/smoke-npm-pack.mjs +187 -0
- package/.qa-ai/scripts/smoke-test.mjs +465 -0
- package/.qa-ai/scripts/sync-agent-adapters.mjs +121 -0
- package/.qa-ai/scripts/test-validators.mjs +334 -0
- package/.qa-ai/scripts/validate-active-specialists.mjs +106 -0
- package/.qa-ai/scripts/validate-features.mjs +277 -0
- package/.qa-ai/scripts/validate-release-gate.mjs +105 -0
- package/.qa-ai/scripts/validate-sync-plan.mjs +186 -0
- package/.qa-ai/scripts/validate-target.mjs +104 -0
- package/.qa-ai/scripts/validate-test-design.mjs +117 -0
- package/.qa-ai/scripts/validate-traceability.mjs +183 -0
- package/.qa-ai/templates/automation-feasibility-report.template.md +21 -0
- package/.qa-ai/templates/automation-implementation-plan.template.md +23 -0
- package/.qa-ai/templates/feature.template +13 -0
- package/.qa-ai/templates/jira-automation-task.template.md +25 -0
- package/.qa-ai/templates/pr-template.md +60 -0
- package/.qa-ai/templates/release-gate.template.yaml +16 -0
- package/.qa-ai/templates/requirement-analysis.template.md +17 -0
- package/.qa-ai/templates/test-design-proposal.template.md +26 -0
- package/.qa-ai/templates/test-design-system.template.md +15 -0
- package/.qa-ai/templates/test-management-mapping.template.json +18 -0
- package/.qa-ai/templates/testrail-coverage-analysis.template.md +17 -0
- package/.qa-ai/templates/testrail-sync-plan.template.md +22 -0
- package/.qa-ai/templates/traceability-matrix.template.md +4 -0
- package/.qa-ai/workflows/automation-analysis.md +23 -0
- package/.qa-ai/workflows/cleanup.md +52 -0
- package/.qa-ai/workflows/context-intake.md +66 -0
- package/.qa-ai/workflows/full-flow.md +55 -0
- package/.qa-ai/workflows/implementation.md +24 -0
- package/.qa-ai/workflows/intake.md +3 -0
- package/.qa-ai/workflows/pr.md +3 -0
- package/.qa-ai/workflows/release-gate.md +22 -0
- package/.qa-ai/workflows/test-design-system.md +33 -0
- package/.qa-ai/workflows/test-design.md +23 -0
- package/.qa-ai/workflows/testrail-sync.md +23 -0
- package/CHANGELOG.md +108 -0
- package/CODE_OF_CONDUCT.md +11 -0
- package/CONTRIBUTING.md +39 -0
- package/LICENSE +21 -0
- package/README.es.md +602 -0
- package/README.md +633 -0
- package/ROADMAP.md +107 -0
- package/SECURITY.md +18 -0
- package/bin/qa-flowkit.mjs +214 -0
- package/docs/qa-ai/agent-compatibility.md +100 -0
- package/docs/qa-ai/architecture.md +130 -0
- package/docs/qa-ai/backlog.md +393 -0
- package/docs/qa-ai/cleanup.md +104 -0
- package/docs/qa-ai/customizing-agents.md +148 -0
- package/docs/qa-ai/getting-started.md +385 -0
- package/docs/qa-ai/implementation-guide-for-codex.md +210 -0
- package/docs/qa-ai/npm-migration-plan.md +50 -0
- package/docs/qa-ai/open-source-release-checklist.md +17 -0
- package/docs/qa-ai/qa-help.md +76 -0
- package/docs/qa-ai/release-gate.md +60 -0
- package/docs/qa-ai/terminal-transcripts.md +316 -0
- package/docs/qa-ai/test-design-dual-mode.md +75 -0
- package/docs/qa-ai/troubleshooting.md +740 -0
- package/docs/qa-ai/workflow.md +147 -0
- package/package.json +72 -0
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
export const SYSTEM_SECTIONS = [
|
|
2
|
+
'## Scope',
|
|
3
|
+
'## Architecture alignment',
|
|
4
|
+
'## Testability risks',
|
|
5
|
+
'## Cross-RF coverage strategy',
|
|
6
|
+
'## Shared fixtures and data',
|
|
7
|
+
'## Non-functional focus',
|
|
8
|
+
'## Open questions'
|
|
9
|
+
];
|
|
10
|
+
|
|
11
|
+
export const PROPOSAL_SECTIONS = [
|
|
12
|
+
'## Official RF ID',
|
|
13
|
+
'## Scope',
|
|
14
|
+
'## Proposed tests',
|
|
15
|
+
'## Existing tests to reuse',
|
|
16
|
+
'## Existing tests requiring modification',
|
|
17
|
+
'## New tests to create',
|
|
18
|
+
'## Ambiguities requiring user decision',
|
|
19
|
+
'## Approval request'
|
|
20
|
+
];
|
|
21
|
+
|
|
22
|
+
const SYSTEM_SECTION_ALIASES = new Map([
|
|
23
|
+
['## Scope', ['## Alcance']],
|
|
24
|
+
['## Architecture alignment', ['## Alineacion con arquitectura']],
|
|
25
|
+
['## Testability risks', ['## Riesgos de testabilidad']],
|
|
26
|
+
['## Cross-RF coverage strategy', ['## Estrategia de cobertura entre RFs']],
|
|
27
|
+
['## Shared fixtures and data', ['## Fixtures y datos compartidos']],
|
|
28
|
+
['## Non-functional focus', ['## Enfoque no funcional']],
|
|
29
|
+
['## Open questions', ['## Preguntas abiertas']]
|
|
30
|
+
]);
|
|
31
|
+
|
|
32
|
+
const PROPOSAL_SECTION_ALIASES = new Map([
|
|
33
|
+
['## Official RF ID', ['## RF oficial']],
|
|
34
|
+
['## Scope', ['## Alcance']],
|
|
35
|
+
['## Proposed tests', ['## Pruebas propuestas']],
|
|
36
|
+
['## Existing tests to reuse', ['## Pruebas existentes para reutilizar']],
|
|
37
|
+
['## Existing tests requiring modification', ['## Pruebas existentes que requieren modificacion']],
|
|
38
|
+
['## New tests to create', ['## Nuevas pruebas a crear']],
|
|
39
|
+
['## Ambiguities requiring user decision', ['## Ambiguedades que requieren decision del usuario']],
|
|
40
|
+
['## Approval request', ['## Solicitud de aprobacion']]
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
function hasSection(content, heading) {
|
|
44
|
+
return String(content || '').includes(heading);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function hasAnySection(content, heading, aliasesByHeading) {
|
|
48
|
+
const candidates = [heading, ...(aliasesByHeading.get(heading) || [])];
|
|
49
|
+
return candidates.some((candidate) => hasSection(content, candidate));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function validateTestDesignSystem(content, options = {}) {
|
|
53
|
+
const errors = [];
|
|
54
|
+
const text = String(content || '').trim();
|
|
55
|
+
if (!text) {
|
|
56
|
+
errors.push('System test design file is empty.');
|
|
57
|
+
return { ok: false, errors };
|
|
58
|
+
}
|
|
59
|
+
if (!/^#\s+/.test(text)) {
|
|
60
|
+
errors.push('System test design must start with a top-level heading (# Title).');
|
|
61
|
+
}
|
|
62
|
+
for (const section of SYSTEM_SECTIONS) {
|
|
63
|
+
if (!hasAnySection(text, section, SYSTEM_SECTION_ALIASES)) {
|
|
64
|
+
errors.push(`Missing section: ${section}`);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
if (options.requireRfReference && !/RF-\d+/i.test(text)) {
|
|
68
|
+
errors.push('System test design should reference at least one RF ID when requirements exist.');
|
|
69
|
+
}
|
|
70
|
+
return { ok: errors.length === 0, errors };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export function validateTestDesignProposal(content, options = {}) {
|
|
74
|
+
const errors = [];
|
|
75
|
+
const text = String(content || '').trim();
|
|
76
|
+
if (!text) {
|
|
77
|
+
errors.push('Per-RF test design proposal is empty.');
|
|
78
|
+
return { ok: false, errors };
|
|
79
|
+
}
|
|
80
|
+
if (!/^#\s+/.test(text)) {
|
|
81
|
+
errors.push('Test design proposal must start with a top-level heading (# Title).');
|
|
82
|
+
}
|
|
83
|
+
for (const section of PROPOSAL_SECTIONS) {
|
|
84
|
+
if (!hasAnySection(text, section, PROPOSAL_SECTION_ALIASES)) {
|
|
85
|
+
errors.push(`Missing section: ${section}`);
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
if (options.requireOfficialRfId && !/RF-\d+/i.test(text)) {
|
|
89
|
+
errors.push('Per-RF test design must mention the official RF ID before final .feature generation.');
|
|
90
|
+
}
|
|
91
|
+
return { ok: errors.length === 0, errors };
|
|
92
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
const validMappingKeyPattern = /^(?:(?:RF|TC|TEST|QA)[-_ ]?[A-Z0-9]+|.*\.feature)$/i;
|
|
2
|
+
const allowedFields = new Set([
|
|
3
|
+
'externalId',
|
|
4
|
+
'section',
|
|
5
|
+
'suite',
|
|
6
|
+
'status',
|
|
7
|
+
'lastReviewedAt',
|
|
8
|
+
'notes'
|
|
9
|
+
]);
|
|
10
|
+
const secretKeyPattern = /(?:token|secret|password|passwd|api[_-]?key|authorization|auth)/i;
|
|
11
|
+
const secretValuePattern = /\b(?:ghp|github_pat|sk|xox[baprs]|glpat|AKIA)[A-Za-z0-9_-]{12,}\b|(?:bearer|basic)\s+[A-Za-z0-9._~+/-]+=*/i;
|
|
12
|
+
|
|
13
|
+
function isPlainObject(value) {
|
|
14
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function scanForSecrets(value, path, errors) {
|
|
18
|
+
if (typeof value === 'string') {
|
|
19
|
+
if (secretKeyPattern.test(path) || secretValuePattern.test(value)) {
|
|
20
|
+
errors.push(`${path} appears to contain a secret or credential; mapping files must not store secrets.`);
|
|
21
|
+
}
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (Array.isArray(value)) {
|
|
25
|
+
value.forEach((item, index) => scanForSecrets(item, `${path}[${index}]`, errors));
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (isPlainObject(value)) {
|
|
29
|
+
for (const [key, nestedValue] of Object.entries(value)) {
|
|
30
|
+
scanForSecrets(nestedValue, `${path}.${key}`, errors);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function validateTestManagementMapping(data, {
|
|
36
|
+
source = 'test management mapping'
|
|
37
|
+
} = {}) {
|
|
38
|
+
const errors = [];
|
|
39
|
+
if (!isPlainObject(data)) {
|
|
40
|
+
return [`${source} must contain a JSON object.`];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const externalIds = new Map();
|
|
44
|
+
for (const [key, value] of Object.entries(data)) {
|
|
45
|
+
if (!validMappingKeyPattern.test(key)) {
|
|
46
|
+
errors.push(`${source} entry "${key}" must be keyed by an RF/test identifier or .feature path.`);
|
|
47
|
+
}
|
|
48
|
+
if (!isPlainObject(value)) {
|
|
49
|
+
errors.push(`${source} entry "${key}" must be an object.`);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
for (const field of Object.keys(value)) {
|
|
54
|
+
if (!allowedFields.has(field)) {
|
|
55
|
+
errors.push(`${source} entry "${key}" has unsupported field "${field}".`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const externalId = String(value.externalId || '').trim();
|
|
60
|
+
if (externalId) {
|
|
61
|
+
const previous = externalIds.get(externalId);
|
|
62
|
+
if (previous) {
|
|
63
|
+
errors.push(`${source} externalId "${externalId}" is used by both "${previous}" and "${key}".`);
|
|
64
|
+
} else {
|
|
65
|
+
externalIds.set(externalId, key);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
scanForSecrets(value, `${source}.${key}`, errors);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return errors;
|
|
73
|
+
}
|
|
@@ -0,0 +1,331 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
export const manifestRelativePath = '.qa-ai/state/init-manifest.json';
|
|
6
|
+
|
|
7
|
+
export async function pathExists(filePath) {
|
|
8
|
+
try {
|
|
9
|
+
await fs.access(filePath);
|
|
10
|
+
return true;
|
|
11
|
+
} catch {
|
|
12
|
+
return false;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export async function ensureDir(dirPath) {
|
|
17
|
+
const created = await fs.mkdir(dirPath, { recursive: true });
|
|
18
|
+
return { type: 'dir', created: Boolean(created), path: dirPath };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function writeFileSafe(filePath, content, { force = false } = {}) {
|
|
22
|
+
if (!force && await pathExists(filePath)) {
|
|
23
|
+
return { type: 'file', written: false, reason: 'exists', path: filePath };
|
|
24
|
+
}
|
|
25
|
+
await ensureDir(path.dirname(filePath));
|
|
26
|
+
await fs.writeFile(filePath, content, 'utf8');
|
|
27
|
+
return { type: 'file', written: true, path: filePath };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export async function readText(filePath) {
|
|
31
|
+
return fs.readFile(filePath, 'utf8');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export async function readTextIfExists(filePath) {
|
|
35
|
+
if (!await pathExists(filePath)) return null;
|
|
36
|
+
return readText(filePath);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export async function copyFileSafe(source, target, { force = false } = {}) {
|
|
40
|
+
if (!force && await pathExists(target)) {
|
|
41
|
+
return { type: 'file', copied: false, reason: 'exists', path: target };
|
|
42
|
+
}
|
|
43
|
+
await ensureDir(path.dirname(target));
|
|
44
|
+
await fs.copyFile(source, target);
|
|
45
|
+
return { type: 'file', copied: true, path: target };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function copyDirSafe(sourceDir, targetDir, { force = false } = {}) {
|
|
49
|
+
if (!await pathExists(sourceDir)) return [];
|
|
50
|
+
const results = [];
|
|
51
|
+
const dirResult = await ensureDir(targetDir);
|
|
52
|
+
if (dirResult.created) results.push(dirResult);
|
|
53
|
+
const items = await fs.readdir(sourceDir, { withFileTypes: true });
|
|
54
|
+
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
55
|
+
for (const item of items) {
|
|
56
|
+
const source = path.join(sourceDir, item.name);
|
|
57
|
+
const target = path.join(targetDir, item.name);
|
|
58
|
+
if (item.isDirectory()) {
|
|
59
|
+
results.push(...await copyDirSafe(source, target, { force }));
|
|
60
|
+
} else if (item.isFile()) {
|
|
61
|
+
results.push(await copyFileSafe(source, target, { force }));
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return results;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function listFilesRecursive(dirPath, predicate = () => true) {
|
|
68
|
+
if (!await pathExists(dirPath)) return [];
|
|
69
|
+
const files = [];
|
|
70
|
+
const items = await fs.readdir(dirPath, { withFileTypes: true });
|
|
71
|
+
items.sort((a, b) => a.name.localeCompare(b.name));
|
|
72
|
+
for (const item of items) {
|
|
73
|
+
const fullPath = path.join(dirPath, item.name);
|
|
74
|
+
if (item.isDirectory()) {
|
|
75
|
+
files.push(...await listFilesRecursive(fullPath, predicate));
|
|
76
|
+
} else if (item.isFile() && predicate(fullPath)) {
|
|
77
|
+
files.push(fullPath);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return files;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function logHeader(title) {
|
|
84
|
+
console.log(`\n=== ${title} ===\n`);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function parseArgs(argv) {
|
|
88
|
+
const args = { _: [] };
|
|
89
|
+
for (let i = 2; i < argv.length; i += 1) {
|
|
90
|
+
const item = argv[i];
|
|
91
|
+
if (!item) continue;
|
|
92
|
+
if (!item.startsWith('--')) {
|
|
93
|
+
args._.push(item);
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
const equalsIndex = item.indexOf('=');
|
|
98
|
+
let key;
|
|
99
|
+
let value;
|
|
100
|
+
if (equalsIndex > -1) {
|
|
101
|
+
key = item.slice(2, equalsIndex);
|
|
102
|
+
value = item.slice(equalsIndex + 1);
|
|
103
|
+
} else {
|
|
104
|
+
key = item.slice(2);
|
|
105
|
+
const next = argv[i + 1];
|
|
106
|
+
if (!next || next.startsWith('--')) {
|
|
107
|
+
value = true;
|
|
108
|
+
} else {
|
|
109
|
+
value = next;
|
|
110
|
+
i += 1;
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (Object.hasOwn(args, key)) {
|
|
115
|
+
args[key] = Array.isArray(args[key]) ? [...args[key], value] : [args[key], value];
|
|
116
|
+
} else {
|
|
117
|
+
args[key] = value;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return args;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function commaList(value) {
|
|
124
|
+
if (value === undefined || value === null || value === false) return [];
|
|
125
|
+
const values = Array.isArray(value) ? value : [value];
|
|
126
|
+
return values
|
|
127
|
+
.flatMap((item) => String(item).split(','))
|
|
128
|
+
.map((item) => item.trim())
|
|
129
|
+
.filter(Boolean);
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
export function relativeTo(cwd, filePath) {
|
|
133
|
+
return path.relative(cwd, filePath).replaceAll(path.sep, '/');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function toPosixPath(filePath) {
|
|
137
|
+
return filePath.replaceAll(path.sep, '/');
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function resolveInsideCwd(cwd, relativePath) {
|
|
141
|
+
const root = path.resolve(cwd);
|
|
142
|
+
const resolved = path.resolve(cwd, relativePath);
|
|
143
|
+
const inside = resolved === root || resolved.startsWith(`${root}${path.sep}`);
|
|
144
|
+
return { resolved, inside };
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export function resolveRepoPath(cwd, relativePath, { label = 'path', allowRoot = false } = {}) {
|
|
148
|
+
const value = String(relativePath || '').trim();
|
|
149
|
+
if (!value) {
|
|
150
|
+
throw new Error(`Invalid ${label}: path is empty.`);
|
|
151
|
+
}
|
|
152
|
+
if (path.isAbsolute(value)) {
|
|
153
|
+
throw new Error(`Invalid ${label}: absolute paths are not allowed (${value}).`);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const root = path.resolve(cwd);
|
|
157
|
+
const target = resolveInsideCwd(cwd, value);
|
|
158
|
+
if (!target.inside || (!allowRoot && target.resolved === root)) {
|
|
159
|
+
throw new Error(`Invalid ${label}: path must stay inside the repository (${value}).`);
|
|
160
|
+
}
|
|
161
|
+
return target.resolved;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export async function hashFile(filePath) {
|
|
165
|
+
const data = await fs.readFile(filePath);
|
|
166
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export function yamlScalar(value) {
|
|
170
|
+
const text = String(value);
|
|
171
|
+
if (/^[A-Za-z0-9_. -]+$/.test(text) && text.trim() === text) return text;
|
|
172
|
+
return JSON.stringify(text);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function parseScalar(value) {
|
|
176
|
+
const text = value.trim();
|
|
177
|
+
if (text === 'true') return true;
|
|
178
|
+
if (text === 'false') return false;
|
|
179
|
+
if (text === 'null' || text === '~') return null;
|
|
180
|
+
if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
|
|
181
|
+
return text.slice(1, -1);
|
|
182
|
+
}
|
|
183
|
+
if (/^-?\d+(?:\.\d+)?$/.test(text)) return Number(text);
|
|
184
|
+
return text;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function yamlLines(content) {
|
|
188
|
+
return content
|
|
189
|
+
.replace(/\r/g, '')
|
|
190
|
+
.split('\n')
|
|
191
|
+
.map((raw) => ({
|
|
192
|
+
indent: raw.match(/^ */)?.[0].length ?? 0,
|
|
193
|
+
text: raw.trim()
|
|
194
|
+
}))
|
|
195
|
+
.filter((line) => line.text && !line.text.startsWith('#'));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function parseSimpleYaml(content) {
|
|
199
|
+
const lines = yamlLines(content);
|
|
200
|
+
const root = {};
|
|
201
|
+
const stack = [{ indent: -1, value: root }];
|
|
202
|
+
|
|
203
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
204
|
+
const line = lines[i];
|
|
205
|
+
while (stack.length > 1 && stack.at(-1).indent >= line.indent) stack.pop();
|
|
206
|
+
const parent = stack.at(-1).value;
|
|
207
|
+
|
|
208
|
+
if (line.text.startsWith('- ')) {
|
|
209
|
+
if (Array.isArray(parent)) parent.push(parseScalar(line.text.slice(2)));
|
|
210
|
+
continue;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
const colonIndex = line.text.indexOf(':');
|
|
214
|
+
if (colonIndex === -1) continue;
|
|
215
|
+
|
|
216
|
+
const key = line.text.slice(0, colonIndex).trim();
|
|
217
|
+
const rest = line.text.slice(colonIndex + 1).trim();
|
|
218
|
+
if (!key || Array.isArray(parent)) continue;
|
|
219
|
+
|
|
220
|
+
if (rest) {
|
|
221
|
+
parent[key] = parseScalar(rest);
|
|
222
|
+
continue;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const next = lines[i + 1];
|
|
226
|
+
const child = next && next.indent > line.indent && next.text.startsWith('- ') ? [] : {};
|
|
227
|
+
parent[key] = child;
|
|
228
|
+
stack.push({ indent: line.indent, value: child });
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return root;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
export function getConfigValue(config, keyPath, fallback = undefined) {
|
|
235
|
+
const parts = Array.isArray(keyPath) ? keyPath : String(keyPath).split('.');
|
|
236
|
+
let current = config;
|
|
237
|
+
for (const part of parts) {
|
|
238
|
+
if (!current || typeof current !== 'object' || !(part in current)) return fallback;
|
|
239
|
+
current = current[part];
|
|
240
|
+
}
|
|
241
|
+
return current === undefined || current === null ? fallback : current;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export async function loadQaAiConfig(cwd) {
|
|
245
|
+
const filePath = path.join(cwd, 'qa-ai.config.yaml');
|
|
246
|
+
if (!await pathExists(filePath)) {
|
|
247
|
+
return { exists: false, path: filePath, content: '', data: {} };
|
|
248
|
+
}
|
|
249
|
+
const content = await readText(filePath);
|
|
250
|
+
return { exists: true, path: filePath, content, data: parseSimpleYaml(content) };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function manifestPath(cwd) {
|
|
254
|
+
return path.join(cwd, manifestRelativePath);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
export async function loadInitManifest(cwd) {
|
|
258
|
+
const filePath = manifestPath(cwd);
|
|
259
|
+
if (!await pathExists(filePath)) {
|
|
260
|
+
return {
|
|
261
|
+
exists: false,
|
|
262
|
+
path: filePath,
|
|
263
|
+
data: {
|
|
264
|
+
version: 1,
|
|
265
|
+
entries: []
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
const content = await readText(filePath);
|
|
271
|
+
const data = JSON.parse(content);
|
|
272
|
+
return {
|
|
273
|
+
exists: true,
|
|
274
|
+
path: filePath,
|
|
275
|
+
data: {
|
|
276
|
+
version: 1,
|
|
277
|
+
...data,
|
|
278
|
+
entries: Array.isArray(data.entries) ? data.entries : []
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export async function saveInitManifest(cwd, manifest) {
|
|
284
|
+
const filePath = manifestPath(cwd);
|
|
285
|
+
const now = new Date().toISOString();
|
|
286
|
+
const normalized = {
|
|
287
|
+
version: 1,
|
|
288
|
+
createdAt: manifest.createdAt || now,
|
|
289
|
+
updatedAt: now,
|
|
290
|
+
entries: [...manifest.entries].sort((a, b) => a.path.localeCompare(b.path))
|
|
291
|
+
};
|
|
292
|
+
await writeFileSafe(filePath, `${JSON.stringify(normalized, null, 2)}\n`, { force: true });
|
|
293
|
+
return normalized;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function manifestEntry(cwd, filePath, { type, category, source }) {
|
|
297
|
+
const relPath = toPosixPath(path.relative(cwd, filePath));
|
|
298
|
+
const entry = {
|
|
299
|
+
path: relPath,
|
|
300
|
+
type,
|
|
301
|
+
category,
|
|
302
|
+
source
|
|
303
|
+
};
|
|
304
|
+
if (type === 'file') entry.sha256 = await hashFile(filePath);
|
|
305
|
+
return entry;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export async function recordManifestEntries(cwd, entries) {
|
|
309
|
+
const filtered = entries.filter(Boolean);
|
|
310
|
+
if (filtered.length === 0) return null;
|
|
311
|
+
|
|
312
|
+
const { data } = await loadInitManifest(cwd);
|
|
313
|
+
const now = new Date().toISOString();
|
|
314
|
+
const byKey = new Map(data.entries.map((entry) => [`${entry.type}:${entry.path}`, entry]));
|
|
315
|
+
|
|
316
|
+
for (const entry of filtered) {
|
|
317
|
+
const key = `${entry.type}:${entry.path}`;
|
|
318
|
+
const previous = byKey.get(key);
|
|
319
|
+
byKey.set(key, {
|
|
320
|
+
...previous,
|
|
321
|
+
...entry,
|
|
322
|
+
createdAt: previous?.createdAt || now,
|
|
323
|
+
updatedAt: now
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return saveInitManifest(cwd, {
|
|
328
|
+
...data,
|
|
329
|
+
entries: [...byKey.values()]
|
|
330
|
+
});
|
|
331
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { formatHelpReport, inspectQaWorkflow } from './lib/qa-next-steps.mjs';
|
|
3
|
+
import { logHeader, parseArgs } from './lib/utils.mjs';
|
|
4
|
+
|
|
5
|
+
const args = parseArgs(process.argv);
|
|
6
|
+
const query = args._.join(' ').trim();
|
|
7
|
+
|
|
8
|
+
function printHelp() {
|
|
9
|
+
console.log(`Usage: node .qa-ai/scripts/qa-help.mjs [options] [question]
|
|
10
|
+
|
|
11
|
+
Options:
|
|
12
|
+
--json Print machine-readable JSON
|
|
13
|
+
--help Show this help
|
|
14
|
+
|
|
15
|
+
Inspects qa-ai.config.yaml, QA artifacts and project.qaTrack to recommend the next workflow step.
|
|
16
|
+
|
|
17
|
+
Examples:
|
|
18
|
+
node .qa-ai/scripts/qa-help.mjs
|
|
19
|
+
node .qa-ai/scripts/qa-help.mjs --json
|
|
20
|
+
node .qa-ai/scripts/qa-help.mjs "where do I start for manual QA?"
|
|
21
|
+
`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
async function main() {
|
|
25
|
+
if (args.help) {
|
|
26
|
+
printHelp();
|
|
27
|
+
return;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
logHeader('QA help');
|
|
31
|
+
const report = await inspectQaWorkflow(process.cwd());
|
|
32
|
+
|
|
33
|
+
if (args.json) {
|
|
34
|
+
console.log(JSON.stringify({ query: query || null, ...report }, null, 2));
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
console.log(formatHelpReport(report, { query }));
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
main().catch((error) => {
|
|
42
|
+
console.error(error);
|
|
43
|
+
process.exit(1);
|
|
44
|
+
});
|