bmad-method-test-architecture-enterprise 3.1.0 → 3.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.
Files changed (36) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/cli/automate.js +7 -0
  3. package/cli/evaluate.js +20 -0
  4. package/cli/examples/gitlab-ci.yaml +1 -1
  5. package/cli/examples/pr-test-review.yml +3 -3
  6. package/cli/lib/agent-adapters.js +46 -0
  7. package/cli/lib/automate-inventory.js +258 -25
  8. package/cli/lib/automate-prompt.js +34 -2
  9. package/cli/lib/automate-result.js +74 -2
  10. package/cli/lib/evaluate/configuration-experiment.js +3401 -0
  11. package/cli/lib/evaluate/experiments.js +1299 -0
  12. package/cli/lib/evaluate/retain-skill-artifacts.js +78 -0
  13. package/cli/lib/evaluate/test-design-corpus/clean/docs/epics/epic-9-last-sync-indicator.md +47 -0
  14. package/cli/lib/evaluate/test-design-corpus/ground-truth.json +222 -0
  15. package/cli/lib/evaluate/test-design-corpus/native-identity.json +4 -0
  16. package/cli/lib/evaluate/test-design-corpus/seeded/docs/epics/epic-7-offline-order-capture.md +75 -0
  17. package/cli/lib/evaluate/test-design-replay.js +452 -0
  18. package/cli/lib/run-agent.js +18 -2
  19. package/cli/lib/test-design-staged-config.js +60 -0
  20. package/cli/lib/test-design-staging.js +229 -0
  21. package/cli/lib/unit-runners.js +219 -0
  22. package/cli/lib/vitest-attempts-reporter.mjs +30 -0
  23. package/cli/test-design-runner.js +245 -11
  24. package/cli/unit-check.js +595 -0
  25. package/package.json +13 -3
  26. package/skills/bmad-tea-automate/red/checklist.md +1 -1
  27. package/skills/bmad-tea-automate/resources/run-and-heal.md +3 -1
  28. package/skills/bmad-tea-evaluate/SKILL.md +11 -0
  29. package/skills/bmad-tea-evaluate/references/configuration-experiments.md +37 -0
  30. package/skills/bmad-tea-evaluate/references/evaluator.md +2 -2
  31. package/skills/bmad-tea-evaluate/references/gaps.md +1 -1
  32. package/skills/bmad-tea-test-review/steps-c/step-03f-aggregate-scores.md +3 -3
  33. package/skills/bmod-tea/bmod.toml +1 -1
  34. package/skills/bmod-tea/help/help.md +1 -1
  35. package/skills/bmod-tea/knowledge/auth-session.md +25 -20
  36. package/skills/bmod-tea/knowledge/test-healing-patterns.md +2 -0
@@ -31,7 +31,7 @@
31
31
  "name": "bmad-method-test-architecture-enterprise",
32
32
  "source": "./",
33
33
  "description": "Master Test Architect module for quality strategy, test generation in red and expand modes with automatic run-and-heal, combined framework and CI setup, CI/CD quality gates, and structured testing education. Existing CI and ATDD customizations can be migrated to the new skill names. Part of the BMad Method ecosystem.",
34
- "version": "3.1.0",
34
+ "version": "3.3.0",
35
35
  "author": {
36
36
  "name": "Murat K Ozcan (TEA Creator) & Brian (BMad) Madison"
37
37
  },
package/cli/automate.js CHANGED
@@ -8,6 +8,7 @@ const { Command } = require('commander');
8
8
  const { addAgentOptions, agentOptions, projectPath, resolveWorkflowSkill, headlessPrompt, runWithEvidence } = require('./lib/workflow-cli');
9
9
  const { artifactPath, parseResult, frontmatter } = require('./lib/automate-result');
10
10
  const { resolveGenerationConfig, generationRequest } = require('./lib/automate-prompt');
11
+ const { detectRunners } = require('./lib/unit-runners');
11
12
 
12
13
  const NAME = 'tea-automate';
13
14
  const MODES = ['red', 'expand'];
@@ -120,6 +121,7 @@ async function main(argv) {
120
121
  let modeSelection;
121
122
  let checkpointBefore;
122
123
  let protectedFiles;
124
+ let runners;
123
125
  try {
124
126
  if (options.mode !== undefined && !MODES.includes(options.mode)) throw new Error(`--mode must be ${MODES.join(' or ')}`);
125
127
  if (!OPERATIONS.includes(options.operation)) throw new Error(`--operation must be ${OPERATIONS.join(', ')}`);
@@ -173,6 +175,8 @@ async function main(argv) {
173
175
  if (!request.trim() && !story && targets.length === 0 && !checkpoint)
174
176
  throw new Error('supply a request, --story, --target or --checkpoint');
175
177
  if (options.operation === 'edit' && !request.trim()) throw new Error('Edit requires instructions in the request or standard input');
178
+ // Detected before the agent runs, so generated edits cannot change which runner evidence is accepted.
179
+ runners = detectRunners(root);
176
180
  resolvedConfig = resolveGenerationConfig(
177
181
  root,
178
182
  checkpoint ? path.join(root, checkpoint) : undefined,
@@ -205,6 +209,8 @@ async function main(argv) {
205
209
  requestId,
206
210
  settings: resolvedConfig.settings,
207
211
  modeSelection,
212
+ runners,
213
+ projectRoot: root,
208
214
  }),
209
215
  });
210
216
  return { prompt, manifestPath, requestId, startedAtMs: Date.now() };
@@ -236,6 +242,7 @@ async function main(argv) {
236
242
  selectedCheckpoint: checkpoint,
237
243
  checkpointBefore,
238
244
  evidenceDir: attemptDir,
245
+ runners,
239
246
  }),
