cadet-agent 0.31.0 → 0.32.1
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 +37 -37
- package/src/cli.mjs +170 -2
- package/src/harness/index.mjs +6 -0
- package/src/harness/verify-acs.mjs +291 -0
package/package.json
CHANGED
|
@@ -1,37 +1,37 @@
|
|
|
1
|
-
{
|
|
2
|
-
"name": "cadet-agent",
|
|
3
|
-
"version": "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
|
-
}
|
|
1
|
+
{
|
|
2
|
+
"name": "cadet-agent",
|
|
3
|
+
"version": "0.32.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
|
+
}
|
package/src/cli.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from 'node:fs';
|
|
1
|
+
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
3
|
import { dirname, join } from 'node:path';
|
|
4
4
|
import { install, sync } from './install.mjs';
|
|
@@ -7,6 +7,8 @@ import {
|
|
|
7
7
|
workItemIdOf, loadPolicy, RunLedger, loadRun, listRuns, cleanupRuns, buildReport, formatReport,
|
|
8
8
|
runVerificationLoop, commandForGate, detectCapabilities, runsDir, gitChangedFiles, PolicyError, StateError,
|
|
9
9
|
detectRepoRole, describeRepoRole, GATES, manualConfirmation,
|
|
10
|
+
parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
|
|
11
|
+
createEvidence, newId, computeInputTreeHash, hashCriteria,
|
|
10
12
|
} from './harness/index.mjs';
|
|
11
13
|
|
|
12
14
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -41,6 +43,7 @@ function showHelp() {
|
|
|
41
43
|
cadet-agent harness record Append a sanitized span/evidence/decision event
|
|
42
44
|
cadet-agent harness confirm Record manual-confirmation evidence (writes ledger + state)
|
|
43
45
|
cadet-agent harness verify Run a bounded, classified verification loop
|
|
46
|
+
cadet-agent harness verify-acs Verify declared AC↔test coverage against a test report
|
|
44
47
|
cadet-agent harness report Summarize budget consumption and failures
|
|
45
48
|
cadet-agent harness cleanup Apply the retention policy to .cadet/runs/
|
|
46
49
|
cadet-agent harness capabilities Report available CLI/Unity/MCP/hook/token/cost telemetry
|
|
@@ -87,6 +90,9 @@ function parseArgs(argv) {
|
|
|
87
90
|
case '--scope': opts.scope = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
88
91
|
case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
|
|
89
92
|
case '--files': opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
93
|
+
case '--story': opts.story = argv[++i]; break;
|
|
94
|
+
case '--report': opts.report = argv[++i]; break;
|
|
95
|
+
case '--write-coverage': opts.writeCoverage = true; break;
|
|
90
96
|
case '--older-than-ms': opts.olderThanMs = Number(argv[++i]); break;
|
|
91
97
|
case '--agents-md': opts.agentsMd = argv[++i]; break;
|
|
92
98
|
case '--yes': case '-y': opts.yes = true; break;
|
|
@@ -537,6 +543,168 @@ async function cmdHarness(opts) {
|
|
|
537
543
|
return;
|
|
538
544
|
}
|
|
539
545
|
|
|
546
|
+
if (sub === 'verify-acs') {
|
|
547
|
+
// Mechanical AC↔test verification (contract v4). Declared tests must appear
|
|
548
|
+
// in the inventory of a run that actually executed them; a name that was
|
|
549
|
+
// never written cannot be asserted into coverage.
|
|
550
|
+
if (!opts.story) fail(opts, 'harness verify-acs requires --story <path>');
|
|
551
|
+
const { exists, state } = readState(opts.targetDir);
|
|
552
|
+
const strict = policy.strictClosure?.enabled === true;
|
|
553
|
+
const workItemId = state ? workItemIdOf(state) : 'unscoped';
|
|
554
|
+
const phase = state?.session?.currentPhase || 'implementation';
|
|
555
|
+
|
|
556
|
+
let criteria;
|
|
557
|
+
try {
|
|
558
|
+
({ criteria } = parseStoryCriteria(opts.story));
|
|
559
|
+
} catch (err) {
|
|
560
|
+
fail(opts, `cannot parse story "${opts.story}": ${err.message}`, () => 1, { ok: false, code: 'story-parse', story: opts.story });
|
|
561
|
+
}
|
|
562
|
+
if (criteria.length === 0) {
|
|
563
|
+
fail(opts, `story "${opts.story}" declares no acceptance criteria (expected a "## Acceptance Criteria" section).`, () => 1, { ok: false, code: 'no-criteria', story: opts.story });
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
// Resolve the inventory: an explicit --report, else the artifact of the most
|
|
567
|
+
// recent passing testsPassed evidence. Neither resolving is `blocked`, never
|
|
568
|
+
// a pass — an unproven inventory cannot satisfy coverage.
|
|
569
|
+
let reportText = null;
|
|
570
|
+
let reportSource = null;
|
|
571
|
+
let reportPath = null;
|
|
572
|
+
if (opts.report) {
|
|
573
|
+
try {
|
|
574
|
+
reportText = readFileSync(opts.report, 'utf-8');
|
|
575
|
+
reportSource = 'explicit';
|
|
576
|
+
reportPath = opts.report;
|
|
577
|
+
} catch (err) {
|
|
578
|
+
fail(opts, `cannot read --report "${opts.report}": ${err.message}`, () => 1, { ok: false, code: 'report-unreadable', report: opts.report });
|
|
579
|
+
}
|
|
580
|
+
} else if (exists) {
|
|
581
|
+
const prior = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
|
|
582
|
+
const passing = prior
|
|
583
|
+
.filter((e) => e.gate === 'testsPassed' && e.status === 'passed' && e.artifactPath)
|
|
584
|
+
.sort((a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt));
|
|
585
|
+
const newest = passing[0];
|
|
586
|
+
if (newest) {
|
|
587
|
+
try {
|
|
588
|
+
reportText = readFileSync(newest.artifactPath, 'utf-8');
|
|
589
|
+
reportSource = 'testsPassed-evidence';
|
|
590
|
+
reportPath = newest.artifactPath;
|
|
591
|
+
} catch { /* fall through to blocked */ }
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
if (reportText === null) {
|
|
595
|
+
const detail = { ok: false, story: opts.story, blocked: true, code: 'no-test-report', reason: 'no test report available: pass --report <path>, or run `cadet-agent harness verify --gate testsPassed` first so its artifact can be read.' };
|
|
596
|
+
if (opts.format === 'json') emit(opts, '', detail);
|
|
597
|
+
else console.error(`❌ ${detail.reason}`);
|
|
598
|
+
process.exit(1);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
const inventory = parseTestInventory(reportText);
|
|
602
|
+
const coverage = compareCoverage(criteria, inventory);
|
|
603
|
+
const gaps = describeCoverageGaps(coverage);
|
|
604
|
+
|
|
605
|
+
// Under strict closure an unknown/empty inventory can never prove coverage,
|
|
606
|
+
// even if every AC declared no tests in a way that looked consistent.
|
|
607
|
+
const unknownInventory = inventory.format === 'unknown' || inventory.names.length === 0;
|
|
608
|
+
const effectiveOk = coverage.ok && !unknownInventory;
|
|
609
|
+
|
|
610
|
+
if (!strict) {
|
|
611
|
+
// v2/v3 parity: report, write nothing, exit 0.
|
|
612
|
+
if (opts.format === 'json') {
|
|
613
|
+
emit(opts, '', { ok: effectiveOk, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, reportPath });
|
|
614
|
+
} else if (effectiveOk) {
|
|
615
|
+
console.log(`✅ AC coverage verified for ${opts.story} (${coverage.ac.length} criteria, ${coverage.inventorySize} tests in inventory).`);
|
|
616
|
+
console.log(' strictClosure is off — reported only, state.json unchanged.');
|
|
617
|
+
} else {
|
|
618
|
+
console.error(`⚠️ AC coverage gaps in ${opts.story} (strictClosure off — reported only):`);
|
|
619
|
+
if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}).`);
|
|
620
|
+
for (const g of gaps) console.error(g);
|
|
621
|
+
}
|
|
622
|
+
if (!effectiveOk) process.exit(1);
|
|
623
|
+
return;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
if (!effectiveOk) {
|
|
627
|
+
const detail = { ok: false, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, code: unknownInventory ? 'inventory-unknown' : 'coverage-gap' };
|
|
628
|
+
if (opts.format === 'json') emit(opts, '', detail);
|
|
629
|
+
else {
|
|
630
|
+
console.error(`❌ Cannot set acceptanceCriteriaValidated for ${opts.story}:`);
|
|
631
|
+
if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}). An unparseable report proves nothing.`);
|
|
632
|
+
for (const g of gaps) console.error(g);
|
|
633
|
+
}
|
|
634
|
+
process.exit(1);
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
const at = new Date();
|
|
638
|
+
const criteriaStrings = coverage.ac.flatMap((a) => [a.id, ...a.declared]);
|
|
639
|
+
const nowIso = at.toISOString();
|
|
640
|
+
const evidence = createEvidence({
|
|
641
|
+
evidenceId: newId(),
|
|
642
|
+
workItemId,
|
|
643
|
+
acceptanceCriterionId: null,
|
|
644
|
+
phase,
|
|
645
|
+
gate: 'acceptanceCriteriaValidated',
|
|
646
|
+
status: 'passed',
|
|
647
|
+
command: `harness verify-acs --story ${opts.story}`,
|
|
648
|
+
result: `AC coverage verified: ${coverage.ac.length} criteria, inventory ${coverage.inventorySize} (${inventory.format})`,
|
|
649
|
+
exitCode: 0,
|
|
650
|
+
inputTreeHash: computeInputTreeHash(opts.targetDir, [opts.story, ...(reportPath ? [reportPath] : [])]),
|
|
651
|
+
criteriaHash: hashCriteria(criteriaStrings),
|
|
652
|
+
relevantFiles: [opts.story, ...(reportPath ? [reportPath] : [])].map((f) => f.replace(/\\/g, '/')),
|
|
653
|
+
createdAt: at,
|
|
654
|
+
expiresAt: null,
|
|
655
|
+
// Schema + validator require an object carrying a `scope`, not a bare
|
|
656
|
+
// string: state.schema.json#/$defs/evidence references
|
|
657
|
+
// harness.schema.json#/$defs/freshnessPolicy, which has required:["scope"]
|
|
658
|
+
// with scope ∈ story|phase|run|manual.
|
|
659
|
+
freshnessPolicy: { scope: 'story' },
|
|
660
|
+
source: 'automated',
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
let coveragePath = null;
|
|
664
|
+
if (opts.writeCoverage) {
|
|
665
|
+
const base = opts.story.replace(/\.md$/, '');
|
|
666
|
+
coveragePath = `${base}.coverage.json`;
|
|
667
|
+
const doc = {
|
|
668
|
+
schemaVersion: 1,
|
|
669
|
+
story: opts.story.replace(/\\/g, '/'),
|
|
670
|
+
generatedAt: nowIso,
|
|
671
|
+
ac: coverage.ac,
|
|
672
|
+
inventorySize: coverage.inventorySize,
|
|
673
|
+
format: inventory.format,
|
|
674
|
+
};
|
|
675
|
+
try { writeFileSync(coveragePath, `${JSON.stringify(doc, null, 2)}\n`, 'utf-8'); } catch { coveragePath = null; }
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
// Ledger first, then state — the v3 ordering: fail toward "less proven".
|
|
679
|
+
const ledger = new RunLedger({ targetDir: opts.targetDir, policy, runId: state?.activeRunId || null, workItemId, phase });
|
|
680
|
+
ledger.addEvidence(evidence);
|
|
681
|
+
ledger.addDecision({ kind: 'stop', reason: `AC coverage verified via ${reportSource}`, scope: `${coverage.ac.length} criteria` });
|
|
682
|
+
ledger.finalize({ status: 'ok' });
|
|
683
|
+
const ledgerPath = ledger.persist();
|
|
684
|
+
|
|
685
|
+
if (exists) {
|
|
686
|
+
const next = { ...state };
|
|
687
|
+
const priorEv = Array.isArray(state.gateEvidence) ? state.gateEvidence : [];
|
|
688
|
+
next.gateEvidence = [
|
|
689
|
+
...priorEv.map((e) => (e.gate === 'acceptanceCriteriaValidated' && (e.status === 'passed' || e.status === 'manual-confirmation')
|
|
690
|
+
? { ...e, status: 'superseded', supersededBy: evidence.evidenceId }
|
|
691
|
+
: e)),
|
|
692
|
+
evidence,
|
|
693
|
+
];
|
|
694
|
+
next.gates = { ...(state.gates || {}), acceptanceCriteriaValidated: true };
|
|
695
|
+
writeState(opts.targetDir, next);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
if (opts.format === 'json') {
|
|
699
|
+
emit(opts, '', { ok: true, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: exists, coveragePath, evidenceId: evidence.evidenceId, runId: ledger.runId, path: ledgerPath });
|
|
700
|
+
} else {
|
|
701
|
+
console.log(`✅ acceptanceCriteriaValidated for ${opts.story} (${coverage.ac.length} criteria, inventory ${coverage.inventorySize}, ${inventory.format}).`);
|
|
702
|
+
console.log(` Ledger: ${ledgerPath}`);
|
|
703
|
+
if (coveragePath) console.log(` Coverage: ${coveragePath}`);
|
|
704
|
+
}
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
|
|
540
708
|
if (sub === 'report') {
|
|
541
709
|
const runs = listRuns(opts.targetDir);
|
|
542
710
|
const target = opts.runId || runs[0]?.runId;
|
|
@@ -556,7 +724,7 @@ async function cmdHarness(opts) {
|
|
|
556
724
|
return;
|
|
557
725
|
}
|
|
558
726
|
|
|
559
|
-
fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|report|cleanup|capabilities.`);
|
|
727
|
+
fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|report|cleanup|capabilities.`);
|
|
560
728
|
}
|
|
561
729
|
|
|
562
730
|
export async function run(argv) {
|
package/src/harness/index.mjs
CHANGED
|
@@ -63,3 +63,9 @@ export {
|
|
|
63
63
|
export {
|
|
64
64
|
REPO_ROLES, REPO_ROLE_MARKER, detectRepoRole, isFrameworkSourceWithoutWorkItem, describeRepoRole,
|
|
65
65
|
} from './repo-role.mjs';
|
|
66
|
+
|
|
67
|
+
export {
|
|
68
|
+
INVENTORY_FORMATS, COVERAGE_STATUSES, DEFAULT_MAX_REPORT_BYTES, DEFAULT_MAX_INVENTORY_ENTRIES,
|
|
69
|
+
normalizeTestName, parseTestInventory, parseStoryCriteria, parseStoryCriteriaText,
|
|
70
|
+
compareCoverage, describeCoverageGaps,
|
|
71
|
+
} from './verify-acs.mjs';
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
import { readFileSync } from 'node:fs';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mechanical AC↔test verification (Harness contract v4).
|
|
5
|
+
*
|
|
6
|
+
* Closes the defect class where a recorded claim names an artifact that does not
|
|
7
|
+
* exist and nothing re-checks the name: an epic's TDD matrix named tests that
|
|
8
|
+
* were never written, and the drift was only noticed at the validation gate,
|
|
9
|
+
* after the story had merged.
|
|
10
|
+
*
|
|
11
|
+
* Three responsibilities:
|
|
12
|
+
* 1. parseTestInventory — extract the identifiers of tests that ACTUALLY RAN
|
|
13
|
+
* from a verification run's report (TAP, JUnit XML, Unity JSON).
|
|
14
|
+
* 2. parseStoryCriteria — read the declared AC → test mapping from the story,
|
|
15
|
+
* which is the single source of truth for the coverage claim.
|
|
16
|
+
* 3. compareCoverage — declared vs found, with every gap reported together.
|
|
17
|
+
*
|
|
18
|
+
* Nothing here "passes" on unknown input: an unparseable report yields an empty
|
|
19
|
+
* inventory, which cannot satisfy coverage (Harness.md §3 — unknown is never
|
|
20
|
+
* silently zero/passing).
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
export const INVENTORY_FORMATS = Object.freeze(['tap', 'junit', 'unity-json', 'unknown']);
|
|
24
|
+
|
|
25
|
+
export const COVERAGE_STATUSES = Object.freeze(['covered', 'missing', 'undeclared']);
|
|
26
|
+
|
|
27
|
+
/** Bound on how many bytes of a report are scanned, so a huge report cannot blow the budget. */
|
|
28
|
+
export const DEFAULT_MAX_REPORT_BYTES = 4 * 1024 * 1024;
|
|
29
|
+
|
|
30
|
+
/** Upper bound on identifiers extracted, mirroring the archive file-count discipline. */
|
|
31
|
+
export const DEFAULT_MAX_INVENTORY_ENTRIES = 20000;
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Normalize a test identifier for comparison.
|
|
35
|
+
*
|
|
36
|
+
* Deliberately conservative: trim, collapse internal whitespace, strip a trailing
|
|
37
|
+
* duplicate-index suffix such as ` (1)`. Case is preserved so `Grid_Foo` and
|
|
38
|
+
* `grid_foo` do NOT match — a fuzzier rule would defeat the purpose of the check.
|
|
39
|
+
* A project that needs looser matching must rename its tests, not loosen this.
|
|
40
|
+
*/
|
|
41
|
+
export function normalizeTestName(name) {
|
|
42
|
+
if (name === null || name === undefined) return '';
|
|
43
|
+
return String(name)
|
|
44
|
+
.replace(/\s+/g, ' ')
|
|
45
|
+
.trim()
|
|
46
|
+
.replace(/\s*\(\d+\)$/, '');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Extract the identifiers of tests that ran from a report.
|
|
51
|
+
*
|
|
52
|
+
* Detection is by content, so a repository needs no extra configuration.
|
|
53
|
+
* Returns `{ format, names, partial, truncatedBytes }`.
|
|
54
|
+
* - `format` is one of INVENTORY_FORMATS.
|
|
55
|
+
* - `partial` is true when the report was truncated by a bound; a partial
|
|
56
|
+
* inventory cannot prove coverage for the truncated region.
|
|
57
|
+
*/
|
|
58
|
+
export function parseTestInventory(
|
|
59
|
+
report,
|
|
60
|
+
{ maxBytes = DEFAULT_MAX_REPORT_BYTES, maxEntries = DEFAULT_MAX_INVENTORY_ENTRIES } = {},
|
|
61
|
+
) {
|
|
62
|
+
if (report === null || report === undefined) {
|
|
63
|
+
return { format: 'unknown', names: [], partial: false, truncatedBytes: 0 };
|
|
64
|
+
}
|
|
65
|
+
const text = typeof report === 'string' ? report : String(report);
|
|
66
|
+
if (text.trim() === '') {
|
|
67
|
+
return { format: 'unknown', names: [], partial: false, truncatedBytes: 0 };
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
let body = text;
|
|
71
|
+
let partial = false;
|
|
72
|
+
let truncatedBytes = 0;
|
|
73
|
+
if (Buffer.byteLength(body, 'utf-8') > maxBytes) {
|
|
74
|
+
body = Buffer.from(body, 'utf-8').subarray(0, maxBytes).toString('utf-8');
|
|
75
|
+
// Drop a possibly-torn final line.
|
|
76
|
+
const lastBreak = body.lastIndexOf('\n');
|
|
77
|
+
if (lastBreak !== -1) body = body.slice(0, lastBreak);
|
|
78
|
+
partial = true;
|
|
79
|
+
truncatedBytes = Buffer.byteLength(text, 'utf-8') - maxBytes;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const trimmed = body.trim();
|
|
83
|
+
|
|
84
|
+
// Unity JSON: a JSON document with a `tests` array of objects carrying `name`.
|
|
85
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
86
|
+
try {
|
|
87
|
+
const parsed = JSON.parse(trimmed);
|
|
88
|
+
const arr = Array.isArray(parsed) ? parsed : (Array.isArray(parsed?.tests) ? parsed.tests : null);
|
|
89
|
+
if (arr) {
|
|
90
|
+
const names = [];
|
|
91
|
+
for (const entry of arr) {
|
|
92
|
+
const n = entry && (entry.name ?? entry.fullName ?? entry.testName);
|
|
93
|
+
if (n) names.push(String(n));
|
|
94
|
+
if (names.length >= maxEntries) { partial = true; break; }
|
|
95
|
+
}
|
|
96
|
+
if (names.length > 0) {
|
|
97
|
+
return { format: 'unity-json', names: dedupe(names), partial, truncatedBytes };
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
} catch {
|
|
101
|
+
// Not JSON after all — fall through to the text formats.
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// JUnit XML: <testcase ... name="...">
|
|
106
|
+
if (/<testcase\b/i.test(body)) {
|
|
107
|
+
const names = [];
|
|
108
|
+
const re = /<testcase\b[^>]*?\bname\s*=\s*"([^"]*)"/gi;
|
|
109
|
+
let m;
|
|
110
|
+
while ((m = re.exec(body)) !== null) {
|
|
111
|
+
names.push(m[1]);
|
|
112
|
+
if (names.length >= maxEntries) { partial = true; break; }
|
|
113
|
+
}
|
|
114
|
+
return { format: 'junit', names: dedupe(names), partial, truncatedBytes };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Node TAP: `ok N - name` / `not ok N - name`. A failing test still ran, so
|
|
118
|
+
// both forms are included.
|
|
119
|
+
const tapRe = /^(?:not ok|ok)\s+\d+\s+-\s+(.*)$/gm;
|
|
120
|
+
const tapNames = [];
|
|
121
|
+
let t;
|
|
122
|
+
while ((t = tapRe.exec(body)) !== null) {
|
|
123
|
+
const name = normalizeTestName(t[1]);
|
|
124
|
+
if (name) tapNames.push(name);
|
|
125
|
+
if (tapNames.length >= maxEntries) { partial = true; break; }
|
|
126
|
+
}
|
|
127
|
+
if (tapNames.length > 0) {
|
|
128
|
+
return { format: 'tap', names: dedupe(tapNames), partial, truncatedBytes };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
return { format: 'unknown', names: [], partial, truncatedBytes };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function dedupe(names) {
|
|
135
|
+
return [...new Set(names.map((n) => String(n)))];
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Parse a story's acceptance criteria and their declared tests.
|
|
140
|
+
*
|
|
141
|
+
* Expected shape (per the story template):
|
|
142
|
+
*
|
|
143
|
+
* ### AC-1: <title>
|
|
144
|
+
* - Given ..., When ..., Then ...
|
|
145
|
+
* - Declared tests:
|
|
146
|
+
* - Test_Name_One
|
|
147
|
+
* - Test_Name_Two
|
|
148
|
+
*
|
|
149
|
+
* or inline: `- Declared tests: Test_Name_One, Test_Name_Two`
|
|
150
|
+
*
|
|
151
|
+
* Returns `{ criteria: [{ id, title, tests }] }`.
|
|
152
|
+
* Throws when an AC heading carries no id, or when two ACs share an id — a story
|
|
153
|
+
* that cannot be parsed is not a story that can be silently accepted.
|
|
154
|
+
*/
|
|
155
|
+
export function parseStoryCriteria(storyPath) {
|
|
156
|
+
const text = readFileSync(storyPath, 'utf-8');
|
|
157
|
+
return parseStoryCriteriaText(text);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** Text-in variant of parseStoryCriteria, for tests and in-memory use. */
|
|
161
|
+
export function parseStoryCriteriaText(text) {
|
|
162
|
+
const lines = String(text).split(/\r?\n/);
|
|
163
|
+
|
|
164
|
+
// Only look inside the Acceptance Criteria section, so a stray `### AC-` in
|
|
165
|
+
// notes cannot be picked up.
|
|
166
|
+
let start = lines.findIndex((l) => /^##\s+Acceptance Criteria\s*$/i.test(l.trim()));
|
|
167
|
+
if (start === -1) return { criteria: [] };
|
|
168
|
+
let end = lines.length;
|
|
169
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
170
|
+
if (/^##\s+/.test(lines[i].trim())) { end = i; break; }
|
|
171
|
+
}
|
|
172
|
+
const section = lines.slice(start + 1, end);
|
|
173
|
+
|
|
174
|
+
const criteria = [];
|
|
175
|
+
const seen = new Map(); // normalized id -> original
|
|
176
|
+
let current = null;
|
|
177
|
+
|
|
178
|
+
const headingRe = /^###\s+(.*)$/;
|
|
179
|
+
const declaredInlineRe = /^[-*]\s*Declared tests?\s*:\s*(.+)$/i;
|
|
180
|
+
const declaredBareRe = /^[-*]\s*Declared tests?\s*:?\s*$/i;
|
|
181
|
+
const bulletRe = /^[-*]\s+(.*)$/;
|
|
182
|
+
|
|
183
|
+
for (const raw of section) {
|
|
184
|
+
const line = raw.trim();
|
|
185
|
+
const heading = headingRe.exec(line);
|
|
186
|
+
if (heading) {
|
|
187
|
+
const rest = heading[1].trim();
|
|
188
|
+
const idMatch = /^(AC-[\w.-]+)\s*:?\s*(.*)$/i.exec(rest);
|
|
189
|
+
if (!idMatch) {
|
|
190
|
+
throw new Error(`acceptance criterion heading has no AC id: "${rest}"`);
|
|
191
|
+
}
|
|
192
|
+
const id = idMatch[1];
|
|
193
|
+
const key = id.toUpperCase();
|
|
194
|
+
if (seen.has(key)) {
|
|
195
|
+
throw new Error(`duplicate AC id "${id}" in story`);
|
|
196
|
+
}
|
|
197
|
+
seen.set(key, id);
|
|
198
|
+
current = { id, title: idMatch[2].trim(), tests: [] };
|
|
199
|
+
criteria.push(current);
|
|
200
|
+
continue;
|
|
201
|
+
}
|
|
202
|
+
if (!current) continue;
|
|
203
|
+
|
|
204
|
+
// `- Declared tests: A, B` (inline) — check before the bare form.
|
|
205
|
+
const inline = declaredInlineRe.exec(line);
|
|
206
|
+
if (inline) {
|
|
207
|
+
for (const name of splitTestList(inline[1])) current.tests.push(name);
|
|
208
|
+
current.bareDeclaredList = true; // allow following nested bullets too
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// `- Declared tests:` on its own line — the following bullets are the tests.
|
|
213
|
+
if (declaredBareRe.test(line)) {
|
|
214
|
+
current.bareDeclaredList = true;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// A nested bullet while in the declared-tests list.
|
|
219
|
+
if (current.bareDeclaredList) {
|
|
220
|
+
const bullet = bulletRe.exec(line);
|
|
221
|
+
if (bullet) {
|
|
222
|
+
current.tests.push(bullet[1].trim());
|
|
223
|
+
continue;
|
|
224
|
+
}
|
|
225
|
+
current.bareDeclaredList = false;
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
for (const c of criteria) {
|
|
230
|
+
delete c.bareDeclaredList;
|
|
231
|
+
c.tests = dedupe(c.tests.map((t) => t.trim()).filter(Boolean));
|
|
232
|
+
}
|
|
233
|
+
return { criteria };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
function splitTestList(text) {
|
|
237
|
+
return text.split(/[,;]/).map((s) => s.trim()).filter(Boolean);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Compare a story's declared tests against a run's inventory.
|
|
242
|
+
*
|
|
243
|
+
* Returns `{ ok, ac: [{ id, declared, found, status }], inventorySize, format }`.
|
|
244
|
+
* `status` is `covered` (all declared found), `missing` (some declared absent),
|
|
245
|
+
* or `undeclared` (the AC declares no test at all).
|
|
246
|
+
*
|
|
247
|
+
* Every gap is reported together; the caller renders all of them, never just the
|
|
248
|
+
* first.
|
|
249
|
+
*/
|
|
250
|
+
export function compareCoverage(criteria, inventory) {
|
|
251
|
+
const found = new Set((inventory?.names || []).map((n) => normalizeTestName(n)));
|
|
252
|
+
const ac = [];
|
|
253
|
+
let ok = true;
|
|
254
|
+
|
|
255
|
+
for (const c of criteria) {
|
|
256
|
+
const declared = (c.tests || []).map((t) => String(t));
|
|
257
|
+
if (declared.length === 0) {
|
|
258
|
+
ok = false;
|
|
259
|
+
ac.push({ id: c.id, declared: [], found: [], status: 'undeclared' });
|
|
260
|
+
continue;
|
|
261
|
+
}
|
|
262
|
+
const present = declared.filter((t) => found.has(normalizeTestName(t)));
|
|
263
|
+
const status = present.length === declared.length ? 'covered' : 'missing';
|
|
264
|
+
if (status !== 'covered') ok = false;
|
|
265
|
+
ac.push({ id: c.id, declared, found: present, status });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
return {
|
|
269
|
+
ok,
|
|
270
|
+
ac,
|
|
271
|
+
inventorySize: (inventory?.names || []).length,
|
|
272
|
+
format: inventory?.format || 'unknown',
|
|
273
|
+
};
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/** Format the gaps as concrete, actionable lines (spec §5.1 step 4). */
|
|
277
|
+
export function describeCoverageGaps(coverage) {
|
|
278
|
+
const lines = [];
|
|
279
|
+
for (const entry of coverage.ac) {
|
|
280
|
+
if (entry.status === 'undeclared') {
|
|
281
|
+
lines.push(` ${entry.id}: declares no test — record the test identifier(s) that prove this criterion.`);
|
|
282
|
+
} else if (entry.status === 'missing') {
|
|
283
|
+
const found = new Set(entry.found.map((n) => normalizeTestName(n)));
|
|
284
|
+
const absent = entry.declared.filter((t) => !found.has(normalizeTestName(t)));
|
|
285
|
+
for (const t of absent) {
|
|
286
|
+
lines.push(` ${entry.id}: declared test "${t}" did not appear in the test report — either it was renamed (update the story) or it was never written.`);
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
return lines;
|
|
291
|
+
}
|