@activate-core/test-forge 0.1.1 → 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.
@@ -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');
@@ -60,7 +61,7 @@ export function runSetup(srcDir) {
60
61
  // Build an env template so the developer knows which credentials each
61
62
  // area/journey needs for running the tests.
62
63
  console.log('Building env template...');
63
- runEnvTemplate('docs', '.env.testing.example');
64
+ runEnvTemplate('docs', '.env.testing.example', srcDir);
64
65
  console.log('');
65
66
  console.log('Setup complete.');
66
67
  console.log('Review the drafts in docs/, fix anything the AI got wrong,');
@@ -0,0 +1,84 @@
1
+ /**
2
+ * testforge — deciding where the journey docs come from.
3
+ *
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:
6
+ *
7
+ * 1. testforge.md — the AI's code-derived view. Fast, but only as
8
+ * correct as the code it read.
9
+ * 2. An external reference — a spec/doc/PDF in the repo, a website, or
10
+ * a Notion page capturing the human/client EXPECTED behavior.
11
+ *
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.
15
+ *
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.
21
+ */
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');
45
+ }
46
+ /**
47
+ * Decide where the docs should come from for this run. May prompt the
48
+ * developer interactively; never blocks in non-interactive contexts.
49
+ */
50
+ export async function resolveDocsSource(opts = {}) {
51
+ if (opts.source === 'map') {
52
+ return { mode: 'map', origin: 'flag' };
53
+ }
54
+ if (opts.reference) {
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' };
61
+ }
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' };
69
+ }
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)',
73
+ 'An external reference (a file/PDF in scope, a website, or a Notion link)',
74
+ ], 1);
75
+ if (choice === 1) {
76
+ return { mode: 'map', origin: 'prompt' };
77
+ }
78
+ const reference = await promptLine('\nProvide the reference (a file path, a URL, or a Notion link): ');
79
+ if (!reference) {
80
+ console.log('No reference given — basing docs on testforge.md.\n');
81
+ return { mode: 'map', origin: 'prompt' };
82
+ }
83
+ return { mode: 'reference', reference, origin: 'prompt' };
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);
@@ -46,7 +48,7 @@ export function runUpdate(srcDir) {
46
48
  cache[SOURCE_KEY] = newHash;
47
49
  saveCache(CACHE_PATH, cache);
48
50
  console.log('Refreshing env template...');
49
- runEnvTemplate('docs', '.env.testing.example');
51
+ runEnvTemplate('docs', '.env.testing.example', srcDir);
50
52
  console.log('');
51
53
  console.log('Update complete.');
52
54
  }
@@ -0,0 +1,82 @@
1
+ /**
2
+ * testforge — generated-code syntax gate.
3
+ *
4
+ * Before any generated test/seed is written to disk, its content is
5
+ * parse-checked here. This is what stops malformed output (prose that
6
+ * leaked past cleaning, a half-written file, an unbalanced brace) from
7
+ * ever reaching the project — the single gate that would have prevented
8
+ * the broken-seed defects.
9
+ *
10
+ * We stay dependency-free:
11
+ * - JavaScript (.js/.cjs/.mjs): checked EXACTLY with Node's built-in
12
+ * `node --check`, which parses without executing.
13
+ * - TypeScript (.ts/.tsx): no exact parser is guaranteed at runtime
14
+ * (the project we run inside may not have `typescript` installed),
15
+ * so we fall back to a structural sanity check that still catches
16
+ * the common failure — prose leaking into the file — without
17
+ * false-rejecting valid TypeScript.
18
+ */
19
+ import { execFileSync } from 'node:child_process';
20
+ import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
21
+ import { join } from 'node:path';
22
+ import { tmpdir } from 'node:os';
23
+ /** True if `content` parses (or, for TS, passes a structural check). */
24
+ export function isSyntaxValid(content, ext) {
25
+ if (!content.trim())
26
+ return false;
27
+ const isJs = /\.(c|m)?js$/i.test(ext);
28
+ if (isJs) {
29
+ return nodeCheck(content, ext);
30
+ }
31
+ return looksStructurallyClean(content);
32
+ }
33
+ /** Write to a temp file and run `node --check` on it. */
34
+ function nodeCheck(content, ext) {
35
+ const dir = mkdtempSync(join(tmpdir(), 'testforge-check-'));
36
+ // `.mjs` gets ES-module parsing; anything else checks fine as `.js`
37
+ // (CommonJS), and `node --check` accepts both `import` and `require`
38
+ // there depending on extension — so preserve an explicit `.mjs`.
39
+ const fileExt = /\.mjs$/i.test(ext) ? '.mjs' : '.js';
40
+ const file = join(dir, `check${fileExt}`);
41
+ try {
42
+ writeFileSync(file, content, 'utf-8');
43
+ execFileSync(process.execPath, ['--check', file], { stdio: 'ignore' });
44
+ return true;
45
+ }
46
+ catch {
47
+ return false;
48
+ }
49
+ finally {
50
+ rmSync(dir, { recursive: true, force: true });
51
+ }
52
+ }
53
+ /**
54
+ * Best-effort TypeScript check: the file must START with code (not a
55
+ * sentence) and have balanced braces/parens. This catches leaked prose
56
+ * — the actual failure mode we saw — without a real TS parser.
57
+ */
58
+ function looksStructurallyClean(content) {
59
+ const firstLine = content
60
+ .trim()
61
+ .split('\n')
62
+ .find((l) => l.trim() !== '') ?? '';
63
+ const t = firstLine.trim();
64
+ const startsWithCode = t.startsWith('//') ||
65
+ t.startsWith('/*') ||
66
+ t.startsWith('import ') ||
67
+ t.startsWith('export ') ||
68
+ t.startsWith('const ') ||
69
+ t.startsWith('let ') ||
70
+ t.startsWith('var ') ||
71
+ t.startsWith('function ') ||
72
+ t.startsWith('async ') ||
73
+ t.startsWith('require(') ||
74
+ t.startsWith("'use strict'") ||
75
+ t.startsWith('"use strict"');
76
+ if (!startsWithCode)
77
+ return false;
78
+ const count = (s, ch) => s.split(ch).length - 1;
79
+ const balanced = count(content, '{') === count(content, '}') &&
80
+ count(content, '(') === count(content, ')');
81
+ return balanced;
82
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.1.1",
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": {