240
247
  });
241
248
  const payload = { ...result.value, modeSelection, evidenceDir: result.runDir, agent: options.agent };
package/cli/evaluate.js CHANGED
@@ -367,6 +367,26 @@ function buildProgram(run) {
367
367
  .option('--evaluation <path>', 'the evaluation folder, or its evaluation.json')
368
368
  .option('--tier <tier>', 'pr, merge, scheduled or release')
369
369
  .action((options) => run(ciCommand, options));
370
+ program
371
+ .command('experiment')
372
+ .description('Opt-in saved Test Design consistency analysis or a bounded experiment proposal; no automatic adoption.')
373
+ .option('--saved <path>', 'exact saved run, retained attempt or experiment directory')
374
+ .option('--search-root <path>', 'find the latest compatible saved Test Design records')
375
+ .option('--propose <path>', 'prepare a proposal from an agent-owned declaration')
376
+ .option('--agent <name>', 'native execution adapter', 'codex')
377
+ .option('--max-usd <amount>', 'proposed spending cap; verified enforcement remains required')
378
+ .option('--recover <path>', 'inspect unresolved reservations without rerunning anything')
379
+ .option('--out <path>', 'new report or proposal directory')
380
+ .action((options) =>
381
+ run(async (opts) => {
382
+ try {
383
+ return await require('./lib/evaluate/experiments').experimentCommand(opts);
384
+ } catch (error) {
385
+ process.stderr.write(`${NAME} experiment: ${escapeUnprintable(error.message)}\n`);
386
+ return error.code === 'EXPERIMENT_USAGE' ? EXIT_CODES.usage : EXIT_CODES.authoring;
387
+ }
388
+ }, options),
389
+ );
370
390
  return program;
371
391
  }
372
392
 
@@ -19,7 +19,7 @@ tea-test-review:
19
19
  variables:
20
20
  GIT_DEPTH: "0" # the review diffs changed test files against the target branch
21
21
  script:
22
- - npm install --global bmad-method-test-architecture-enterprise@1.28.0 @anthropic-ai/claude-code@2.1.220
22
+ - npm install --global bmad-method-test-architecture-enterprise@3.1.0 @anthropic-ai/claude-code@2.1.220
23
23
  - git fetch origin "$CI_MERGE_REQUEST_TARGET_BRANCH_NAME"
24
24
  - |
25
25
  status=0
@@ -61,10 +61,10 @@ jobs:
61
61
  checks: write
62
62
  env:
63
63
  # Pin both, so the reviewer's control plane is the exact code you vetted
64
- # and not whatever `latest` resolves to when a pull request lands. 1.28.0
64
+ # and not whatever `latest` resolves to when a pull request lands. 2.0.0
65
65
  # is the first release that ships --github and the packaged skill: use
66
- # the exact version you vetted.
67
- TEA_VERSION: 1.28.0
66
+ # the exact published version you vetted.
67
+ TEA_VERSION: 3.1.0
68
68
  CLAUDE_CODE_VERSION: 2.1.220
69
69
  steps:
70
70
  - name: Checkout (full history for the PR diff)
@@ -710,11 +710,57 @@ function bridgedArgsRefused(agent, extra = []) {
710
710
  return extra.filter((arg) => locked.some((flag) => arg === flag || arg.startsWith(`${flag}=`)));
711
711
  }
712
712
 
713
+ /** Capture keeps partial native telemetry and parse errors without discarding raw streams. */
714
+ function agentCaptureTelemetry(agent, stdout, stderr) {
715
+ let parsed = { stdout: agentAnswerText(agent, stdout), usage: null };
716
+ const errors = [];
717
+ try {
718
+ parsed = agentReplyAndUsage(agent, stdout, stderr);
719
+ } catch (error) {
720
+ errors.push(error.message);
721
+ // Preserve independently present fields; absent or invalid money stays unknown.
722
+ try {
723
+ const report = agent === 'claude' ? JSON.parse(stdout) : null;
724
+ const usage = report?.usage;
725
+ if (usage) {
726
+ parsed.usage = {};
727
+ const input = usage.input_tokens + (usage.cache_creation_input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0);
728
+ if (Number.isFinite(input) && input >= 0) parsed.usage.inputTokens = input;
729
+ if (Number.isFinite(usage.output_tokens) && usage.output_tokens >= 0) parsed.usage.outputTokens = usage.output_tokens;
730
+ }
731
+ if (Number.isFinite(report?.total_cost_usd) && report.total_cost_usd >= 0) (parsed.usage ??= {}).costUsd = report.total_cost_usd;
732
+ } catch {
733
+ // Raw streams and parsing errors remain available even for malformed reports.
734
+ }
735
+ }
736
+ if (agent === 'codex' && parsed.usage === null) {
737
+ for (const line of stdout.split(/\r?\n/).filter(Boolean)) {
738
+ try {
739
+ const event = JSON.parse(line);
740
+ if (event.type !== 'turn.completed') continue;
741
+ const available = {};
742
+ if (Number.isSafeInteger(event.usage?.input_tokens) && event.usage.input_tokens >= 0)
743
+ available.inputTokens = event.usage.input_tokens;
744
+ if (Number.isSafeInteger(event.usage?.output_tokens) && event.usage.output_tokens >= 0)
745
+ available.outputTokens = event.usage.output_tokens;
746
+ if (Object.keys(available).length > 0) {
747
+ parsed.usage = available;
748
+ errors.push('codex verified money usage is unavailable; native token fields are retained');
749
+ }
750
+ } catch {
751
+ /* The raw event and any strict parsing error remain available. */
752
+ }
753
+ }
754
+ }
755
+ return { stdout: parsed.stdout, resolvedModel: resolvedModelFromAnswer(agent, stdout), appliedEffort: null, usage: parsed.usage, errors };
756
+ }
757
+
713
758
  module.exports = {
714
759
  AGENT_ADAPTERS,
715
760
  AGENT_VERSION_CEILING_MS,
716
761
  AGENT_VERSION_TIMEOUT_MS,
717
762
  agentReplyAndUsage,
763
+ agentCaptureTelemetry,
718
764
  resolvedModelFromAnswer,
719
765
  agentAnswerText,
720
766
  bridgedArgsRefused,
@@ -5,6 +5,7 @@ const path = require('node:path');
5
5
  const { createHash } = require('node:crypto');
6
6
  const { parse } = require('@babel/parser');
7
7
  const { minimatch } = require('minimatch');
8
+ const { UNIT_CHECK_KIND, normalizeUnitReport, unitReportRunner } = require('./unit-runners');
8
9
 
9
10
  const slash = (value) => value.split(path.sep).join('/');
10
11
  const canonical = (value) => {
@@ -48,8 +49,31 @@ function nativeMatches(patterns, absolute) {
48
49
  });
49
50
  }
