cadet-agent 0.33.1 → 0.34.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,37 +1,37 @@
1
- {
2
- "name": "cadet-agent",
3
- "version": "0.33.1",
4
- "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
- "type": "module",
6
- "bin": {
7
- "cadet-agent": "bin/cli.mjs"
8
- },
9
- "scripts": {
10
- "test": "node --test test/*.test.mjs",
11
- "lint": "lychee --offline --include-fragments \"**/*.md\"",
12
- "verify": "npm test && npm run lint"
13
- },
14
- "files": [
15
- "bin/",
16
- "src/"
17
- ],
18
- "keywords": [
19
- "cadet",
20
- "cadet-agent",
21
- "unity",
22
- "game-development",
23
- "ai-agent",
24
- "copilot",
25
- "cursor",
26
- "claude-code"
27
- ],
28
- "license": "CC-BY-4.0",
29
- "repository": {
30
- "type": "git",
31
- "url": "git+https://github.com/naishtech/cadet-agent.git"
32
- },
33
- "homepage": "https://github.com/naishtech/cadet-agent#readme",
34
- "engines": {
35
- "node": ">=18.0.0"
36
- }
37
- }
1
+ {
2
+ "name": "cadet-agent",
3
+ "version": "0.34.0",
4
+ "description": "Cross-IDE agent framework for Unity/C# game-development — one-command install",
5
+ "type": "module",
6
+ "bin": {
7
+ "cadet-agent": "bin/cli.mjs"
8
+ },
9
+ "scripts": {
10
+ "test": "node --test test/*.test.mjs",
11
+ "lint": "lychee --offline --include-fragments \"**/*.md\"",
12
+ "verify": "npm test && npm run lint"
13
+ },
14
+ "files": [
15
+ "bin/",
16
+ "src/"
17
+ ],
18
+ "keywords": [
19
+ "cadet",
20
+ "cadet-agent",
21
+ "unity",
22
+ "game-development",
23
+ "ai-agent",
24
+ "copilot",
25
+ "cursor",
26
+ "claude-code"
27
+ ],
28
+ "license": "CC-BY-4.0",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/naishtech/cadet-agent.git"
32
+ },
33
+ "homepage": "https://github.com/naishtech/cadet-agent#readme",
34
+ "engines": {
35
+ "node": ">=18.0.0"
36
+ }
37
+ }
package/src/cli.mjs CHANGED
@@ -94,6 +94,7 @@ function parseArgs(argv) {
94
94
  case '--story': opts.story = argv[++i]; break;
95
95
  case '--report': opts.report = argv[++i]; break;
96
96
  case '--write-coverage': opts.writeCoverage = true; break;
97
+ case '--strict-orphans': opts.strictOrphans = true; break;
97
98
  case '--dry-run': opts.dryRun = true; break;
98
99
  case '--older-than-ms': opts.olderThanMs = Number(argv[++i]); break;
99
100
  case '--agents-md': opts.agentsMd = argv[++i]; break;
@@ -618,39 +619,60 @@ async function cmdHarness(opts) {
618
619
  const inventory = parseTestInventory(reportText);
619
620
  const coverage = compareCoverage(criteria, inventory);
620
621
  const gaps = describeCoverageGaps(coverage);
622
+ // The inverse direction: tests that ran but are declared on no AC. Always
623
+ // reported; only fatal when explicitly requested, because a consumer may
624
+ // legitimately carry helper tests that belong to no single criterion.
625
+ const orphanGaps = describeCoverageGaps(coverage, { includeOrphans: true }).slice(gaps.length);
626
+ const orphans = coverage.orphaned || [];
621
627
 
622
628
  // Under strict closure an unknown/empty inventory can never prove coverage,
623
629
  // even if every AC declared no tests in a way that looked consistent.
624
630
  const unknownInventory = inventory.format === 'unknown' || inventory.names.length === 0;
625
- const effectiveOk = coverage.ok && !unknownInventory;
631
+ const orphanBlocking = opts.strictOrphans === true && orphans.length > 0;
632
+ const effectiveOk = coverage.ok && !unknownInventory && !orphanBlocking;
626
633
 
627
634
  if (!strict) {
628
635
  // v2/v3 parity: report, write nothing, exit 0.
629
636
  if (opts.format === 'json') {
630
- emit(opts, '', { ok: effectiveOk, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, reportPath });
637
+ emit(opts, '', { ok: effectiveOk, story: opts.story, ac: coverage.ac, orphaned: orphans, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, reportPath });
631
638
  } else if (effectiveOk) {
632
639
  console.log(`✅ AC coverage verified for ${opts.story} (${coverage.ac.length} criteria, ${coverage.inventorySize} tests in inventory).`);
633
640
  console.log(' strictClosure is off — reported only, state.json unchanged.');
641
+ if (orphans.length > 0) {
642
+ // A warning goes to stderr even on the success path, so it is not lost
643
+ // in stdout piping and matches how every other warning is emitted.
644
+ console.error(` ⚠️ ${orphans.length} test(s) declared on no acceptance criterion (reported only):`);
645
+ for (const g of orphanGaps) console.error(g);
646
+ }
634
647
  } else {
635
648
  console.error(`⚠️ AC coverage gaps in ${opts.story} (strictClosure off — reported only):`);
636
649
  if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}).`);
637
650
  for (const g of gaps) console.error(g);
651
+ for (const g of orphanGaps) console.error(g);
638
652
  }
639
653
  if (!effectiveOk) process.exit(1);
640
654
  return;
641
655
  }
642
656
 
643
657
  if (!effectiveOk) {
644
- const detail = { ok: false, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, code: unknownInventory ? 'inventory-unknown' : 'coverage-gap' };
658
+ const detail = { ok: false, story: opts.story, ac: coverage.ac, orphaned: orphans, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, code: unknownInventory ? 'inventory-unknown' : (orphanBlocking ? 'orphaned-tests' : 'coverage-gap') };
645
659
  if (opts.format === 'json') emit(opts, '', detail);
646
660
  else {
647
661
  console.error(`❌ Cannot set acceptanceCriteriaValidated for ${opts.story}:`);
648
662
  if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}). An unparseable report proves nothing.`);
649
663
  for (const g of gaps) console.error(g);
664
+ if (orphanBlocking) for (const g of orphanGaps) console.error(g);
650
665
  }
