@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/README.md CHANGED
@@ -40,7 +40,7 @@ testforge update # re-check and refresh only what changed
40
40
  | Command | What it does |
41
41
  | -------------------- | ---------------------------------------------------------------------------------------------------------------------------------- |
42
42
  | `testforge setup` | One-time setup: runs init, map, and discover in order, with progress. |
43
- | `testforge generate` | Generates a test file and a seed file for each journey doc. Only changed docs are regenerated. |
43
+ | `testforge generate` | Generates a test file and a seed file for each journey doc. Only changed docs are regenerated. First asks what the tests should be based on — the AI-derived docs, or an external reference (see [Source of truth](#source-of-truth)). |
44
44
  | `testforge update` | Detects source-code changes and refreshes the map, docs, and tests when needed. Skips everything if nothing changed (no AI calls). |
45
45
 
46
46
  ### Advanced (run a single step)
@@ -73,6 +73,49 @@ generates a seed file that provisions the test data each test needs.
73
73
  **update** hashes your source code and compares it to the last run. No
74
74
  change means no work and no AI calls; a change triggers a refresh.
75
75
 
76
+ ## Source of truth
77
+
78
+ By default, testforge's docs are derived from your **code** — the AI reads
79
+ the project and writes `testforge.md` and the journey docs. That is fast,
80
+ but it inherits whatever the code does: if a flow is buggy or a scenario
81
+ is mishandled, the AI faithfully documents the wrong behavior, and the
82
+ tests then lock that wrong behavior in.
83
+
84
+ So when you run `testforge generate`, it first asks what the tests should
85
+ be based on:
86
+
87
+ ```
88
+ What should the tests be based on?
89
+ [1] testforge.md + docs/ (what the AI derived from your code) (default)
90
+ [2] An external reference (a file/PDF in scope, a website, or a Notion link)
91
+ ```
92
+
93
+ Choose **[2]** to give testforge an authoritative reference of the
94
+ _expected_ behavior — a spec or requirements doc in the repo, a PDF, a
95
+ website, or a Notion page. testforge then:
96
+
97
+ 1. reads the reference and compares it against the current docs,
98
+ 2. writes a full comparison to **`testforge.diff.md`** and prints a short
99
+ summary of the differences,
100
+ 3. asks you to confirm, and on confirmation **reconciles the docs** so the
101
+ reference wins (fixing wrong expectations, adding missing journeys),
102
+ 4. generates the tests from those reconciled docs.
103
+
104
+ The reference becomes the source of truth — expected behavior from a human
105
+ or client, not just what the code happens to do. Your review of
106
+ `testforge.diff.md` stays as an audit of what changed and why.
107
+
108
+ **Non-interactive use (CI, scripts):**
109
+
110
+ ```bash
111
+ testforge generate --source docs # skip the prompt, use the docs as-is
112
+ testforge generate --reference ./spec.md # reconcile against a reference, no prompts
113
+ testforge generate --reference https://… # a URL or Notion link works too
114
+ ```
115
+
116
+ With no flags and no terminal attached, testforge does not block — it
117
+ falls back to the docs and prints a note.
118
+
76
119
  ## Running the generated tests
77
120
 
78
121
  testforge generates the tests and seed files — you run them with your
@@ -159,7 +202,12 @@ The test runner produces its own pass/fail report.
159
202
 
160
203
  - **aiCommand** — the CLI used to call the AI. Any tool that reads a
161
204
  prompt from stdin and prints the reply works, so testforge is not tied
162
- to one provider.
205
+ to one provider. testforge appends the right flags per phase: the
206
+ setup phases (init/map/discover) run with edit permission because the
207
+ model writes files, while the generation phases (tests/seeds) run with
208
+ no tools (`--allowedTools ""` for Claude) so the model just prints code
209
+ instead of trying to create files — which would otherwise leak "the
210
+ write wasn't approved…" prose into the output.
163
211
  - **testOutput** — where tests go, per area. Areas are not fixed — use
164
212
  whatever your project has (`api`, `web`, `ops`, `lambda`, ...).
165
213
  - **framework** — which test framework each area uses.
package/dist/cli/index.js CHANGED
@@ -3,12 +3,15 @@
3
3
  * testforge — command-line entry point.
4
4
  *
5
5
  * Commands:
6
- * testforge generate Generate tests for changed journeys (AI).
7
- * testforge generate --basic Use the built-in basic generator only.
8
- * testforge generate --force Regenerate everything, ignoring the cache.
6
+ * testforge generate Generate tests for changed journeys (AI).
7
+ * testforge generate --basic Use the built-in basic generator only.
8
+ * testforge generate --force Regenerate everything, ignoring the cache.
9
+ * testforge generate --source docs Skip the source-of-truth prompt, use docs.
10
+ * testforge generate --reference <x> Reconcile docs against a reference first,
11
+ * where <x> is a file path, URL, or Notion link.
9
12
  */
10
13
  import { VERSION } from '../core/index.js';
11
- import { runGenerate } from '../core/run-generate.js';
14
+ import { runGenerateCommand } from '../core/generate-command.js';
12
15
  import { runDiscover } from '../core/discover.js';
13
16
  import { runMap } from '../core/map.js';
14
17
  import { runInit } from '../core/init.js';
@@ -18,6 +21,16 @@ import { runEnvTemplate } from '../core/env-template.js';
18
21
  const command = process.argv[2];
19
22
  const useBasic = process.argv.includes('--basic');
20
23
  const force = process.argv.includes('--force');
24
+ /** Read the value that follows a `--flag <value>` option, if present. */
25
+ function flagValue(name) {
26
+ const i = process.argv.indexOf(name);
27
+ if (i === -1)
28
+ return undefined;
29
+ const value = process.argv[i + 1];
30
+ if (value === undefined || value.startsWith('--'))
31
+ return undefined;
32
+ return value;
33
+ }
21
34
  if (command === 'update') {
22
35
  runUpdate('.');
23
36
  }
@@ -53,27 +66,16 @@ else if (command === 'generate') {
53
66
  console.log(useAI
54
67
  ? 'Generating tests with AI for changed journeys...\n'
55
68
  : 'Generating tests with the basic generator...\n');
56
- const results = runGenerate('docs', useAI, force);
57
- if (results.length === 0) {
58
- console.log('No journey files found in docs/. Add a .md file and try again.');
59
- }
60
- else {
61
- for (const r of results) {
62
- if (r.status === 'skipped') {
63
- console.log(` [skip] ${r.journeyName} (unchanged)`);
64
- }
65
- else {
66
- const tag = r.status === 'ai' ? '[AI]' : '[basic]';
67
- console.log(` ${tag} ${r.journeyName}`);
68
- console.log(` test: ${r.testPath}`);
69
- if (r.status === 'ai')
70
- console.log(` seed: ${r.seedPath}`);
71
- }
72
- }
73
- const generated = results.filter((r) => r.status !== 'skipped').length;
74
- const skipped = results.filter((r) => r.status === 'skipped').length;
75
- console.log(`\nDone. ${generated} generated, ${skipped} skipped (unchanged).`);
76
- }
69
+ runGenerateCommand({
70
+ docsDir: 'docs',
71
+ useAI,
72
+ force,
73
+ reference: flagValue('--reference'),
74
+ source: flagValue('--source'),
75
+ }).catch((err) => {
76
+ console.error(err instanceof Error ? err.message : String(err));
77
+ process.exit(1);
78
+ });
77
79
  }
78
80
  else {
79
81
  console.log(`testforge v${VERSION}`);
@@ -82,6 +84,8 @@ else {
82
84
  console.log(' testforge generate Generate tests for changed journeys');
83
85
  console.log(' testforge generate --basic Use the basic generator only');
84
86
  console.log(' testforge generate --force Regenerate everything');
87
+ console.log(' testforge generate --source docs Skip the source-of-truth prompt');
88
+ console.log(' testforge generate --reference <file|url> Reconcile docs against a reference first');
85
89
  console.log(' testforge discover Scan source code and draft journey files');
86
90
  console.log(' testforge map Scan project, write journey + data map');
87
91
  console.log(' testforge init Inspect project and write config');
@@ -12,10 +12,10 @@
12
12
  */
13
13
  import { execFileSync } from 'node:child_process';
14
14
  import { readFileSync, existsSync } from 'node:fs';
15
- import { loadConfig, splitCommand } from './config.js';
15
+ import { loadConfig, splitCommand, withTextOnlyTools } from './config.js';
16
16
  const MAP_PATH = 'testforge.md';
17
17
  /** Build the prompt we send to the AI for one journey. */
18
- export function buildPrompt(journey, map) {
18
+ export function buildPrompt(journey, map, testPath) {
19
19
  const config = loadConfig();
20
20
  const fw = config.framework?.[journey.type];
21
21
  const isApi = journey.type === 'api' || fw === 'jest' || fw === 'supertest';
@@ -42,6 +42,16 @@ export function buildPrompt(journey, map) {
42
42
  `useful). For payment/webhook journeys, test idempotency, amount/`,
43
43
  `price tampering, and signature/verification where relevant.`,
44
44
  ``,
45
+ `IMPORTANT — do not presume the app is vulnerable. For every security`,
46
+ `probe, assert the SECURE expectation (e.g. auth bypass returns 401/403,`,
47
+ `a tampered amount is rejected, a duplicate charge is idempotent). Only`,
48
+ `assert that a vulnerability exists when the journey doc EXPLICITLY`,
49
+ `documents it as a known/accepted behavior. If you cannot tell from the`,
50
+ `journey or map whether a defense exists, write the check as`,
51
+ `test.todo(...) or it.skip(...) with a "// TODO:" note — never assume a`,
52
+ `guard is missing and produce a failing test that falsely brands the`,
53
+ `product as vulnerable.`,
54
+ ``,
45
55
  isApi
46
56
  ? [
47
57
  `Because this is an API journey, write Jest tests that call the`,
@@ -60,6 +70,17 @@ export function buildPrompt(journey, map) {
60
70
  `they are real.`,
61
71
  ].join('\n'),
62
72
  ``,
73
+ ...(testPath
74
+ ? [
75
+ `This test file will be saved at EXACTLY this path:`,
76
+ ` ${testPath}`,
77
+ `Compute every require()/import path (to app source, helpers, the`,
78
+ `seed file, etc.) RELATIVE TO THAT EXACT location — count the`,
79
+ `directory depth from that path, do not assume a conventional`,
80
+ `depth. Getting the relative depth wrong breaks the whole file.`,
81
+ ``,
82
+ ]
83
+ : []),
63
84
  `This test relies on seeded test data (a separate seed file provisions`,
64
85
  `the database before tests run). Assume the seed has created the`,
65
86
  `records described in the journey's Data section, using stable test`,
@@ -96,10 +117,12 @@ export function buildPrompt(journey, map) {
96
117
  * journey. Reads the project map (testforge.md) for data-layer
97
118
  * context. Returns the test file content.
98
119
  */
99
- export function generateWithAI(journey) {
120
+ export function generateWithAI(journey, testPath) {
100
121
  const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
101
- const prompt = buildPrompt(journey, map);
102
- const { program, args } = splitCommand(loadConfig().aiCommand);
122
+ const prompt = buildPrompt(journey, map, testPath);
123
+ // Generation is pure text — run with NO tools so the model just
124
+ // prints code instead of trying to write files.
125
+ const { program, args } = withTextOnlyTools(splitCommand(loadConfig().aiCommand));
103
126
  const output = execFileSync(program, args, {
104
127
  input: prompt,
105
128
  encoding: 'utf-8',
@@ -107,35 +130,65 @@ export function generateWithAI(journey) {
107
130
  });
108
131
  return cleanOutput(output);
109
132
  }
133
+ /** Does this line look like the start (or body) of real code? */
134
+ function looksLikeCode(line) {
135
+ const t = line.trim();
136
+ return (t.startsWith('//') ||
137
+ t.startsWith('/*') ||
138
+ t.startsWith('*') ||
139
+ t.startsWith('import ') ||
140
+ t.startsWith('export ') ||
141
+ t.startsWith('const ') ||
142
+ t.startsWith('let ') ||
143
+ t.startsWith('var ') ||
144
+ t.startsWith('function ') ||
145
+ t.startsWith('async ') ||
146
+ t.startsWith('require(') ||
147
+ t.startsWith("'use strict'") ||
148
+ t.startsWith('"use strict"'));
149
+ }
110
150
  /**
111
151
  * Extract clean code from the AI's reply.
112
152
  *
113
153
  * 1. If there's a fenced code block, take its contents.
114
- * 2. Otherwise, drop any leading prose by starting from the first line
115
- * that looks like code (a comment, import, require, or known
116
- * statement keyword). This rescues cases where the AI adds a
117
- * sentence before the code despite instructions.
154
+ * 2. Otherwise, keep only the span between the first and the last
155
+ * code-ish line — this strips BOTH leading prose ("Here is the
156
+ * code:") and trailing prose ("Note: I attempted to write the
157
+ * file...") that the model sometimes adds despite instructions.
158
+ * 3. If no code can be detected at all, throw so the caller can
159
+ * retry or skip — we never return junk to be written to disk.
160
+ *
161
+ * Exported so the seed generator can share the exact same cleaning
162
+ * logic instead of a weaker fence-only strip.
118
163
  */
119
- function cleanOutput(text) {
164
+ export function cleanOutput(text) {
120
165
  // 1. Prefer a fenced code block if present.
121
- const fence = text.match(/```[a-zA-Z]*\n([\s\S]*?)\n```/);
166
+ const fence = text.match(/```[a-zA-Z]*\n([\s\S]*?)```/);
122
167
  if (fence) {
123
- return fence[1].trim();
168
+ const inner = fence[1].trim();
169
+ if (!inner) {
170
+ throw new Error('AI output contained an empty code fence');
171
+ }
172
+ return inner;
124
173
  }
125
- // 2. No fence — find the first line that looks like real code.
174
+ // 2. No fence — locate the first line that looks like real code.
126
175
  const lines = text.split('\n');
127
- const codeStart = lines.findIndex((line) => {
128
- const t = line.trim();
129
- return (t.startsWith('//') ||
130
- t.startsWith('/*') ||
131
- t.startsWith('import ') ||
132
- t.startsWith('const ') ||
133
- t.startsWith('require(') ||
134
- t.startsWith("'use strict'") ||
135
- t.startsWith('"use strict"'));
136
- });
137
- if (codeStart > 0) {
138
- return lines.slice(codeStart).join('\n').trim();
176
+ const codeStart = lines.findIndex(looksLikeCode);
177
+ if (codeStart === -1) {
178
+ throw new Error('AI output contained no recognizable code');
179
+ }
180
+ // Trim trailing prose: walk back from the end to the last line that
181
+ // is code-ish or ends with a code terminator (; } ) ]). A trailing
182
+ // sentence ("Note: ...") ends with a word/period, so it is dropped.
183
+ let codeEnd = codeStart;
184
+ for (let i = lines.length - 1; i >= codeStart; i--) {
185
+ const t = lines[i].trim();
186
+ if (t === '')
187
+ continue;
188
+ if (looksLikeCode(t) || /[;})\]]$/.test(t)) {
189
+ codeEnd = i;
190
+ break;
191
+ }
139
192
  }
140
- return text.trim();
193
+ return lines.slice(codeStart, codeEnd + 1).join('\n').trim();
141
194
  }
@@ -14,7 +14,16 @@
14
14
  *
15
15
  * If no config file exists, we default to "claude -p" so users with
16
16
  * Claude installed work out of the box. Users of other tools (Gemini,
17
- * Codex, etc.) set aiCommand to their own CLI.
17
+ * Codex, etc.) set aiCommand to their own CLI — it just needs to read a
18
+ * prompt on stdin and print the reply to stdout.
19
+ *
20
+ * The command is a BASE. Each phase appends the flags it needs (these
21
+ * are tool-specific; for Claude): the setup phases (init/map/discover)
22
+ * add `--permission-mode acceptEdits` because the model reads/writes
23
+ * files, while the pure-text generation phases add `--allowedTools ""`
24
+ * so the model has NO tools and just prints code — it can't try (and
25
+ * fail) to write a file and leak "the write wasn't approved..." prose
26
+ * into the output. See withTextOnlyTools / withEditTools.
18
27
  */
19
28
  import { readFileSync, existsSync } from 'node:fs';
20
29
  import { join } from 'node:path';
@@ -44,14 +53,80 @@ export function loadConfig() {
44
53
  }
45
54
  /**
46
55
  * Split an AI command string into a program and its arguments.
47
- * Example: "claude -p" -> { program: "claude", args: ["-p"] }
56
+ *
57
+ * Quote-aware: single or double quotes group a token and can produce an
58
+ * EMPTY-string argument, which matters for invocations like
59
+ * `claude -p --allowedTools ""` (disable all tools). A naive
60
+ * whitespace split would turn "" into the literal two-char token `""`.
61
+ *
62
+ * Example: `claude -p --allowedTools ""`
63
+ * -> { program: "claude", args: ["-p", "--allowedTools", ""] }
48
64
  */
49
65
  export function splitCommand(command) {
50
- const parts = command.trim().split(/\s+/);
51
- const program = parts[0];
52
- const args = parts.slice(1);
66
+ const tokens = [];
67
+ let current = '';
68
+ let quote = null;
69
+ let started = false; // did the current token have any chars (incl. empty quotes)?
70
+ for (const ch of command.trim()) {
71
+ if (quote) {
72
+ if (ch === quote) {
73
+ quote = null; // closing quote — token may be empty
74
+ }
75
+ else {
76
+ current += ch;
77
+ }
78
+ continue;
79
+ }
80
+ if (ch === '"' || ch === "'") {
81
+ quote = ch;
82
+ started = true;
83
+ continue;
84
+ }
85
+ if (/\s/.test(ch)) {
86
+ if (started) {
87
+ tokens.push(current);
88
+ current = '';
89
+ started = false;
90
+ }
91
+ continue;
92
+ }
93
+ current += ch;
94
+ started = true;
95
+ }
96
+ if (started)
97
+ tokens.push(current);
98
+ const program = tokens[0] ?? '';
99
+ const args = tokens.slice(1);
53
100
  return { program, args };
54
101
  }
102
+ /** Is this command Claude's CLI (so Claude-specific flags apply)? */
103
+ function isClaude(program) {
104
+ const base = program.split(/[\\/]/).pop() ?? program;
105
+ return base === 'claude';
106
+ }
107
+ /**
108
+ * Args for a pure-text generation call (test/seed): no tools at all, so
109
+ * the model prints code instead of attempting file writes. Only applies
110
+ * the Claude flag when the base command is Claude; other CLIs get the
111
+ * command unchanged (configure their own text-only mode if needed).
112
+ */
113
+ export function withTextOnlyTools(cmd) {
114
+ if (!isClaude(cmd.program))
115
+ return cmd;
116
+ return { program: cmd.program, args: [...cmd.args, '--allowedTools', ''] };
117
+ }
118
+ /**
119
+ * Args for a phase where the model must read/write files (init, map,
120
+ * discover). Mirrors withTextOnlyTools but grants edit permission.
121
+ */
122
+ export function withEditTools(cmd) {
123
+ if (!isClaude(cmd.program))
124
+ return cmd;
125
+ return {
126
+ program: cmd.program,
127
+ args: [...cmd.args, '--permission-mode', 'acceptEdits'],
128
+ };
129
+ }
55
130
  /**
56
131
  * Decide where a journey's test file should go, based on its type
57
132
  * and the config. Falls back to "generated-tests/<area>" when the
@@ -15,7 +15,7 @@
15
15
  * keeps this simple and works for projects of any size.
16
16
  */
17
17
  import { execFileSync } from 'node:child_process';
18
- import { loadConfig, splitCommand } from './config.js';
18
+ import { loadConfig, splitCommand, withEditTools } from './config.js';
19
19
  /** Build the discovery prompt. */
20
20
  export function buildDiscoverPrompt(srcDir, docsDir) {
21
21
  return [
@@ -93,8 +93,8 @@ export function runDiscover(srcDir, docsDir) {
93
93
  // read source files and write markdown files. The flags for that
94
94
  // are tool-specific; for Claude we add acceptEdits. Other tools may
95
95
  // need different setup (see README).
96
- const { program, args } = splitCommand(loadConfig().aiCommand);
97
- const output = execFileSync(program, [...args, '--permission-mode', 'acceptEdits'], {
96
+ const { program, args } = withEditTools(splitCommand(loadConfig().aiCommand));
97
+ const output = execFileSync(program, args, {
98
98
  input: prompt,
99
99
  encoding: 'utf-8',
100
100
  maxBuffer: 10 * 1024 * 1024,
@@ -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()}`);
@@ -0,0 +1,55 @@
1
+ /**
2
+ * testforge — the `generate` command (CLI orchestration).
3
+ *
4
+ * This ties together the source-of-truth barrier and the generate
5
+ * pipeline, and prints results for the terminal. It is async because the
6
+ * barrier may prompt the developer interactively (see source-of-truth.ts).
7
+ *
8
+ * Kept separate from the pure runGenerate() engine so that engine stays
9
+ * synchronous and easy to call from update.ts, while the human-facing
10
+ * command handles prompting and output.
11
+ */
12
+ import { runGenerate } from './run-generate.js';
13
+ import { resolveSourceOfTruth } from './source-of-truth.js';
14
+ export async function runGenerateCommand(opts) {
15
+ // 1. Decide (and possibly reconcile) the source of truth first.
16
+ await resolveSourceOfTruth({
17
+ docsDir: opts.docsDir,
18
+ reference: opts.reference,
19
+ source: opts.source,
20
+ });
21
+ // 2. Generate from whatever the docs now say.
22
+ const results = runGenerate(opts.docsDir, opts.useAI, opts.force);
23
+ if (results.length === 0) {
24
+ console.log(`No journey files found in ${opts.docsDir}/. Add a .md file and try again.`);
25
+ return;
26
+ }
27
+ for (const r of results) {
28
+ if (r.status === 'skipped') {
29
+ console.log(` [skip] ${r.journeyName} (unchanged)`);
30
+ }
31
+ else {
32
+ const tag = r.status === 'ai' ? '[AI]' : '[basic]';
33
+ console.log(` ${tag} ${r.journeyName}`);
34
+ console.log(` test: ${r.testPath}`);
35
+ if (r.status === 'ai') {
36
+ if (r.seedStatus === 'ok') {
37
+ console.log(` seed: ${r.seedPath}`);
38
+ }
39
+ else if (r.seedStatus === 'failed') {
40
+ console.log(` seed: (not written — generation failed)`);
41
+ }
42
+ }
43
+ }
44
+ for (const w of r.warnings ?? []) {
45
+ console.log(` ! ${w}`);
46
+ }
47
+ }
48
+ const generated = results.filter((r) => r.status !== 'skipped').length;
49
+ const skipped = results.filter((r) => r.status === 'skipped').length;
50
+ const seedFailures = results.filter((r) => r.seedStatus === 'failed').length;
51
+ console.log(`\nDone. ${generated} generated, ${skipped} skipped (unchanged).`);
52
+ if (seedFailures > 0) {
53
+ console.log(`Warning: ${seedFailures} seed file(s) could not be generated — see notes above.`);
54
+ }
55
+ }
@@ -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.2.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,