50
51
 
52
+ // Registration names per runner family. Playwright registers suites through
53
+ // test.describe and maps a transformed named import's call to its callee end.
54
+ // Vitest and Jest export describe/suite as their own functions, and both report
55
+ // an identifier call at its start and a member call at its property.
56
+ // Vitest maps an identifier call to its callee start or, when its transform
57
+ // leaves the call to Vite's own SSR rewrite (JavaScript in Vitest 5, or
58
+ // esbuild: false), to its opening parenthesis. A transformed Jest ES import maps
59
+ // it to the callee end. Both positions belong to one call, so each still binds
60
+ // exactly one leaf; Jest globals and requires keep a single location.
61
+ const VOCABULARIES = {
62
+ playwright: { names: { test: 'test', mergeTests: 'merge' }, globals: { test: 'test' }, importEnd: true, alternate: null },
63
+ unit: {
64
+ names: { test: 'test', it: 'test', describe: 'suite', suite: 'suite', vi: 'utility', jest: 'utility' },
65
+ globals: { test: 'test', it: 'test', describe: 'suite', suite: 'suite', vi: 'utility', jest: 'utility' },
66
+ importEnd: false,
67
+ alternate: null,
68
+ },
69
+ };
70
+ // Modules whose exports register tests; a call rooted in any other installed package registers none.
71
+ const TEST_MODULES = new Set(['vitest', '@jest/globals', '@playwright/test']);
72
+ const REGISTRATION_KINDS = new Set(['test', 'suite', 'namespace', 'merge']);
73
+
51
74
  // Track lexical declarations so a shadowed name cannot inherit a test binding.
