@activate-core/test-forge 0.1.1 → 0.2.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/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,157 @@
1
+ /**
2
+ * testforge — reconcile docs against an external reference.
3
+ *
4
+ * By default testforge's source of truth is what the AI derived from the
5
+ * project's own code (testforge.md + the journey docs under docs/). That
6
+ * is convenient, but it inherits any bugs or wrong assumptions in the
7
+ * code: if the code mishandles a scenario, the AI documents the wrong
8
+ * behavior, and every test then locks that wrong behavior in.
9
+ *
10
+ * This module lets a human point testforge at an AUTHORITATIVE reference
11
+ * of the expected behavior — a spec file in the repo, a document/PDF, a
12
+ * website, or a Notion page — and:
13
+ *
14
+ * 1. analyzeReference() — compares the reference against the current
15
+ * testforge.md/docs, writes a comparison report to testforge.diff.md,
16
+ * and prints a short summary. It changes nothing else.
17
+ *
18
+ * 2. applyReconciliation() — once the human confirms, rewrites the
19
+ * journey docs so they reflect the reference as the source of truth
20
+ * (fixing wrong expectations, adding missing journeys/cases).
21
+ *
22
+ * Both steps drive the configured AI CLI with file-read/write (and, for
23
+ * URLs/Notion, web) access. As with discover/map, this is tool-specific;
24
+ * it is tuned for the Claude CLI and other CLIs may need their own setup.
25
+ */
26
+ import { execFileSync } from 'node:child_process';
27
+ import { loadConfig, splitCommand, withEditTools } from './config.js';
28
+ /** Where the comparison report is written. */
29
+ export const DIFF_PATH = 'testforge.diff.md';
30
+ const MAP_PATH = 'testforge.md';
31
+ /** Run the configured AI command with read/write/web access. */
32
+ function runAI(prompt) {
33
+ const { program, args } = withEditTools(splitCommand(loadConfig().aiCommand));
34
+ const output = execFileSync(program, args, {
35
+ input: prompt,
36
+ encoding: 'utf-8',
37
+ maxBuffer: 10 * 1024 * 1024,
38
+ });
39
+ return output.trim();
40
+ }
41
+ /**
42
+ * The journey-doc format, kept in sync with discover.ts so newly created
43
+ * docs match the ones setup produced. Referenced from the apply prompt.
44
+ */
45
+ const DOC_FORMAT = [
46
+ `# Journey: <Name>`,
47
+ ``,
48
+ `Type: web (use "web" for a page/UI, "api" for an endpoint)`,
49
+ `Route: <path> (the URL path or endpoint, e.g. /login)`,
50
+ ``,
51
+ `Coverage:`,
52
+ `- functional`,
53
+ `- security (add more if relevant: performance, accessibility, ...)`,
54
+ ``,
55
+ `Then whichever sections apply: Fields, Actions, Expected, Negative`,
56
+ `Cases, Race Conditions, Webhooks, Security Notes, Data.`,
57
+ ].join('\n');
58
+ /** Build the prompt for the compare-only (analyze) step. */
59
+ export function buildAnalyzePrompt(reference, docsDir) {
60
+ return [
61
+ `You are a senior QA engineer. The developer has given you an`,
62
+ `AUTHORITATIVE reference describing how this product is EXPECTED to`,
63
+ `behave. This reference comes from a human/client, NOT from the code,`,
64
+ `so where it disagrees with the current docs the reference is right`,
65
+ `and the docs (which were derived from possibly-buggy code) are wrong.`,
66
+ ``,
67
+ `In THIS step you only COMPARE. Do not change the docs.`,
68
+ ``,
69
+ `Reference provided by the developer:`,
70
+ ` ${reference}`,
71
+ ``,
72
+ `This may be a path to a file in this project, a path to a document`,
73
+ `(e.g. a PDF), a URL to a website, or a link to a Notion page. Read`,
74
+ `it with your available tools (read the file, fetch the URL/page). If`,
75
+ `you cannot access it at all, print a line starting with "ERROR:"`,
76
+ `explaining why, and stop.`,
77
+ ``,
78
+ `Then read:`,
79
+ ` - the project map at "${MAP_PATH}" (if it exists)`,
80
+ ` - every journey doc under "${docsDir}/"`,
81
+ ``,
82
+ `Compare what the reference specifies against what the map and docs`,
83
+ `currently say, focusing on things that matter for testing:`,
84
+ ` - journeys/flows the reference describes that are MISSING from docs`,
85
+ ` - expected results / acceptance criteria that DIFFER`,
86
+ ` - fields, routes, validation, or error cases the reference requires`,
87
+ ` that the docs omit or get wrong`,
88
+ ` - anything the docs assert that the reference contradicts`,
89
+ ``,
90
+ `Write a comparison report to "${DIFF_PATH}" at the project root with`,
91
+ `these sections:`,
92
+ ` # Source-of-truth comparison`,
93
+ ` ## Reference (what you read, and where from)`,
94
+ ` ## Summary (a few bullet lines)`,
95
+ ` ## Differences (per journey: docs say X vs reference says Y,`,
96
+ ` and which should win)`,
97
+ ` ## Missing in docs (journeys/cases only in the reference)`,
98
+ ` ## Docs-only (in the docs but not the reference — may be`,
99
+ ` code-derived assumptions to double-check)`,
100
+ ``,
101
+ `Do NOT modify "${MAP_PATH}" or anything under "${docsDir}/" in this`,
102
+ `step — only write "${DIFF_PATH}". When done, print a SHORT plain-text`,
103
+ `summary (about 5-10 lines) of the key differences.`,
104
+ ].join('\n');
105
+ }
106
+ /** Build the prompt for the apply (reconcile) step. */
107
+ export function buildApplyPrompt(reference, docsDir) {
108
+ return [
109
+ `You are a senior QA engineer. The developer has CONFIRMED that the`,
110
+ `reference below is the SOURCE OF TRUTH for tests, overriding what was`,
111
+ `previously derived from the code.`,
112
+ ``,
113
+ `Reference: ${reference}`,
114
+ `The comparison has already been computed — read "${DIFF_PATH}" for the`,
115
+ `differences you must resolve.`,
116
+ ``,
117
+ `Update the journey docs under "${docsDir}/" so they reflect the`,
118
+ `reference as the source of truth:`,
119
+ ` - Fix expected results / acceptance criteria to match the reference`,
120
+ ` wherever they differ.`,
121
+ ` - Add sections or cases the reference requires that are missing.`,
122
+ ` - For a journey the reference describes that has NO doc yet, create`,
123
+ ` "${docsDir}/<area>/<journey>.md" (area = folder, short kebab-case`,
124
+ ` journey name) using this format:`,
125
+ ``,
126
+ DOC_FORMAT,
127
+ ``,
128
+ ` - Where a doc made an assumption from the code that the reference`,
129
+ ` does not mention, KEEP it, but if it conflicts with the reference`,
130
+ ` add a line starting with "# TODO:" describing the conflict.`,
131
+ ` - If the reference reveals models/fields the map missed, update`,
132
+ ` "${MAP_PATH}" too.`,
133
+ ``,
134
+ `Never include secrets — refer to env var names only. Preserve the`,
135
+ `existing doc format. When done, print a SHORT summary listing which`,
136
+ `docs you created or changed.`,
137
+ ].join('\n');
138
+ }
139
+ /**
140
+ * Compare-only step. Reads the reference and the current docs/map, writes
141
+ * testforge.diff.md, and returns the AI's short summary. Changes nothing
142
+ * else. Throws if the AI reports it could not read the reference.
143
+ */
144
+ export function analyzeReference(reference, docsDir) {
145
+ const summary = runAI(buildAnalyzePrompt(reference, docsDir));
146
+ if (/^ERROR:/m.test(summary)) {
147
+ throw new Error(`Could not read the reference "${reference}":\n${summary}`);
148
+ }
149
+ return summary;
150
+ }
151
+ /**
152
+ * Apply step. Rewrites the docs (and map if needed) so they reflect the
153
+ * reference. Returns the AI's short summary of what it changed.
154
+ */
155
+ export function applyReconciliation(reference, docsDir) {
156
+ return runAI(buildApplyPrompt(reference, docsDir));
157
+ }
@@ -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
  }
