@activate-core/test-forge 0.10.2 → 0.11.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.
@@ -39,15 +39,19 @@ function runWithStdin(program, args, input) {
39
39
  });
40
40
  }
41
41
  /** Build the prompt we send to the AI for one journey. */
42
- export function buildPrompt(journey, map, testPath, focus, harnessPath) {
42
+ export function buildPrompt(journey, map, testPath, focus, harnessPath, transport) {
43
43
  const config = loadConfig();
44
44
  // Opinionated: testforge only generates Jest (api/backend) or Playwright
45
45
  // (web/UI) tests — any other framework (e.g. a project's Cypress setup)
46
46
  // is clamped to the right one for the journey. Single source of truth in
47
47
  // resolveFramework, shared with the output-layout decision.
48
48
  const isApi = resolveFramework(config, journey.area, journey.type) === 'jest';
49
+ // Do NOT assume an HTTP client here — the transport is detected from the
50
+ // project's real dependencies and passed in (Hapi/Fastify inject,
51
+ // supertest only if installed, or native http). Hardcoding supertest
52
+ // was what produced ~half the API suites that never loaded.
49
53
  const framework = isApi
50
- ? 'Jest (with supertest for HTTP) for an API/backend test'
54
+ ? 'Jest for an API/backend test'
51
55
  : 'Playwright for a web/UI test';
52
56
  // Pending-test syntax differs by runner: Jest has test.todo/it.skip;
53
57
  // Playwright has NEITHER (test.todo throws) — it uses test.fixme/skip.
@@ -107,6 +111,10 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath) {
107
111
  `the behavior genuinely cannot be determined from the source — do not`,
108
112
  `leave something pending that a quick read of the code would answer.`,
109
113
  `Do NOT write or edit any file; output only the test code to stdout.`,
114
+ `EVERY runnable test MUST contain at least one real assertion. If you`,
115
+ `genuinely cannot assert an outcome, make the case pending (${pending})`,
116
+ `— never write a test body with no assertion, which passes without`,
117
+ `testing anything.`,
110
118
  ``,
111
119
  isApi
112
120
  ? [
@@ -118,6 +126,13 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath) {
118
126
  `duplicate exists, correct fields) using the database/ORM the`,
119
127
  `project uses. Connect via the env var named in the map (e.g.`,
120
128
  `process.env.DB_URL) — never hardcode a connection string.`,
129
+ `MOCKING: if the code calls an external service (e.g. an identity`,
130
+ `or payment API), mock it AT THE SEAM THE CODE ACTUALLY USES —`,
131
+ `read the source to see which client it imports (e.g. axios) and`,
132
+ `how it is called, then jest.mock that exact module so the mock`,
133
+ `truly intercepts. Do NOT mock a client the code doesn't use, and`,
134
+ `do NOT drive the server out-of-process while expecting an`,
135
+ `in-process mock to fire — they cannot both be true.`,
121
136
  ].join('\n')
122
137
  : [
123
138
  `Because this is a web journey, write Playwright tests that drive`,
@@ -160,6 +175,9 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath) {
160
175
  ``,
161
176
  ]
162
177
  : []),
178
+ ...(transport
179
+ ? [transport, `Do NOT import any library that is not already a dependency of this project.`, ``]
180
+ : []),
163
181
  ...(harnessPath
164
182
  ? [
165
183
  `SHARED HARNESS — a testkit already exists at:`,
@@ -229,9 +247,9 @@ export function generateWithAI(journey, testPath, focus) {
229
247
  * journey's suite can be generated concurrently (each spawns its own
230
248
  * `claude` process). Same prompt, same read-only exploration.
231
249
  */
232
- export async function generateWithAIAsync(journey, testPath, focus, harnessPath) {
250
+ export async function generateWithAIAsync(journey, testPath, focus, harnessPath, transport) {
233
251
  const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
234
- const prompt = buildPrompt(journey, map, testPath, focus, harnessPath);
252
+ const prompt = buildPrompt(journey, map, testPath, focus, harnessPath, transport);
235
253
  const { program, args } = withReadOnlyTools(splitCommand(loadConfig().aiCommand));
236
254
  const stdout = await runWithStdin(program, args, prompt);
237
255
  return cleanOutput(stdout);
@@ -0,0 +1,133 @@
1
+ /**
2
+ * testforge — project capability detection (dependency-aware, no guessing).
3
+ *
4
+ * Generated tests must fit the project they land in: they may only import
5
+ * libraries the project actually has, and backend suites must drive the
6
+ * server the way the project's stack supports (Hapi/Fastify inject,
7
+ * supertest, or a live HTTP call) — never by assuming a convention.
8
+ *
9
+ * Everything here is read from the real files (package.json + node_modules)
10
+ * walking up from the test location, so it works on ANY project, including
11
+ * a greenfield one with no existing tests.
12
+ */
13
+ import { existsSync, readFileSync, readdirSync } from 'node:fs';
14
+ import { builtinModules } from 'node:module';
15
+ import { dirname, join } from 'node:path';
16
+ /** Read a package.json's dependency names (all dependency fields). */
17
+ function depsOf(pkgPath) {
18
+ try {
19
+ const pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
20
+ const fields = [
21
+ 'dependencies',
22
+ 'devDependencies',
23
+ 'peerDependencies',
24
+ 'optionalDependencies',
25
+ ];
26
+ const names = [];
27
+ for (const f of fields) {
28
+ const obj = pkg[f];
29
+ if (obj)
30
+ names.push(...Object.keys(obj));
31
+ }
32
+ return names;
33
+ }
34
+ catch {
35
+ return [];
36
+ }
37
+ }
38
+ /** Top-level entries of a node_modules dir, incl. scoped (@scope/pkg). */
39
+ function nodeModulesNames(nmDir) {
40
+ if (!existsSync(nmDir))
41
+ return [];
42
+ const out = [];
43
+ let entries;
44
+ try {
45
+ entries = readdirSync(nmDir);
46
+ }
47
+ catch {
48
+ return [];
49
+ }
50
+ for (const e of entries) {
51
+ if (e.startsWith('.'))
52
+ continue;
53
+ if (e.startsWith('@')) {
54
+ try {
55
+ for (const sub of readdirSync(join(nmDir, e))) {
56
+ if (!sub.startsWith('.'))
57
+ out.push(`${e}/${sub}`);
58
+ }
59
+ }
60
+ catch {
61
+ /* ignore */
62
+ }
63
+ }
64
+ else {
65
+ out.push(e);
66
+ }
67
+ }
68
+ return out;
69
+ }
70
+ /**
71
+ * The set of module specifiers a test at `fromDir` may import: every
72
+ * dependency declared in any package.json from `fromDir` up to the repo
73
+ * root, every package physically present in node_modules along the way,
74
+ * and all Node builtins. Union is intentional — being permissive here
75
+ * only risks NOT rejecting a real dep; the goal is to catch clearly
76
+ * absent libraries (e.g. supertest) without false-rejecting hoisted ones.
77
+ */
78
+ export function installedModules(fromDir) {
79
+ const mods = new Set();
80
+ for (const b of builtinModules) {
81
+ mods.add(b);
82
+ mods.add(`node:${b}`);
83
+ }
84
+ let dir = fromDir;
85
+ for (let i = 0; i < 12; i++) {
86
+ const pkg = join(dir, 'package.json');
87
+ if (existsSync(pkg))
88
+ depsOf(pkg).forEach((d) => mods.add(d));
89
+ nodeModulesNames(join(dir, 'node_modules')).forEach((d) => mods.add(d));
90
+ const parent = dirname(dir);
91
+ if (parent === dir)
92
+ break;
93
+ dir = parent;
94
+ }
95
+ return mods;
96
+ }
97
+ /**
98
+ * A concrete instruction for how a backend (Jest) suite should reach the
99
+ * server, based on what the project actually depends on — so the AI does
100
+ * not fall back to an uninstalled default (the supertest failure).
101
+ */
102
+ export function transportHint(fromDir, framework) {
103
+ if (framework === 'playwright') {
104
+ return `This is a Playwright area: use the browser + the testkit's apiContext(request) for API calls. Do NOT use supertest.`;
105
+ }
106
+ const mods = installedModules(fromDir);
107
+ const has = (m) => mods.has(m);
108
+ if (has('@hapi/hapi') || has('hapi')) {
109
+ return [
110
+ `TRANSPORT: this is a Hapi backend. Drive the server IN-PROCESS with`,
111
+ `\`server.inject({ method, url, payload, headers })\` — the same way`,
112
+ `the project's own tests do. Do NOT use supertest (it is not a`,
113
+ `dependency and would run a separate process the in-process mocks`,
114
+ `can't intercept). Require the app's server/init from the real source.`,
115
+ ].join('\n');
116
+ }
117
+ if (has('fastify')) {
118
+ return [
119
+ `TRANSPORT: this is a Fastify backend. Drive it IN-PROCESS with`,
120
+ `\`app.inject({ method, url, payload, headers })\`. Do NOT use supertest.`,
121
+ ].join('\n');
122
+ }
123
+ if (has('supertest')) {
124
+ return `TRANSPORT: use supertest (it is installed) against the app's exported Express/Koa/HTTP handler.`;
125
+ }
126
+ // Express/Koa/etc. without supertest, or unknown: don't invent a dep.
127
+ return [
128
+ `TRANSPORT: do NOT import any HTTP-testing library that is not already`,
129
+ `a dependency of this project. Prefer the framework's own in-process`,
130
+ `injection if it has one; otherwise use Node's built-in http/fetch`,
131
+ `against process.env.BASE_URL. Never add a new dependency.`,
132
+ ].join('\n');
133
+ }
@@ -107,8 +107,10 @@ function buildHarnessPrompt(harnessPath, framework) {
107
107
  `Because this is a JEST/backend area, do NOT require or import`,
108
108
  `'@playwright/test' anywhere (it is not installed in a backend`,
109
109
  `package — a top-level require would break every suite that loads`,
110
- `this testkit). OMIT the Playwright-only apiContext/login exports;`,
111
- `backend suites make HTTP calls with supertest, not Playwright.`,
110
+ `this testkit). OMIT the Playwright-only apiContext/login exports.`,
111
+ `Backend suites drive the server in-process via the framework's own`,
112
+ `inject (or an already-installed HTTP client) — never Playwright,`,
113
+ `and never a library this project has not installed.`,
112
114
  ``,
113
115
  ]
114
116
  : []),
@@ -1,47 +1,62 @@
1
1
  /**
2
- * testforge — Playwright test file writer.
2
+ * testforge — deferred-stub writer (fallback only).
3
3
  *
4
- * Takes a Journey and its generated test cases and produces the
5
- * text of a runnable Playwright test file (.test.ts). It does not
6
- * write to disk itself; it returns the file content as a string so
7
- * the caller decides where to save it.
4
+ * When AI generation is unavailable (usage limit / repeated invalid
5
+ * output), testforge still needs to leave SOMETHING at the file path so
6
+ * the journey is visibly accounted for. It must NEVER be a test that
7
+ * runs and passes with no assertions — a silent false-green is the worst
8
+ * QA outcome. So this writer emits PENDING placeholders only:
9
+ * - Jest → test.todo(...) (pending; no assertions; no import)
10
+ * - Playwright → test.fixme(...) (skipped; never counts as a pass)
8
11
  *
9
- * The generated tests use placeholder selectors and values, marked
10
- * with TODO comments, because testforge cannot know the real page
11
- * structure. A developer fills those in. Every test is tagged
12
- * @generated so it is easy to spot auto-created tests.
12
+ * It is also runner-aware: a Jest area never receives a
13
+ * `@playwright/test` import (which would break every suite that loads).
14
+ * A developer (or a later AI run) replaces these with real tests.
13
15
  */
14
- /** Build the import header and describe block opening. */
15
- function header(journey) {
16
+ const BANNER = [
17
+ `// @generated by testforge — DEFERRED stub (AI generation was unavailable).`,
18
+ `// These are PLACEHOLDERS, not real tests: they are pending so they do`,
19
+ `// NOT pass silently. Regenerate with AI once available, or implement`,
20
+ `// them by hand.`,
21
+ ].join('\n');
22
+ /** One pending entry, phrased for the area's runner. */
23
+ function pendingLine(testCase, framework) {
24
+ const outcome = testCase.expect === 'pass' ? 'succeed' : 'be rejected';
25
+ const title = `${testCase.title} — TODO: implement (expected to ${outcome})`;
26
+ const safe = title.replace(/"/g, '\\"');
27
+ return framework === 'jest'
28
+ ? ` test.todo("${safe}");`
29
+ : ` test.fixme("${safe}", async () => {});`;
30
+ }
31
+ /**
32
+ * Build the full deferred-stub file for a journey, correct for the area's
33
+ * test runner. Every entry is pending, so the file contributes zero
34
+ * false-passing tests.
35
+ */
36
+ export function writeDeferredStub(journey, cases, framework) {
37
+ const entries = (cases.length ? cases : [{ title: journey.name, expect: 'pass' }])
38
+ .map((c) => pendingLine(c, framework))
39
+ .join('\n');
40
+ if (framework === 'jest') {
41
+ // Jest globals — no import (a `@playwright/test` import here would
42
+ // break the whole suite in a backend package).
43
+ return [
44
+ BANNER,
45
+ ``,
46
+ `describe(${JSON.stringify(journey.name)}, () => {`,
47
+ entries,
48
+ `});`,
49
+ ``,
50
+ ].join('\n');
51
+ }
16
52
  return [
17
- `import { test, expect } from "@playwright/test";`,
53
+ `import { test } from "@playwright/test";`,
54
+ ``,
55
+ BANNER,
18
56
  ``,
19
- `// @generated by testforge — review before relying on these tests.`,
20
- `test.describe("${journey.name}", () => {`,
57
+ `test.describe(${JSON.stringify(journey.name)}, () => {`,
58
+ entries,
59
+ `});`,
21
60
  ``,
22
61
  ].join('\n');
23
62
  }
24
- /** Build a single Playwright test block from one test case. */
25
- function testBlock(journey, testCase) {
26
- const lines = [];
27
- lines.push(` // @generated`);
28
- lines.push(` test("${testCase.title}", async ({ page }) => {`);
29
- lines.push(` await page.goto("${journey.route}");`);
30
- lines.push(``);
31
- lines.push(` // TODO: fill in the steps for this test.`);
32
- if (testCase.expect === 'pass') {
33
- lines.push(` // This case is expected to succeed.`);
34
- lines.push(` // TODO: assert the success outcome (e.g. redirect or success message).`);
35
- }
36
- else {
37
- lines.push(` // This case is expected to fail / be rejected.`);
38
- lines.push(` // TODO: assert the error outcome (e.g. validation message shown).`);
39
- }
40
- lines.push(` });`);
41
- return lines.join('\n');
42
- }
43
- /** Build the full Playwright test file content for a journey. */
44
- export function writePlaywrightFile(journey, cases) {
45
- const blocks = cases.map((c) => testBlock(journey, c)).join('\n\n');
46
- return header(journey) + blocks + `\n\n});\n`;
47
- }
@@ -16,15 +16,16 @@
16
16
  * so tests/seeds land where the project expects them. Defaults to
17
17
  * generated-tests/ when not configured.
18
18
  */
19
- import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, } from 'node:fs';
19
+ import { readFileSync, writeFileSync, mkdirSync, existsSync, readdirSync, rmSync, } from 'node:fs';
20
20
  import { join, dirname } from 'node:path';
21
21
  import { parseJourney } from './parser.js';
22
22
  import { generateTestCases } from './generator.js';
23
- import { writePlaywrightFile } from './playwright-writer.js';
23
+ import { writeDeferredStub } from './playwright-writer.js';
24
24
  import { generateWithAI, generateWithAIAsync } from './ai-generator.js';
25
25
  import { generateSeed } from './seed-generator.js';
26
26
  import { planSuite } from './suite-planner.js';
27
- import { isSyntaxValid } from './validate.js';
27
+ import { isSyntaxValid, checkTestGates, analyzeFile } from './validate.js';
28
+ import { installedModules, transportHint } from './detect.js';
28
29
  import { harnessInfo, generateHarnessAsync, generateHarness, playwrightConfig, globalSetupContent, } from './harness.js';
29
30
  import { hashContent, loadCache, saveCache } from './cache.js';
30
31
  import { findMarkdownFiles } from './find-files.js';
@@ -55,7 +56,7 @@ async function pool(items, limit, fn) {
55
56
  return results;
56
57
  }
57
58
  /** Async version of generateValidated (for concurrent per-file gen). */
58
- async function generateValidatedAsync(generate, ext) {
59
+ async function generateValidatedAsync(generate, ext, gate) {
59
60
  let content = '';
60
61
  for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
61
62
  try {
@@ -64,7 +65,7 @@ async function generateValidatedAsync(generate, ext) {
64
65
  catch {
65
66
  continue;
66
67
  }
67
- if (isSyntaxValid(content, ext))
68
+ if (isSyntaxValid(content, ext) && !(gate && gate(content)))
68
69
  return content;
69
70
  }
70
71
  return null;
@@ -74,7 +75,7 @@ async function generateValidatedAsync(generate, ext) {
74
75
  * couple of times on malformed results. Returns the valid content, or
75
76
  * null if every attempt failed to produce parseable code.
76
77
  */
77
- function generateValidated(generate, ext) {
78
+ function generateValidated(generate, ext, gate) {
78
79
  let content = '';
79
80
  for (let attempt = 0; attempt < MAX_GEN_ATTEMPTS; attempt++) {
80
81
  try {
@@ -84,7 +85,7 @@ function generateValidated(generate, ext) {
84
85
  // generation (or output cleaning) threw — retry
85
86
  continue;
86
87
  }
87
- if (isSyntaxValid(content, ext)) {
88
+ if (isSyntaxValid(content, ext) && !(gate && gate(content))) {
88
89
  return content;
89
90
  }
90
91
  }
@@ -95,16 +96,18 @@ function generateValidated(generate, ext) {
95
96
  * syntax gate + retry) and falls back to the deterministic basic
96
97
  * generator if AI output can't be made valid.
97
98
  */
98
- function buildTest(journey, useAI, ext, testPath, focus) {
99
+ function buildTest(journey, useAI, ext, testPath, framework, focus) {
99
100
  if (useAI) {
100
101
  const ai = generateValidated(() => generateWithAI(journey, testPath, focus), ext);
101
102
  if (ai !== null) {
102
103
  return { content: ai, status: 'ai' };
103
104
  }
104
- // fall through to the basic generator
105
+ // fall through to the deferred stub
105
106
  }
107
+ // NOT a runnable test: pending placeholders only, correct for the
108
+ // runner, so nothing ever passes silently.
106
109
  const cases = generateTestCases(journey);
107
- return { content: writePlaywrightFile(journey, cases), status: 'basic' };
110
+ return { content: writeDeferredStub(journey, cases, framework), status: 'basic' };
108
111
  }
109
112
  /**
110
113
  * Turn a journey id into the alternate naming conventions we might
@@ -188,6 +191,60 @@ export async function runHarness(only) {
188
191
  return written;
189
192
  }
190
193
  const PLAN_DIR = '.testforge/plans';
194
+ /** Marker every testforge-written test carries, so stale-file cleanup
195
+ * (F6) only ever removes OUR files, never hand-authored ones. */
196
+ const GEN_MARKER = '@generated by testforge';
197
+ /**
198
+ * F9 — a journey doc that is still an unconfirmed stub. Deep-generating
199
+ * confident assertions from it produces UNVALIDATED tests, so we skip it
200
+ * and tell the user to confirm the spec first.
201
+ */
202
+ function isStubDoc(markdown) {
203
+ return (/^#.*\btodo\b/im.test(markdown) ||
204
+ /not covered by the reference/i.test(markdown) ||
205
+ /confirm (the )?expected behaviou?r/i.test(markdown) ||
206
+ /todo:\s*not covered/i.test(markdown));
207
+ }
208
+ /** Slugs recorded in a journey's existing plan cache (the previous run). */
209
+ function readPlanSlugs(journey) {
210
+ const p = join(PLAN_DIR, `${journey.area}__${journey.id}.json`);
211
+ try {
212
+ const c = JSON.parse(readFileSync(p, 'utf-8'));
213
+ if (Array.isArray(c.files)) {
214
+ return c.files.map((f) => f.slug).filter((s) => !!s);
215
+ }
216
+ }
217
+ catch {
218
+ /* no previous plan */
219
+ }
220
+ return [];
221
+ }
222
+ /**
223
+ * F6 — replace, don't duplicate. When a journey is (re)generated and its
224
+ * plan changed, the files for slugs that no longer exist are stale. Delete
225
+ * them — but ONLY files that carry our marker, so we never touch a
226
+ * developer's own tests. Prevents the twin-suite accumulation.
227
+ */
228
+ function cleanStaleFiles(testDir, journeyId, ext, flat, keepSlugs, dropSlugs) {
229
+ const removed = [];
230
+ const keep = new Set(keepSlugs);
231
+ for (const slug of dropSlugs) {
232
+ if (keep.has(slug))
233
+ continue;
234
+ const base = flat ? `${journeyId}-${slug}` : slug;
235
+ const f = join(testDir, `${base}${ext}`);
236
+ try {
237
+ if (existsSync(f) && readFileSync(f, 'utf-8').includes(GEN_MARKER)) {
238
+ rmSync(f);
239
+ removed.push(`${base}${ext}`);
240
+ }
241
+ }
242
+ catch {
243
+ /* best-effort */
244
+ }
245
+ }
246
+ return removed;
247
+ }
191
248
  /**
192
249
  * Get a journey's file plan, cached by doc hash. Caching the plan keeps
193
250
  * the file slugs STABLE across runs, so per-file resume (skip-existing)
@@ -304,6 +361,12 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
304
361
  const flat = framework === 'jest';
305
362
  const testDir = flat ? areaDir : join(areaDir, journey.id);
306
363
  const seedPath = join(seedDir, `${journey.id}${seedExt}`);
364
+ // Dependency-aware guardrails (F1): what this area may import, and how
365
+ // it should reach the server — both read from the real project, so the
366
+ // AI can't fall back to an uninstalled convention (e.g. supertest).
367
+ const allowedModules = installedModules(testDir);
368
+ const transport = transportHint(testDir, framework);
369
+ const testGate = (content) => checkTestGates(content, ext, { framework, allowedModules });
307
370
  // Skip if unchanged.
308
371
  if (!force && cache[file] === hash) {
309
372
  results.push({
@@ -320,6 +383,22 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
320
383
  const collision = collisionWarning(areaDir, journey.id, ext);
321
384
  if (collision)
322
385
  warnings.push(collision);
386
+ // F9 — don't deep-generate from an unconfirmed TODO/stub doc: the
387
+ // assertions would be guesses. Skip it and ask for a confirmed spec.
388
+ if (isStubDoc(markdown)) {
389
+ results.push({
390
+ journeyName: journey.name,
391
+ testDir,
392
+ testFiles: [],
393
+ seedPath,
394
+ status: 'skipped',
395
+ seedStatus: 'none',
396
+ warnings: [
397
+ 'doc is an unconfirmed TODO/stub — confirm the expected behaviour before generating (skipped to avoid unvalidated tests)',
398
+ ],
399
+ });
400
+ continue;
401
+ }
323
402
  // Ensure this area's shared harness exists (once), so every file
324
403
  // imports the same testkit instead of inventing its own.
325
404
  const harnessPath = useAI
@@ -330,9 +409,17 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
330
409
  }
331
410
  // 1. Plan the suite (deep mode), then generate one focused file per
332
411
  // concern. In basic mode there is no AI planner — one file only.
412
+ // Capture the PREVIOUS plan's slugs first so we can clean up any
413
+ // files whose slug no longer exists (F6 replace-don't-duplicate).
414
+ const prevSlugs = useAI ? readPlanSlugs(journey) : [];
333
415
  const planned = useAI
334
416
  ? loadOrPlan(journey, hash)
335
417
  : [{ slug: journey.id, title: journey.name, covers: '' }];
418
+ const plannedSlugs = planned.map((p) => p.slug);
419
+ const staleRemoved = cleanStaleFiles(testDir, journey.id, ext, flat, plannedSlugs, prevSlugs);
420
+ if (staleRemoved.length) {
421
+ warnings.push(`removed ${staleRemoved.length} stale file(s) from a previous plan: ${staleRemoved.join(', ')}`);
422
+ }
336
423
  // Generate all focused files CONCURRENTLY (bounded pool). Each AI
337
424
  // call is independent, so a 15-file journey finishes in ~4 waves
338
425
  // instead of 15 serial calls.
@@ -359,22 +446,27 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
359
446
  let content;
360
447
  let status;
361
448
  if (useAI) {
362
- const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath), ext);
449
+ const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath, transport), ext, testGate);
363
450
  if (ai !== null) {
364
451
  content = ai;
365
452
  status = 'ai';
366
453
  }
367
454
  else {
368
- const built = buildTest(journey, false, ext, filePath);
455
+ const built = buildTest(journey, false, ext, filePath, framework);
369
456
  content = built.content;
370
457
  status = 'basic';
371
458
  }
372
459
  }
373
460
  else {
374
- const built = buildTest(journey, false, ext, filePath);
461
+ const built = buildTest(journey, false, ext, filePath, framework);
375
462
  content = built.content;
376
463
  status = 'basic';
377
464
  }
465
+ // Stamp our marker (F6) so stale-file cleanup can recognise OUR
466
+ // files later without ever touching a developer's own tests.
467
+ if (!content.includes(GEN_MARKER)) {
468
+ content = `// ${GEN_MARKER}\n${content}`;
469
+ }
378
470
  // Write IMMEDIATELY (not batched at the end of the journey), so an
379
471
  // interruption preserves every file generated so far.
380
472
  mkdirSync(dirname(filePath), { recursive: true });
@@ -389,6 +481,15 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
389
481
  const anyAI = testFiles.some((t) => t.status === 'ai');
390
482
  const anyBasic = testFiles.some((t) => t.status === 'basic');
391
483
  const status = anyAI || !anyBasic ? 'ai' : 'basic';
484
+ // F10 — complete-generation honesty: any file that fell back to a
485
+ // deferred stub is NOT real coverage. Surface it clearly (and the
486
+ // journey stays uncached below, so a later run retries it with AI).
487
+ const deferred = testFiles.filter((t) => t.status === 'basic');
488
+ if (useAI && deferred.length) {
489
+ warnings.push(`${deferred.length} file(s) could not be generated with AI and were left as DEFERRED stubs (pending, not real coverage): ${deferred
490
+ .map((t) => t.slug)
491
+ .join(', ')}`);
492
+ }
392
493
  // 2. Seed file (only when using AI — basic mode has no seed concept).
393
494
  let seedStatus = 'none';
394
495
  if (useAI) {
@@ -433,5 +534,49 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
433
534
  });
434
535
  }
435
536
  saveCache(CACHE_PATH, cache);
537
+ writeCoverageManifest(results);
436
538
  return results;
437
539
  }
540
+ /**
541
+ * F8 — write an honest coverage manifest. For every generated file it
542
+ * records real (has assertions) vs deferred (pending only), so a "green"
543
+ * run is never mistaken for full coverage. Written to
544
+ * .testforge/coverage.json and summarised to the console.
545
+ */
546
+ function writeCoverageManifest(results) {
547
+ const journeys = results
548
+ .filter((r) => r.testFiles.length)
549
+ .map((r) => {
550
+ const files = r.testFiles.map((t) => {
551
+ let a = { activeTests: 0, pending: 0, assertions: 0, real: false };
552
+ try {
553
+ a = analyzeFile(readFileSync(t.path, 'utf-8'));
554
+ }
555
+ catch {
556
+ /* file unreadable — count as empty */
557
+ }
558
+ return { file: t.path, status: t.status, ...a };
559
+ });
560
+ return {
561
+ journey: r.journeyName,
562
+ real: files.filter((f) => f.real).length,
563
+ deferred: files.filter((f) => !f.real).length,
564
+ files,
565
+ };
566
+ });
567
+ if (!journeys.length)
568
+ return;
569
+ const totalReal = journeys.reduce((n, j) => n + j.real, 0);
570
+ const totalDeferred = journeys.reduce((n, j) => n + j.deferred, 0);
571
+ try {
572
+ mkdirSync('.testforge', { recursive: true });
573
+ writeFileSync(join('.testforge', 'coverage.json'), JSON.stringify({ real: totalReal, deferred: totalDeferred, journeys }, null, 2), 'utf-8');
574
+ }
575
+ catch {
576
+ /* best-effort */
577
+ }
578
+ console.log(`\nCoverage: ${totalReal} real test file(s), ${totalDeferred} deferred/placeholder — see .testforge/coverage.json`);
579
+ if (totalDeferred) {
580
+ console.log(` (deferred files are pending, NOT real coverage — regenerate or implement them.)`);
581
+ }
582
+ }
@@ -20,6 +20,86 @@ import { execFileSync } from 'node:child_process';
20
20
  import { writeFileSync, mkdtempSync, rmSync } from 'node:fs';
21
21
  import { join } from 'node:path';
22
22
  import { tmpdir } from 'node:os';
23
+ /**
24
+ * Semantic gate for a generated test file. Returns a human-readable
25
+ * reason string if the content violates a guardrail, or null if it is
26
+ * clean. Runs AFTER syntax validation.
27
+ */
28
+ export function checkTestGates(content, _ext, ctx) {
29
+ // F3 — runner correctness: a Jest/backend area must never pull in
30
+ // Playwright (not installed there; a top-level import breaks the suite).
31
+ if (ctx.framework === 'jest' && /@playwright\/test/.test(content)) {
32
+ return "jest area must not import '@playwright/test'";
33
+ }
34
+ // F4 — goto guard: `page.goto(...)` must take a real route/URL, not a
35
+ // pasted description. Literal args containing whitespace are prose.
36
+ const gotoRe = /\.goto\(\s*(['"])([\s\S]*?)\1/g;
37
+ let m;
38
+ while ((m = gotoRe.exec(content)) !== null) {
39
+ if (/\s/.test(m[2])) {
40
+ return `goto() argument looks like prose, not a URL/path: "${m[2].slice(0, 40)}…"`;
41
+ }
42
+ }
43
+ // F2 — no silent false-green: a file with RUNNABLE tests must assert
44
+ // something. Pending placeholders (test.todo/skip/fixme) are exempt —
45
+ // they don't count as passing. This is what stops an empty-but-green
46
+ // file (the worst QA outcome) from ever being written.
47
+ const activeTests = (content.match(/\b(?:it|test)\s*\(/g) || []).length +
48
+ (content.match(/\b(?:it|test)\.(?:each|concurrent)\b/g) || []).length;
49
+ const assertions = (content.match(/\bexpect\s*\(/g) || []).length +
50
+ (content.match(/\bassert\b/g) || []).length;
51
+ if (activeTests > 0 && assertions === 0) {
52
+ return 'has runnable test(s) but no assertions — would pass without testing anything';
53
+ }
54
+ // F1 — dependency awareness: reject imports/requires of a module the
55
+ // project can't resolve (bare specifier not in allowedModules).
56
+ if (ctx.allowedModules) {
57
+ for (const mod of importedModules(content)) {
58
+ const top = topLevelModule(mod);
59
+ if (!ctx.allowedModules.has(top)) {
60
+ return `imports '${mod}' which the project does not have installed`;
61
+ }
62
+ }
63
+ }
64
+ return null;
65
+ }
66
+ /**
67
+ * F8 — lightweight coverage stats for one generated file, so a manifest
68
+ * can tell REAL coverage from deferred placeholders (a green run is never
69
+ * mistaken for full coverage).
70
+ */
71
+ export function analyzeFile(content) {
72
+ const activeTests = (content.match(/\b(?:it|test)\s*\(/g) || []).length +
73
+ (content.match(/\b(?:it|test)\.(?:each|concurrent)\b/g) || []).length;
74
+ const pending = (content.match(/\b(?:it|test|describe)\.(?:todo|skip|fixme)\b/g) || []).length;
75
+ const assertions = (content.match(/\bexpect\s*\(/g) || []).length;
76
+ return { activeTests, pending, assertions, real: activeTests > 0 && assertions > 0 };
77
+ }
78
+ /** Extract bare module specifiers from import/require (skips relative). */
79
+ function importedModules(content) {
80
+ const mods = new Set();
81
+ const patterns = [
82
+ /\bimport\b[^'"]*?from\s*(['"])([^'"]+)\1/g,
83
+ /\bimport\s*(['"])([^'"]+)\1/g,
84
+ /\brequire\(\s*(['"])([^'"]+)\1\s*\)/g,
85
+ ];
86
+ for (const re of patterns) {
87
+ let m;
88
+ while ((m = re.exec(content)) !== null) {
89
+ const spec = m[2];
90
+ if (!spec.startsWith('.') && !spec.startsWith('/'))
91
+ mods.add(spec);
92
+ }
93
+ }
94
+ return [...mods];
95
+ }
96
+ /** `@scope/pkg/sub` -> `@scope/pkg`; `pkg/sub` -> `pkg`; `node:fs` -> `node:fs`. */
97
+ function topLevelModule(spec) {
98
+ if (spec.startsWith('node:'))
99
+ return spec;
100
+ const parts = spec.split('/');
101
+ return spec.startsWith('@') ? parts.slice(0, 2).join('/') : parts[0];
102
+ }
23
103
  /** True if `content` parses (or, for TS, passes a structural check). */
24
104
  export function isSyntaxValid(content, ext) {
25
105
  if (!content.trim())
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.10.2",
3
+ "version": "0.11.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": {