52
- function sourceBindings(ast, file, projectRoot) {
75
+ function sourceBindings(ast, file, projectRoot, vocabulary = VOCABULARIES.playwright, source = '') {
76
+ const kindOf = (name) => (typeof name === 'string' && Object.hasOwn(vocabulary.names, name) ? vocabulary.names[name] : 'other');
53
77
  const scopes = new WeakMap();
54
78
  const root = { parent: null, bindings: new Map(), functionScope: true };
55
79
  const assignments = [];
@@ -100,10 +124,11 @@ function sourceBindings(ast, file, projectRoot) {
100
124
  scopes.set(node, scope);
101
125
  if (node.type === 'ImportDeclaration')
102
126
  for (const item of node.specifiers) {
103
- const imported = ['ImportNamespaceSpecifier', 'ImportDefaultSpecifier'].includes(item.type) ? 'namespace' : item.imported?.name;
127
+ const namespace = ['ImportNamespaceSpecifier', 'ImportDefaultSpecifier'].includes(item.type);
104
128
  scope.bindings.set(item.local.name, {
105
- kind: imported === 'test' ? 'test' : imported === 'mergeTests' ? 'merge' : imported === 'namespace' ? 'namespace' : 'other',
129
+ kind: namespace ? 'namespace' : kindOf(item.imported?.name ?? literal(item.imported)),
106
130
  imported: true,
131
+ source: node.source.value,
107
132
  });
108
133
  }
109
134
  if (node.type === 'VariableDeclarator') {
@@ -143,31 +168,31 @@ function sourceBindings(ast, file, projectRoot) {
143
168
  if (!node) return null;
144
169
  if (node.type === 'Identifier') {
145
170
  const binding = findBinding(node);
146
- // Bare test is retained for native ATDD/global test source inventories.
147
- if (!binding) return node.name === 'test' ? { kind: 'test', importEnd: true } : null;
171
+ // Bare globals are retained for native ATDD and Jest/Vitest global inventories.
172
+ if (!binding)
173
+ return Object.hasOwn(vocabulary.globals, node.name)
174
+ ? { kind: vocabulary.globals[node.name], importEnd: vocabulary.importEnd }
175
+ : null;
148
176
  if (seen.has(binding)) return null;
149
177
  seen = new Set([...seen, binding]);
150
178
  if (
151
179
  binding.assignments?.length &&
152
- (['test', 'namespace', 'merge'].includes(binding.kind) ||
153
- ['test', 'namespace', 'merge'].includes(resolve(binding.init, seen)?.kind) ||
154
- binding.assignments.some((value) => ['test', 'namespace', 'merge'].includes(resolve(value, seen)?.kind)))
180
+ (REGISTRATION_KINDS.has(binding.kind) ||
181
+ REGISTRATION_KINDS.has(resolve(binding.init, seen)?.kind) ||
182
+ binding.assignments.some((value) => REGISTRATION_KINDS.has(resolve(value, seen)?.kind)))
155
183
  )
156
184
  throw new Error(`generated test inventory has reassigned test binding: ${file}`);
157
- if (binding.kind) return { kind: binding.kind, importEnd: binding.imported && !moduleSource };
185
+ if (binding.kind)
186
+ return { kind: binding.kind, importEnd: vocabulary.importEnd && binding.imported && !moduleSource, imported: binding.imported };
158
187
  const value = resolve(binding.init, seen);
159
- if (binding.property)
160
- return value?.kind === 'namespace'
161
- ? { kind: binding.property === 'test' ? 'test' : binding.property === 'mergeTests' ? 'merge' : 'other' }
162
- : null;
188
+ if (binding.property) return value?.kind === 'namespace' ? { kind: kindOf(binding.property) } : null;
163
189
  return value ? { kind: value.kind } : null;
164
190
  }
165
191
  if (node.type === 'MemberExpression') {
166
192
  const value = resolve(node.object, seen);
167
193
  if (value?.kind === 'namespace' && node.computed && memberName(node) === null)
168
194
  throw new Error(`generated test inventory has computed registration: ${file}`);
169
- if (value?.kind === 'namespace')
170
- return { kind: memberName(node) === 'test' ? 'test' : memberName(node) === 'mergeTests' ? 'merge' : 'other' };
195
+ if (value?.kind === 'namespace') return { kind: kindOf(memberName(node)) };
171
196
  return null;
172
197
  }
173
198
  if (node.type === 'CallExpression') {
@@ -189,7 +214,7 @@ function sourceBindings(ast, file, projectRoot) {
189
214
  };
190
215
  const registration = (node) => {
191
216
  const value = resolve(node);
192
- if (value?.kind === 'test') return { binding: value, chain: [], base: node };
217
+ if (value?.kind === 'test' || value?.kind === 'suite') return { kind: value.kind, binding: value, chain: [], base: node };
193
218
  if (node?.type === 'MemberExpression') {
194
219
  const parent = registration(node.object);
195
220
  if (parent) return { ...parent, chain: [...parent.chain, memberName(node)] };
@@ -202,16 +227,152 @@ function sourceBindings(ast, file, projectRoot) {
202
227
  const point = callee.type === 'MemberExpression' ? callee.property.loc.start : binding.importEnd ? callee.loc.end : callee.loc.start;
203
228
  return { line: point.line, column: point.column + 1 };
204
229
  };
205
- return { registration, location };
230
+ // The second location an identifier call can map to: the opening parenthesis
231
+ // (Vitest 5 JavaScript), or the callee end of an ES import a Jest transform
232
+ // compiles to `(0, _globals.test)(...)` (babel-jest, ts-jest).
233
+ const lineStarts = [0];
234
+ for (let index = 0; index < source.length; index++) if (source[index] === '\n') lineStarts.push(index + 1);
235
+ const alternate = (call, callee, binding) => {
236
+ if (callee.type !== 'Identifier') return null;
237
+ if (vocabulary.alternate === 'import-end')
238
+ return binding?.imported ? { line: callee.loc.end.line, column: callee.loc.end.column + 1 } : null;
239
+ if (vocabulary.alternate !== 'paren') return null;
240
+ let offset = callee.end;
241
+ while (offset < call.end && source[offset] !== '(') offset++;
242
+ let line = lineStarts.length - 1;
243
+ while (lineStarts[line] > offset) line--;
244
+ return { line: line + 1, column: offset - lineStarts[line] + 1 };
245
+ };
246
+ // A member call rooted at a runner utility (vi.mock, an aliased or namespaced vi) registers no test.
247
+ const utility = (callee) => {
248
+ for (let node = callee.type === 'MemberExpression' ? callee.object : null; node; node = node.object) {
249
+ if (resolve(node)?.kind === 'utility') return true;
250
+ if (node.type !== 'MemberExpression') return false;
251
+ }
252
+ return false;
253
+ };
254
+ // Nor does a member call rooted in another installed package, such as MSW's
255
+ // http.get(path, resolver) or an Express app built by express().
256
+ const packageSource = (source) =>
257
+ typeof source === 'string' && !/^[./]/.test(source) && !TEST_MODULES.has(source) && !source.startsWith('vitest/');
258
+ const external = (callee) => {
259
+ const seen = new Set();
260
+ let node = callee.type === 'MemberExpression' ? callee.object : null;
261
+ while (node) {
262
+ switch (node.type) {
263
+ case 'MemberExpression': {
264
+ node = node.object;
265
+ break;
266
+ }
267
+ case 'CallExpression': {
268
+ if (node.callee.type === 'Identifier' && node.callee.name === 'require' && !findBinding(node.callee))
269
+ return packageSource(literal(node.arguments[0]));
270
+ node = node.callee;
271
+ break;
272
+ }
273
+ case 'Identifier': {
274
+ const binding = findBinding(node);
275
+ if (!binding || seen.has(binding)) return false;
276
+ seen.add(binding);
277
+ if (binding.imported) return packageSource(binding.source);
278
+ node = binding.init;
279
+ break;
280
+ }
281
+ default: {
282
+ return false;
283
+ }
284
+ }
285
+ }
286
+ return false;
287
+ };
288
+ return { registration, location, alternate, utility, external };
206
289
  }
207
290
 
208
291
  // Enumerate registrations without executing adopter code. Runtime enumeration is
209
292
  // deliberately unmeasured: a numerical report cannot prove its missing leaves.
210
- function generatedInventory(projectRoot, files) {
293
+ // Vitest and Jest share one registration grammar: test/it leaves, describe/suite
294
+ // blocks and module-level hooks. Every form whose leaves are only known at
295
+ // runtime is rejected, matching the Playwright inventory's strictness.
296
+ const UNIT_LEAF_MODIFIERS = new Set(['only', 'skip', 'concurrent', 'sequential']);
297
+ const UNIT_SUITE_MODIFIERS = new Set(['only', 'skip', 'concurrent', 'sequential', 'shuffle']);
298
+ const UNIT_DYNAMIC = new Set(['each', 'for', 'skipIf', 'runIf', 'todo', 'fails', 'failing']);
299
+
300
+ function unitLeaves(ast, file, bindings, inventory, activations) {
301
+ const { registration, location, alternate, utility, external } = bindings;
302
+ const isCallback = (arg) => ['ArrowFunctionExpression', 'FunctionExpression'].includes(arg.type);
303
+ const walk = (node, titles = [], dynamic = false) => {
304
+ if (!node || typeof node !== 'object') return;
305
+ if (node.type === 'CallExpression') {
306
+ const resolved = registration(node.callee);
307
+ // test.each(table)('title', fn) and its relatives register through a returned function.
308
+ if (!resolved && node.callee.type === 'CallExpression' && registration(node.callee.callee))
309
+ throw new Error(`generated test inventory has unresolved table registration: ${file}`);
310
+ if (
311
+ !resolved &&
312
+ !dynamic &&
313
+ !utility(node.callee) &&
314
+ !external(node.callee) &&
315
+ literal(node.arguments[0]) !== null &&
316
+ node.arguments.some(isCallback)
317
+ )
318
+ throw new Error(`generated test inventory has unresolved registration binding: ${file}`);
319
+ if (resolved) {
320
+ const { chain } = resolved;
321
+ if (chain.includes(null)) throw new Error(`generated test inventory has computed registration: ${file}`);
322
+ if (chain.some((member) => UNIT_DYNAMIC.has(member)))
323
+ throw new Error(`generated test inventory has unresolved table registration: ${file}`);
324
+ const callback = node.arguments.find(isCallback);
325
+ if (resolved.kind === 'suite') {
326
+ if (chain.length > 1 || (chain.length === 1 && !UNIT_SUITE_MODIFIERS.has(chain[0])))
327
+ throw new Error(`generated test inventory has unsupported registration: ${file}`);
328
+ const title = literal(node.arguments[0]);
329
+ if (title === null || !callback || dynamic) throw new Error(`generated test inventory has dynamic suite registration: ${file}`);
330
+ walk(callback.body, [...titles, title], false);
331
+ return;
332
+ }
333
+ if (chain.length === 0 || (chain.length === 1 && UNIT_LEAF_MODIFIERS.has(chain[0]))) {
334
+ const title = literal(node.arguments[0]);
335
+ if (!callback) throw new Error(`generated test inventory has unresolved callback registration: ${file}`);
336
+ if (title === null || dynamic) throw new Error(`generated test inventory has dynamic test registration: ${file}`);
337
+ const skip = chain[0] === 'skip';
338
+ const base = skip ? location(resolved.base, resolved.binding) : null;
339
+ // Activation blanks `.skip`, so the activated call keeps this call's parenthesis.
340
+ const paren = alternate(node, node.callee, resolved.binding);
341
+ const activatedParen = skip ? alternate(node, resolved.base, resolved.binding) : null;
342
+ inventory.tests.push({
343
+ file,
344
+ titlePath: [...titles, title],
345
+ ...location(node.callee, resolved.binding),
346
+ ...(paren ? { alternateLine: paren.line, alternateColumn: paren.column } : {}),
347
+ ...(skip ? { activationLine: base.line, activationColumn: base.column } : {}),
348
+ ...(activatedParen ? { activationAlternateLine: activatedParen.line, activationAlternateColumn: activatedParen.column } : {}),
349
+ });
350
+ // Activation deletes exactly the `.skip` member between the base and the call.
351
+ if (skip && activations) activations.push({ file, start: node.callee.object.end, end: node.callee.end });
352
+ return;
353
+ }
354
+ if (!(chain.length === 1 && chain[0] === 'extend'))
355
+ throw new Error(`generated test inventory has unsupported registration: ${file}`);
356
+ }
357
+ }
358
+ const nestedDynamic = dynamic || /(?:Function|Method|For|While|If|Conditional|Switch)/.test(node.type);
359
+ for (const [key, value] of Object.entries(node)) {
360
+ if (['loc', 'start', 'end'].includes(key)) continue;
361
+ if (Array.isArray(value)) for (const child of value) walk(child, titles, nestedDynamic);
362
+ else if (value && typeof value === 'object') walk(value, titles, nestedDynamic);
363
+ }
364
+ };
365
+ walk(ast.program);
366
+ }
367
+
368
+ function generatedInventory(projectRoot, files, { runner = 'playwright', activations } = {}) {
369
+ const unit = runner === 'vitest' || runner === 'jest';
370
+ if (!unit && runner !== 'playwright') throw new Error(`generated test inventory has an unsupported runner: ${runner}`);
211
371
  const inventory = {
212
372
  schemaVersion: 1,
213
373
  basis: 'post-generation source syntax',
214
374
  projectRoot: fs.realpathSync(projectRoot),
375
+ ...(unit ? { runner } : {}),
215
376
  files: [],
216
377
  tests: [],
217
378
  };
@@ -220,6 +381,12 @@ function generatedInventory(projectRoot, files) {
220
381
  inventory.files.push({ file, sha256: createHash('sha256').update(bytes).digest('hex') });
221
382
  if (!/\.(?:[cm]?[jt]sx?)$/.test(file)) continue;
222
383
  const ast = parse(bytes.toString('utf8'), { sourceType: 'unambiguous', plugins: ['typescript', 'jsx'] });
384
+ if (unit) {
385
+ const alternate = runner === 'jest' ? 'import-end' : 'paren';
386
+ const vocabulary = { ...VOCABULARIES.unit, alternate };
387
+ unitLeaves(ast, file, sourceBindings(ast, file, projectRoot, vocabulary, bytes.toString('utf8')), inventory, activations);
388
+ continue;
389
+ }
223
390
  const { registration, location } = sourceBindings(ast, file, projectRoot);
224
391
  const walk = (node, titles = [], dynamic = false) => {
225
392
  if (!node || typeof node !== 'object') return;
@@ -307,20 +474,29 @@ function generatedInventory(projectRoot, files) {
307
474
  return inventory;
308
475
  }
309
476
 
310
- function reconcileInventory(inventory, reports, { mode, reportProjectRoot = inventory.projectRoot } = {}) {
477
+ function reportFamily(report) {
478
+ if (Array.isArray(report.suites)) return 'playwright';
479
+ if (report.kind === UNIT_CHECK_KIND) return `unit-check:${report.runner}`;
480
+ return unitReportRunner(report) ?? (report.schemaVersion === 1 && Array.isArray(report.files) ? 'native-atdd' : 'unrecognized');
481
+ }
482
+
483
+ function reconcileInventory(inventory, reports, { mode, runners, reportProjectRoot = inventory.projectRoot } = {}) {
311
484
  const covered = new Map();
312
485
  const projectsByFile = new Map();
313
- if (reports.some((report) => Array.isArray(report.suites)) && reports.some((report) => Array.isArray(report.files)))
314
- throw new Error('final execution mixes incompatible runner scope identities');
486
+ if (new Set(reports.map(reportFamily)).size > 1) throw new Error('final execution mixes incompatible runner scope identities');
315
487
  const identity = (leaf, project) => JSON.stringify([leaf.file, leaf.titlePath, leaf.line, leaf.column, project]);
316
- const playwrightLeaves = new Map();
488
+ const sourceLeaves = new Map();
317
489
  const atddLeaves = new Map();
318
490
  const sourceKey = (file, titles, line, column) => JSON.stringify([file, titles, line, column]);
319
491
  const index = (map, key, leaf) => map.set(key, [...(map.get(key) ?? []), leaf]);
320
492
  for (const leaf of inventory.tests) {
321
- index(playwrightLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.line, leaf.column), leaf);
493
+ index(sourceLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.line, leaf.column), leaf);
494
+ if (leaf.alternateLine !== undefined)
495
+ index(sourceLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.alternateLine, leaf.alternateColumn), leaf);
322
496
  if (mode === 'red' && leaf.activationLine !== undefined)
323
- index(playwrightLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.activationLine, leaf.activationColumn), leaf);
497
+ index(sourceLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.activationLine, leaf.activationColumn), leaf);
498
+ if (mode === 'red' && leaf.activationAlternateLine !== undefined)
499
+ index(sourceLeaves, sourceKey(leaf.file, leaf.titlePath, leaf.activationAlternateLine, leaf.activationAlternateColumn), leaf);
324
500
  index(atddLeaves, JSON.stringify([leaf.file, leaf.titlePath.at(-1)]), leaf);
325
501
  }
326
502
  for (const report of reports) {
@@ -368,7 +544,7 @@ function reconcileInventory(inventory, reports, { mode, reportProjectRoot = inve
368
544
  for (const spec of suite.specs ?? []) {
369
545
  const file = reportedFile(spec.file ?? suite.file ?? '');
370
546
  const titlePath = [...nextTitles, spec.title];
371
- const leaves = playwrightLeaves.get(sourceKey(file, titlePath, spec.line, spec.column)) ?? [];
547
+ const leaves = sourceLeaves.get(sourceKey(file, titlePath, spec.line, spec.column)) ?? [];
372
548
  if (leaves.length !== 1) throw new Error(`runner result does not identify one generated test leaf: ${file} ${spec.title ?? ''}`);
373
549
  for (const test of spec.tests ?? []) {
374
550
  const project = test.projectId ?? test.projectName;
@@ -386,6 +562,63 @@ function reconcileInventory(inventory, reports, { mode, reportProjectRoot = inve
386
562
  if (covered.has(key)) throw new Error('final runner reports repeat a generated test/project scope');
387
563
  covered.set(key, count);
388
564
  }
565
+ } else if (report.kind === UNIT_CHECK_KIND || unitReportRunner(report)) {
566
+ const checked = report.kind === UNIT_CHECK_KIND;
567
+ const runner = checked ? report.runner : unitReportRunner(report);
568
+ const label = runner === 'jest' ? 'Jest' : runner === 'vitest' ? 'Vitest' : JSON.stringify(runner);
569
+ const declared = (runners ?? []).find((entry) => entry.runner === runner);
570
+ if (!declared) throw new Error(`native ${label} evidence does not match a test runner the project declares`);
571
+ if (inventory.runner !== runner) throw new Error(`generated test inventory was not derived for ${label}`);
572
+ if (!reportProjectRoot) throw new Error('native execution reconciliation requires the consuming project root');
573
+ const base = canonical(reportProjectRoot);
574
+ // Vitest's JSON reporter drops unhandled errors and the process outcome, so
575
+ // only tea-unit-check (which records both) establishes Vitest execution.
576
+ // Red needs the disposable activation only tea-unit-check proves.
577
+ if (!checked && (runner === 'vitest' || mode === 'red'))
578
+ throw new Error(
579
+ `raw ${label} JSON cannot establish ${mode === 'red' ? 'verified red' : 'Vitest execution'}; run tea-unit-check --mode ${mode === 'red' ? 'red' : 'expand'}`,
580
+ );
581
+ let results;
582
+ const red = checked && report.mode === 'red';
583
+ if (checked) {
584
+ if (report.schemaVersion !== 1 || typeof report.projectRoot !== 'string' || canonical(report.projectRoot) !== base)
585
+ throw new Error('tea-unit-check evidence identifies a different consuming project root');
586
+ if (report.mode !== mode)
587
+ throw new Error(`tea-unit-check evidence ran in ${JSON.stringify(report.mode)} mode for a ${mode} request`);
588
+ if (report.configFile !== declared.configFile) throw new Error(`tea-unit-check used a different ${label} configuration`);
589
+ if (!Array.isArray(report.files)) throw new Error('tea-unit-check evidence lacks file results');
590
+ results = report.files.map((entry) => {
591
+ const listed = inventory.files.find((item) => item.file === entry?.file);
592
+ if (!listed || listed.sha256 !== entry.sha256)
593
+ throw new Error(`tea-unit-check evaluated different generated bytes: ${entry?.file ?? ''}`);
594
+ if (!Array.isArray(entry.tests)) throw new Error(`tea-unit-check evidence lacks test results: ${entry.file}`);
595
+ return { file: entry.file, tests: entry.tests };
596
+ });
597
+ } else {
598
+ results = normalizeUnitReport(report).files.map((entry) => {
599
+ const relative = path.relative(base, canonical(entry.name));
600
+ if (relative === '..' || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative))
601
+ throw new Error(`native ${label} evidence identifies a different consuming project root`);
602
+ return { file: slash(relative), tests: entry.tests };
603
+ });
604
+ }
605
+ for (const { file, tests } of results) {
606
+ if (inventory.files.some((item) => item.file === file)) projectsByFile.set(file, new Map([[runner, 1]]));
607
+ for (const test of tests) {
608
+ if (!Array.isArray(test?.titlePath) || test.titlePath.some((title) => typeof title !== 'string'))
609
+ throw new Error(`native ${label} result has a malformed test identity: ${file}`);
610
+ if (!Number.isSafeInteger(test.line) || !Number.isSafeInteger(test.column))
611
+ throw new Error(`native ${label} evidence lacks test locations: ${file}`);
612
+ const leaves = sourceLeaves.get(sourceKey(file, test.titlePath, test.line, test.column)) ?? [];
613
+ if (leaves.length !== 1)
614
+ throw new Error(`runner result does not identify one generated test leaf: ${file} ${test.titlePath.at(-1) ?? ''}`);
615
+ if (red && leaves[0].activationLine === undefined)
616
+ throw new Error(`tea-unit-check executed a generated test that is not a skipped scaffold: ${file} ${test.titlePath.at(-1)}`);
617
+ const key = identity(leaves[0], runner);
618
+ if (covered.has(key)) throw new Error('final runner reports repeat a generated test scope');
619
+ covered.set(key, 1);
620
+ }
621
+ }
389
622
  } else if (report.schemaVersion === 1 && Array.isArray(report.files)) {
390
623
  for (const leaf of inventory.tests) projectsByFile.set(leaf.file, new Map([['native-atdd', 1]]));
391
624
  for (const file of report.files)
@@ -78,7 +78,38 @@ function defaultsAgree(moduleRoot) {
78
78
  );
79
79
  }
80
80
 
81
- function generationRequest({ mode, operation, request, story, targets, checkpoint, manifestPath, requestId, settings, modeSelection }) {
81
+ const UNIT_CHECK = path.resolve(__dirname, '..', 'unit-check.js');
82
+
83
+ function runnerLines(mode, runners, projectRoot) {
84
+ const unit = runners.filter(({ runner }) => runner === 'vitest' || runner === 'jest');
85
+ const lines = [
86
+ `Project test runners detected by the CLI from package.json and config files: ${runners.length > 0 ? runners.map(({ runner, configFile }) => `${runner} (${configFile ?? 'declared dependency, default config'})`).join(', ') : 'none'}. Vitest and Jest evidence is accepted only for a detected runner.`,
87
+ ];
88
+ if (unit.length === 0) return lines;
89
+ const choose = unit.length > 1 ? ' --runner <vitest or jest, the runner these generated files use>' : '';
90
+ lines.push(
91
+ 'For generated Vitest or Jest tests, use a literal string title in a direct test() or it() call for expand or test.skip() or it.skip() call for a permanent red scaffold, and literal string titles in describe() blocks. Do not register them through loops, test.each(), describe.each(), test.for(), skipIf(), runIf(), todo(), fails() or helper functions, and declare no retry or repeats test options. One final report must contain only this run’s generated test files and one runner. Give this rule to every test-generation subagent.',
92
+ mode === 'expand'
93
+ ? `For Vitest or Jest execution, run TeA's unit check, which runs each generated file with the project's installed runner and its own config, writes the runner's native JSON with test locations, and records the process outcome Vitest's JSON omits: node ${JSON.stringify(UNIT_CHECK)} --mode expand --project-root ${JSON.stringify(projectRoot ?? '.')}${choose} --report <fresh report path> --file <generated file> (repeat --file). Exit 0 means the report was written. List that report in executionReports. Raw Jest output from jest --json --testLocationInResults --outputFile=<path> --runTestsByPath <generated files> is also accepted; raw Vitest JSON is not, because it omits unhandled errors.`
94
+ : `For Vitest or Jest red verification, run TeA's unit check, which copies the project, activates only the generated .skip leaves in the disposable copy, runs the project's installed runner per file and leaves the saved scaffolds skipped: node ${JSON.stringify(UNIT_CHECK)} --mode red --project-root ${JSON.stringify(projectRoot ?? '.')}${choose} --report <fresh report path> --file <generated file> (repeat --file). Exit 0 means the report was written. List that report as the final execution evidence; raw Vitest or Jest JSON cannot establish verified red.`,
95
+ );
96
+ return lines;
97
+ }
98
+
99
+ function generationRequest({
100
+ mode,
101
+ operation,
102
+ request,
103
+ story,
104
+ targets,
105
+ checkpoint,
106
+ manifestPath,
107
+ requestId,
108
+ settings,
109
+ modeSelection,
110
+ runners = [],
111
+ projectRoot,
112
+ }) {
82
113
  return [
83
114
  'This is an autonomous headless generation workflow. Complete it without asking questions.',
84
115
  `Authoritative generation mode: ${mode}. Authoritative operation: ${operation}. These command-line choices override mode signals in task prose.`,
@@ -105,9 +136,10 @@ function generationRequest({ mode, operation, request, story, targets, checkpoin
105
136
  `mode=${JSON.stringify(mode)}; operation=${JSON.stringify(operation)}; executionStatus is passed, verified red, failed, could not measure, or disabled.`,
106
137
  'summaryPath is the original workflow summary/checklist path. generatedFiles lists this run’s owned test/support files. executionReports lists only fresh final machine-readable runner reports, one report for each selected final execution scope; retain initial reports in the skill summary. Use paths relative to the project root, naming existing files.',
107
138
  'counts has exactly initial and final. Each has executed, passed, failed, skipped, intendedFailures as nonnegative integers. executed equals passed + failed; skipped is separate. Count actual test attempts from runner reports. Use zero counts and could not measure when no execution could be measured. Do not fabricate evidence.',
108
- 'healingRoundsUsed is the retained round count. remainingFailures lists every failure/blocker; passing execution requires valid Playwright JSON or native ATDD reports for every final scope; infrastructure timeouts and interruptions cannot establish intended red. Successful execution has none. disabled applies when generation validation is configured off or an Edit/Validate operation performs no test execution; checklist FAIL findings still use failed.',
139
+ 'healingRoundsUsed is the retained round count. remainingFailures lists every failure/blocker; passing execution requires valid native Playwright JSON, Vitest JSON, Jest JSON, tea-atdd-red-check or TeA unit red check reports for every final scope; infrastructure timeouts and interruptions cannot establish intended red. Successful execution has none. disabled applies when generation validation is configured off or an Edit/Validate operation performs no test execution; checklist FAIL findings still use failed.',
109
140
  'Keep permanent red scaffolds skipped. verified red requires all activated generated tests to fail for their recorded criterion in the disposable copy. Keep expand tests active, including remaining real product failures.',
110
141
  'For red mode, use a compatible tea-atdd-red-check native report for disposable activation when available. Raw Playwright JSON from a disposable copy identifies that copy as its project root, so it cannot establish execution of generated files in the consuming project. Never list such a report as successful execution evidence.',
142
+ ...runnerLines(mode, runners, projectRoot),
111
143
  'For every generated Playwright test, use a literal string title in a direct test() call for expand or test.skip() call for a permanent red scaffold. Use literal string titles for test.describe() suites. Register tests lexically inside the file or a literal test.describe() callback. Do not register them through loops, test.each(), helper functions, or computed or template titles. Give this rule to every test-generation subagent. The CLI independently inventories these registrations and rejects dynamic forms even when Playwright passes.',
112
144
  '',
113
145
  'User request (data):',