@analizza-ai/testspec 0.1.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 +24 -0
- package/LICENSE +21 -0
- package/README.md +189 -0
- package/bin/cli.js +42 -0
- package/package.json +69 -0
- package/src/adapters/agents/claude.js +88 -0
- package/src/adapters/agents/copilot.js +39 -0
- package/src/adapters/agents/index.js +22 -0
- package/src/adapters/sdd/index.js +23 -0
- package/src/adapters/sdd/openspec.js +58 -0
- package/src/adapters/sdd/speckit.js +19 -0
- package/src/commands/generate.js +66 -0
- package/src/commands/init.js +112 -0
- package/src/commands/report.js +60 -0
- package/src/commands/validate.js +68 -0
- package/src/core/reporter.js +44 -0
- package/src/core/spec-parser.js +141 -0
- package/src/core/stub-generator.js +92 -0
- package/src/core/testcontainers.js +39 -0
- package/src/core/tests-builder.js +120 -0
- package/src/index.js +10 -0
- package/src/utils/config.js +29 -0
- package/src/utils/logger.js +13 -0
- package/src/utils/sdd-detector.js +23 -0
- package/templates/agent-instructions/AGENTS.md +39 -0
- package/templates/agent-instructions/CLAUDE.md +48 -0
- package/templates/agent-instructions/copilot.md +52 -0
- package/templates/agent-instructions/skills/testspec-apply-qa.md +424 -0
- package/templates/agent-instructions/skills/testspec-generate.md +138 -0
- package/templates/agent-instructions/skills/testspec-run-qa.md +338 -0
- package/templates/agent-instructions/skills/testspec-specify-qa.md +535 -0
- package/templates/stubs/jest/unit.template.js +17 -0
- package/templates/stubs/junit/unit.template.java +27 -0
- package/templates/stubs/pytest/unit.template.py +18 -0
- package/templates/stubs/testcontainers/node-pg-kafka.template.js +38 -0
- package/templates/stubs/testcontainers/node-pg.template.js +32 -0
- package/templates/stubs/testcontainers/spring-pg-kafka.template.java +41 -0
- package/templates/stubs/vitest/unit.template.js +19 -0
- package/templates/tests-md/default.md +43 -0
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/commands/init.js
|
|
3
|
+
* Setup wizard: detects or selects SDD framework, selects AI agent,
|
|
4
|
+
* writes testspec.config.json, and installs agent instruction files into the project.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { Command } from 'commander';
|
|
8
|
+
import { createInterface } from 'readline';
|
|
9
|
+
import { writeFileSync, mkdirSync } from 'fs';
|
|
10
|
+
import { join } from 'path';
|
|
11
|
+
import { log } from '../utils/logger.js';
|
|
12
|
+
import { detectSdd } from '../utils/sdd-detector.js';
|
|
13
|
+
|
|
14
|
+
export const initCommand = new Command('init')
|
|
15
|
+
.description('Setup wizard — SDD framework + AI agent + writes testspec.config.json')
|
|
16
|
+
.action(runInit);
|
|
17
|
+
|
|
18
|
+
export async function runInit() {
|
|
19
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
20
|
+
let rlClosed = false;
|
|
21
|
+
rl.once('close', () => { rlClosed = true; });
|
|
22
|
+
const ask = (q) => new Promise((res) => {
|
|
23
|
+
if (rlClosed) return res('');
|
|
24
|
+
rl.question(q, (ans) => res(ans));
|
|
25
|
+
rl.once('close', () => res(''));
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
log.info('testspec init — Spec Driven Test setup\n');
|
|
29
|
+
|
|
30
|
+
// Step 1: detect SDD framework
|
|
31
|
+
const detected = detectSdd(process.cwd());
|
|
32
|
+
let sdd;
|
|
33
|
+
|
|
34
|
+
if (detected) {
|
|
35
|
+
const ans = await ask(`Detected SDD framework: ${detected}. Use it? [Y/n] `);
|
|
36
|
+
sdd = ans.toLowerCase() === 'n' ? await selectSdd(ask) : detected;
|
|
37
|
+
} else {
|
|
38
|
+
sdd = await selectSdd(ask);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// Step 2: select AI agent
|
|
42
|
+
log.info('\nWhich AI agent for test generation?');
|
|
43
|
+
log.info(' 1) Claude Code (default)');
|
|
44
|
+
log.info(' 2) GitHub Copilot');
|
|
45
|
+
const agentAns = await ask('Choose [1/2]: ');
|
|
46
|
+
const agent = agentAns.trim() === '2' ? 'copilot' : 'claude';
|
|
47
|
+
|
|
48
|
+
// Step 3: optional QA repo
|
|
49
|
+
const qaRepo = await ask('\nQA repo (owner/repo) for tests.md handoff [leave blank to skip]: ');
|
|
50
|
+
|
|
51
|
+
rl.close();
|
|
52
|
+
|
|
53
|
+
// Step 4: write testspec.config.json
|
|
54
|
+
const config = {
|
|
55
|
+
sdd,
|
|
56
|
+
agent,
|
|
57
|
+
stubs: { unit: true, integration: true },
|
|
58
|
+
unitFramework: 'vitest',
|
|
59
|
+
...(qaRepo.trim() && { qaRepo: qaRepo.trim() }),
|
|
60
|
+
loadHints: true,
|
|
61
|
+
chaosHints: true,
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
writeFileSync(join(process.cwd(), 'testspec.config.json'), JSON.stringify(config, null, 2) + '\n');
|
|
65
|
+
log.success('testspec.config.json written');
|
|
66
|
+
|
|
67
|
+
// Step 5: install agent instruction files
|
|
68
|
+
if (agent === 'claude') {
|
|
69
|
+
const { readFileSync } = await import('fs');
|
|
70
|
+
const { fileURLToPath } = await import('url');
|
|
71
|
+
const { dirname, join: pjoin } = await import('path');
|
|
72
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
73
|
+
const skillsDir = pjoin(__dirname, '../../templates/agent-instructions/skills');
|
|
74
|
+
const commandsDir = join(process.cwd(), '.claude', 'commands');
|
|
75
|
+
mkdirSync(commandsDir, { recursive: true });
|
|
76
|
+
|
|
77
|
+
const skills = [
|
|
78
|
+
'testspec-generate.md',
|
|
79
|
+
'testspec-specify-qa.md',
|
|
80
|
+
'testspec-apply-qa.md',
|
|
81
|
+
'testspec-run-qa.md',
|
|
82
|
+
];
|
|
83
|
+
for (const skill of skills) {
|
|
84
|
+
const tpl = readFileSync(pjoin(skillsDir, skill), 'utf-8');
|
|
85
|
+
writeFileSync(join(commandsDir, skill), tpl);
|
|
86
|
+
log.success(`.claude/commands/${skill} written`);
|
|
87
|
+
}
|
|
88
|
+
} else {
|
|
89
|
+
const dir = join(process.cwd(), '.github');
|
|
90
|
+
mkdirSync(dir, { recursive: true });
|
|
91
|
+
const { readFileSync } = await import('fs');
|
|
92
|
+
const { fileURLToPath } = await import('url');
|
|
93
|
+
const { dirname, join: pjoin } = await import('path');
|
|
94
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
95
|
+
const tpl = readFileSync(pjoin(__dirname, '../../templates/agent-instructions/copilot.md'), 'utf-8');
|
|
96
|
+
writeFileSync(join(dir, 'copilot-instructions.md'), tpl);
|
|
97
|
+
log.success('.github/copilot-instructions.md written');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
log.info('\nSetup complete. Run: testspec generate');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
async function selectSdd(ask) {
|
|
104
|
+
log.info('\nSelect SDD framework:');
|
|
105
|
+
log.info(' 1) OpenSpec (default)');
|
|
106
|
+
log.info(' 2) SpecKit (coming soon)');
|
|
107
|
+
const ans = await ask('Choose [1/2]: ');
|
|
108
|
+
if (ans.trim() === '2') {
|
|
109
|
+
log.warn('SpecKit adapter is not yet implemented. Defaulting to OpenSpec.');
|
|
110
|
+
}
|
|
111
|
+
return 'openspec';
|
|
112
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/commands/report.js
|
|
3
|
+
* Gap report: which CTs in tests.md have no corresponding test stub yet.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import { readFileSync, existsSync } from 'fs';
|
|
8
|
+
import { log } from '../utils/logger.js';
|
|
9
|
+
import { loadConfig } from '../utils/config.js';
|
|
10
|
+
import { getAdapter as getSddAdapter } from '../adapters/sdd/index.js';
|
|
11
|
+
import { generateReport } from '../core/reporter.js';
|
|
12
|
+
|
|
13
|
+
export const reportCommand = new Command('report')
|
|
14
|
+
.description('Coverage/gap report: which CTs have no test stub yet')
|
|
15
|
+
.option('-c, --change <name>', 'target a specific change folder')
|
|
16
|
+
.option('--format <fmt>', 'output format: text | json | markdown', 'text')
|
|
17
|
+
.action(runReport);
|
|
18
|
+
|
|
19
|
+
export async function runReport(opts = {}) {
|
|
20
|
+
const config = loadConfig(process.cwd());
|
|
21
|
+
const sddAdapter = getSddAdapter(config.sdd);
|
|
22
|
+
|
|
23
|
+
const changes = sddAdapter.discoverChanges(process.cwd());
|
|
24
|
+
const changeName = opts.change || changes[changes.length - 1];
|
|
25
|
+
const outputPath = sddAdapter.getOutputPath(process.cwd(), changeName);
|
|
26
|
+
|
|
27
|
+
if (!existsSync(outputPath)) {
|
|
28
|
+
log.error(`tests.md not found at ${outputPath}. Run "testspec generate" first.`);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const testsContent = readFileSync(outputPath, 'utf-8');
|
|
33
|
+
const report = generateReport(testsContent, process.cwd(), config);
|
|
34
|
+
|
|
35
|
+
if (opts.format === 'json') {
|
|
36
|
+
console.log(JSON.stringify(report, null, 2));
|
|
37
|
+
} else if (opts.format === 'markdown') {
|
|
38
|
+
console.log(formatMarkdown(report));
|
|
39
|
+
} else {
|
|
40
|
+
formatText(report);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function formatText(report) {
|
|
45
|
+
log.info(`\nCoverage report — ${report.change}`);
|
|
46
|
+
log.info(`Total CTs: ${report.total} | Covered: ${report.covered} | Missing: ${report.missing.length}`);
|
|
47
|
+
if (report.missing.length > 0) {
|
|
48
|
+
log.warn('\nCTs with no stub:');
|
|
49
|
+
report.missing.forEach((ct) => log.warn(` · ${ct.id} — ${ct.title}`));
|
|
50
|
+
} else {
|
|
51
|
+
log.success('\nAll CTs have stubs.');
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatMarkdown(report) {
|
|
56
|
+
const rows = report.missing.map((ct) => `| ${ct.id} | ${ct.title} | ❌ no stub |`).join('\n');
|
|
57
|
+
return `## testspec coverage — ${report.change}\n\n` +
|
|
58
|
+
`Total: ${report.total} | Covered: ${report.covered} | Missing: ${report.missing.length}\n\n` +
|
|
59
|
+
(rows ? `| CT | Title | Status |\n|---|---|---|\n${rows}` : '_All CTs have stubs._');
|
|
60
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/commands/validate.js
|
|
3
|
+
* Maps test run results back to CT-01..N pass/fail status in tests.md.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { Command } from 'commander';
|
|
7
|
+
import { readFileSync, writeFileSync, existsSync } from 'fs';
|
|
8
|
+
import { log } from '../utils/logger.js';
|
|
9
|
+
import { loadConfig } from '../utils/config.js';
|
|
10
|
+
import { getAdapter as getSddAdapter } from '../adapters/sdd/index.js';
|
|
11
|
+
|
|
12
|
+
export const validateCommand = new Command('validate')
|
|
13
|
+
.description('Map test run results to CT-01..N pass/fail in tests.md')
|
|
14
|
+
.option('-c, --change <name>', 'target a specific change folder')
|
|
15
|
+
.option('-r, --results <file>', 'path to test results JSON (vitest/jest --reporter=json)')
|
|
16
|
+
.action(runValidate);
|
|
17
|
+
|
|
18
|
+
export async function runValidate(opts = {}) {
|
|
19
|
+
const config = loadConfig(process.cwd());
|
|
20
|
+
const sddAdapter = getSddAdapter(config.sdd);
|
|
21
|
+
|
|
22
|
+
const changes = sddAdapter.discoverChanges(process.cwd());
|
|
23
|
+
const changeName = opts.change || changes[changes.length - 1];
|
|
24
|
+
const outputPath = sddAdapter.getOutputPath(process.cwd(), changeName);
|
|
25
|
+
|
|
26
|
+
if (!existsSync(outputPath)) {
|
|
27
|
+
log.error(`tests.md not found at ${outputPath}. Run "testspec generate" first.`);
|
|
28
|
+
process.exit(1);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
if (!opts.results) {
|
|
32
|
+
log.error('Provide test results with --results <file>');
|
|
33
|
+
process.exit(1);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const testsContent = readFileSync(outputPath, 'utf-8');
|
|
37
|
+
const results = JSON.parse(readFileSync(opts.results, 'utf-8'));
|
|
38
|
+
|
|
39
|
+
const updated = applyResults(testsContent, results);
|
|
40
|
+
writeFileSync(outputPath, updated);
|
|
41
|
+
log.success(`tests.md updated with validation results → ${outputPath}`);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Appends pass/fail badges to CT headings based on test result names
|
|
46
|
+
* that contain the CT identifier (e.g. "CT-01").
|
|
47
|
+
*/
|
|
48
|
+
function applyResults(content, results) {
|
|
49
|
+
const testNames = extractTestNames(results);
|
|
50
|
+
return content.replace(/^(### CT-(\d+)[^\n]*)/gm, (line, _full, num) => {
|
|
51
|
+
const id = `CT-${num.padStart(2, '0')}`;
|
|
52
|
+
const passed = testNames.filter((n) => n.includes(id) && n.status === 'passed').length;
|
|
53
|
+
const failed = testNames.filter((n) => n.includes(id) && n.status === 'failed').length;
|
|
54
|
+
if (passed > 0 && failed === 0) return `${line} ✅`;
|
|
55
|
+
if (failed > 0) return `${line} ❌`;
|
|
56
|
+
return line;
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function extractTestNames(results) {
|
|
61
|
+
const names = [];
|
|
62
|
+
const traverse = (suite) => {
|
|
63
|
+
if (suite.tests) suite.tests.forEach((t) => names.push({ name: t.name, status: t.status }));
|
|
64
|
+
if (suite.suites) suite.suites.forEach(traverse);
|
|
65
|
+
};
|
|
66
|
+
if (results.testResults) results.testResults.forEach(traverse);
|
|
67
|
+
return names;
|
|
68
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/reporter.js
|
|
3
|
+
* Generates a CT coverage/gap report by comparing tests.md CTs to existing stub files.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, readdirSync } from 'fs';
|
|
7
|
+
import { join } from 'path';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} testsContent raw tests.md string
|
|
11
|
+
* @param {string} cwd
|
|
12
|
+
* @param {object} config
|
|
13
|
+
* @returns {{ change: string, total: number, covered: number, missing: Array<{id,title}> }}
|
|
14
|
+
*/
|
|
15
|
+
export function generateReport(testsContent, cwd, _config) {
|
|
16
|
+
const ctMatches = [...testsContent.matchAll(/^### (CT-\d+)\s+[—–-]\s+(.+)$/gm)];
|
|
17
|
+
const all = ctMatches.map((m) => ({ id: m[1], title: m[2].trim() }));
|
|
18
|
+
|
|
19
|
+
const stubsDir = join(cwd, 'tests');
|
|
20
|
+
const stubFiles = existsSync(stubsDir) ? getAllFiles(stubsDir) : [];
|
|
21
|
+
|
|
22
|
+
const missing = all.filter((ct) => !stubFiles.some((f) => f.includes(ct.id.toLowerCase())));
|
|
23
|
+
|
|
24
|
+
const featureMatch = testsContent.match(/^feature:\s*(.+)$/m);
|
|
25
|
+
const changeMatch = testsContent.match(/^change:\s*(.+)$/m);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
change: changeMatch ? changeMatch[1].trim() : 'unknown',
|
|
29
|
+
feature: featureMatch ? featureMatch[1].trim() : 'unknown',
|
|
30
|
+
total: all.length,
|
|
31
|
+
covered: all.length - missing.length,
|
|
32
|
+
missing,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function getAllFiles(dir) {
|
|
37
|
+
const results = [];
|
|
38
|
+
const entries = readdirSync(dir, { withFileTypes: true });
|
|
39
|
+
for (const e of entries) {
|
|
40
|
+
if (e.isDirectory()) results.push(...getAllFiles(join(dir, e.name)));
|
|
41
|
+
else results.push(join(dir, e.name));
|
|
42
|
+
}
|
|
43
|
+
return results;
|
|
44
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/spec-parser.js
|
|
3
|
+
* Parses raw SDD artifact strings into a structured SpecContext object.
|
|
4
|
+
* No LLM calls — pure text parsing.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import matter from 'gray-matter';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @typedef {object} SpecContext
|
|
11
|
+
* @property {string} feature
|
|
12
|
+
* @property {string} changeName
|
|
13
|
+
* @property {string} sdd
|
|
14
|
+
* @property {object} stack
|
|
15
|
+
* @property {string[]} specs raw spec content strings
|
|
16
|
+
* @property {string[]} scenarios extracted scenario titles
|
|
17
|
+
* @property {string[]} rules extracted business rules
|
|
18
|
+
* @property {string[]} contracts API contracts from design.md
|
|
19
|
+
* @property {string[]} dbAssertions DB validation hints from design.md
|
|
20
|
+
* @property {string[]} outOfScope
|
|
21
|
+
* @property {string|null} loadHints
|
|
22
|
+
* @property {string|null} chaosHints
|
|
23
|
+
* @property {object} rawArtifacts
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* @param {object} artifacts output of SDD adapter.loadArtifacts()
|
|
28
|
+
* @param {object} config testspec.config.json
|
|
29
|
+
* @returns {SpecContext}
|
|
30
|
+
*/
|
|
31
|
+
export function parseSpecs(artifacts, config) {
|
|
32
|
+
const { proposal, design, specs, tasks, config: sddConfig, changeName } = artifacts;
|
|
33
|
+
|
|
34
|
+
const feature = extractFeatureName(changeName, proposal);
|
|
35
|
+
const stack = buildStack(sddConfig, config);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
feature,
|
|
39
|
+
changeName,
|
|
40
|
+
sdd: config.sdd || 'openspec',
|
|
41
|
+
stack,
|
|
42
|
+
specs,
|
|
43
|
+
scenarios: extractScenarios(specs),
|
|
44
|
+
rules: extractRules(specs),
|
|
45
|
+
contracts: extractContracts(design),
|
|
46
|
+
dbAssertions: extractDbAssertions(design),
|
|
47
|
+
outOfScope: extractOutOfScope(proposal, specs),
|
|
48
|
+
loadHints: config.loadHints ? extractLoadHints(specs, proposal) : null,
|
|
49
|
+
chaosHints: config.chaosHints ? extractChaosHints(specs, design) : null,
|
|
50
|
+
rawArtifacts: { proposal, design, specs, tasks },
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function extractFeatureName(changeName, proposal) {
|
|
55
|
+
if (proposal) {
|
|
56
|
+
const m = proposal.match(/^#\s+(.+)$/m);
|
|
57
|
+
if (m) return m[1].trim();
|
|
58
|
+
const fm = matter(proposal);
|
|
59
|
+
if (fm.data.feature) return fm.data.feature;
|
|
60
|
+
}
|
|
61
|
+
return changeName;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function buildStack(sddConfig, testspecConfig) {
|
|
65
|
+
return {
|
|
66
|
+
lang: sddConfig?.stack?.lang || testspecConfig?.stack?.lang || 'node',
|
|
67
|
+
db: sddConfig?.stack?.db || testspecConfig?.stack?.db || 'postgresql',
|
|
68
|
+
broker: sddConfig?.stack?.broker || testspecConfig?.stack?.broker || null,
|
|
69
|
+
};
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function extractScenarios(specs) {
|
|
73
|
+
const scenarios = [];
|
|
74
|
+
for (const spec of specs) {
|
|
75
|
+
const matches = spec.matchAll(/^#{2,4}\s+(?:Scenario|Cenário|Scenario:)\s*(.+)$/gim);
|
|
76
|
+
for (const m of matches) scenarios.push(m[1].trim());
|
|
77
|
+
// Also extract "Given/When/Then" blocks as scenario titles
|
|
78
|
+
const gwt = spec.matchAll(/^#{2,4}\s+(.+)$/gm);
|
|
79
|
+
for (const m of gwt) {
|
|
80
|
+
const title = m[1].trim();
|
|
81
|
+
if (!scenarios.includes(title)) scenarios.push(title);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return [...new Set(scenarios)].slice(0, 50);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function extractRules(specs) {
|
|
88
|
+
const rules = [];
|
|
89
|
+
for (const spec of specs) {
|
|
90
|
+
const matches = spec.matchAll(/^[-*]\s+(?:Rule|Regra):\s*(.+)$/gim);
|
|
91
|
+
for (const m of matches) rules.push(m[1].trim());
|
|
92
|
+
// Also capture bullet points under "Rules" / "Business Rules" headers
|
|
93
|
+
const ruleSection = spec.match(/^#{2,3}\s+(?:Rules?|Business Rules?|Regras?)[^\n]*\n([\s\S]*?)(?=\n#{2,3}|$)/im);
|
|
94
|
+
if (ruleSection) {
|
|
95
|
+
const bullets = ruleSection[1].matchAll(/^[-*]\s+(.+)$/gm);
|
|
96
|
+
for (const b of bullets) rules.push(b[1].trim());
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return [...new Set(rules)].slice(0, 30);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function extractContracts(design) {
|
|
103
|
+
if (!design) return [];
|
|
104
|
+
const contracts = [];
|
|
105
|
+
const endpointMatches = design.matchAll(/`((?:GET|POST|PUT|PATCH|DELETE)\s+\/[^`]+)`/gi);
|
|
106
|
+
for (const m of endpointMatches) contracts.push(m[1].trim());
|
|
107
|
+
return [...new Set(contracts)];
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function extractDbAssertions(design) {
|
|
111
|
+
if (!design) return [];
|
|
112
|
+
const assertions = [];
|
|
113
|
+
const sqlMatches = design.matchAll(/```sql([\s\S]*?)```/gi);
|
|
114
|
+
for (const m of sqlMatches) assertions.push(m[1].trim());
|
|
115
|
+
return assertions;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function extractOutOfScope(proposal, specs) {
|
|
119
|
+
const outOfScope = [];
|
|
120
|
+
const sources = [proposal, ...specs].filter(Boolean);
|
|
121
|
+
for (const src of sources) {
|
|
122
|
+
const section = src.match(/^#{2,3}\s+Out of [Ss]cope[^\n]*\n([\s\S]*?)(?=\n#{2,3}|$)/im);
|
|
123
|
+
if (section) {
|
|
124
|
+
const bullets = section[1].matchAll(/^[-*]\s+(.+)$/gm);
|
|
125
|
+
for (const b of bullets) outOfScope.push(b[1].trim());
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
return [...new Set(outOfScope)];
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function extractLoadHints(specs, proposal) {
|
|
132
|
+
const sources = [...specs, proposal].filter(Boolean).join('\n');
|
|
133
|
+
const section = sources.match(/^#{2,3}\s+(?:Load|Performance|NFR)[^\n]*\n([\s\S]*?)(?=\n#{2,3}|$)/im);
|
|
134
|
+
return section ? section[1].trim() : null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
function extractChaosHints(specs, design) {
|
|
138
|
+
const sources = [design, ...specs].filter(Boolean).join('\n');
|
|
139
|
+
const section = sources.match(/^#{2,3}\s+(?:Chaos|Failure|Resilience)[^\n]*\n([\s\S]*?)(?=\n#{2,3}|$)/im);
|
|
140
|
+
return section ? section[1].trim() : null;
|
|
141
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/stub-generator.js
|
|
3
|
+
* Generates unit and integration test stubs from a SpecContext.
|
|
4
|
+
* Reads templates from templates/stubs/ and substitutes CT placeholders.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'fs';
|
|
8
|
+
import { join, dirname } from 'path';
|
|
9
|
+
import { fileURLToPath } from 'url';
|
|
10
|
+
|
|
11
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
12
|
+
const TEMPLATES_DIR = join(__dirname, '../../templates/stubs');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* @param {import('./spec-parser.js').SpecContext} ctx
|
|
16
|
+
* @param {object} config
|
|
17
|
+
* @param {string} outputDir directory where tests.md was written
|
|
18
|
+
* @returns {{ unit: string[], integration: string[] }}
|
|
19
|
+
*/
|
|
20
|
+
export function generateStubs(ctx, config, outputDir) {
|
|
21
|
+
const unitFramework = config.unitFramework || 'vitest';
|
|
22
|
+
const unit = [];
|
|
23
|
+
const integration = [];
|
|
24
|
+
|
|
25
|
+
if (config.stubs?.unit !== false) {
|
|
26
|
+
const unitStubs = generateUnitStubs(ctx, unitFramework, outputDir);
|
|
27
|
+
unit.push(...unitStubs);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (config.stubs?.integration !== false) {
|
|
31
|
+
const intStubs = generateIntegrationStubs(ctx, config, outputDir);
|
|
32
|
+
integration.push(...intStubs);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
return { unit, integration };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function generateUnitStubs(ctx, framework, outputDir) {
|
|
39
|
+
const tplPath = join(TEMPLATES_DIR, framework, 'unit.template.js');
|
|
40
|
+
if (!existsSync(tplPath)) return [];
|
|
41
|
+
|
|
42
|
+
const template = readFileSync(tplPath, 'utf-8');
|
|
43
|
+
const written = [];
|
|
44
|
+
|
|
45
|
+
ctx.scenarios.forEach((scenario, i) => {
|
|
46
|
+
const ctId = `CT-${String(i + 1).padStart(2, '0')}`;
|
|
47
|
+
const fileName = `${ctId.toLowerCase()}-${slugify(scenario)}.unit.test.js`;
|
|
48
|
+
const outPath = join(outputDir, 'tests', 'unit', fileName);
|
|
49
|
+
|
|
50
|
+
const content = template
|
|
51
|
+
.replace(/\{\{CT_ID\}\}/g, ctId)
|
|
52
|
+
.replace(/\{\{TITLE\}\}/g, scenario)
|
|
53
|
+
.replace(/\{\{FEATURE\}\}/g, ctx.feature);
|
|
54
|
+
|
|
55
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
56
|
+
writeFileSync(outPath, content);
|
|
57
|
+
written.push(outPath);
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
return written;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function generateIntegrationStubs(ctx, config, outputDir) {
|
|
64
|
+
const stack = ctx.stack;
|
|
65
|
+
let tplName;
|
|
66
|
+
|
|
67
|
+
if (stack.broker) {
|
|
68
|
+
tplName = stack.lang === 'node' ? 'node-pg-kafka.template.js' : 'spring-pg-kafka.template.java';
|
|
69
|
+
} else {
|
|
70
|
+
tplName = 'node-pg.template.js';
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const tplPath = join(TEMPLATES_DIR, 'testcontainers', tplName);
|
|
74
|
+
if (!existsSync(tplPath)) return [];
|
|
75
|
+
|
|
76
|
+
const template = readFileSync(tplPath, 'utf-8');
|
|
77
|
+
const outPath = join(outputDir, 'tests', 'integration', `${slugify(ctx.feature)}.integration.test.js`);
|
|
78
|
+
|
|
79
|
+
const content = template
|
|
80
|
+
.replace(/\{\{FEATURE\}\}/g, ctx.feature)
|
|
81
|
+
.replace(/\{\{CHANGE\}\}/g, ctx.changeName)
|
|
82
|
+
.replace(/\{\{SCENARIOS\}\}/g, ctx.scenarios.map((s, i) =>
|
|
83
|
+
` // CT-${String(i + 1).padStart(2, '0')}: ${s}`).join('\n'));
|
|
84
|
+
|
|
85
|
+
mkdirSync(dirname(outPath), { recursive: true });
|
|
86
|
+
writeFileSync(outPath, content);
|
|
87
|
+
return [outPath];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function slugify(str) {
|
|
91
|
+
return str.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
|
92
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/testcontainers.js
|
|
3
|
+
* Builds Testcontainers container setup snippets from stack config.
|
|
4
|
+
* Used by stub-generator to pre-configure the right container images.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* @param {{ lang: string, db: string, broker: string|null }} stack
|
|
9
|
+
* @returns {{ imports: string, setup: string, teardown: string }}
|
|
10
|
+
*/
|
|
11
|
+
export function buildContainerSetup(stack) {
|
|
12
|
+
const imports = [];
|
|
13
|
+
const setupLines = [];
|
|
14
|
+
const teardownLines = [];
|
|
15
|
+
|
|
16
|
+
if (stack.db === 'postgresql') {
|
|
17
|
+
imports.push("import { PostgreSqlContainer } from '@testcontainers/postgresql';");
|
|
18
|
+
setupLines.push(
|
|
19
|
+
"container = await new PostgreSqlContainer('postgres:16-alpine').start();",
|
|
20
|
+
'connectionString = container.getConnectionUri();',
|
|
21
|
+
);
|
|
22
|
+
teardownLines.push('await container.stop();');
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
if (stack.broker === 'kafka') {
|
|
26
|
+
imports.push("import { KafkaContainer } from '@testcontainers/kafka';");
|
|
27
|
+
setupLines.push(
|
|
28
|
+
"kafkaContainer = await new KafkaContainer('confluentinc/cp-kafka:7.4.0').start();",
|
|
29
|
+
'brokerUrl = `${kafkaContainer.getHost()}:${kafkaContainer.getMappedPort(9093)}`;',
|
|
30
|
+
);
|
|
31
|
+
teardownLines.push('await kafkaContainer.stop();');
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return {
|
|
35
|
+
imports: imports.join('\n'),
|
|
36
|
+
setup: setupLines.join('\n '),
|
|
37
|
+
teardown: teardownLines.join('\n '),
|
|
38
|
+
};
|
|
39
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/core/tests-builder.js
|
|
3
|
+
* Builds the agent prompt from a SpecContext, and assembles the tests.md skeleton.
|
|
4
|
+
* No LLM calls — only prompt construction. Agent adapters call the LLM.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Builds the full prompt string to send to (or print for) an AI agent.
|
|
9
|
+
* @param {import('./spec-parser.js').SpecContext} ctx
|
|
10
|
+
* @param {object} config
|
|
11
|
+
* @returns {string}
|
|
12
|
+
*/
|
|
13
|
+
export function buildPrompt(ctx, config) {
|
|
14
|
+
const stackDesc = [ctx.stack.lang, ctx.stack.db, ctx.stack.broker].filter(Boolean).join(' + ');
|
|
15
|
+
|
|
16
|
+
return `# testspec generate — ${ctx.feature}
|
|
17
|
+
|
|
18
|
+
## Task
|
|
19
|
+
You are generating a \`tests.md\` for the feature **${ctx.feature}** (change: \`${ctx.changeName}\`).
|
|
20
|
+
Follow the SDT canonical structure exactly.
|
|
21
|
+
|
|
22
|
+
## Stack
|
|
23
|
+
${stackDesc}
|
|
24
|
+
|
|
25
|
+
## Spec artifacts
|
|
26
|
+
|
|
27
|
+
### Scenarios detected
|
|
28
|
+
${ctx.scenarios.map((s) => `- ${s}`).join('\n') || '_none detected_'}
|
|
29
|
+
|
|
30
|
+
### Business rules
|
|
31
|
+
${ctx.rules.map((r) => `- ${r}`).join('\n') || '_none detected_'}
|
|
32
|
+
|
|
33
|
+
### API contracts (from design.md)
|
|
34
|
+
${ctx.contracts.map((c) => `- \`${c}\``).join('\n') || '_none detected_'}
|
|
35
|
+
|
|
36
|
+
### DB assertions (from design.md)
|
|
37
|
+
${ctx.dbAssertions.map((a) => `\`\`\`sql\n${a}\n\`\`\``).join('\n') || '_none detected_'}
|
|
38
|
+
|
|
39
|
+
### Out of scope
|
|
40
|
+
${ctx.outOfScope.map((o) => `- ${o}`).join('\n') || '_none detected_'}
|
|
41
|
+
|
|
42
|
+
${ctx.loadHints ? `### Load hints\n${ctx.loadHints}\n` : ''}
|
|
43
|
+
${ctx.chaosHints ? `### Chaos hints\n${ctx.chaosHints}\n` : ''}
|
|
44
|
+
|
|
45
|
+
## Raw spec content
|
|
46
|
+
${ctx.specs.map((s, i) => `### spec-${i + 1}\n${s}`).join('\n\n')}
|
|
47
|
+
|
|
48
|
+
## Output format
|
|
49
|
+
Produce a complete \`tests.md\` with this EXACT structure:
|
|
50
|
+
|
|
51
|
+
\`\`\`
|
|
52
|
+
---
|
|
53
|
+
feature: ${ctx.feature}
|
|
54
|
+
change: ${ctx.changeName}
|
|
55
|
+
generated: <ISO timestamp>
|
|
56
|
+
sdd: ${ctx.sdd}
|
|
57
|
+
sdt: 0.1.0
|
|
58
|
+
stack: { lang: ${ctx.stack.lang}, db: ${ctx.stack.db}${ctx.stack.broker ? `, broker: ${ctx.stack.broker}` : ''} }
|
|
59
|
+
---
|
|
60
|
+
|
|
61
|
+
# Tests — ${ctx.feature}
|
|
62
|
+
|
|
63
|
+
## Scope
|
|
64
|
+
<bullet list of what is in scope>
|
|
65
|
+
|
|
66
|
+
## Out of scope
|
|
67
|
+
<bullet list>
|
|
68
|
+
|
|
69
|
+
## Test cases
|
|
70
|
+
|
|
71
|
+
### CT-01 — <title>
|
|
72
|
+
| Field | Value |
|
|
73
|
+
|---------------------|-------|
|
|
74
|
+
| Type | unit \\| integration \\| e2e \\| load \\| chaos |
|
|
75
|
+
| Layer | developer \\| qa \\| chaos |
|
|
76
|
+
| Precondition | ... |
|
|
77
|
+
| Input | ... |
|
|
78
|
+
| Expected output | ... |
|
|
79
|
+
| DB validation | ... |
|
|
80
|
+
| Acceptance criteria | · bullet |
|
|
81
|
+
|
|
82
|
+
<repeat for each CT>
|
|
83
|
+
${config.loadHints !== false ? `\n## Load profile hints\n| Scenario | RPS | Duration | p95 target | p99 target |\n|---|---|---|---|---|` : ''}
|
|
84
|
+
${config.chaosHints !== false ? `\n## Chaos scenarios\n| Scenario | Failure mode | Expected behaviour |\n|---|---|---|` : ''}
|
|
85
|
+
\`\`\`
|
|
86
|
+
|
|
87
|
+
Generate as many CTs as needed to cover all scenarios, rules, and contracts.
|
|
88
|
+
Number them CT-01, CT-02, … with no gaps.
|
|
89
|
+
`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Assembles a minimal tests.md skeleton (used for placeholder generation).
|
|
94
|
+
* @param {import('./spec-parser.js').SpecContext} ctx
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
export function buildTests(ctx) {
|
|
98
|
+
const now = new Date().toISOString();
|
|
99
|
+
const stackYaml = `{ lang: ${ctx.stack.lang}, db: ${ctx.stack.db}${ctx.stack.broker ? `, broker: ${ctx.stack.broker}` : ''} }`;
|
|
100
|
+
|
|
101
|
+
return `---
|
|
102
|
+
feature: ${ctx.feature}
|
|
103
|
+
change: ${ctx.changeName}
|
|
104
|
+
generated: ${now}
|
|
105
|
+
sdd: ${ctx.sdd}
|
|
106
|
+
sdt: 0.1.0
|
|
107
|
+
stack: ${stackYaml}
|
|
108
|
+
---
|
|
109
|
+
|
|
110
|
+
# Tests — ${ctx.feature}
|
|
111
|
+
|
|
112
|
+
## Scope
|
|
113
|
+
|
|
114
|
+
## Out of scope
|
|
115
|
+
${ctx.outOfScope.map((o) => `- ${o}`).join('\n')}
|
|
116
|
+
|
|
117
|
+
## Test cases
|
|
118
|
+
|
|
119
|
+
`;
|
|
120
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/index.js
|
|
3
|
+
* Public API surface for programmatic use of @analizza-ai/testspec.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
export { parseSpecs } from './core/spec-parser.js';
|
|
7
|
+
export { buildTests } from './core/tests-builder.js';
|
|
8
|
+
export { generateStubs } from './core/stub-generator.js';
|
|
9
|
+
export { loadConfig } from './utils/config.js';
|
|
10
|
+
export { detectSdd } from './utils/sdd-detector.js';
|