@activate-core/test-forge 0.2.0 → 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.
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. First asks what the tests should be based on — the AI-derived docs, or an external reference (see [Source of truth](#source-of-truth)). |
43
+ | `testforge generate` | Generates a test file and a seed file for each journey doc. Only changed docs are regenerated. |
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)
@@ -75,18 +75,22 @@ change means no work and no AI calls; a change triggers a refresh.
75
75
 
76
76
  ## Source of truth
77
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.
78
+ The journey docs in `docs/` are the source of truth for the tests —
79
+ `generate` reads the docs and nothing else. So where the docs come from
80
+ decides everything.
83
81
 
84
- So when you run `testforge generate`, it first asks what the tests should
85
- be based on:
82
+ By default the docs are derived from your **code** (the AI reads the
83
+ project, writes `testforge.md`, then drafts the docs from it). That is
84
+ fast, but it inherits whatever the code does: if a flow is buggy or a
85
+ scenario is mishandled, the AI faithfully documents the wrong behavior,
86
+ and the tests then lock that wrong behavior in.
87
+
88
+ So when the docs are drafted — during `testforge setup` (or `testforge
89
+ discover`) — testforge asks what they should be based on:
86
90
 
87
91
  ```
88
- What should the tests be based on?
89
- [1] testforge.md + docs/ (what the AI derived from your code) (default)
92
+ What should the journey docs be based on?
93
+ [1] testforge.md (what the AI derived from your code) (default)
90
94
  [2] An external reference (a file/PDF in scope, a website, or a Notion link)
91
95
  ```
92
96
 
@@ -94,27 +98,31 @@ Choose **[2]** to give testforge an authoritative reference of the
94
98
  _expected_ behavior — a spec or requirements doc in the repo, a PDF, a
95
99
  website, or a Notion page. testforge then:
96
100
 
97
- 1. reads the reference and compares it against the current docs,
101
+ 1. reads the reference and compares it against `testforge.md`,
98
102
  2. writes a full comparison to **`testforge.diff.md`** and prints a short
99
103
  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.
104
+ 3. asks you to confirm, and on confirmation **drafts the docs from the
105
+ reference** — expected behavior comes from the reference, while routes,
106
+ fields, models, and env var names come from the code/map,
107
+ 4. `generate` then turns those docs into tests as usual.
103
108
 
104
109
  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.
110
+ or client, not just what the code happens to do. The choice is remembered
111
+ in `.testforge/source.json`, so a later `testforge update` refreshes the
112
+ docs from the same source without asking again. Your review of
113
+ `testforge.diff.md` stays as an audit of what differed and why.
107
114
 
108
115
  **Non-interactive use (CI, scripts):**
109
116
 
110
117
  ```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
118
+ testforge setup --source map # base the docs on testforge.md, no prompt
119
+ testforge setup --reference ./spec.md # base the docs on a reference, no prompts
120
+ testforge setup --reference https://… # a URL or Notion link works too
114
121
  ```
115
122
 
116
- With no flags and no terminal attached, testforge does not block — it
117
- falls back to the docs and prints a note.
123
+ The same flags work on `testforge discover`. With no flags and no terminal
124
+ attached, testforge does not block — it falls back to `testforge.md` and
125
+ prints a note.
118
126
 
119
127
  ## Running the generated tests
120
128
 
package/dist/cli/index.js CHANGED
@@ -3,15 +3,19 @@
3
3
  * testforge — command-line entry point.
4
4
  *
5
5
  * Commands:
6
- * testforge generate Generate tests for changed journeys (AI).
6
+ * testforge setup init + map + discover. discover asks
7
+ * whether the docs come from testforge.md
8
+ * or an external reference (the source of
9
+ * truth for every test).
10
+ * testforge setup --reference <x> Base the docs on reference <x> (a file
11
+ * path, URL, or Notion link) — no prompt.
12
+ * testforge setup --source map Base the docs on testforge.md — no prompt.
13
+ * testforge generate Generate tests from the journey docs.
7
14
  * testforge generate --basic Use the built-in basic generator only.
8
15
  * 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.
12
16
  */
13
17
  import { VERSION } from '../core/index.js';
14
- import { runGenerateCommand } from '../core/generate-command.js';
18
+ import { runGenerate } from '../core/run-generate.js';
15
19
  import { runDiscover } from '../core/discover.js';
16
20
  import { runMap } from '../core/map.js';
17
21
  import { runInit } from '../core/init.js';
@@ -31,65 +35,102 @@ function flagValue(name) {
31
35
  return undefined;
32
36
  return value;
33
37
  }
34
- if (command === 'update') {
35
- runUpdate('.');
38
+ /** Source-of-truth options shared by setup and discover. */
39
+ function sourceOptions() {
40
+ return { reference: flagValue('--reference'), source: flagValue('--source') };
36
41
  }
37
- else if (command === 'setup') {
38
- runSetup('.');
39
- }
40
- else if (command === 'init') {
41
- console.log('Inspecting your project to set up testforge...\n');
42
- const output = runInit();
43
- console.log(output);
44
- console.log('\ntestforge.config.json written. Review it, then run `testforge map`.');
45
- }
46
- else if (command === 'map') {
47
- console.log('Building a high-level map of your project with AI...');
48
- console.log('This scans your source code and writes testforge.md\n');
49
- const output = runMap('.');
50
- console.log(output);
51
- console.log('\nProject map written to testforge.md');
52
- }
53
- else if (command === 'discover') {
54
- console.log('Scanning your project with AI to draft journey files...');
55
- console.log('This reads your source code and writes drafts into docs/.\n');
56
- const output = runDiscover('.', 'docs');
57
- console.log(output);
58
- console.log('\nDraft journeys written to docs/. Please review and edit them,');
59
- console.log('then run `testforge generate`.');
60
- }
61
- else if (command === 'env') {
62
- runEnvTemplate('docs', '.env.testing.example');
63
- }
64
- else if (command === 'generate') {
65
- const useAI = !useBasic;
66
- console.log(useAI
67
- ? 'Generating tests with AI for changed journeys...\n'
68
- : 'Generating tests with the basic generator...\n');
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
- });
79
- }
80
- else {
42
+ function printUsage() {
81
43
  console.log(`testforge v${VERSION}`);
82
44
  console.log('');
83
45
  console.log('Usage:');
84
- console.log(' testforge generate Generate tests for changed journeys');
46
+ console.log(' testforge setup Run init + map + discover in order');
47
+ console.log(' testforge setup --reference <file|url> Base the docs on a reference');
48
+ console.log(' testforge setup --source map Base the docs on testforge.md');
49
+ console.log(' testforge generate Generate tests from the journey docs');
85
50
  console.log(' testforge generate --basic Use the basic generator only');
86
51
  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');
89
- console.log(' testforge discover Scan source code and draft journey files');
52
+ console.log(' testforge discover Draft journey docs (asks: map or reference)');
90
53
  console.log(' testforge map Scan project, write journey + data map');
91
54
  console.log(' testforge init Inspect project and write config');
92
- console.log(' testforge setup Run init + map + discover in order');
93
55
  console.log(' testforge update Re-check and update only what changed');
94
56
  console.log(' testforge env Write .env.testing.example from docs');
95
57
  }
58
+ async function main() {
59
+ if (command === 'update') {
60
+ await runUpdate('.');
61
+ }
62
+ else if (command === 'setup') {
63
+ await runSetup('.', sourceOptions());
64
+ }
65
+ else if (command === 'init') {
66
+ console.log('Inspecting your project to set up testforge...\n');
67
+ const output = runInit();
68
+ console.log(output);
69
+ console.log('\ntestforge.config.json written. Review it, then run `testforge map`.');
70
+ }
71
+ else if (command === 'map') {
72
+ console.log('Building a high-level map of your project with AI...');
73
+ console.log('This scans your source code and writes testforge.md\n');
74
+ const output = runMap('.');
75
+ console.log(output);
76
+ console.log('\nProject map written to testforge.md');
77
+ }
78
+ else if (command === 'discover') {
79
+ console.log('Drafting journey files with AI...');
80
+ console.log('This writes drafts into docs/ from your chosen source.\n');
81
+ const output = await runDiscover('.', 'docs', sourceOptions());
82
+ console.log(output);
83
+ console.log('\nDraft journeys written to docs/. Please review and edit them,');
84
+ console.log('then run `testforge generate`.');
85
+ }
86
+ else if (command === 'env') {
87
+ runEnvTemplate('docs', '.env.testing.example');
88
+ }
89
+ else if (command === 'generate') {
90
+ const useAI = !useBasic;
91
+ console.log(useAI
92
+ ? 'Generating tests with AI for changed journeys...\n'
93
+ : 'Generating tests with the basic generator...\n');
94
+ const results = runGenerate('docs', useAI, force);
95
+ if (results.length === 0) {
96
+ console.log('No journey files found in docs/. Add a .md file and try again.');
97
+ }
98
+ else {
99
+ for (const r of results) {
100
+ if (r.status === 'skipped') {
101
+ console.log(` [skip] ${r.journeyName} (unchanged)`);
102
+ }
103
+ else {
104
+ const tag = r.status === 'ai' ? '[AI]' : '[basic]';
105
+ console.log(` ${tag} ${r.journeyName}`);
106
+ console.log(` test: ${r.testPath}`);
107
+ if (r.status === 'ai') {
108
+ if (r.seedStatus === 'ok') {
109
+ console.log(` seed: ${r.seedPath}`);
110
+ }
111
+ else if (r.seedStatus === 'failed') {
112
+ console.log(` seed: (not written — generation failed)`);
113
+ }
114
+ }
115
+ }
116
+ for (const w of r.warnings ?? []) {
117
+ console.log(` ! ${w}`);
118
+ }
119
+ }
120
+ const generated = results.filter((r) => r.status !== 'skipped').length;
121
+ const skipped = results.filter((r) => r.status === 'skipped').length;
122
+ const seedFailures = results.filter((r) => r.seedStatus === 'failed').length;
123
+ console.log(`\nDone. ${generated} generated, ${skipped} skipped (unchanged).`);
124
+ if (seedFailures > 0) {
125
+ console.log(`Warning: ${seedFailures} seed file(s) could not be generated — see notes above.`);
126
+ }
127
+ }
128
+ }
129
+ else {
130
+ printUsage();
131
+ }
132
+ }
133
+ main().catch((err) => {
134
+ console.error(err instanceof Error ? err.message : String(err));
135
+ process.exit(1);
136
+ });
@@ -16,6 +16,9 @@
16
16
  */
17
17
  import { execFileSync } from 'node:child_process';
18
18
  import { loadConfig, splitCommand, withEditTools } from './config.js';
19
+ import { promptConfirm } from './prompt.js';
20
+ import { resolveDocsSource, saveSourceChoice, } from './source-of-truth.js';
21
+ import { analyzeReference, draftDocsFromReference, DIFF_PATH, } from './reference.js';
19
22
  /** Build the discovery prompt. */
20
23
  export function buildDiscoverPrompt(srcDir, docsDir) {
21
24
  return [
@@ -83,11 +86,10 @@ export function buildDiscoverPrompt(srcDir, docsDir) {
83
86
  ].join('\n');
84
87
  }
85
88
  /**
86
- * Run discovery. This invokes the Claude CLI with permission to read
87
- * and write files in the project. Returns the CLI's text output (the
88
- * summary of what it created).
89
+ * Draft the docs from the project's own code (the default). Invokes the
90
+ * AI CLI with permission to read source and write markdown files.
89
91
  */
90
- export function runDiscover(srcDir, docsDir) {
92
+ function runCodeDiscover(srcDir, docsDir) {
91
93
  const prompt = buildDiscoverPrompt(srcDir, docsDir);
92
94
  // Use the configured AI command. Note: discovery needs the AI to
93
95
  // read source files and write markdown files. The flags for that
@@ -101,3 +103,39 @@ export function runDiscover(srcDir, docsDir) {
101
103
  });
102
104
  return output.trim();
103
105
  }
106
+ /**
107
+ * Run discovery. First decides the source of truth for the docs (the
108
+ * code-derived map, or an external reference — see source-of-truth.ts),
109
+ * then drafts the journey docs from it. When a reference is chosen it is
110
+ * compared against the map first (writing testforge.diff.md), and, for a
111
+ * fresh interactive choice, confirmed before the docs are rewritten.
112
+ *
113
+ * Async because the source-of-truth barrier may prompt the developer.
114
+ * Returns the AI's text summary of what it created.
115
+ */
116
+ export async function runDiscover(srcDir, docsDir, opts = {}) {
117
+ const source = await resolveDocsSource(opts);
118
+ if (source.mode === 'reference' && source.reference) {
119
+ console.log(`\nComparing the reference against testforge.md: ${source.reference}`);
120
+ console.log('(reading the reference and the map — this may take a moment)\n');
121
+ const summary = analyzeReference(source.reference, docsDir);
122
+ console.log(summary);
123
+ console.log(`\nFull comparison written to ${DIFF_PATH}.`);
124
+ // Only a freshly-typed interactive choice gets a confirmation step;
125
+ // an explicit flag or a remembered choice proceeds directly.
126
+ if (source.origin === 'prompt') {
127
+ const ok = await promptConfirm('\nDraft the docs from this reference (make it the source of truth)?', true);
128
+ if (!ok) {
129
+ console.log('Basing docs on testforge.md instead.\n');
130
+ saveSourceChoice({ mode: 'map' });
131
+ return runCodeDiscover(srcDir, docsDir);
132
+ }
133
+ }
134
+ console.log('\nDrafting docs from the reference...\n');
135
+ const out = draftDocsFromReference(srcDir, docsDir, source.reference);
136
+ saveSourceChoice({ mode: 'reference', reference: source.reference });
137
+ return out;
138
+ }
139
+ saveSourceChoice({ mode: 'map' });
140
+ return runCodeDiscover(srcDir, docsDir);
141
+ }
@@ -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.2.0';
7
+ export const VERSION = '0.3.0';
@@ -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
+ }
@@ -16,7 +16,7 @@ import { runInit } from './init.js';
16
16
  import { runMap } from './map.js';
17
17
  import { runDiscover } from './discover.js';
18
18
  import { runEnvTemplate } from './env-template.js';
19
- export function runSetup(srcDir) {
19
+ export async function runSetup(srcDir, opts = {}) {
20
20
  // If the project already looks set up, don't redo it — point the user
21
21
  // to `update` instead, which only refreshes what actually changed.
22
22
  const alreadySetUp = existsSync('testforge.config.json') &&
@@ -49,9 +49,10 @@ export function runSetup(srcDir) {
49
49
  else {
50
50
  console.log(' warning: testforge.md was not created\n');
51
51
  }
52
- // Step 3 — discover.
52
+ // Step 3 — discover. This is where the source-of-truth barrier lives:
53
+ // the docs may be based on testforge.md or on an external reference.
53
54
  console.log('Step 3/3: Drafting a doc for each journey...');
54
- runDiscover(srcDir, 'docs');
55
+ await runDiscover(srcDir, 'docs', opts);
55
56
  console.log(' done — docs/\n');
56
57
  // Remember the source state so the first `update` can detect "no change".
57
58
  const cache = loadCache('.testforge/cache.json');
@@ -1,95 +1,84 @@
1
1
  /**
2
- * testforge — the "source of truth" barrier for `generate`.
2
+ * testforge — deciding where the journey docs come from.
3
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.
4
+ * The docs are the source of truth for the tests, so before drafting
5
+ * them (in discover / setup) testforge asks WHAT to base them on:
9
6
  *
7
+ * 1. testforge.md — the AI's code-derived view. Fast, but only as
8
+ * correct as the code it read.
10
9
  * 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.
10
+ * a Notion page capturing the human/client EXPECTED behavior.
16
11
  *
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.
12
+ * The choice is remembered in .testforge/source.json so later refreshes
13
+ * (`update`) keep using the same source without asking again — otherwise
14
+ * an update would silently regenerate reference-based docs from the code.
20
15
  *
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.
16
+ * Non-interactive callers are never blocked:
17
+ * - source: 'map' → use testforge.md, skip the prompt.
18
+ * - reference: <locator> → use that reference, skip the prompt.
19
+ * - noPrompt (e.g. update) → reuse the remembered choice, else the map.
20
+ * - no TTY → fall back to the map with a printed note.
27
21
  */
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');
22
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs';
23
+ import { dirname } from 'node:path';
24
+ import { isInteractive, promptChoice, promptLine } from './prompt.js';
25
+ const SOURCE_PATH = '.testforge/source.json';
26
+ /** Read the remembered source choice, or null if none/invalid. */
27
+ export function loadSourceChoice() {
28
+ if (!existsSync(SOURCE_PATH))
29
+ return null;
30
+ try {
31
+ const parsed = JSON.parse(readFileSync(SOURCE_PATH, 'utf-8'));
32
+ if (parsed && (parsed.mode === 'map' || parsed.mode === 'reference')) {
33
+ return { mode: parsed.mode, reference: parsed.reference };
34
+ }
35
+ }
36
+ catch {
37
+ // ignore a corrupt file — treat as "no choice remembered"
38
+ }
39
+ return null;
40
+ }
41
+ /** Remember the source choice for later refreshes. */
42
+ export function saveSourceChoice(choice) {
43
+ mkdirSync(dirname(SOURCE_PATH), { recursive: true });
44
+ writeFileSync(SOURCE_PATH, JSON.stringify(choice, null, 2), 'utf-8');
41
45
  }
42
46
  /**
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.
47
+ * Decide where the docs should come from for this run. May prompt the
48
+ * developer interactively; never blocks in non-interactive contexts.
46
49
  */
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.
50
+ export async function resolveDocsSource(opts = {}) {
51
+ if (opts.source === 'map') {
52
+ return { mode: 'map', origin: 'flag' };
53
+ }
53
54
  if (opts.reference) {
54
- reconcileToReference(opts.reference, docsDir);
55
- return;
55
+ return { mode: 'reference', reference: opts.reference, origin: 'flag' };
56
+ }
57
+ // Reuse a previously remembered choice (so `update` stays consistent).
58
+ const stored = loadSourceChoice();
59
+ if (stored) {
60
+ return { ...stored, origin: 'stored' };
56
61
  }
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;
62
+ // Nothing chosen and nowhere to ask — safe default.
63
+ if (opts.noPrompt || !isInteractive()) {
64
+ if (!opts.noPrompt) {
65
+ console.log('Basing docs on testforge.md (non-interactive; pass ' +
66
+ '--reference <file|url> to use an external reference).');
67
+ }
68
+ return { mode: 'map', origin: 'default' };
63
69
  }
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)',
70
+ // Interactive: ask what the docs should be based on.
71
+ const choice = await promptChoice('What should the journey docs be based on?', [
72
+ 'testforge.md (what the AI derived from your code)',
67
73
  'An external reference (a file/PDF in scope, a website, or a Notion link)',
68
74
  ], 1);
69
75
  if (choice === 1) {
70
- console.log('Using testforge.md + docs/ as the source of truth.\n');
71
- return;
76
+ return { mode: 'map', origin: 'prompt' };
72
77
  }
73
78
  const reference = await promptLine('\nProvide the reference (a file path, a URL, or a Notion link): ');
74
79
  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;
80
+ console.log('No reference given — basing docs on testforge.md.\n');
81
+ return { mode: 'map', origin: 'prompt' };
90
82
  }
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');
83
+ return { mode: 'reference', reference, origin: 'prompt' };
95
84
  }
@@ -16,7 +16,7 @@ import { hashProjectSource, loadCache, saveCache } from './cache.js';
16
16
  import { runEnvTemplate } from './env-template.js';
17
17
  const CACHE_PATH = '.testforge/cache.json';
18
18
  const SOURCE_KEY = '__source__';
19
- export function runUpdate(srcDir) {
19
+ export async function runUpdate(srcDir) {
20
20
  if (!existsSync('testforge.md') || !existsSync('testforge.config.json')) {
21
21
  console.log("This project isn't set up yet.");
22
22
  console.log('Run `testforge setup` first.');
@@ -35,8 +35,10 @@ export function runUpdate(srcDir) {
35
35
  console.log('Re-mapping the project...');
36
36
  runMap(srcDir);
37
37
  console.log(' done — testforge.md\n');
38
+ // Reuse the source of truth chosen at setup (map or a reference)
39
+ // without prompting — an update is a refresh, not a re-decision.
38
40
  console.log('Updating journey docs...');
39
- runDiscover(srcDir, 'docs');
41
+ await runDiscover(srcDir, 'docs', { noPrompt: true });
40
42
  console.log(' done — docs/\n');
41
43
  console.log('Regenerating tests for changed journeys...');
42
44
  const results = runGenerate('docs', true, false);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.2.0",
3
+ "version": "0.3.0",
4
4
  "description": "AI-powered QA automation. Scans a project, drafts journey docs, and generates seeded Jest/Playwright tests. Works in any project, with any AI CLI.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,55 +0,0 @@
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
- }
@@ -1,157 +0,0 @@
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
- }