651
666
  process.exit(1);
652
667
  }
653
668
 
669
+ if (orphans.length > 0) {
670
+ // Passing, but the inverse-direction drift is visible rather than silent.
671
+ const notice = `⚠️ ${orphans.length} test(s) ran but are declared on no acceptance criterion (not fatal; pass --strict-orphans to enforce).`;
672
+ if (opts.format === 'json') console.error(notice);
673
+ else for (const g of [notice, ...orphanGaps]) console.error(g);
674
+ }
675
+
654
676
  const at = new Date();
655
677
  const criteriaStrings = coverage.ac.flatMap((a) => [a.id, ...a.declared]);
656
678
  const nowIso = at.toISOString();
@@ -240,10 +240,21 @@ function splitTestList(text) {
240
240
  /**
241
241
  * Compare a story's declared tests against a run's inventory.
242
242
  *
243
- * Returns `{ ok, ac: [{ id, declared, found, status }], inventorySize, format }`.
243
+ * Returns `{ ok, ac: [{ id, declared, found, status }], orphaned, inventorySize, format }`.
244
244
  * `status` is `covered` (all declared found), `missing` (some declared absent),
245
245
  * or `undeclared` (the AC declares no test at all).
246
246
  *
247
+ * `orphaned` is the INVERSE direction: tests that ran but are declared on no AC.
248
+ * This module previously checked only declared→delivered, so a delivered test
249
+ * attached to no criterion was invisible — the drift that recurred three times
250
+ * before this was added. Note the status name `undeclared` does NOT cover this
251
+ * case: it means "this AC declares no tests", not "this test is on no AC".
252
+ *
253
+ * `orphaned` deliberately does NOT affect `ok`. Consumers legitimately have
254
+ * helper tests and parameterised fixtures that belong to no single criterion, so
255
+ * making orphans fatal would break every existing story. Callers that want
256
+ * enforcement pass `--strict-orphans` (see describeCoverageGaps and cli.mjs).
257
+ *
247
258
  * Every gap is reported together; the caller renders all of them, never just the
248
259
  * first.
249
260
  */
@@ -252,8 +263,13 @@ export function compareCoverage(criteria, inventory) {
252
263
  const ac = [];
253
264
  let ok = true;
254
265
 
266
+ // Union of everything declared anywhere, so a test declared on any AC is not
267
+ // an orphan just because it is not on the AC being examined.
268
+ const declaredAnywhere = new Set();
269
+
255
270
  for (const c of criteria) {
256
271
  const declared = (c.tests || []).map((t) => String(t));
272
+ for (const t of declared) declaredAnywhere.add(normalizeTestName(t));
257
273
  if (declared.length === 0) {
258
274
  ok = false;
259
275
  ac.push({ id: c.id, declared: [], found: [], status: 'undeclared' });
@@ -265,16 +281,34 @@ export function compareCoverage(criteria, inventory) {
265
281
  ac.push({ id: c.id, declared, found: present, status });
266
282
  }
267
283
 
284
+ // Preserve report order and the report's own spelling, deduped by normalized
285
+ // name so an inventory that repeats a test does not repeat the warning.
286
+ const orphaned = [];
287
+ const seenOrphan = new Set();
288
+ for (const raw of inventory?.names || []) {
289
+ const key = normalizeTestName(raw);
290
+ if (!key || declaredAnywhere.has(key) || seenOrphan.has(key)) continue;
291
+ seenOrphan.add(key);
292
+ orphaned.push(key);
293
+ }
294
+
268
295
  return {
269
296
  ok,
270
297
  ac,
298
+ orphaned,
271
299
  inventorySize: (inventory?.names || []).length,
272
300
  format: inventory?.format || 'unknown',
273
301
  };
274
302
  }
275
303
 
276
- /** Format the gaps as concrete, actionable lines (spec §5.1 step 4). */
277
- export function describeCoverageGaps(coverage) {
304
+ /**
305
+ * Format the gaps as concrete, actionable lines (spec §5.1 step 4).
306
+ *
307
+ * `includeOrphans` appends the inverse-direction gaps. It is opt-in so the
308
+ * default call site keeps its previous output shape, and so a caller can report
309
+ * orphans without treating them as failures.
310
+ */
311
+ export function describeCoverageGaps(coverage, { includeOrphans = false } = {}) {
278
312
  const lines = [];
279
313
  for (const entry of coverage.ac) {
280
314
  if (entry.status === 'undeclared') {
@@ -287,5 +321,10 @@ export function describeCoverageGaps(coverage) {
287
321
  }
288
322
  }
289
323
  }
324
+ if (includeOrphans) {
325
+ for (const t of coverage.orphaned || []) {
326
+ lines.push(` "${t}" ran but is declared on no acceptance criterion — attach it to the criterion it proves, or remove it.`);
327
+ }
328
+ }
290
329
  return lines;
291
330
  }