cadet-agent 0.33.1 → 0.35.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 +1 -1
- package/src/cli.mjs +35 -4
- package/src/harness/util.mjs +172 -149
- package/src/harness/verify-acs.mjs +42 -3
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -90,10 +90,14 @@ function parseArgs(argv) {
|
|
|
90
90
|
case '--environment': opts.environment = argv[++i]; break;
|
|
91
91
|
case '--scope': opts.scope = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
92
92
|
case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
|
|
93
|
-
|
|
93
|
+
// Track that the flag was supplied even when its value is empty, so an
|
|
94
|
+
// empty `--files ""` is rejected rather than silently falling back to the
|
|
95
|
+
// working-tree scan (which could bind evidence to Cadet's own files).
|
|
96
|
+
case '--files': opts.filesGiven = true; opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
94
97
|
case '--story': opts.story = argv[++i]; break;
|
|
95
98
|
case '--report': opts.report = argv[++i]; break;
|
|
96
99
|
case '--write-coverage': opts.writeCoverage = true; break;
|
|
100
|
+
case '--strict-orphans': opts.strictOrphans = true; break;
|
|
97
101
|
case '--dry-run': opts.dryRun = true; break;
|
|
98
102
|
case '--older-than-ms': opts.olderThanMs = Number(argv[++i]); break;
|
|
99
103
|
case '--agents-md': opts.agentsMd = argv[++i]; break;
|
|
@@ -319,6 +323,9 @@ async function cmdHarness(opts) {
|
|
|
319
323
|
// Freshness binding mirrors `harness verify`: never record a gate against an
|
|
320
324
|
// unknown input tree unless the repository explicitly opted out.
|
|
321
325
|
const allowEmpty = policy?.allowEmptyFreshness === true;
|
|
326
|
+
if (opts.filesGiven && (!opts.files || opts.files.length === 0)) {
|
|
327
|
+
fail(opts, '--files was given with no paths. Pass a comma-separated list of the files this gate covers (e.g. --files src/Foo.cs,test/FooTests.cs), or omit --files to auto-detect changed files.', () => 1, { ok: false, gate, code: 'empty-files' });
|
|
328
|
+
}
|
|
322
329
|
let relevantFiles;
|
|
323
330
|
if (opts.files && opts.files.length) {
|
|
324
331
|
relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
|
|
@@ -458,6 +465,9 @@ async function cmdHarness(opts) {
|
|
|
458
465
|
const allowEmpty = policy?.allowEmptyFreshness === true;
|
|
459
466
|
let relevantFiles;
|
|
460
467
|
let filesSource;
|
|
468
|
+
if (opts.filesGiven && (!opts.files || opts.files.length === 0)) {
|
|
469
|
+
fail(opts, '--files was given with no paths. Pass a comma-separated list of the files this gate covers (e.g. --files src/Foo.cs,test/FooTests.cs), or omit --files to auto-detect changed files.', () => 1, { ok: false, gate, code: 'empty-files' });
|
|
470
|
+
}
|
|
461
471
|
if (opts.files && opts.files.length) {
|
|
462
472
|
relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
|
|
463
473
|
filesSource = 'explicit';
|
|
@@ -618,39 +628,60 @@ async function cmdHarness(opts) {
|
|
|
618
628
|
const inventory = parseTestInventory(reportText);
|
|
619
629
|
const coverage = compareCoverage(criteria, inventory);
|
|
620
630
|
const gaps = describeCoverageGaps(coverage);
|
|
631
|
+
// The inverse direction: tests that ran but are declared on no AC. Always
|
|
632
|
+
// reported; only fatal when explicitly requested, because a consumer may
|
|
633
|
+
// legitimately carry helper tests that belong to no single criterion.
|
|
634
|
+
const orphanGaps = describeCoverageGaps(coverage, { includeOrphans: true }).slice(gaps.length);
|
|
635
|
+
const orphans = coverage.orphaned || [];
|
|
621
636
|
|
|
622
637
|
// Under strict closure an unknown/empty inventory can never prove coverage,
|
|
623
638
|
// even if every AC declared no tests in a way that looked consistent.
|
|
624
639
|
const unknownInventory = inventory.format === 'unknown' || inventory.names.length === 0;
|
|
625
|
-
const
|
|
640
|
+
const orphanBlocking = opts.strictOrphans === true && orphans.length > 0;
|
|
641
|
+
const effectiveOk = coverage.ok && !unknownInventory && !orphanBlocking;
|
|
626
642
|
|
|
627
643
|
if (!strict) {
|
|
628
644
|
// v2/v3 parity: report, write nothing, exit 0.
|
|
629
645
|
if (opts.format === 'json') {
|
|
630
|
-
emit(opts, '', { ok: effectiveOk, story: opts.story, ac: coverage.ac, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, reportPath });
|
|
646
|
+
emit(opts, '', { ok: effectiveOk, story: opts.story, ac: coverage.ac, orphaned: orphans, inventorySize: coverage.inventorySize, format: inventory.format, gateSet: false, reportPath });
|
|
631
647
|
} else if (effectiveOk) {
|
|
632
648
|
console.log(`✅ AC coverage verified for ${opts.story} (${coverage.ac.length} criteria, ${coverage.inventorySize} tests in inventory).`);
|
|
633
649
|
console.log(' strictClosure is off — reported only, state.json unchanged.');
|
|
650
|
+
if (orphans.length > 0) {
|
|
651
|
+
// A warning goes to stderr even on the success path, so it is not lost
|
|
652
|
+
// in stdout piping and matches how every other warning is emitted.
|
|
653
|
+
console.error(` ⚠️ ${orphans.length} test(s) declared on no acceptance criterion (reported only):`);
|
|
654
|
+
for (const g of orphanGaps) console.error(g);
|
|
655
|
+
}
|
|
634
656
|
} else {
|
|
635
657
|
console.error(`⚠️ AC coverage gaps in ${opts.story} (strictClosure off — reported only):`);
|
|
636
658
|
if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}).`);
|
|
637
659
|
for (const g of gaps) console.error(g);
|
|
660
|
+
for (const g of orphanGaps) console.error(g);
|
|
638
661
|
}
|
|
639
662
|
if (!effectiveOk) process.exit(1);
|
|
640
663
|
return;
|
|
641
664
|
}
|
|
642
665
|
|
|
643
666
|
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' };
|
|
667
|
+
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
668
|
if (opts.format === 'json') emit(opts, '', detail);
|
|
646
669
|
else {
|
|
647
670
|
console.error(`❌ Cannot set acceptanceCriteriaValidated for ${opts.story}:`);
|
|
648
671
|
if (unknownInventory) console.error(` no test inventory could be derived from ${reportPath || 'the report'} (format: ${inventory.format}). An unparseable report proves nothing.`);
|
|
649
672
|
for (const g of gaps) console.error(g);
|
|
673
|
+
if (orphanBlocking) for (const g of orphanGaps) console.error(g);
|
|
650
674
|
}
|
|
651
675
|
process.exit(1);
|
|
652
676
|
}
|
|
653
677
|
|
|
678
|
+
if (orphans.length > 0) {
|
|
679
|
+
// Passing, but the inverse-direction drift is visible rather than silent.
|
|
680
|
+
const notice = `⚠️ ${orphans.length} test(s) ran but are declared on no acceptance criterion (not fatal; pass --strict-orphans to enforce).`;
|
|
681
|
+
if (opts.format === 'json') console.error(notice);
|
|
682
|
+
else for (const g of [notice, ...orphanGaps]) console.error(g);
|
|
683
|
+
}
|
|
684
|
+
|
|
654
685
|
const at = new Date();
|
|
655
686
|
const criteriaStrings = coverage.ac.flatMap((a) => [a.id, ...a.declared]);
|
|
656
687
|
const nowIso = at.toISOString();
|
package/src/harness/util.mjs
CHANGED
|
@@ -1,149 +1,172 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shared harness primitives: UUIDv4, SHA-256 over UTF-8 bytes, tree hashing,
|
|
3
|
-
* deterministic JSON serialization, and UTC timestamps.
|
|
4
|
-
*
|
|
5
|
-
* Contract: docs/core/HarnessContract.md §2 (identifiers, hashes).
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
import { createHash, randomUUID } from 'node:crypto';
|
|
9
|
-
import { readFileSync, existsSync } from 'node:fs';
|
|
10
|
-
import { spawnSync } from 'node:child_process';
|
|
11
|
-
|
|
12
|
-
/** UUIDv4 identifier. */
|
|
13
|
-
export function newId() {
|
|
14
|
-
return randomUUID();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function isUuid(value) {
|
|
18
|
-
return typeof value === 'string'
|
|
19
|
-
&& /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
/** SHA-256 hex digest over UTF-8 bytes. */
|
|
23
|
-
export function sha256(value) {
|
|
24
|
-
return createHash('sha256').update(value, 'utf-8').digest('hex');
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/** SHA-256 hex digest over raw bytes (Buffers are hashed as-is). */
|
|
28
|
-
export function sha256Bytes(buf) {
|
|
29
|
-
return createHash('sha256').update(buf).digest('hex');
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
/** Hash of a file's exact bytes, or null when the file is missing. */
|
|
33
|
-
export function hashFile(path) {
|
|
34
|
-
if (!existsSync(path)) return null;
|
|
35
|
-
try {
|
|
36
|
-
return sha256Bytes(readFileSync(path));
|
|
37
|
-
} catch {
|
|
38
|
-
return null;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
/**
|
|
43
|
-
* Deterministic hash of a set of `(relativePath, fileHash)` pairs.
|
|
44
|
-
* Pairs are sorted by path so the hash is order-independent and stable across
|
|
45
|
-
* platforms. Files without a resolvable hash are recorded as `missing`.
|
|
46
|
-
*/
|
|
47
|
-
export function hashTree(pairs) {
|
|
48
|
-
const normalized = [...pairs]
|
|
49
|
-
.map(({ path, hash }) => ({
|
|
50
|
-
path: String(path).replace(/\\/g, '/'),
|
|
51
|
-
hash: hash || 'missing',
|
|
52
|
-
}))
|
|
53
|
-
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
54
|
-
return sha256(JSON.stringify(normalized));
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* Hash an acceptance-criteria document or list. `criteria` may be a string or an
|
|
59
|
-
* array of strings; a stable serialization is used either way.
|
|
60
|
-
*/
|
|
61
|
-
export function hashCriteria(criteria) {
|
|
62
|
-
if (criteria === null || criteria === undefined) return sha256('[]');
|
|
63
|
-
const arr = Array.isArray(criteria) ? criteria.map(String) : [String(criteria)];
|
|
64
|
-
return sha256(JSON.stringify(arr));
|
|
65
|
-
}
|
|
66
|
-
|
|
67
|
-
/** ISO-8601 UTC timestamp for a Date or "now". */
|
|
68
|
-
export function timestamp(at = new Date()) {
|
|
69
|
-
return (at instanceof Date ? at : new Date(at)).toISOString();
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
/** Current time provider; injectable for deterministic tests. */
|
|
73
|
-
export function nowMs() {
|
|
74
|
-
return Date.now();
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
/**
|
|
78
|
-
* Canonical JSON with sorted keys. Used for stable hashes and for comparing
|
|
79
|
-
* evidence records without key-order noise.
|
|
80
|
-
*/
|
|
81
|
-
export function canonicalJson(value) {
|
|
82
|
-
return JSON.stringify(sortKeys(value));
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function sortKeys(value) {
|
|
86
|
-
if (Array.isArray(value)) return value.map(sortKeys);
|
|
87
|
-
if (value && typeof value === 'object') {
|
|
88
|
-
const out = {};
|
|
89
|
-
for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key]);
|
|
90
|
-
return out;
|
|
91
|
-
}
|
|
92
|
-
return value;
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
export { canonicalJson as stableStringify };
|
|
96
|
-
|
|
97
|
-
/**
|
|
98
|
-
* List the files changed in the working tree relative to HEAD, using git.
|
|
99
|
-
* Returns forward-slash relative paths. Returns an empty array when git is
|
|
100
|
-
* unavailable or the directory is not a repository — callers must not assume
|
|
101
|
-
* freshness coverage in that case; use `gitChangedFiles` when the distinction
|
|
102
|
-
* between "no changes" and "no git" matters.
|
|
103
|
-
*/
|
|
104
|
-
export function changedFiles(cwd, { runner = defaultGitRunner } = {}) {
|
|
105
|
-
return gitChangedFiles(cwd, { runner }).files;
|
|
106
|
-
}
|
|
107
|
-
|
|
108
|
-
/**
|
|
109
|
-
*
|
|
110
|
-
*
|
|
111
|
-
*
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
return
|
|
145
|
-
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
}
|
|
149
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Shared harness primitives: UUIDv4, SHA-256 over UTF-8 bytes, tree hashing,
|
|
3
|
+
* deterministic JSON serialization, and UTC timestamps.
|
|
4
|
+
*
|
|
5
|
+
* Contract: docs/core/HarnessContract.md §2 (identifiers, hashes).
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
9
|
+
import { readFileSync, existsSync } from 'node:fs';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
/** UUIDv4 identifier. */
|
|
13
|
+
export function newId() {
|
|
14
|
+
return randomUUID();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function isUuid(value) {
|
|
18
|
+
return typeof value === 'string'
|
|
19
|
+
&& /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** SHA-256 hex digest over UTF-8 bytes. */
|
|
23
|
+
export function sha256(value) {
|
|
24
|
+
return createHash('sha256').update(value, 'utf-8').digest('hex');
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** SHA-256 hex digest over raw bytes (Buffers are hashed as-is). */
|
|
28
|
+
export function sha256Bytes(buf) {
|
|
29
|
+
return createHash('sha256').update(buf).digest('hex');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Hash of a file's exact bytes, or null when the file is missing. */
|
|
33
|
+
export function hashFile(path) {
|
|
34
|
+
if (!existsSync(path)) return null;
|
|
35
|
+
try {
|
|
36
|
+
return sha256Bytes(readFileSync(path));
|
|
37
|
+
} catch {
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Deterministic hash of a set of `(relativePath, fileHash)` pairs.
|
|
44
|
+
* Pairs are sorted by path so the hash is order-independent and stable across
|
|
45
|
+
* platforms. Files without a resolvable hash are recorded as `missing`.
|
|
46
|
+
*/
|
|
47
|
+
export function hashTree(pairs) {
|
|
48
|
+
const normalized = [...pairs]
|
|
49
|
+
.map(({ path, hash }) => ({
|
|
50
|
+
path: String(path).replace(/\\/g, '/'),
|
|
51
|
+
hash: hash || 'missing',
|
|
52
|
+
}))
|
|
53
|
+
.sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
54
|
+
return sha256(JSON.stringify(normalized));
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Hash an acceptance-criteria document or list. `criteria` may be a string or an
|
|
59
|
+
* array of strings; a stable serialization is used either way.
|
|
60
|
+
*/
|
|
61
|
+
export function hashCriteria(criteria) {
|
|
62
|
+
if (criteria === null || criteria === undefined) return sha256('[]');
|
|
63
|
+
const arr = Array.isArray(criteria) ? criteria.map(String) : [String(criteria)];
|
|
64
|
+
return sha256(JSON.stringify(arr));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** ISO-8601 UTC timestamp for a Date or "now". */
|
|
68
|
+
export function timestamp(at = new Date()) {
|
|
69
|
+
return (at instanceof Date ? at : new Date(at)).toISOString();
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/** Current time provider; injectable for deterministic tests. */
|
|
73
|
+
export function nowMs() {
|
|
74
|
+
return Date.now();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Canonical JSON with sorted keys. Used for stable hashes and for comparing
|
|
79
|
+
* evidence records without key-order noise.
|
|
80
|
+
*/
|
|
81
|
+
export function canonicalJson(value) {
|
|
82
|
+
return JSON.stringify(sortKeys(value));
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function sortKeys(value) {
|
|
86
|
+
if (Array.isArray(value)) return value.map(sortKeys);
|
|
87
|
+
if (value && typeof value === 'object') {
|
|
88
|
+
const out = {};
|
|
89
|
+
for (const key of Object.keys(value).sort()) out[key] = sortKeys(value[key]);
|
|
90
|
+
return out;
|
|
91
|
+
}
|
|
92
|
+
return value;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export { canonicalJson as stableStringify };
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* List the files changed in the working tree relative to HEAD, using git.
|
|
99
|
+
* Returns forward-slash relative paths. Returns an empty array when git is
|
|
100
|
+
* unavailable or the directory is not a repository — callers must not assume
|
|
101
|
+
* freshness coverage in that case; use `gitChangedFiles` when the distinction
|
|
102
|
+
* between "no changes" and "no git" matters.
|
|
103
|
+
*/
|
|
104
|
+
export function changedFiles(cwd, { runner = defaultGitRunner } = {}) {
|
|
105
|
+
return gitChangedFiles(cwd, { runner }).files;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Cadet's own bookkeeping — never a meaningful verification input.
|
|
110
|
+
*
|
|
111
|
+
* `state.json` is rewritten by the very command that records a gate, and
|
|
112
|
+
* `runs/*.json` gains a new ledger on every harness invocation. If either were
|
|
113
|
+
* auto-detected as a relevant file, the evidence hash would describe a file the
|
|
114
|
+
* recording itself mutates: the gate would be stale the moment it was written,
|
|
115
|
+
* and the resulting record would certify no story code. Excluded here, at the
|
|
116
|
+
* single scan used by both `harness verify` and `harness confirm`.
|
|
117
|
+
*/
|
|
118
|
+
const CADET_MACHINERY = ['.cadet/state.json', '.cadet/runs/'];
|
|
119
|
+
|
|
120
|
+
/** True when a repository-relative path is Cadet's own bookkeeping. */
|
|
121
|
+
function isCadetMachinery(relPath) {
|
|
122
|
+
return CADET_MACHINERY.some((p) => (p.endsWith('/') ? relPath.startsWith(p) : relPath === p));
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* List changed files and report whether git was actually queryable.
|
|
127
|
+
* Returns `{ available, files, reason }`. `available: false` means freshness
|
|
128
|
+
* coverage could not be established and callers must fail safe.
|
|
129
|
+
*
|
|
130
|
+
* Cadet's own machinery (`.cadet/state.json`, `.cadet/runs/**`) is filtered out
|
|
131
|
+
* of `files`; see `CADET_MACHINERY`.
|
|
132
|
+
*/
|
|
133
|
+
export function gitChangedFiles(cwd, { runner = defaultGitRunner } = {}) {
|
|
134
|
+
let res;
|
|
135
|
+
try {
|
|
136
|
+
res = runner('git', ['-C', cwd, 'status', '--porcelain', '--untracked-files=all']);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
return { available: false, files: [], reason: `git invocation failed: ${err.message}` };
|
|
139
|
+
}
|
|
140
|
+
if (!res) {
|
|
141
|
+
return { available: false, files: [], reason: 'git is not available' };
|
|
142
|
+
}
|
|
143
|
+
if (res.error || res.status === null) {
|
|
144
|
+
return { available: false, files: [], reason: 'git is not installed or could not be executed' };
|
|
145
|
+
}
|
|
146
|
+
if (res.status !== 0) {
|
|
147
|
+
// Not a repository, or git refused the query.
|
|
148
|
+
return { available: false, files: [], reason: String(res.stderr || '').trim() || `git exited ${res.status}` };
|
|
149
|
+
}
|
|
150
|
+
const files = new Set();
|
|
151
|
+
for (const line of String(res.stdout || '').split(/\r?\n/)) {
|
|
152
|
+
if (!line.trim()) continue;
|
|
153
|
+
// Porcelain v1: XY<space>path (rename: "old -> new").
|
|
154
|
+
let path = line.slice(3).trim();
|
|
155
|
+
if (path.includes(' -> ')) path = path.split(' -> ').pop().trim();
|
|
156
|
+
path = path.replace(/^"|"$/g, '');
|
|
157
|
+
if (!path) continue;
|
|
158
|
+
const rel = path.replace(/\\/g, '/');
|
|
159
|
+
if (isCadetMachinery(rel)) continue;
|
|
160
|
+
files.add(rel);
|
|
161
|
+
}
|
|
162
|
+
return { available: true, files: [...files].sort(), reason: null };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function defaultGitRunner(cmd, args) {
|
|
166
|
+
try {
|
|
167
|
+
return spawnSync(cmd, args, { encoding: 'utf-8', windowsHide: true });
|
|
168
|
+
} catch {
|
|
169
|
+
return null;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
|
|
@@ -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
|
-
/**
|
|
277
|
-
|
|
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
|
}
|