@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 +50 -2
- package/dist/cli/index.js +29 -25
- package/dist/core/ai-generator.js +79 -26
- package/dist/core/config.js +80 -5
- package/dist/core/discover.js +3 -3
- package/dist/core/env-template.js +91 -5
- package/dist/core/generate-command.js +55 -0
- package/dist/core/index.js +1 -1
- package/dist/core/init.js +3 -3
- package/dist/core/map.js +3 -3
- package/dist/core/prompt.js +70 -0
- package/dist/core/reconcile.js +157 -0
- package/dist/core/run-generate.js +98 -17
- package/dist/core/seed-generator.js +23 -10
- package/dist/core/setup.js +1 -1
- package/dist/core/source-of-truth.js +95 -0
- package/dist/core/update.js +1 -1
- package/dist/core/validate.js +82 -0
- package/package.json +1 -1
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
|
|
7
|
-
* testforge generate --basic
|
|
8
|
-
* testforge generate --force
|
|
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 {
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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
|
-
|
|
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,
|
|
115
|
-
*
|
|
116
|
-
*
|
|
117
|
-
*
|
|
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]*?)
|
|
166
|
+
const fence = text.match(/```[a-zA-Z]*\n([\s\S]*?)```/);
|
|
122
167
|
if (fence) {
|
|
123
|
-
|
|
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 —
|
|
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(
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
|
193
|
+
return lines.slice(codeStart, codeEnd + 1).join('\n').trim();
|
|
141
194
|
}
|
package/dist/core/config.js
CHANGED
|
@@ -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
|
-
*
|
|
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
|
|
51
|
-
|
|
52
|
-
|
|
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
|
package/dist/core/discover.js
CHANGED
|
@@ -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,
|
|
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
|
-
|
|
80
|
-
|
|
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
|
+
}
|
package/dist/core/index.js
CHANGED
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,
|
|
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,
|