@@ -60,7 +60,7 @@ export function runSetup(srcDir) {
60
60
  // Build an env template so the developer knows which credentials each
61
61
  // area/journey needs for running the tests.
62
62
  console.log('Building env template...');
63
- runEnvTemplate('docs', '.env.testing.example');
63
+ runEnvTemplate('docs', '.env.testing.example', srcDir);
64
64
  console.log('');
65
65
  console.log('Setup complete.');
66
66
  console.log('Review the drafts in docs/, fix anything the AI got wrong,');
@@ -0,0 +1,95 @@
1
+ /**
2
+ * testforge — the "source of truth" barrier for `generate`.
3
+ *
4
+ * Before generating tests, testforge asks the developer WHAT the tests
5
+ * should be based on. Two answers are possible:
6
+ *
7
+ * 1. testforge.md + docs/ — what the AI derived from the project's own
8
+ * code. Fast, but only as correct as the code it read.
9
+ *
10
+ * 2. An external reference — a spec/doc/PDF in the repo, a website, or
11
+ * a Notion page — that captures the HUMAN/CLIENT expected behavior.
12
+ * testforge compares it against the current docs (writing a diff for
13
+ * the developer to inspect), and, on confirmation, reconciles the
14
+ * docs so the reference wins. Tests are then generated from those
15
+ * reconciled docs.
16
+ *
17
+ * This keeps the generate pipeline itself unchanged (it still reads
18
+ * docs/) — the barrier just decides whether the docs get reconciled
19
+ * against a reference first.
20
+ *
21
+ * Non-interactive callers (CI, cron, piped stdin) must not be blocked on
22
+ * a prompt, so:
23
+ * - `source: 'docs'` → skip the barrier, use docs as-is.
24
+ * - `reference: <locator>` → use that reference, analyze + apply with
25
+ * no prompts (implicit confirmation).
26
+ * - neither, and no TTY → fall back to docs with a printed note.
27
+ */
28
+ import { isInteractive, promptChoice, promptConfirm, promptLine, } from './prompt.js';
29
+ import { analyzeReference, applyReconciliation, DIFF_PATH } from './reconcile.js';
30
+ /** Run the analyze step and reconcile the docs against the reference. */
31
+ function reconcileToReference(reference, docsDir) {
32
+ console.log(`\nAnalyzing reference against current docs: ${reference}`);
33
+ console.log('(reading the reference, the map and docs/ — this may take a moment)\n');
34
+ const summary = analyzeReference(reference, docsDir);
35
+ console.log(summary);
36
+ console.log(`\nFull comparison written to ${DIFF_PATH}.`);
37
+ console.log('\nReconciling docs so the reference is the source of truth...\n');
38
+ const applied = applyReconciliation(reference, docsDir);
39
+ console.log(applied);
40
+ console.log('\nDocs updated. Generating tests from the reconciled docs.\n');
41
+ }
42
+ /**
43
+ * Resolve the source of truth before generation. May reconcile the docs
44
+ * in place (when a reference is chosen). Returns once docs/ reflects the
45
+ * chosen source; the caller then runs the normal generate pipeline.
46
+ */
47
+ export async function resolveSourceOfTruth(opts) {
48
+ const { docsDir } = opts;
49
+ // Explicit "use the docs" — nothing to do.
50
+ if (opts.source === 'docs')
51
+ return;
52
+ // Explicit reference via flag: non-interactive analyze + apply.
53
+ if (opts.reference) {
54
+ reconcileToReference(opts.reference, docsDir);
55
+ return;
56
+ }
57
+ // No explicit choice and no terminal to ask on — do the safe,
58
+ // non-blocking thing: use the docs as they are.
59
+ if (!isInteractive()) {
60
+ console.log('Using testforge.md + docs/ as the source of truth ' +
61
+ '(non-interactive; pass --reference <file|url> to use an external reference).');
62
+ return;
63
+ }
64
+ // Interactive: ask what to base the tests on.
65
+ const choice = await promptChoice('What should the tests be based on?', [
66
+ 'testforge.md + docs/ (what the AI derived from your code)',
67
+ 'An external reference (a file/PDF in scope, a website, or a Notion link)',
68
+ ], 1);
69
+ if (choice === 1) {
70
+ console.log('Using testforge.md + docs/ as the source of truth.\n');
71
+ return;
72
+ }
73
+ const reference = await promptLine('\nProvide the reference (a file path, a URL, or a Notion link): ');
74
+ if (!reference) {
75
+ console.log('No reference given — falling back to testforge.md + docs/.\n');
76
+ return;
77
+ }
78
+ // Analyze first so the developer can see the differences, then confirm
79
+ // before we rewrite any docs.
80
+ console.log(`\nAnalyzing reference against current docs: ${reference}`);
81
+ console.log('(reading the reference, the map and docs/ — this may take a moment)\n');
82
+ const summary = analyzeReference(reference, docsDir);
83
+ console.log(summary);
84
+ console.log(`\nFull comparison written to ${DIFF_PATH}.`);
85
+ const apply = await promptConfirm('\nApply these changes to docs/ (make the reference the source of truth)?', false);
86
+ if (!apply) {
87
+ console.log(`\nKeeping the current docs. The comparison is saved in ${DIFF_PATH} ` +
88
+ 'for you to review. Generating tests from the existing docs.\n');
89
+ return;
90
+ }
91
+ console.log('\nReconciling docs so the reference is the source of truth...\n');
92
+ const applied = applyReconciliation(reference, docsDir);
93
+ console.log(applied);
94
+ console.log('\nDocs updated. Generating tests from the reconciled docs.\n');
95
+ }
@@ -46,7 +46,7 @@ export function runUpdate(srcDir) {
46
46
  cache[SOURCE_KEY] = newHash;
47
47
  saveCache(CACHE_PATH, cache);
48
48
  console.log('Refreshing env template...');
49
- runEnvTemplate('docs', '.env.testing.example');
49
+ runEnvTemplate('docs', '.env.testing.example', srcDir);
50
50
  console.log('');
51
51
  console.log('Update complete.');
52
52
  }