@activate-core/test-forge 0.15.0 → 0.16.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 +23 -0
- package/dist/cli/index.js +8 -1
- package/dist/core/discover.js +90 -4
- package/dist/core/reference.js +22 -7
- package/dist/core/source-of-truth.js +38 -3
- package/dist/core/update.js +10 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -54,6 +54,29 @@ testforge generate --journey refund-flow
|
|
|
54
54
|
testforge generate --area ops
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
### A feature that arrives on its own document
|
|
58
|
+
|
|
59
|
+
A ticket turns up with a PDF, or a spec for one feature. Point `discover` at it
|
|
60
|
+
directly — you do not name the journey, the AI reads the document and works out
|
|
61
|
+
which journey it describes:
|
|
62
|
+
|
|
63
|
+
```bash
|
|
64
|
+
testforge discover --reference ./docs/tickets/zh-4521.pdf
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
**A reference only writes the journeys it actually covers.** The ticket's PDF
|
|
68
|
+
has never heard of your other journeys, so their docs are left exactly as they
|
|
69
|
+
are, and the project's own source of truth stays where it was. TestForge then
|
|
70
|
+
remembers that this one journey came from that document, and refreshes it from
|
|
71
|
+
there on every later `update` — not from the project spec, which still does not
|
|
72
|
+
describe it.
|
|
73
|
+
|
|
74
|
+
When a reference really is the whole product's new specification, say so:
|
|
75
|
+
|
|
76
|
+
```bash
|
|
77
|
+
testforge discover --reference ./docs/prd-v2.md --replace
|
|
78
|
+
```
|
|
79
|
+
|
|
57
80
|
A journey **is** its doc: `generate` reads `docs/` and treats every markdown
|
|
58
81
|
file there as one journey. So a doc you write by hand — for a feature the
|
|
59
82
|
reference does not cover yet, or one taken from a ticket — works exactly like a
|
package/dist/cli/index.js
CHANGED
|
@@ -45,7 +45,11 @@ function flagValue(name) {
|
|
|
45
45
|
}
|
|
46
46
|
/** Source-of-truth options shared by setup and discover. */
|
|
47
47
|
function sourceOptions() {
|
|
48
|
-
return {
|
|
48
|
+
return {
|
|
49
|
+
reference: flagValue('--reference'),
|
|
50
|
+
source: flagValue('--source'),
|
|
51
|
+
replace: process.argv.includes('--replace'),
|
|
52
|
+
};
|
|
49
53
|
}
|
|
50
54
|
/**
|
|
51
55
|
* The output dirs to scan for env vars the GENERATED suite reads (test
|
|
@@ -78,6 +82,9 @@ function printUsage() {
|
|
|
78
82
|
console.log(' testforge discover Draft journey docs (asks: map or reference)');
|
|
79
83
|
console.log(' testforge discover --journey <name> Draft just ONE journey doc');
|
|
80
84
|
console.log(' testforge discover --reference <file|url> Base docs on a reference');
|
|
85
|
+
console.log(' (a reference only writes the journeys it');
|
|
86
|
+
console.log(' describes — add --replace to let it');
|
|
87
|
+
console.log(' redraft the whole project)');
|
|
81
88
|
console.log(' testforge map Scan project, write journey + data map');
|
|
82
89
|
console.log(' testforge init Inspect project and write config');
|
|
83
90
|
console.log(' testforge update Re-check and update only what changed');
|
package/dist/core/discover.js
CHANGED
|
@@ -15,10 +15,12 @@
|
|
|
15
15
|
* keeps this simple and works for projects of any size.
|
|
16
16
|
*/
|
|
17
17
|
import { execFileSync } from 'node:child_process';
|
|
18
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, } from 'node:fs';
|
|
19
|
+
import { join, relative, sep } from 'node:path';
|
|
18
20
|
import { loadConfig, splitCommand, withEditTools } from './config.js';
|
|
19
21
|
import { DEEP_DOC_GUIDANCE } from './doc-guidance.js';
|
|
20
22
|
import { promptConfirm } from './prompt.js';
|
|
21
|
-
import { resolveDocsSource, saveSourceChoice, } from './source-of-truth.js';
|
|
23
|
+
import { loadSourceChoice, resolveDocsSource, saveJourneyReferences, saveSourceChoice, } from './source-of-truth.js';
|
|
22
24
|
import { analyzeReference, draftDocsFromReference, DIFF_PATH, } from './reference.js';
|
|
23
25
|
/** Scope line: one journey, or all of them. */
|
|
24
26
|
function scopeLines(journey) {
|
|
@@ -118,6 +120,30 @@ function runCodeDiscover(srcDir, docsDir, journey) {
|
|
|
118
120
|
});
|
|
119
121
|
return output.trim();
|
|
120
122
|
}
|
|
123
|
+
/**
|
|
124
|
+
* Every journey doc currently on disk, keyed by its path relative to the docs
|
|
125
|
+
* dir ("web/refund-flow.md"), with its exact contents. Taking this BEFORE a
|
|
126
|
+
* draft is what lets us tell three things apart afterwards: a doc the
|
|
127
|
+
* reference newly created, one it rewrote, and one it should never have
|
|
128
|
+
* touched at all.
|
|
129
|
+
*/
|
|
130
|
+
function snapshotDocs(docsDir) {
|
|
131
|
+
const found = new Map();
|
|
132
|
+
if (!existsSync(docsDir))
|
|
133
|
+
return found;
|
|
134
|
+
const walk = (dir) => {
|
|
135
|
+
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
136
|
+
const full = join(dir, entry.name);
|
|
137
|
+
if (entry.isDirectory())
|
|
138
|
+
walk(full);
|
|
139
|
+
else if (entry.name.endsWith('.md')) {
|
|
140
|
+
found.set(relative(docsDir, full).split(sep).join('/'), readFileSync(full, 'utf-8'));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
walk(docsDir);
|
|
145
|
+
return found;
|
|
146
|
+
}
|
|
121
147
|
/**
|
|
122
148
|
* Run discovery. First decides the source of truth for the docs (the
|
|
123
149
|
* code-derived map, or an external reference — see source-of-truth.ts),
|
|
@@ -146,9 +172,69 @@ export async function runDiscover(srcDir, docsDir, opts = {}, journey) {
|
|
|
146
172
|
return runCodeDiscover(srcDir, docsDir, journey);
|
|
147
173
|
}
|
|
148
174
|
}
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
175
|
+
// Is this the product's specification, or one feature's? A ticket's PDF
|
|
176
|
+
// knows nothing about the other 26 journeys, so letting it rewrite them
|
|
177
|
+
// would swap a reviewed spec for whatever the code happens to do today —
|
|
178
|
+
// the exact inversion these tests exist to catch. So a reference only
|
|
179
|
+
// writes what it covers, unless it IS the project's spec or --replace
|
|
180
|
+
// says to treat it as one.
|
|
181
|
+
const stored = loadSourceChoice();
|
|
182
|
+
const before = snapshotDocs(docsDir);
|
|
183
|
+
const bootstrapping = before.size === 0;
|
|
184
|
+
const wholeProject = Boolean(opts.replace) ||
|
|
185
|
+
bootstrapping ||
|
|
186
|
+
!stored?.reference ||
|
|
187
|
+
stored.reference === source.reference;
|
|
188
|
+
// Docs that belong to a DIFFERENT reference — a ticket's spec, say — are
|
|
189
|
+
// off limits even to the project's own specification, which has never
|
|
190
|
+
// heard of that feature and would redraft it from the code.
|
|
191
|
+
const ownedElsewhere = new Set(Object.entries(stored?.journeys || {})
|
|
192
|
+
.filter(([, ref]) => ref !== source.reference)
|
|
193
|
+
.map(([doc]) => doc));
|
|
194
|
+
const offLimits = wholeProject
|
|
195
|
+
? [...ownedElsewhere]
|
|
196
|
+
: [...new Set([...before.keys(), ...ownedElsewhere])];
|
|
197
|
+
console.log(wholeProject
|
|
198
|
+
? '\nDrafting docs from the reference...\n'
|
|
199
|
+
: '\nDrafting docs from the reference (adding to the existing docs)...\n');
|
|
200
|
+
const out = draftDocsFromReference(srcDir, docsDir, source.reference, journey, offLimits.length ? offLimits : undefined);
|
|
201
|
+
const after = snapshotDocs(docsDir);
|
|
202
|
+
const added = [];
|
|
203
|
+
const rewritten = [];
|
|
204
|
+
for (const [path, text] of after) {
|
|
205
|
+
const prior = before.get(path);
|
|
206
|
+
if (prior === undefined)
|
|
207
|
+
added.push(path);
|
|
208
|
+
else if (prior !== text)
|
|
209
|
+
rewritten.push(path);
|
|
210
|
+
}
|
|
211
|
+
// Put back anything it reached for that this reference does not own. The
|
|
212
|
+
// prompt asks it not to; this makes it true whether or not it complied.
|
|
213
|
+
const restored = rewritten.filter((path) => wholeProject ? ownedElsewhere.has(path) : true);
|
|
214
|
+
for (const path of restored) {
|
|
215
|
+
writeFileSync(join(docsDir, path), before.get(path), 'utf-8');
|
|
216
|
+
}
|
|
217
|
+
if (wholeProject) {
|
|
218
|
+
saveSourceChoice({ mode: 'reference', reference: source.reference });
|
|
219
|
+
if (added.length)
|
|
220
|
+
saveJourneyReferences(added, source.reference);
|
|
221
|
+
if (restored.length) {
|
|
222
|
+
console.log(`\n${restored.length} doc(s) kept as they were — they come from a ` +
|
|
223
|
+
'reference of their own, not the project specification.');
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
else {
|
|
227
|
+
saveJourneyReferences(added, source.reference);
|
|
228
|
+
console.log(`\n${added.length} journey doc(s) written from this reference` +
|
|
229
|
+
(added.length ? `: ${added.join(', ')}` : '') + '.');
|
|
230
|
+
if (restored.length) {
|
|
231
|
+
console.log(`${restored.length} existing doc(s) left as they were — this ` +
|
|
232
|
+
'reference does not describe them.');
|
|
233
|
+
}
|
|
234
|
+
console.log('The project\'s source of truth is unchanged' +
|
|
235
|
+
(stored?.reference ? `: ${stored.reference}` : '') +
|
|
236
|
+
'. Use --replace to hand that role to this reference instead.');
|
|
237
|
+
}
|
|
152
238
|
return out;
|
|
153
239
|
}
|
|
154
240
|
saveSourceChoice({ mode: 'map' });
|
package/dist/core/reference.js
CHANGED
|
@@ -88,7 +88,7 @@ export function buildAnalyzePrompt(reference, docsDir) {
|
|
|
88
88
|
* treating it as the source of truth for behavior. Mirrors the code-based
|
|
89
89
|
* discover format so the docs are interchangeable downstream.
|
|
90
90
|
*/
|
|
91
|
-
export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey) {
|
|
91
|
+
export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey, existingDocs) {
|
|
92
92
|
const scope = journey
|
|
93
93
|
? [
|
|
94
94
|
`SCOPE: document ONLY the single journey matching "${journey}"`,
|
|
@@ -123,11 +123,26 @@ export function buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey
|
|
|
123
123
|
`For EACH journey the reference describes, create one markdown file at`,
|
|
124
124
|
`"${docsDir}/<area>/<journey>.md" (area = folder such as api/web/ops,`,
|
|
125
125
|
`short kebab-case journey name; do NOT put the area in the file name).`,
|
|
126
|
-
`Also create a doc for any journey that exists in the map but NOT in`,
|
|
127
|
-
`the reference, drafted from the map, and add a line`,
|
|
128
|
-
`"# TODO: not covered by the reference — confirm expected behavior"`,
|
|
129
|
-
`at its top so it is clearly flagged.`,
|
|
130
126
|
``,
|
|
127
|
+
...(existingDocs
|
|
128
|
+
? [
|
|
129
|
+
`THIS REFERENCE COVERS PART OF THE PRODUCT, NOT ALL OF IT. The`,
|
|
130
|
+
`journeys below already have reviewed docs written from the`,
|
|
131
|
+
`project's own specification, which this reference has never heard`,
|
|
132
|
+
`of. Do NOT open, rewrite, re-draft or "improve" any of them, and do`,
|
|
133
|
+
`NOT add a not-covered note to them. Touch ONLY the journey or`,
|
|
134
|
+
`journeys this reference actually describes — creating a new file if`,
|
|
135
|
+
`the feature is new, or updating that one file if it already exists.`,
|
|
136
|
+
...existingDocs.map((d) => ` - ${d}`),
|
|
137
|
+
``,
|
|
138
|
+
]
|
|
139
|
+
: [
|
|
140
|
+
`Also create a doc for any journey that exists in the map but NOT in`,
|
|
141
|
+
`the reference, drafted from the map, and add a line`,
|
|
142
|
+
`"# TODO: not covered by the reference — confirm expected behavior"`,
|
|
143
|
+
`at its top so it is clearly flagged.`,
|
|
144
|
+
``,
|
|
145
|
+
]),
|
|
131
146
|
`Each file starts with this header (required):`,
|
|
132
147
|
``,
|
|
133
148
|
`# Journey: <Name>`,
|
|
@@ -181,6 +196,6 @@ export function analyzeReference(reference, docsDir) {
|
|
|
181
196
|
* Draft the journey docs from the reference. Returns the AI's summary of
|
|
182
197
|
* the journeys it documented.
|
|
183
198
|
*/
|
|
184
|
-
export function draftDocsFromReference(srcDir, docsDir, reference, journey) {
|
|
185
|
-
return runAI(buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey));
|
|
199
|
+
export function draftDocsFromReference(srcDir, docsDir, reference, journey, existingDocs) {
|
|
200
|
+
return runAI(buildReferenceDiscoverPrompt(srcDir, docsDir, reference, journey, existingDocs));
|
|
186
201
|
}
|
|
@@ -30,7 +30,13 @@ export function loadSourceChoice() {
|
|
|
30
30
|
try {
|
|
31
31
|
const parsed = JSON.parse(readFileSync(SOURCE_PATH, 'utf-8'));
|
|
32
32
|
if (parsed && (parsed.mode === 'map' || parsed.mode === 'reference')) {
|
|
33
|
-
return {
|
|
33
|
+
return {
|
|
34
|
+
mode: parsed.mode,
|
|
35
|
+
reference: parsed.reference,
|
|
36
|
+
journeys: parsed.journeys && typeof parsed.journeys === 'object'
|
|
37
|
+
? parsed.journeys
|
|
38
|
+
: undefined,
|
|
39
|
+
};
|
|
34
40
|
}
|
|
35
41
|
}
|
|
36
42
|
catch {
|
|
@@ -38,10 +44,39 @@ export function loadSourceChoice() {
|
|
|
38
44
|
}
|
|
39
45
|
return null;
|
|
40
46
|
}
|
|
41
|
-
/**
|
|
47
|
+
/**
|
|
48
|
+
* Remember the PROJECT's source choice. The per-journey map is carried over
|
|
49
|
+
* untouched: a ticket-sized reference must never cost the project the record
|
|
50
|
+
* of where its other journeys came from.
|
|
51
|
+
*/
|
|
42
52
|
export function saveSourceChoice(choice) {
|
|
53
|
+
const existing = loadSourceChoice();
|
|
54
|
+
const journeys = choice.journeys ?? existing?.journeys;
|
|
55
|
+
const next = { mode: choice.mode };
|
|
56
|
+
if (choice.reference)
|
|
57
|
+
next.reference = choice.reference;
|
|
58
|
+
if (journeys && Object.keys(journeys).length)
|
|
59
|
+
next.journeys = journeys;
|
|
43
60
|
mkdirSync(dirname(SOURCE_PATH), { recursive: true });
|
|
44
|
-
writeFileSync(SOURCE_PATH, JSON.stringify(
|
|
61
|
+
writeFileSync(SOURCE_PATH, JSON.stringify(next, null, 2), 'utf-8');
|
|
62
|
+
}
|
|
63
|
+
/**
|
|
64
|
+
* Record that these docs came from a reference of their own — a ticket, a
|
|
65
|
+
* one-feature PDF — so a later refresh goes back to THAT document and not to
|
|
66
|
+
* the project-wide spec, which has never heard of the feature.
|
|
67
|
+
*/
|
|
68
|
+
export function saveJourneyReferences(docPaths, reference) {
|
|
69
|
+
if (!docPaths.length)
|
|
70
|
+
return;
|
|
71
|
+
const existing = loadSourceChoice() || { mode: 'reference' };
|
|
72
|
+
const journeys = { ...(existing.journeys || {}) };
|
|
73
|
+
for (const d of docPaths)
|
|
74
|
+
journeys[d] = reference;
|
|
75
|
+
saveSourceChoice({
|
|
76
|
+
mode: existing.mode,
|
|
77
|
+
reference: existing.reference,
|
|
78
|
+
journeys,
|
|
79
|
+
});
|
|
45
80
|
}
|
|
46
81
|
/**
|
|
47
82
|
* Decide where the docs should come from for this run. May prompt the
|
package/dist/core/update.js
CHANGED
|
@@ -70,6 +70,16 @@ export async function runUpdate(srcDir, opts = {}) {
|
|
|
70
70
|
// without prompting — an update is a refresh, not a re-decision.
|
|
71
71
|
console.log('Updating journey docs...');
|
|
72
72
|
await runDiscover(srcDir, 'docs', { noPrompt: true });
|
|
73
|
+
// Journeys that arrived on their own document — a ticket's attached spec —
|
|
74
|
+
// are refreshed from THAT document. The project specification does not
|
|
75
|
+
// describe them, so refreshing them from it would quietly replace a written
|
|
76
|
+
// requirement with whatever the code currently does.
|
|
77
|
+
const ownRefs = Object.entries(src.journeys || {}).filter(([, ref]) => ref !== src.reference);
|
|
78
|
+
for (const [doc, ref] of ownRefs) {
|
|
79
|
+
const name = doc.replace(/\.md$/, '').split('/').pop();
|
|
80
|
+
console.log(` refreshing ${name} from its own reference: ${ref}`);
|
|
81
|
+
await runDiscover(srcDir, 'docs', { reference: ref, noPrompt: true }, name);
|
|
82
|
+
}
|
|
73
83
|
console.log(' done — docs/\n');
|
|
74
84
|
console.log('Regenerating tests for changed journeys...');
|
|
75
85
|
const results = await runGenerate('docs', true, false);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@activate-core/test-forge",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.16.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": {
|