@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.
Files changed (39) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/LICENSE +21 -0
  3. package/README.md +189 -0
  4. package/bin/cli.js +42 -0
  5. package/package.json +69 -0
  6. package/src/adapters/agents/claude.js +88 -0
  7. package/src/adapters/agents/copilot.js +39 -0
  8. package/src/adapters/agents/index.js +22 -0
  9. package/src/adapters/sdd/index.js +23 -0
  10. package/src/adapters/sdd/openspec.js +58 -0
  11. package/src/adapters/sdd/speckit.js +19 -0
  12. package/src/commands/generate.js +66 -0
  13. package/src/commands/init.js +112 -0
  14. package/src/commands/report.js +60 -0
  15. package/src/commands/validate.js +68 -0
  16. package/src/core/reporter.js +44 -0
  17. package/src/core/spec-parser.js +141 -0
  18. package/src/core/stub-generator.js +92 -0
  19. package/src/core/testcontainers.js +39 -0
  20. package/src/core/tests-builder.js +120 -0
  21. package/src/index.js +10 -0
  22. package/src/utils/config.js +29 -0
  23. package/src/utils/logger.js +13 -0
  24. package/src/utils/sdd-detector.js +23 -0
  25. package/templates/agent-instructions/AGENTS.md +39 -0
  26. package/templates/agent-instructions/CLAUDE.md +48 -0
  27. package/templates/agent-instructions/copilot.md +52 -0
  28. package/templates/agent-instructions/skills/testspec-apply-qa.md +424 -0
  29. package/templates/agent-instructions/skills/testspec-generate.md +138 -0
  30. package/templates/agent-instructions/skills/testspec-run-qa.md +338 -0
  31. package/templates/agent-instructions/skills/testspec-specify-qa.md +535 -0
  32. package/templates/stubs/jest/unit.template.js +17 -0
  33. package/templates/stubs/junit/unit.template.java +27 -0
  34. package/templates/stubs/pytest/unit.template.py +18 -0
  35. package/templates/stubs/testcontainers/node-pg-kafka.template.js +38 -0
  36. package/templates/stubs/testcontainers/node-pg.template.js +32 -0
  37. package/templates/stubs/testcontainers/spring-pg-kafka.template.java +41 -0
  38. package/templates/stubs/vitest/unit.template.js +19 -0
  39. 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';