@activate-core/test-forge 0.16.0 → 0.17.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.
@@ -57,7 +57,7 @@ function runWithStdin(program, args, input, timeoutMs = 300_000) {
57
57
  });
58
58
  }
59
59
  /** Build the prompt we send to the AI for one journey. */
60
- export function buildPrompt(journey, map, testPath, focus, harnessPath, transport) {
60
+ export function buildPrompt(journey, map, testPath, focus, harnessPath, transport, grounding) {
61
61
  const config = loadConfig();
62
62
  // Opinionated: testforge only generates Jest (api/backend) or Playwright
63
63
  // (web/UI) tests — any other framework (e.g. a project's Cypress setup)
@@ -118,6 +118,10 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
118
118
  ` shipping"). If the expectation is merely implied, inferred, or`,
119
119
  ` "good practice" — including a rule that sounds obviously right —`,
120
120
  ` it is NOT an oracle: write it ${pending} with a "// TODO:" note.`,
121
+ ` But look properly before you conclude it is absent: the requirement`,
122
+ ` is often stated in another section of the same document, in a §`,
123
+ ` the text cross-references, or in an acceptance-criteria list rather`,
124
+ ` than the prose. Only after checking those is it genuinely unstated.`,
121
125
  ` An assertion you cannot trace to a stated requirement is a false`,
122
126
  ` positive waiting to happen. Anything under a "Suggested (not a`,
123
127
  ` requirement…)" heading is explicitly NOT an oracle — never assert`,
@@ -129,6 +133,14 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
129
133
  ` test with a one-line reason naming exactly what was missing. Never`,
130
134
  ` let a missing precondition reach an assertion and report as a bug.`,
131
135
  ` Guard such tests at the top: check the precondition, skip if unmet.`,
136
+ ` ESTABLISH IT BEFORE YOU SKIP, THOUGH. A precondition is missing far`,
137
+ ` less often than it looks: the credential may be under a different`,
138
+ ` env name, the second identity may already exist in the harness, the`,
139
+ ` fixture may be one seed call away, the service may be on another`,
140
+ ` port. Look for it the way you would look for a bug, and only skip`,
141
+ ` when it genuinely is not there — then name exactly what you looked`,
142
+ ` for. A suite that skips what it could have set up reports nothing`,
143
+ ` about the product, and nobody can tell that from real coverage.`,
132
144
  ` A CREDENTIAL THAT EXISTS IS NOT A CREDENTIAL THAT WORKS. A supplied`,
133
145
  ` token expires; treat "present" and "usable" as different checks.`,
134
146
  ` Where the harness accepts a pre-minted token, verify it is still in`,
@@ -162,13 +174,30 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
162
174
  ` input is accepted and the invalid one is not. An input that only`,
163
175
  ` looks tampered turns correct behaviour into a reported breach, which`,
164
176
  ` is the most damaging false alarm there is.`,
165
- `(6) A STUB MUST NEVER INTERCEPT THE APPLICATION ITSELF. When you fake a`,
166
- ` third party, match it by HOST (or an exact URL), never by a substring`,
167
- ` of the URL: a pattern like "**evil.example**" also matches your own`,
168
- ` page's "?next=https%3A%2F%2Fevil.example", so the app is served the`,
169
- ` stub, nothing renders, and the test fails on a screen that never`,
170
- ` loaded. Compare hostnames case-insensitively. After installing`,
171
- ` stubs, satisfy yourself the page under test still loads.`,
177
+ `(6) A STUB MUST NEVER INTERCEPT THE APPLICATION ITSELF — AND "I MIGHT`,
178
+ ` HIT THE APP" IS NOT A REASON TO GIVE UP. Never match a third party by`,
179
+ ` a substring of the URL: a pattern like "**evil.example**" also matches`,
180
+ ` your own page's "?next=https%3A%2F%2Fevil.example", so the app is`,
181
+ ` served the stub, nothing renders, and the test fails on a screen that`,
182
+ ` never loaded.`,
183
+ ` Instead, find the NARROWEST thing that tells the dependency's traffic`,
184
+ ` apart from the app's own, and scope the stub to exactly that. Work`,
185
+ ` down this list and stop at the first that separates them:`,
186
+ ` - a different host — match the hostname, case-insensitively;`,
187
+ ` - the same host on a different PORT — match host AND port: a`,
188
+ ` service on :4000 is a different origin from an app on :3000, and`,
189
+ ` scoping to the bare hostname would swallow both;`,
190
+ ` - the same origin under a different PATH prefix (/socket.io/,`,
191
+ ` /api/chat/) — match the prefix;`,
192
+ ` - otherwise a predicate on the request itself: its method, its`,
193
+ ` resource type, a header only that client sends.`,
194
+ ` A dependency you cannot separate from the app by ANY of those is the`,
195
+ ` only one you may leave pending, and then say which of these you`,
196
+ ` checked. Do not abandon a whole area because the dependency happens to`,
197
+ ` sit on the same hostname — that is usually just a different port, and`,
198
+ ` writing it off costs the product its coverage for no reason.`,
199
+ ` After installing stubs, satisfy yourself the page under test still`,
200
+ ` loads.`,
172
201
  `(7) BIND A FIXTURE TO THE IDENTITY THE SCREEN IS SCOPED BY. Before`,
173
202
  ` seeding for a role-scoped page, find the exact key the server scopes`,
174
203
  ` on — a user _id, a phone number, an account id — and which sign-in`,
@@ -184,11 +213,25 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
184
213
  ` fields that transition writes. A hand-assembled record combining`,
185
214
  ` fields no code path puts together is not a test case — the product`,
186
215
  ` owes it no behaviour, so whatever it does with it proves nothing.`,
187
- ` Before seeding a state, find the write that produces it; if there`,
188
- ` is none, the scenario is imaginary — drop it.`,
189
- `(9) A TIMEOUT IS NOT A BUG REPORT. If an action legitimately waits on`,
190
- ` something external, verify availability first and skip when absent.`,
191
- ` A test that times out tells the team nothing about the product.`,
216
+ ` Before seeding a state, find the write that produces it. Search for`,
217
+ ` it properly — the transition may live in a cron job, a webhook`,
218
+ ` handler, an admin route or a background worker rather than the`,
219
+ ` obvious controller, and it may set the field under a different`,
220
+ ` name. Only when no code path anywhere produces the state is the`,
221
+ ` scenario imaginary — then drop it and say what you searched.`,
222
+ `(9) A TIMEOUT IS NOT A BUG REPORT — AND IT IS USUALLY YOU WAITING FOR`,
223
+ ` THE WRONG THING. Before you accept a wait, ask what event actually`,
224
+ ` arrives. A page whose job is to REDIRECT never fires a load event on`,
225
+ ` the URL you asked for, so waiting for one burns the whole timeout on`,
226
+ ` a page that already left: ask for the response instead and let the`,
227
+ ` assertions that follow wait for the real outcome. A control the app`,
228
+ ` hides from the accessibility tree is not there for a role query, so`,
229
+ ` query it the way the app exposes it. A message that only renders`,
230
+ ` after a round-trip needs a retrying assertion, not a longer sleep.`,
231
+ ` Only once you are sure you are waiting for the right thing does`,
232
+ ` availability come into it: verify it first and skip when absent. A`,
233
+ ` test that times out tells the team nothing about the product, and`,
234
+ ` "it timed out" is never the finding — find what it was waiting for.`,
192
235
  `(10) THE HARNESS IS SOURCE TOO — READ IT BEFORE YOU CALL IT. Open the`,
193
236
  ` helper you are about to use and check what it actually RETURNS: a`,
194
237
  ` setup routine that publishes env and returns nothing hands you`,
@@ -214,16 +257,32 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
214
257
  `When rules conflict, prefer reporting LESS: a pending test costs a line`,
215
258
  `in the report; a false failure costs the team a release.`,
216
259
  ``,
217
- `GROUND EVERYTHING IN THE REAL SOURCE. You are running inside the`,
218
- `project's repository and have read-only tools (Read, Grep, Glob, LS).`,
219
- `BEFORE writing tests, look up the actual code for this journey — use`,
220
- `the route/endpoint named below to grep for the router, then read the`,
221
- `route file, its controller/handler, the service it calls, and the`,
222
- `models it touches. Confirm the REAL endpoint paths, HTTP methods,`,
223
- `request/response shapes, status codes, auth requirements, and function`,
224
- `signatures from the code — that is the MECHANICS: HOW to reach and`,
225
- `drive the behaviour (paths, methods, payload/response shape,`,
226
- `selectors, model/collection names). But the EXPECTED OUTCOME you`,
260
+ ...(grounding
261
+ ? [
262
+ `THE MECHANICS ARE ALREADY ESTABLISHED. The CODE FACTS section`,
263
+ `below was read from this project's real source for this journey —`,
264
+ `routes with their full mounted paths, auth behaviour, request and`,
265
+ `response shapes, models, externals, and the selectors the screens`,
266
+ `genuinely expose. Use it. Do NOT re-derive it: opening the same`,
267
+ `controllers and components again for every file in a journey is`,
268
+ `what makes generation take an hour instead of minutes, and it`,
269
+ `arrives at the same answer. Read source only for a specific detail`,
270
+ `the CODE FACTS section marks "unknown" or genuinely does not`,
271
+ `cover. Those facts are the MECHANICS: HOW to reach and`,
272
+ `drive the behaviour. But the EXPECTED OUTCOME you`,
273
+ ]
274
+ : [
275
+ `GROUND EVERYTHING IN THE REAL SOURCE. You are running inside the`,
276
+ `project's repository and have read-only tools (Read, Grep, Glob, LS).`,
277
+ `BEFORE writing tests, look up the actual code for this journey — use`,
278
+ `the route/endpoint named below to grep for the router, then read the`,
279
+ `route file, its controller/handler, the service it calls, and the`,
280
+ `models it touches. Confirm the REAL endpoint paths, HTTP methods,`,
281
+ `request/response shapes, status codes, auth requirements, and function`,
282
+ `signatures from the code — that is the MECHANICS: HOW to reach and`,
283
+ `drive the behaviour (paths, methods, payload/response shape,`,
284
+ `selectors, model/collection names). But the EXPECTED OUTCOME you`,
285
+ ]),
227
286
  `assert on is the ORACLE, and its source of truth is the JOURNEY`,
228
287
  `DOCUMENT below — NOT what the code currently does. The code tells you`,
229
288
  `HOW; the document tells you WHAT is correct. If the code's real`,
@@ -314,7 +373,7 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
314
373
  `never a description/comment, and never a route-file placeholder`,
315
374
  `like "/s/[shortCode]" or "/user/:id". Fill the path with a real`,
316
375
  `value (a seeded id/code, or the concrete route) using a backtick`,
317
- "template, e.g. page.goto(`${base}/s/${code}`). A goto with prose",
376
+ 'template, e.g. page.goto(`${base}/s/${code}`). A goto with prose',
318
377
  `or an unfilled [param]/:param is rejected by the gate.`,
319
378
  `CRITICAL Playwright API: import { test, expect } from`,
320
379
  `'@playwright/test'. There is NO test.todo / it.* / describe.skip`,
@@ -339,7 +398,11 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
339
398
  ].join('\n'),
340
399
  ``,
341
400
  ...(transport
342
- ? [transport, `Do NOT import any library that is not already a dependency of this project.`, ``]
401
+ ? [
402
+ transport,
403
+ `Do NOT import any library that is not already a dependency of this project.`,
404
+ ``,
405
+ ]
343
406
  : []),
344
407
  ...(harnessPath
345
408
  ? [
@@ -378,6 +441,12 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
378
441
  `--- PROJECT MAP (data layer) ---`,
379
442
  map,
380
443
  ``,
444
+ // Read from the project's real source once per journey, before any file is
445
+ // written, and handed unchanged to every file in it. Sits with the map and
446
+ // the journey so the whole block stays a stable prefix across the journey.
447
+ ...(grounding
448
+ ? [`--- CODE FACTS (read from this project's source) ---`, grounding, ``]
449
+ : []),
381
450
  `--- JOURNEY ---`,
382
451
  `It may include sections beyond fields — actions, expected results,`,
383
452
  `negative cases, race conditions, webhooks, security notes, and a`,
@@ -421,9 +490,9 @@ export function buildPrompt(journey, map, testPath, focus, harnessPath, transpor
421
490
  * journey. Reads the project map (testforge.md) for data-layer
422
491
  * context. Returns the test file content.
423
492
  */
424
- export function generateWithAI(journey, testPath, focus) {
493
+ export function generateWithAI(journey, testPath, focus, grounding) {
425
494
  const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
426
- const prompt = buildPrompt(journey, map, testPath, focus);
495
+ const prompt = buildPrompt(journey, map, testPath, focus, undefined, undefined, grounding);
427
496
  // Allow read-only exploration so the model grounds the test in the
428
497
  // real source (routes/controllers/models); Write stays denied, so it
429
498
  // still can't create files — it reads, then prints the code.
@@ -445,9 +514,9 @@ export function generateWithAI(journey, testPath, focus) {
445
514
  * journey's suite can be generated concurrently (each spawns its own
446
515
  * `claude` process). Same prompt, same read-only exploration.
447
516
  */
448
- export async function generateWithAIAsync(journey, testPath, focus, harnessPath, transport) {
517
+ export async function generateWithAIAsync(journey, testPath, focus, harnessPath, transport, grounding) {
449
518
  const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
450
- const prompt = buildPrompt(journey, map, testPath, focus, harnessPath, transport);
519
+ const prompt = buildPrompt(journey, map, testPath, focus, harnessPath, transport, grounding);
451
520
  const config = loadConfig();
452
521
  const { program, args } = withReadOnlyTools(splitCommand(config.aiCommand));
453
522
  const stdout = await runWithStdin(program, args, prompt, config.aiTimeoutMs);
@@ -513,5 +582,8 @@ export function cleanOutput(text) {
513
582
  break;
514
583
  }
515
584
  }
516
- return lines.slice(codeStart, codeEnd + 1).join('\n').trim();
585
+ return lines
586
+ .slice(codeStart, codeEnd + 1)
587
+ .join('\n')
588
+ .trim();
517
589
  }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * testforge — reading the journey's real code ONCE.
3
+ *
4
+ * Every per-file generation used to carry the same instruction: grep for the
5
+ * router, open the route file, its controller, its service, its models, and —
6
+ * for a UI journey — the page's components. A journey that splits into
7
+ * fourteen files therefore read the same handful of files fourteen times, and
8
+ * each of those reads is an agentic round-trip. That, not the size of the
9
+ * prompt text, is what made one journey take the better part of an hour.
10
+ *
11
+ * The mechanics a test needs — real paths, methods, status codes, model names,
12
+ * the selectors a page actually exposes — are the same for every file in the
13
+ * journey. So we establish them once, cache them beside the suite plan, and
14
+ * hand the same block to each file.
15
+ *
16
+ * What this is NOT: an oracle. It records only HOW to reach the behaviour. The
17
+ * journey document remains the sole source of what is CORRECT, and the
18
+ * generation prompt says so in the same breath as it presents these facts.
19
+ */
20
+ import { execFileSync } from 'node:child_process';
21
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
22
+ import { dirname, join } from 'node:path';
23
+ import { loadConfig, splitCommand, withReadOnlyTools } from './config.js';
24
+ import { hashProjectSource } from './cache.js';
25
+ import { STATE_DIR } from './paths.js';
26
+ const MAP_PATH = 'testforge.md';
27
+ /** Where a journey's grounded facts live, mirroring the plan cache layout. */
28
+ function groundingPath(journey) {
29
+ const area = (journey.area || 'misc').replace(/[^a-z0-9-]/gi, '-');
30
+ const slug = (journey.name || 'journey')
31
+ .toLowerCase()
32
+ .replace(/[^a-z0-9]+/g, '-')
33
+ .replace(/^-|-$/g, '');
34
+ return join(STATE_DIR, 'grounding', area, `${slug}.json`);
35
+ }
36
+ function buildGroundingPrompt(journey, map, srcDir) {
37
+ const isUi = journey.type !== 'api';
38
+ return [
39
+ `You are a senior engineer establishing the MECHANICS of one user`,
40
+ `journey so tests can be written against it without every writer`,
41
+ `re-reading the same source.`,
42
+ ``,
43
+ `You are inside the project's repository with read-only tools (Read,`,
44
+ `Grep, Glob, LS). Read the real code for this journey under "${srcDir}":`,
45
+ `start from the route/endpoint the journey names, then the`,
46
+ `controller/handler, the service it calls, and the models it touches.`,
47
+ ``,
48
+ `Report ONLY what you actually read. Never guess a path, a status code`,
49
+ `or a field name: a fact invented here is repeated into every test file`,
50
+ `in the journey, so one wrong line becomes a whole suite that cannot`,
51
+ `run. If something is genuinely not discoverable, write "unknown" for`,
52
+ `it rather than filling the gap.`,
53
+ ``,
54
+ `Record, as compact plain text under these headings:`,
55
+ ` ROUTES — every endpoint this journey uses, as the FULL mounted path`,
56
+ ` the router registers, including any version/area prefix and the`,
57
+ ` env var the prefix is read from. Give the HTTP method for each.`,
58
+ ` AUTH — for each route: is it declared authenticated? Does a bearer`,
59
+ ` still populate the request user via a pre-auth extension? Which`,
60
+ ` field is ownership scoped on?`,
61
+ ` SHAPES — the request payload and response body each route actually`,
62
+ ` returns, and the status codes the handler can emit.`,
63
+ ` DATA — the models/collections written or read, and the fields that`,
64
+ ` matter to this journey (including enum values where they exist).`,
65
+ ` EXTERNALS — any third-party client the code calls, the module name`,
66
+ ` a test would have to stand in for, and the env var its base URL or`,
67
+ ` key comes from.`,
68
+ ...(isUi
69
+ ? [
70
+ ` SCREENS — the page/component files this journey renders, and the`,
71
+ ` locators they GENUINELY expose: roles with accessible names,`,
72
+ ` label/placeholder text, on-screen text, input name/id`,
73
+ ` attributes, and any data-testid that truly exists in the`,
74
+ ` source. Never list a selector you did not see.`,
75
+ ]
76
+ : []),
77
+ ``,
78
+ `Be terse. This is a reference sheet, not prose: no preamble, no`,
79
+ `restating the journey, no advice about testing. Aim for well under 200`,
80
+ `lines. Output the headings and their contents only.`,
81
+ ``,
82
+ `--- PROJECT MAP ---`,
83
+ map,
84
+ ``,
85
+ `--- JOURNEY ---`,
86
+ journey.rawMarkdown || journey.name,
87
+ ].join('\n');
88
+ }
89
+ /**
90
+ * Establish (or reuse) the journey's code facts. Cached against the doc hash
91
+ * AND the source hash: the mechanics only move when the code or the journey
92
+ * moves, so an unchanged re-run costs nothing.
93
+ *
94
+ * Never throws. If the AI call fails we return null and the caller falls back
95
+ * to telling each file to read the source itself — slower, but still correct.
96
+ */
97
+ export function groundJourney(journey, docHash, srcDir = 'src') {
98
+ const path = groundingPath(journey);
99
+ const map = existsSync(MAP_PATH) ? readFileSync(MAP_PATH, 'utf-8') : '';
100
+ let sourceHash = '';
101
+ try {
102
+ sourceHash = hashProjectSource(srcDir);
103
+ }
104
+ catch {
105
+ // unreadable source tree — fall back to keying on the doc alone
106
+ }
107
+ const key = `${docHash}:${sourceHash}`;
108
+ if (existsSync(path)) {
109
+ try {
110
+ const cached = JSON.parse(readFileSync(path, 'utf-8'));
111
+ if (cached.key === key && typeof cached.facts === 'string' && cached.facts.trim()) {
112
+ return cached.facts;
113
+ }
114
+ }
115
+ catch {
116
+ // corrupt cache — re-ground
117
+ }
118
+ }
119
+ const config = loadConfig();
120
+ const { program, args } = withReadOnlyTools(splitCommand(config.aiCommand));
121
+ let facts;
122
+ try {
123
+ facts = execFileSync(program, args, {
124
+ input: buildGroundingPrompt(journey, map, srcDir),
125
+ encoding: 'utf-8',
126
+ maxBuffer: 10 * 1024 * 1024,
127
+ timeout: config.aiTimeoutMs ?? 300_000,
128
+ }).trim();
129
+ }
130
+ catch {
131
+ return null;
132
+ }
133
+ if (!facts)
134
+ return null;
135
+ try {
136
+ mkdirSync(dirname(path), { recursive: true });
137
+ writeFileSync(path, JSON.stringify({ key, facts }, null, 2), 'utf-8');
138
+ }
139
+ catch {
140
+ // best-effort cache
141
+ }
142
+ return facts;
143
+ }
@@ -24,6 +24,7 @@ import { parseJourney } from './parser.js';
24
24
  import { generateTestCases } from './generator.js';
25
25
  import { writeDeferredStub } from './playwright-writer.js';
26
26
  import { generateWithAI, generateWithAIAsync } from './ai-generator.js';
27
+ import { groundJourney } from './grounding.js';
27
28
  import { generateSeed } from './seed-generator.js';
28
29
  import { planSuite } from './suite-planner.js';
29
30
  import { isSyntaxValid, checkTestGates, analyzeFile } from './validate.js';
@@ -97,9 +98,9 @@ function generateValidated(generate, ext, gate) {
97
98
  * syntax gate + retry) and falls back to the deterministic basic
98
99
  * generator if AI output can't be made valid.
99
100
  */
100
- function buildTest(journey, useAI, ext, testPath, framework, focus) {
101
+ function buildTest(journey, useAI, ext, testPath, framework, focus, grounding) {
101
102
  if (useAI) {
102
- const ai = generateValidated(() => generateWithAI(journey, testPath, focus), ext);
103
+ const ai = generateValidated(() => generateWithAI(journey, testPath, focus, grounding), ext);
103
104
  if (ai !== null) {
104
105
  return { content: ai, status: 'ai' };
105
106
  }
@@ -459,6 +460,15 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
459
460
  ? loadOrPlan(journey, hash)
460
461
  : [{ slug: journey.id, title: journey.name, covers: '' }];
461
462
  const plannedSlugs = planned.map((p) => p.slug);
463
+ // Read the journey's real code ONCE, before any file is written, and hand
464
+ // the same facts to every file. Without this each file re-opened the same
465
+ // routes, controllers and components — the same answer, N times over, and
466
+ // the single biggest cost in a multi-file journey. Best-effort: if it
467
+ // fails, `grounding` stays undefined and each file falls back to reading
468
+ // the source itself, which is slower but still correct.
469
+ const grounding = useAI
470
+ ? groundJourney(journey, hash) ?? undefined
471
+ : undefined;
462
472
  const staleRemoved = cleanStaleFiles(testDir, journey.id, ext, flat, plannedSlugs, prevSlugs);
463
473
  if (staleRemoved.length) {
464
474
  warnings.push(`removed ${staleRemoved.length} stale file(s) from a previous plan: ${staleRemoved.join(', ')}`);
@@ -495,7 +505,7 @@ export async function runGenerate(docsDir, useAI = true, force = false, only, ar
495
505
  let content;
496
506
  let status;
497
507
  if (useAI) {
498
- const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath, transport), ext, testGate);
508
+ const ai = await generateValidatedAsync(() => generateWithAIAsync(journey, filePath, { title: pf.title, covers: pf.covers }, harnessPath, transport, grounding), ext, testGate);
499
509
  if (ai !== null) {
500
510
  content = ai;
501
511
  status = 'ai';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@activate-core/test-forge",
3
- "version": "0.16.0",
3
+ "version": "0.17.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": {