@activate-core/test-forge 0.1.1 → 0.3.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.
@@ -11,15 +11,19 @@
11
11
  * Only names are collected, never values. The developer fills in real
12
12
  * values in their own .env.testing (which stays out of version control).
13
13
  */
14
- import { readFileSync, writeFileSync, existsSync } from 'node:fs';
14
+ import { readFileSync, writeFileSync, existsSync, readdirSync, } from 'node:fs';
15
+ import { join } from 'node:path';
15
16
  import { findMarkdownFiles } from './find-files.js';
16
17
  /** Pull env var names from a chunk of text. */
17
18
  function extractEnvVars(text) {
18
19
  const vars = new Set();
19
- // process.env.XXX
20
+ // process.env.XXX and process.env["XXX"] / process.env['XXX']
20
21
  for (const m of text.matchAll(/process\.env\.([A-Z0-9_]+)/gi)) {
21
22
  vars.add(m[1]);
22
23
  }
24
+ for (const m of text.matchAll(/process\.env\[\s*['"]([A-Z0-9_]+)['"]\s*\]/gi)) {
25
+ vars.add(m[1]);
26
+ }
23
27
  // backtick-wrapped ALL_CAPS names in "env var" lines
24
28
  for (const line of text.split('\n')) {
25
29
  if (/env var|secret|credential/i.test(line)) {
@@ -30,6 +34,69 @@ function extractEnvVars(text) {
30
34
  }
31
35
  return vars;
32
36
  }
37
+ /** Directories we never want to scan for app env vars. */
38
+ const SKIP_DIRS = new Set([
39
+ 'node_modules',
40
+ '.git',
41
+ 'dist',
42
+ 'build',
43
+ 'coverage',
44
+ 'docs',
45
+ 'generated-tests',
46
+ '.testforge',
47
+ ]);
48
+ const SOURCE_EXT = /\.(c|m)?[jt]sx?$/i;
49
+ /**
50
+ * Scan the project's application SOURCE (not just the docs) for
51
+ * process.env references. Docs describe features; they routinely miss
52
+ * boot-critical vars (Sentry DSN, Twilio keys, feature flags) that the
53
+ * app reads directly at startup. Without those in the example file, the
54
+ * server won't even boot for a fresh clone. We walk the source tree,
55
+ * skipping vendored/build/test output, with a file cap so a huge repo
56
+ * can't stall the run.
57
+ */
58
+ function scanSourceEnvVars(srcDir, maxFiles = 4000) {
59
+ const vars = new Set();
60
+ let seen = 0;
61
+ const walk = (dir) => {
62
+ if (seen >= maxFiles)
63
+ return;
64
+ let entries;
65
+ try {
66
+ entries = readdirSync(dir, { withFileTypes: true });
67
+ }
68
+ catch {
69
+ return;
70
+ }
71
+ for (const entry of entries) {
72
+ if (seen >= maxFiles)
73
+ return;
74
+ const full = join(dir, entry.name);
75
+ if (entry.isDirectory()) {
76
+ if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.'))
77
+ continue;
78
+ walk(full);
79
+ }
80
+ else if (SOURCE_EXT.test(entry.name)) {
81
+ // Skip test files — we want app source, not existing tests.
82
+ if (/\.(test|spec)\./i.test(entry.name))
83
+ continue;
84
+ seen++;
85
+ try {
86
+ for (const v of extractEnvVars(readFileSync(full, 'utf-8'))) {
87
+ vars.add(v);
88
+ }
89
+ }
90
+ catch {
91
+ // unreadable file — ignore
92
+ }
93
+ }
94
+ }
95
+ };
96
+ if (existsSync(srcDir))
97
+ walk(srcDir);
98
+ return vars;
99
+ }
33
100
  /** Derive "area" and "journey" from a doc file name like web-payments.md */
34
101
  function areaAndJourney(file) {
35
102
  // file is a relative path like "api/payments.md" (folder = area).
@@ -41,7 +108,7 @@ function areaAndJourney(file) {
41
108
  // Fallback: flat file with no area folder.
42
109
  return { area: 'general', journey: noExt };
43
110
  }
44
- export function runEnvTemplate(docsDir, outPath) {
111
+ export function runEnvTemplate(docsDir, outPath, srcDir = '.') {
45
112
  if (!existsSync(docsDir)) {
46
113
  console.log(`No docs found at "${docsDir}". Run \`testforge setup\` first.`);
47
114
  return;
@@ -76,8 +143,18 @@ export function runEnvTemplate(docsDir, outPath) {
76
143
  }
77
144
  }
78
145
  }
79
- if (byArea.size === 0) {
80
- console.log('No environment variables found in the docs.');
146
+ // Vars the app reads directly from source but that no doc/map mentioned.
147
+ // These are the ones that silently break `boot` when missing, so they get
148
+ // their own clearly-flagged section.
149
+ const knownFromDocs = new Set();
150
+ for (const areaMap of byArea.values()) {
151
+ for (const v of areaMap.keys())
152
+ knownFromDocs.add(v);
153
+ }
154
+ const sourceVars = scanSourceEnvVars(srcDir);
155
+ const bootVars = [...sourceVars].filter((v) => !knownFromDocs.has(v)).sort();
156
+ if (byArea.size === 0 && bootVars.length === 0) {
157
+ console.log('No environment variables found in the docs or source.');
81
158
  return;
82
159
  }
83
160
  // Build the file.
@@ -90,6 +167,15 @@ export function runEnvTemplate(docsDir, outPath) {
90
167
  '# Keep .env.testing OUT of version control.',
91
168
  '',
92
169
  ];
170
+ if (bootVars.length > 0) {
171
+ lines.push(`# ${'='.repeat(40)}`);
172
+ lines.push('# APP (read directly from source — REQUIRED to boot)');
173
+ lines.push(`# ${'='.repeat(40)}`);
174
+ for (const v of bootVars) {
175
+ lines.push(`${v}= # used by: app source`);
176
+ }
177
+ lines.push('');
178
+ }
93
179
  for (const area of [...byArea.keys()].sort()) {
94
180
  lines.push(`# ${'='.repeat(40)}`);
95
181
  lines.push(`# ${area.toUpperCase()}`);
@@ -4,4 +4,4 @@
4
4
  * Currently exposes the package version only.
5
5
  * Model, parser, and engines will be added in upcoming steps.
6
6
  */
7
- export const VERSION = '0.1.0';
7
+ export const VERSION = '0.3.0';
package/dist/core/init.js CHANGED
@@ -11,7 +11,7 @@
11
11
  * infers it from what the project actually looks like.
12
12
  */
13
13
  import { execFileSync } from 'node:child_process';
14
- import { loadConfig, splitCommand } from './config.js';
14
+ import { loadConfig, splitCommand, withEditTools } from './config.js';
15
15
  /** Build the prompt that asks the AI to write testforge.config.json. */
16
16
  export function buildInitPrompt() {
17
17
  return [
@@ -57,8 +57,8 @@ export function buildInitPrompt() {
57
57
  /** Run init via the configured AI command. */
58
58
  export function runInit() {
59
59
  const prompt = buildInitPrompt();
60
- const { program, args } = splitCommand(loadConfig().aiCommand);
61
- const output = execFileSync(program, [...args, '--permission-mode', 'acceptEdits'], {
60
+ const { program, args } = withEditTools(splitCommand(loadConfig().aiCommand));
61
+ const output = execFileSync(program, args, {
62
62
  input: prompt,
63
63
  encoding: 'utf-8',
64
64
  maxBuffer: 10 * 1024 * 1024,
package/dist/core/map.js CHANGED
@@ -18,7 +18,7 @@
18
18
  * only env var names.
19
19
  */
20
20
  import { execFileSync } from 'node:child_process';
21
- import { loadConfig, splitCommand } from './config.js';
21
+ import { loadConfig, splitCommand, withEditTools } from './config.js';
22
22
  /** Build the prompt that asks the AI to produce the project map. */
23
23
  export function buildMapPrompt(srcDir) {
24
24
  return [
@@ -80,8 +80,8 @@ export function buildMapPrompt(srcDir) {
80
80
  /** Run the map builder via the configured AI command. */
81
81
  export function runMap(srcDir) {
82
82
  const prompt = buildMapPrompt(srcDir);
83
- const { program, args } = splitCommand(loadConfig().aiCommand);
84
- const output = execFileSync(program, [...args, '--permission-mode', 'acceptEdits'], {
83
+ const { program, args } = withEditTools(splitCommand(loadConfig().aiCommand));
84
+ const output = execFileSync(program, args, {
85
85
  input: prompt,
86
86
  encoding: 'utf-8',
87
87
  maxBuffer: 10 * 1024 * 1024,
@@ -0,0 +1,70 @@
1
+ /**
2
+ * testforge — small interactive prompt helpers.
3
+ *
4
+ * The rest of the CLI is non-interactive: it feeds prompts to the AI on
5
+ * a child process's stdin and never reads from the terminal. The
6
+ * source-of-truth barrier (see source-of-truth.ts) is the one place we
7
+ * genuinely need to ask the human a question, so these helpers read from
8
+ * the parent process's own stdin (the TTY) using node:readline.
9
+ *
10
+ * They are careful about two things:
11
+ * - Non-interactive runs (CI, piped stdin): callers should check
12
+ * isInteractive() first and fall back to a safe default instead of
13
+ * blocking forever waiting for input that will never come.
14
+ * - The child-process AI calls use a separate pipe for their prompt
15
+ * (execFileSync `input`), so reading the TTY here does not clash
16
+ * with them — they never touch process.stdin.
17
+ */
18
+ import { createInterface } from 'node:readline/promises';
19
+ /**
20
+ * Is the process attached to an interactive terminal on BOTH stdin and
21
+ * stdout? If not (piped input, CI, cron), we must not block on a prompt.
22
+ */
23
+ export function isInteractive() {
24
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY);
25
+ }
26
+ /** Ask a free-text question and return the trimmed answer. */
27
+ export async function promptLine(question) {
28
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
29
+ try {
30
+ const answer = await rl.question(question);
31
+ return answer.trim();
32
+ }
33
+ finally {
34
+ rl.close();
35
+ }
36
+ }
37
+ /**
38
+ * Ask a yes/no question. Returns true only for an explicit yes.
39
+ * `defaultYes` controls what an empty answer (just Enter) means.
40
+ */
41
+ export async function promptConfirm(question, defaultYes = false) {
42
+ const hint = defaultYes ? '[Y/n]' : '[y/N]';
43
+ const answer = (await promptLine(`${question} ${hint} `)).toLowerCase();
44
+ if (answer === '')
45
+ return defaultYes;
46
+ return answer === 'y' || answer === 'yes';
47
+ }
48
+ /**
49
+ * Present a numbered list of choices and return the 1-based index the
50
+ * user picked. Re-asks until a valid number is entered. `defaultIndex`
51
+ * (1-based) is used when the user just presses Enter.
52
+ */
53
+ export async function promptChoice(title, options, defaultIndex = 1) {
54
+ console.log(title);
55
+ options.forEach((opt, i) => {
56
+ const marker = i + 1 === defaultIndex ? ' (default)' : '';
57
+ console.log(` [${i + 1}] ${opt}${marker}`);
58
+ });
59
+ // Loop until we get a valid selection.
60
+ for (;;) {
61
+ const raw = await promptLine(`Choose [1-${options.length}]: `);
62
+ if (raw === '')
63
+ return defaultIndex;
64
+ const n = Number(raw);
65
+ if (Number.isInteger(n) && n >= 1 && n <= options.length) {
66
+ return n;
67
+ }
68
+ console.log(`Please enter a number between 1 and ${options.length}.`);
69
+ }
70
+ }
@@ -0,0 +1,162 @@
1
+ /**
2
+ * testforge — external reference support for doc generation.
3
+ *
4
+ * The journey docs are the source of truth for the tests: `generate`
5
+ * reads the docs and nothing else. So WHERE the docs come from decides
6
+ * everything. By default they are derived from the project's own code
7
+ * (via testforge.md), which bakes in any bugs the code has.
8
+ *
9
+ * This module lets the docs be drafted from an AUTHORITATIVE reference
10
+ * instead — a spec/requirements doc in the repo, a PDF, a website, or a
11
+ * Notion page that captures the human/client EXPECTED behavior:
12
+ *
13
+ * - analyzeReference() compares the reference against the current
14
+ * map (testforge.md) and any existing docs, and
15
+ * writes a comparison to testforge.diff.md.
16
+ * - draftDocsFromReference() drafts the journey docs with the reference
17
+ * as the source of truth for behavior, using the
18
+ * map/code only for technical detail.
19
+ *
20
+ * Both drive the configured AI CLI with file-read/write (and, for
21
+ * URLs/Notion, web) access. As with discover/map this is tool-specific
22
+ * and tuned for the Claude CLI.
23
+ */
24
+ import { execFileSync } from 'node:child_process';
25
+ import { loadConfig, splitCommand, withEditTools } from './config.js';
26
+ /** Where the comparison report is written. */
27
+ export const DIFF_PATH = 'testforge.diff.md';
28
+ const MAP_PATH = 'testforge.md';
29
+ /** Run the configured AI command with read/write/web access. */
30
+ function runAI(prompt) {
31
+ const { program, args } = withEditTools(splitCommand(loadConfig().aiCommand));
32
+ const output = execFileSync(program, args, {
33
+ input: prompt,
34
+ encoding: 'utf-8',
35
+ maxBuffer: 10 * 1024 * 1024,
36
+ });
37
+ return output.trim();
38
+ }
39
+ /** Build the prompt for the compare-only (analyze) step. */
40
+ export function buildAnalyzePrompt(reference, docsDir) {
41
+ return [
42
+ `You are a senior QA engineer. The developer has given you an`,
43
+ `AUTHORITATIVE reference describing how this product is EXPECTED to`,
44
+ `behave. It comes from a human/client, NOT from the code, so where it`,
45
+ `disagrees with what the code implies, the reference is right.`,
46
+ ``,
47
+ `In THIS step you only COMPARE — you do not write any journey docs.`,
48
+ ``,
49
+ `Reference provided by the developer:`,
50
+ ` ${reference}`,
51
+ ``,
52
+ `This may be a path to a file in this project, a path to a document`,
53
+ `(e.g. a PDF), a URL to a website, or a link to a Notion page. Read`,
54
+ `it with your available tools. If you cannot access it at all, print`,
55
+ `a single line starting with "ERROR:" explaining why, and stop.`,
56
+ ``,
57
+ `Then read the project map at "${MAP_PATH}" (the AI's code-derived view`,
58
+ `of the journeys and data layer) and any existing docs under`,
59
+ `"${docsDir}/".`,
60
+ ``,
61
+ `Compare what the reference specifies against what the map/docs imply,`,
62
+ `focusing on things that matter for testing:`,
63
+ ` - journeys/flows the reference describes that the map is missing`,
64
+ ` - expected results / acceptance criteria that DIFFER`,
65
+ ` - fields, routes, validation, or error cases the reference requires`,
66
+ ` that the code-derived view omits or gets wrong`,
67
+ ` - anything the code-derived view asserts that the reference contradicts`,
68
+ ``,
69
+ `Write a comparison report to "${DIFF_PATH}" at the project root with`,
70
+ `these sections:`,
71
+ ` # Source-of-truth comparison`,
72
+ ` ## Reference (what you read, and where from)`,
73
+ ` ## Summary (a few bullet lines)`,
74
+ ` ## Differences (per journey: the code implies X vs the`,
75
+ ` reference says Y, and which should win)`,
76
+ ` ## Missing (journeys/cases only in the reference)`,
77
+ ` ## Code-only (in the code-derived view but not the reference —`,
78
+ ` likely assumptions to double-check)`,
79
+ ``,
80
+ `Do NOT write any files under "${docsDir}/" in this step — only write`,
81
+ `"${DIFF_PATH}". When done, print a SHORT plain-text summary (about`,
82
+ `5-10 lines) of the key differences.`,
83
+ ].join('\n');
84
+ }
85
+ /**
86
+ * Build the prompt that drafts the journey docs FROM the reference,
87
+ * treating it as the source of truth for behavior. Mirrors the code-based
88
+ * discover format so the docs are interchangeable downstream.
89
+ */
90
+ export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference) {
91
+ return [
92
+ `You are a senior QA engineer creating per-journey documentation so a`,
93
+ `QA tool can generate tests from it. The developer has chosen an`,
94
+ `AUTHORITATIVE reference as the SOURCE OF TRUTH for how the product is`,
95
+ `EXPECTED to behave. The reference wins over the code: where the code`,
96
+ `does something different, document the reference's expected behavior`,
97
+ `(the code may be buggy — that is exactly what these tests must catch).`,
98
+ ``,
99
+ `Reference: ${reference}`,
100
+ `Read it with your available tools (read the file, fetch the URL/page).`,
101
+ `The comparison you need is already in "${DIFF_PATH}" — use it.`,
102
+ ``,
103
+ `Use the project map at "${MAP_PATH}" and the source under "${srcDir}"`,
104
+ `ONLY for TECHNICAL detail the reference does not spell out: routes,`,
105
+ `field names, models/collections, enums, and env var names. NEVER take`,
106
+ `expected behavior, acceptance criteria, or negative cases from the`,
107
+ `code — those come from the reference.`,
108
+ ``,
109
+ `For EACH journey the reference describes, create one markdown file at`,
110
+ `"${docsDir}/<area>/<journey>.md" (area = folder such as api/web/ops,`,
111
+ `short kebab-case journey name; do NOT put the area in the file name).`,
112
+ `Also create a doc for any journey that exists in the map but NOT in`,
113
+ `the reference, drafted from the map, and add a line`,
114
+ `"# TODO: not covered by the reference — confirm expected behavior"`,
115
+ `at its top so it is clearly flagged.`,
116
+ ``,
117
+ `Each file starts with this header (required):`,
118
+ ``,
119
+ `# Journey: <Name>`,
120
+ ``,
121
+ `Type: web (use "web" for a page/UI, "api" for an endpoint)`,
122
+ `Route: <path> (the URL path or endpoint, e.g. /login)`,
123
+ ``,
124
+ `Coverage:`,
125
+ `- functional`,
126
+ `- security (add more if relevant: performance, accessibility, ...)`,
127
+ ``,
128
+ `Then whichever of these sections apply: Fields, Actions, Expected,`,
129
+ `Negative Cases, Race Conditions, Webhooks, Security Notes, Data. The`,
130
+ `Expected / Negative Cases / acceptance criteria MUST reflect the`,
131
+ `reference. For the Data section use the map's data layer.`,
132
+ ``,
133
+ `Rules:`,
134
+ `- Base behavior on the reference and technical detail on real code;`,
135
+ ` do not invent either with false confidence.`,
136
+ `- Where the reference is silent on a needed detail, add a "# TODO:"`,
137
+ ` line describing what the developer must verify.`,
138
+ `- Never include secrets — refer to env var names only.`,
139
+ `- Create the area subfolders as needed.`,
140
+ `- After creating the files, print the list of journeys you documented`,
141
+ ` and remind the developer to review them.`,
142
+ ].join('\n');
143
+ }
144
+ /**
145
+ * Compare-only step. Reads the reference and the current map/docs, writes
146
+ * testforge.diff.md, and returns the AI's short summary. Throws if the AI
147
+ * reports it could not read the reference.
148
+ */
149
+ export function analyzeReference(reference, docsDir) {
150
+ const summary = runAI(buildAnalyzePrompt(reference, docsDir));
151
+ if (/^ERROR:/m.test(summary)) {
152
+ throw new Error(`Could not read the reference "${reference}":\n${summary}`);
153
+ }
154
+ return summary;
155
+ }
156
+ /**
157
+ * Draft the journey docs from the reference. Returns the AI's summary of
158
+ * the journeys it documented.
159
+ */
160
+ export function draftDocsFromReference(srcDir, docsDir, reference) {
161
+ return runAI(buildReferenceDiscoverPrompt(srcDir, docsDir, reference));
162
+ }
@@ -7,35 +7,101 @@
7
7
  * falling back to the basic generator if AI fails
8
8
  * - generates a SEED file for the journey's test data via AI
9
9
  *
10
+ * Everything generated by AI passes a syntax gate BEFORE it is written
11
+ * (see validate.ts). If a generation is malformed we regenerate a
12
+ * couple of times; if it still won't parse we skip writing it and
13
+ * report that clearly rather than dropping junk on disk.
14
+ *
10
15
  * Output locations come from config (testOutput per type, seedOutput),
11
16
  * so tests/seeds land where the project expects them. Defaults to
12
17
  * generated-tests/ when not configured.
13
18
  */
14
- import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
19
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, } from 'node:fs';
15
20
  import { join, dirname } from 'node:path';
16
21
  import { parseJourney } from './parser.js';
17
22
  import { generateTestCases } from './generator.js';
18
23
  import { writePlaywrightFile } from './playwright-writer.js';
19
24
  import { generateWithAI } from './ai-generator.js';
20
25
  import { generateSeed } from './seed-generator.js';
26
+ import { isSyntaxValid } from './validate.js';
21
27
  import { hashContent, loadCache, saveCache } from './cache.js';
22
28
  import { findMarkdownFiles } from './find-files.js';
23
29
  import { loadConfig, testDirForType } from './config.js';
24
30
  const CACHE_PATH = '.testforge/cache.json';
25
- /** Produce the test file content for one journey. */
26
- function buildTest(markdown, useAI) {
27
- const journey = parseJourney(markdown);
28
- if (useAI) {
31
+ /** How many times to re-ask the AI when output won't parse. */
32
+ const MAX_GEN_ATTEMPTS = 2;
33
+ /**
34
+ * Run an AI generator, cleaning + syntax-gating its output, retrying a
35
+ * couple of times on malformed results. Returns the valid content, or
36
+ * null if every attempt failed to produce parseable code.
37
+ */
38
+ function generateValidated(generate, ext) {
39
+ let content = '';
40
+ for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
29
41
  try {
30
- return { content: generateWithAI(journey), status: 'ai' };
42
+ content = generate();
31
43
  }
32
44
  catch {
33
- // fall through to basic
45
+ // generation (or output cleaning) threw — retry
46
+ continue;
47
+ }
48
+ if (isSyntaxValid(content, ext)) {
49
+ return content;
50
+ }
51
+ }
52
+ return null;
53
+ }
54
+ /**
55
+ * Produce the test file content for one journey. Uses the AI (with a
56
+ * syntax gate + retry) and falls back to the deterministic basic
57
+ * generator if AI output can't be made valid.
58
+ */
59
+ function buildTest(markdown, useAI, ext, testPath) {
60
+ const journey = parseJourney(markdown);
61
+ if (useAI) {
62
+ const ai = generateValidated(() => generateWithAI(journey, testPath), ext);
63
+ if (ai !== null) {
64
+ return { content: ai, status: 'ai' };
34
65
  }
66
+ // fall through to the basic generator
35
67
  }
36
68
  const cases = generateTestCases(journey);
37
69
  return { content: writePlaywrightFile(journey, cases), status: 'basic' };
38
70
  }
71
+ /**
72
+ * Turn a journey id into the alternate naming conventions we might
73
+ * collide with (kebab <-> camel). Used to spot a pre-existing test for
74
+ * the same journey written under a different filename style.
75
+ */
76
+ function alternateIds(id) {
77
+ const kebab = id
78
+ .replace(/([a-z0-9])([A-Z])/g, '$1-$2')
79
+ .replace(/_/g, '-')
80
+ .toLowerCase();
81
+ const camel = id.replace(/[-_]([a-zA-Z0-9])/g, (_, c) => c.toUpperCase());
82
+ return [...new Set([kebab, camel])].filter((x) => x !== id);
83
+ }
84
+ /**
85
+ * Warn if a test for this same journey already exists under a different
86
+ * naming convention (e.g. urlShortener.test.js vs url-shortener.test.js),
87
+ * which would silently create a duplicate.
88
+ */
89
+ function collisionWarning(testDir, id, ext) {
90
+ if (!existsSync(testDir))
91
+ return null;
92
+ const alts = new Set(alternateIds(id).map((a) => `${a}${ext}`));
93
+ let existing;
94
+ try {
95
+ existing = readdirSync(testDir);
96
+ }
97
+ catch {
98
+ return null;
99
+ }
100
+ const clashes = existing.filter((f) => alts.has(f));
101
+ if (clashes.length === 0)
102
+ return null;
103
+ return `an existing test with different naming already covers this journey: ${clashes.join(', ')} — new file may duplicate it`;
104
+ }
39
105
  export function runGenerate(docsDir, useAI = true, force = false) {
40
106
  const files = findMarkdownFiles(docsDir);
41
107
  if (files.length === 0) {
@@ -44,6 +110,7 @@ export function runGenerate(docsDir, useAI = true, force = false) {
44
110
  const config = loadConfig();
45
111
  const ext = config.testExtension ?? '.test.ts';
46
112
  const seedDir = config.seedOutput ?? 'generated-tests/seed';
113
+ const seedExt = ext.endsWith('.js') ? '.seed.js' : '.seed.ts';
47
114
  const cache = loadCache(CACHE_PATH);
48
115
  const results = [];
49
116
  for (const file of files) {
@@ -53,9 +120,6 @@ export function runGenerate(docsDir, useAI = true, force = false) {
53
120
  // Where this journey's test and seed go.
54
121
  const testDir = testDirForType(config, journey.type);
55
122
  const testPath = join(testDir, `${journey.id}${ext}`);
56
- const seedExt = (config.testExtension ?? '.test.ts').endsWith('.js')
57
- ? '.seed.js'
58
- : '.seed.ts';
59
123
  const seedPath = join(seedDir, `${journey.id}${seedExt}`);
60
124
  // Skip if unchanged.
61
125
  if (!force && cache[file] === hash) {
@@ -64,26 +128,43 @@ export function runGenerate(docsDir, useAI = true, force = false) {
64
128
  testPath,
65
129
  seedPath,
66
130
  status: 'skipped',
131
+ seedStatus: 'none',
67
132
  });
68
133
  continue;
69
134
  }
70
- // 1. Test file.
71
- const { content, status } = buildTest(markdown, useAI);
135
+ const warnings = [];
136
+ const collision = collisionWarning(testDir, journey.id, ext);
137
+ if (collision)
138
+ warnings.push(collision);
139
+ // 1. Test file (syntax-gated before writing).
140
+ const { content, status } = buildTest(markdown, useAI, ext, testPath);
72
141
  mkdirSync(dirname(testPath), { recursive: true });
73
142
  writeFileSync(testPath, content, 'utf-8');
74
143
  // 2. Seed file (only when using AI — basic mode has no seed concept).
144
+ let seedStatus = 'none';
75
145
  if (useAI) {
76
- try {
77
- const seed = generateSeed(markdown);
146
+ const seed = generateValidated(() => generateSeed(markdown, seedPath), seedExt);
147
+ if (seed !== null) {
78
148
  mkdirSync(dirname(seedPath), { recursive: true });
79
149
  writeFileSync(seedPath, seed, 'utf-8');
150
+ seedStatus = 'ok';
80
151
  }
81
- catch {
82
- // seed generation failed — leave a note but don't fail the run
152
+ else {
153
+ // Do NOT write a broken seed. Report it so the user sees which
154
+ // seeds were not produced.
155
+ seedStatus = 'failed';
156
+ warnings.push(`seed generation failed after ${MAX_GEN_ATTEMPTS} attempts — no seed written`);
83
157
  }
84
158
  }
85
159
  cache[file] = hash;
86
- results.push({ journeyName: journey.name, testPath, seedPath, status });
160
+ results.push({
161
+ journeyName: journey.name,
162
+ testPath,
163
+ seedPath,
164
+ status,
165
+ seedStatus,
166
+ warnings: warnings.length ? warnings : undefined,
167
+ });
87
168
  }
88
169
  saveCache(CACHE_PATH, cache);
89
170
  return results;
@@ -16,10 +16,11 @@
16
16
  */
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { readFileSync, existsSync } from 'node:fs';
19
- import { loadConfig, splitCommand } from './config.js';
19
+ import { loadConfig, splitCommand, withTextOnlyTools } from './config.js';
20
+ import { cleanOutput } from './ai-generator.js';
20
21
  const MAP_PATH = 'testforge.md';
21
22
  /** Build the seed-generation prompt for one journey. */
22
- export function buildSeedPrompt(journeyDoc) {
23
+ export function buildSeedPrompt(journeyDoc, seedPath) {
23
24
  const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
24
25
  const ext = loadConfig().testExtension ?? '.test.ts';
25
26
  const isJs = ext.endsWith('.js');
@@ -50,6 +51,17 @@ export function buildSeedPrompt(journeyDoc) {
50
51
  `- Use the database driver/ORM the project already uses (infer from`,
51
52
  ` the map / schema style).`,
52
53
  ``,
54
+ ...(seedPath
55
+ ? [
56
+ `This seed file will be saved at EXACTLY this path:`,
57
+ ` ${seedPath}`,
58
+ `Compute every require()/import path (to app source, models, config,`,
59
+ `etc.) RELATIVE TO THAT EXACT location — count the directory depth`,
60
+ `from that path, do not assume a conventional depth. A wrong`,
61
+ `relative depth makes the seed fail to load.`,
62
+ ``,
63
+ ]
64
+ : []),
53
65
  `Write the seed in ${language}.`,
54
66
  `Output ONLY the seed code. No explanation, no markdown code fences.`,
55
67
  `The very first characters must be code (a comment or import/require).`,
@@ -64,17 +76,18 @@ export function buildSeedPrompt(journeyDoc) {
64
76
  ].join('\n');
65
77
  }
66
78
  /** Generate a seed file's content for one journey doc, via the AI. */
67
- export function generateSeed(journeyDoc) {
68
- const prompt = buildSeedPrompt(journeyDoc);
69
- const { program, args } = splitCommand(loadConfig().aiCommand);
79
+ export function generateSeed(journeyDoc, seedPath) {
80
+ const prompt = buildSeedPrompt(journeyDoc, seedPath);
81
+ // Pure text — no tools, so the model prints the seed instead of
82
+ // attempting to write it (which leaks approval prose into the output).
83
+ const { program, args } = withTextOnlyTools(splitCommand(loadConfig().aiCommand));
70
84
  const output = execFileSync(program, args, {
71
85
  input: prompt,
72
86
  encoding: 'utf-8',
73
87
  maxBuffer: 10 * 1024 * 1024,
74
88
  });
75
- // Strip code fences if the AI added them despite instructions.
76
- return output
77
- .replace(/^```[a-zA-Z]*\n/, '')
78
- .replace(/\n```\s*$/, '')
79
- .trim();
89
+ // Share the test generator's robust cleaning: strips leading AND
90
+ // trailing prose, and throws if no code is found (so the caller can
91
+ // retry or skip rather than writing junk to disk).
92
+ return cleanOutput(output);
80
93
  }