cadet-agent 0.34.0 → 0.36.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 +37 -37
- package/src/cli.mjs +109 -3
- package/src/harness/index.mjs +4 -0
- package/src/harness/matrix-check.mjs +122 -0
- package/src/harness/state.mjs +82 -0
- package/src/harness/util.mjs +172 -149
- package/src/harness/verification.mjs +4 -1
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.36.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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { readFileSync, writeFileSync } from 'node:fs';
|
|
2
2
|
import { fileURLToPath } from 'node:url';
|
|
3
|
-
import { dirname, join } from 'node:path';
|
|
3
|
+
import { dirname, join, resolve } from 'node:path';
|
|
4
4
|
import { install, sync } from './install.mjs';
|
|
5
5
|
import {
|
|
6
6
|
validateState, migrateStateFile, readState, writeState, evaluateTransition, applyTransition,
|
|
@@ -9,6 +9,7 @@ import {
|
|
|
9
9
|
detectRepoRole, describeRepoRole, GATES, manualConfirmation,
|
|
10
10
|
parseTestInventory, parseStoryCriteria, compareCoverage, describeCoverageGaps,
|
|
11
11
|
createEvidence, newId, computeInputTreeHash, hashCriteria,
|
|
12
|
+
collectDeclaredTestNames, reconcileTestNames,
|
|
12
13
|
} from './harness/index.mjs';
|
|
13
14
|
|
|
14
15
|
const __filename = fileURLToPath(import.meta.url);
|
|
@@ -90,9 +91,17 @@ function parseArgs(argv) {
|
|
|
90
91
|
case '--environment': opts.environment = argv[++i]; break;
|
|
91
92
|
case '--scope': opts.scope = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
92
93
|
case '--evidence-status': opts.evidenceStatus = argv[++i]; break;
|
|
93
|
-
|
|
94
|
+
// Track that the flag was supplied even when its value is empty, so an
|
|
95
|
+
// empty `--files ""` is rejected rather than silently falling back to the
|
|
96
|
+
// working-tree scan (which could bind evidence to Cadet's own files).
|
|
97
|
+
case '--files': opts.filesGiven = true; opts.files = (argv[++i] || '').split(',').map((s) => s.trim()).filter(Boolean); break;
|
|
94
98
|
case '--story': opts.story = argv[++i]; break;
|
|
95
99
|
case '--report': opts.report = argv[++i]; break;
|
|
100
|
+
// AR-1: the revision a gate record attests, so a gate-related fix claim
|
|
101
|
+
// can be traced to the commit that contains it.
|
|
102
|
+
case '--commit': opts.commitGiven = true; opts.commit = argv[++i]; break;
|
|
103
|
+
case '--matrix': opts.matrix = argv[++i]; break;
|
|
104
|
+
case '--inventory': opts.inventory = argv[++i]; break;
|
|
96
105
|
case '--write-coverage': opts.writeCoverage = true; break;
|
|
97
106
|
case '--strict-orphans': opts.strictOrphans = true; break;
|
|
98
107
|
case '--dry-run': opts.dryRun = true; break;
|
|
@@ -320,6 +329,9 @@ async function cmdHarness(opts) {
|
|
|
320
329
|
// Freshness binding mirrors `harness verify`: never record a gate against an
|
|
321
330
|
// unknown input tree unless the repository explicitly opted out.
|
|
322
331
|
const allowEmpty = policy?.allowEmptyFreshness === true;
|
|
332
|
+
if (opts.filesGiven && (!opts.files || opts.files.length === 0)) {
|
|
333
|
+
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' });
|
|
334
|
+
}
|
|
323
335
|
let relevantFiles;
|
|
324
336
|
if (opts.files && opts.files.length) {
|
|
325
337
|
relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
|
|
@@ -355,6 +367,7 @@ async function cmdHarness(opts) {
|
|
|
355
367
|
relevantFiles,
|
|
356
368
|
rootDir: opts.targetDir,
|
|
357
369
|
approvedBy: opts.approvedBy || 'user',
|
|
370
|
+
commit: opts.commit || null,
|
|
358
371
|
at,
|
|
359
372
|
});
|
|
360
373
|
|
|
@@ -459,6 +472,9 @@ async function cmdHarness(opts) {
|
|
|
459
472
|
const allowEmpty = policy?.allowEmptyFreshness === true;
|
|
460
473
|
let relevantFiles;
|
|
461
474
|
let filesSource;
|
|
475
|
+
if (opts.filesGiven && (!opts.files || opts.files.length === 0)) {
|
|
476
|
+
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' });
|
|
477
|
+
}
|
|
462
478
|
if (opts.files && opts.files.length) {
|
|
463
479
|
relevantFiles = opts.files.map((f) => f.replace(/\\/g, '/'));
|
|
464
480
|
filesSource = 'explicit';
|
|
@@ -517,6 +533,7 @@ async function cmdHarness(opts) {
|
|
|
517
533
|
phase: ledger.phase || 'implementation',
|
|
518
534
|
relevantFiles,
|
|
519
535
|
rootDir: opts.targetDir,
|
|
536
|
+
commit: opts.commit || null,
|
|
520
537
|
policy,
|
|
521
538
|
budgets: ledger.tracker,
|
|
522
539
|
artifactDir: join(runsDir(opts.targetDir), 'artifacts'),
|
|
@@ -755,6 +772,95 @@ async function cmdHarness(opts) {
|
|
|
755
772
|
return;
|
|
756
773
|
}
|
|
757
774
|
|
|
775
|
+
// AR-5. Reconcile a TDD matrix's DELIVERED test-name claims against a compiled
|
|
776
|
+
// inventory. Read-only: it reports, and never writes state, so it can be run at
|
|
777
|
+
// authoring time (before anything has been implemented) as well as in a gate.
|
|
778
|
+
if (sub === 'matrix-check') {
|
|
779
|
+
if (!opts.matrix) fail(opts, 'harness matrix-check requires --matrix <path-to-matrix.md>');
|
|
780
|
+
const matrixPath = resolve(opts.targetDir, opts.matrix);
|
|
781
|
+
let matrixText;
|
|
782
|
+
try {
|
|
783
|
+
matrixText = readFileSync(matrixPath, 'utf-8');
|
|
784
|
+
} catch (err) {
|
|
785
|
+
fail(opts, `cannot read matrix ${opts.matrix}: ${err.message}`, () => 2);
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// The inventory may come from a run report (strongest: it proves the test
|
|
789
|
+
// RAN) or from C# sources (a name inventory only). Prefer a report.
|
|
790
|
+
let inventory = null;
|
|
791
|
+
let inventorySource = null;
|
|
792
|
+
if (opts.report) {
|
|
793
|
+
const reportPath = resolve(opts.targetDir, opts.report);
|
|
794
|
+
let reportText;
|
|
795
|
+
try {
|
|
796
|
+
reportText = readFileSync(reportPath, 'utf-8');
|
|
797
|
+
} catch (err) {
|
|
798
|
+
fail(opts, `cannot read report ${opts.report}: ${err.message}`, () => 2);
|
|
799
|
+
}
|
|
800
|
+
const parsed = parseTestInventory(reportText);
|
|
801
|
+
if (parsed && parsed.format !== 'unknown' && parsed.names.length > 0) {
|
|
802
|
+
inventory = new Set(parsed.names);
|
|
803
|
+
inventorySource = `${opts.report} (${parsed.format})`;
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
if (!inventory && opts.inventory) {
|
|
807
|
+
const invPath = resolve(opts.targetDir, opts.inventory);
|
|
808
|
+
let raw;
|
|
809
|
+
try {
|
|
810
|
+
raw = readFileSync(invPath, 'utf-8');
|
|
811
|
+
} catch (err) {
|
|
812
|
+
fail(opts, `cannot read inventory ${opts.inventory}: ${err.message}`, () => 2);
|
|
813
|
+
}
|
|
814
|
+
inventory = new Set(String(raw).split(/\r?\n/).map((s) => s.trim()).filter(Boolean));
|
|
815
|
+
inventorySource = opts.inventory;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const collected = collectDeclaredTestNames(matrixText);
|
|
819
|
+
if (!inventory) {
|
|
820
|
+
// Without an inventory this cannot prove anything, so it must not report
|
|
821
|
+
// success. Returning the collected names is still useful at authoring time:
|
|
822
|
+
// it shows what the matrix claims, and the caller can spot a name they know
|
|
823
|
+
// they never wrote.
|
|
824
|
+
const detail = {
|
|
825
|
+
ok: false,
|
|
826
|
+
code: 'inventory-unavailable',
|
|
827
|
+
matrix: opts.matrix,
|
|
828
|
+
claims: collected.claims,
|
|
829
|
+
intents: collected.intents,
|
|
830
|
+
};
|
|
831
|
+
if (opts.format === 'json') emit(opts, '', detail);
|
|
832
|
+
else {
|
|
833
|
+
console.error('❌ No inventory supplied, so no claim can be checked. Pass --report <test-results> (preferred, proves the test ran) or --inventory <names.txt>.');
|
|
834
|
+
console.error(` The matrix declares ${collected.claims.length} delivered claim(s) across ${collected.intents.length} undelivered intention(s).`);
|
|
835
|
+
}
|
|
836
|
+
process.exit(1);
|
|
837
|
+
}
|
|
838
|
+
|
|
839
|
+
const result = reconcileTestNames(collected, inventory);
|
|
840
|
+
const ok = result.missingFromInventory.length === 0;
|
|
841
|
+
const detail = {
|
|
842
|
+
ok,
|
|
843
|
+
matrix: opts.matrix,
|
|
844
|
+
inventory: inventorySource,
|
|
845
|
+
checked: result.checked,
|
|
846
|
+
intents: collected.intents.length,
|
|
847
|
+
missingFromInventory: result.missingFromInventory,
|
|
848
|
+
unmatchedIntents: result.unmatchedIntents,
|
|
849
|
+
};
|
|
850
|
+
if (opts.format === 'json') emit(opts, '', detail);
|
|
851
|
+
else if (ok) {
|
|
852
|
+
console.log(`✅ Every delivered test-name claim exists in the inventory (${result.checked} checked from ${inventorySource}).`);
|
|
853
|
+
if (result.unmatchedIntents.length > 0) {
|
|
854
|
+
console.log(` ℹ️ ${result.unmatchedIntents.length} undelivered intention(s) now exist and the row may be stale — consider marking it DELIVERED.`);
|
|
855
|
+
}
|
|
856
|
+
} else {
|
|
857
|
+
console.error(`❌ ${result.missingFromInventory.length} delivered test-name claim(s) do not exist in the inventory (${inventorySource}):`);
|
|
858
|
+
for (const n of result.missingFromInventory) console.error(` "${n}" — declared as delivered but absent. Attach it to the criterion it proves, or correct the name.`);
|
|
859
|
+
}
|
|
860
|
+
if (!ok) process.exit(1);
|
|
861
|
+
return;
|
|
862
|
+
}
|
|
863
|
+
|
|
758
864
|
if (sub === 'cleanup') {
|
|
759
865
|
const { deleted, kept } = cleanupRuns(opts.targetDir, policy, {
|
|
760
866
|
olderThanMs: Number.isFinite(opts.olderThanMs) ? opts.olderThanMs : null,
|
|
@@ -763,7 +869,7 @@ async function cmdHarness(opts) {
|
|
|
763
869
|
return;
|
|
764
870
|
}
|
|
765
871
|
|
|
766
|
-
fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|report|cleanup|capabilities.`);
|
|
872
|
+
fail(opts, `Unknown harness subcommand: ${sub || '(none)'}. Use record|confirm|verify|verify-acs|matrix-check|report|cleanup|capabilities.`);
|
|
767
873
|
}
|
|
768
874
|
|
|
769
875
|
export async function run(argv) {
|
package/src/harness/index.mjs
CHANGED
|
@@ -69,3 +69,7 @@ export {
|
|
|
69
69
|
normalizeTestName, parseTestInventory, parseStoryCriteria, parseStoryCriteriaText,
|
|
70
70
|
compareCoverage, describeCoverageGaps,
|
|
71
71
|
} from './verify-acs.mjs';
|
|
72
|
+
|
|
73
|
+
export {
|
|
74
|
+
collectDeclaredTestNames, reconcileTestNames, inventoryFromCSharpSources,
|
|
75
|
+
} from './matrix-check.mjs';
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
// AR-5 — mechanical reconciliation of a TDD matrix's test-name claims against a
|
|
2
|
+
// compiled test inventory.
|
|
3
|
+
//
|
|
4
|
+
// THE DEFECT THIS EXISTS TO REMOVE. A TDD matrix row names the tests that prove
|
|
5
|
+
// an acceptance criterion. Those rows are authored during architecture, BEFORE
|
|
6
|
+
// implementation, so a name can be an intention that changes (or never happens)
|
|
7
|
+
// while nothing re-checks the row. In one real project this produced the SAME
|
|
8
|
+
// phantom-test-name defect three times, and all three were found late — by the
|
|
9
|
+
// validation gate, two stories after the claim was written. A name that does not
|
|
10
|
+
// exist reads as proof and is not.
|
|
11
|
+
//
|
|
12
|
+
// THE TWO DIRECTIONS, AND WHY THE DISTINCTION MATTERS.
|
|
13
|
+
//
|
|
14
|
+
// DELIVERED rows carry a claim: "these tests exist and prove this criterion".
|
|
15
|
+
// A name here that is absent from the inventory is a DEFECT.
|
|
16
|
+
// undelivered rows carry an intention for planned work. A name here that is
|
|
17
|
+
// absent is EXPECTED and must NOT be reported.
|
|
18
|
+
//
|
|
19
|
+
// Collapsing the two produces false failures, and a false failure is how a real
|
|
20
|
+
// check gets switched off. This module keeps them separate and makes the
|
|
21
|
+
// separation the caller's explicit choice.
|
|
22
|
+
//
|
|
23
|
+
// Deliberately dependency-free and side-effect-free: it returns findings and
|
|
24
|
+
// never throws on content, so it can run at authoring time as well as in a gate.
|
|
25
|
+
|
|
26
|
+
/** Marker that separates a row's claims from its explanatory prose. */
|
|
27
|
+
const DELIVERED_MARKER = 'DELIVERED';
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* A test name is `Identifier_LikeThis` — at least one underscore, both sides
|
|
31
|
+
* identifier-shaped. This deliberately rejects prose symbols that appear in
|
|
32
|
+
* backticks (type names such as `ViewExtent`, single letters such as `u`), which
|
|
33
|
+
* is the specific false-positive class that made an earlier checker unusable.
|
|
34
|
+
*/
|
|
35
|
+
const TEST_NAME = /^[A-Za-z][A-Za-z0-9]*_[A-Za-z0-9_]+$/;
|
|
36
|
+
|
|
37
|
+
/** Extract every backticked token that looks like a test name. */
|
|
38
|
+
function backtickedTestNames(text) {
|
|
39
|
+
const out = [];
|
|
40
|
+
for (const m of String(text).matchAll(/`([^`]+)`/g)) {
|
|
41
|
+
const name = m[1].trim();
|
|
42
|
+
if (TEST_NAME.test(name)) out.push(name);
|
|
43
|
+
}
|
|
44
|
+
return out;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Split a markdown table row into its cells (leading/trailing pipes dropped). */
|
|
48
|
+
function cellsOf(line) {
|
|
49
|
+
const trimmed = line.trim();
|
|
50
|
+
if (!trimmed.startsWith('|')) return null;
|
|
51
|
+
const parts = trimmed.split('|');
|
|
52
|
+
// A row looks like `| a | b | c |`; the split yields ['', ' a ', ' b ', ' c ', ''].
|
|
53
|
+
return parts.slice(1, -1).map((c) => c.trim());
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Collect test names from a TDD-matrix-style markdown document.
|
|
58
|
+
*
|
|
59
|
+
* Only the "declared tests" cell — the SECOND column — is read, and within a
|
|
60
|
+
* DELIVERED row only the text BEFORE the marker. Both restrictions exist because
|
|
61
|
+
* a matrix row's later columns discuss the design in prose and legitimately name
|
|
62
|
+
* types, symbols and fractions in backticks; treating those as claims is exactly
|
|
63
|
+
* the false-failure class this module was written to avoid.
|
|
64
|
+
*
|
|
65
|
+
* @returns {{claims: string[], intents: string[]}} deduplicated, source-ordered
|
|
66
|
+
*/
|
|
67
|
+
export function collectDeclaredTestNames(markdown) {
|
|
68
|
+
const claims = [];
|
|
69
|
+
const intents = [];
|
|
70
|
+
for (const line of String(markdown).split(/\r?\n/)) {
|
|
71
|
+
const cells = cellsOf(line);
|
|
72
|
+
if (!cells || cells.length < 2) continue;
|
|
73
|
+
const declaredCell = cells[1];
|
|
74
|
+
if (!declaredCell) continue;
|
|
75
|
+
// A separator row (`| --- | --- |`) has no test names and no marker.
|
|
76
|
+
const markerAt = declaredCell.indexOf(DELIVERED_MARKER);
|
|
77
|
+
const isDelivered = markerAt !== -1;
|
|
78
|
+
const scope = isDelivered ? declaredCell.slice(0, markerAt) : declaredCell;
|
|
79
|
+
const names = backtickedTestNames(scope);
|
|
80
|
+
(isDelivered ? claims : intents).push(...names);
|
|
81
|
+
}
|
|
82
|
+
return { claims: [...new Set(claims)], intents: [...new Set(intents)] };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Compare collected names against a compiled inventory.
|
|
87
|
+
*
|
|
88
|
+
* @param {{claims: string[], intents: string[]}} collected
|
|
89
|
+
* @param {Set<string>|string[]} inventory compiled test method names
|
|
90
|
+
* @returns {{missingFromInventory: string[], unmatchedIntents: string[], checked: number}}
|
|
91
|
+
*/
|
|
92
|
+
export function reconcileTestNames(collected, inventory) {
|
|
93
|
+
const have = inventory instanceof Set ? inventory : new Set(inventory || []);
|
|
94
|
+
const claims = Array.isArray(collected?.claims) ? collected.claims : [];
|
|
95
|
+
const intents = Array.isArray(collected?.intents) ? collected.intents : [];
|
|
96
|
+
return {
|
|
97
|
+
// Only DELIVERED claims can be defects. An intention is allowed to be absent.
|
|
98
|
+
missingFromInventory: claims.filter((n) => !have.has(n)),
|
|
99
|
+
// Reported separately and informationally: an intention that HAS landed is
|
|
100
|
+
// not a defect, but it usually means the row is stale and should be marked
|
|
101
|
+
// DELIVERED. Surfaced so the drift is visible rather than silent.
|
|
102
|
+
unmatchedIntents: intents.filter((n) => have.has(n)),
|
|
103
|
+
checked: claims.length,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Extract public test-method names from C# test sources.
|
|
109
|
+
*
|
|
110
|
+
* Used to build an inventory when no test report is available — for example at
|
|
111
|
+
* authoring time, before anything has run. This is a NAME inventory, not a
|
|
112
|
+
* pass/fail one: it proves a name exists, never that the test passes. Callers
|
|
113
|
+
* that need the stronger claim must use a run report.
|
|
114
|
+
*/
|
|
115
|
+
export function inventoryFromCSharpSources(sources) {
|
|
116
|
+
const names = new Set();
|
|
117
|
+
const pattern = /public\s+(?:async\s+)?(?:void|Task)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/g;
|
|
118
|
+
for (const text of sources) {
|
|
119
|
+
for (const m of String(text).matchAll(pattern)) names.add(m[1]);
|
|
120
|
+
}
|
|
121
|
+
return names;
|
|
122
|
+
}
|
package/src/harness/state.mjs
CHANGED
|
@@ -212,6 +212,47 @@ export function validateState(state, context = {}) {
|
|
|
212
212
|
if (lt.to !== undefined && !PHASES.includes(lt.to)) errors.push({ path: 'lastTransition.to', message: `unknown phase "${lt.to}"` });
|
|
213
213
|
}
|
|
214
214
|
}
|
|
215
|
+
|
|
216
|
+
// AR-2. A story marked `done` must have SOME evidence record of its own.
|
|
217
|
+
//
|
|
218
|
+
// WHY THIS IS NEEDED. Every gate rule in this file is scoped to the ACTIVE
|
|
219
|
+
// work item, so `state validate` could report a document as fully valid while
|
|
220
|
+
// an already-completed story had no evidence whatsoever. In one real project
|
|
221
|
+
// eight `done` stories had zero records and validation said "valid, 0 errors,
|
|
222
|
+
// 0 warnings" — the gaps were invisible until they were looked for by hand.
|
|
223
|
+
//
|
|
224
|
+
// SCOPE, DELIBERATELY NARROW. This asserts COVERAGE, not gate completeness:
|
|
225
|
+
// it asks only "is there any evidence for this story at all?". Whether every
|
|
226
|
+
// required gate was satisfied for the right phase is already enforced at
|
|
227
|
+
// transition time, against the active work item, where the phase is known.
|
|
228
|
+
// Re-deciding that here would duplicate the transition matrix and risk the
|
|
229
|
+
// two disagreeing.
|
|
230
|
+
//
|
|
231
|
+
// It is an ERROR, not a warning, because a `done` story with no evidence is
|
|
232
|
+
// indistinguishable from a story that was never verified — which is the
|
|
233
|
+
// condition the framework exists to prevent. Projects that closed stories
|
|
234
|
+
// before the harness existed can resolve it with a scoped gate exception or
|
|
235
|
+
// by re-recording; silently tolerating it is what let the gap grow.
|
|
236
|
+
if (isPlainObject(state.epics) && Array.isArray(state.gateEvidence)) {
|
|
237
|
+
const evidenced = new Set(
|
|
238
|
+
state.gateEvidence
|
|
239
|
+
.map((e) => (isPlainObject(e) ? e.workItemId : null))
|
|
240
|
+
.filter((id) => typeof id === 'string' && id.length > 0),
|
|
241
|
+
);
|
|
242
|
+
for (const [epicId, epic] of Object.entries(state.epics)) {
|
|
243
|
+
if (!isPlainObject(epic) || !isPlainObject(epic.stories)) continue;
|
|
244
|
+
for (const [storyId, status] of Object.entries(epic.stories)) {
|
|
245
|
+
if (status !== 'done') continue;
|
|
246
|
+
if (evidenced.has(`${epicId}::${storyId}`)) continue;
|
|
247
|
+
errors.push({
|
|
248
|
+
path: `epics.${epicId}.stories.${storyId}`,
|
|
249
|
+
message: `story "${storyId}" is marked done but has no evidence record for its work item `
|
|
250
|
+
+ `"${epicId}::${storyId}". A completed story must be backed by at least one evidence `
|
|
251
|
+
+ 'record; otherwise it is indistinguishable from one that was never verified.',
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
}
|
|
215
256
|
} else if (state.gateEvidence !== undefined) {
|
|
216
257
|
warnings.push({ path: 'gateEvidence', message: 'gateEvidence on a v1 state is ignored until migration' });
|
|
217
258
|
}
|
|
@@ -275,6 +316,15 @@ function validateEvidenceShape(ev, strict = null) {
|
|
|
275
316
|
if (ev.result !== undefined && ev.result !== null && typeof ev.result !== 'string') {
|
|
276
317
|
errors.push({ path: 'result', message: 'result must be a string or null' });
|
|
277
318
|
}
|
|
319
|
+
// AR-1. `commit` is optional (a v2-shaped record omits it) but must be a real
|
|
320
|
+
// revision identifier when present — a branch or tag name would read as a
|
|
321
|
+
// citation while being uncheckable later, which is worse than none.
|
|
322
|
+
if (ev.commit !== undefined && ev.commit !== null && !/^[0-9a-fA-F]{4,40}$/.test(String(ev.commit))) {
|
|
323
|
+
errors.push({
|
|
324
|
+
path: 'commit',
|
|
325
|
+
message: 'commit must be a 4-40 character hex revision identifier, or null',
|
|
326
|
+
});
|
|
327
|
+
}
|
|
278
328
|
if (ev.createdAt !== undefined && ev.createdAt !== null && Number.isNaN(Date.parse(ev.createdAt))) {
|
|
279
329
|
errors.push({ path: 'createdAt', message: 'createdAt must be an ISO-8601 date-time' });
|
|
280
330
|
}
|
|
@@ -552,6 +602,30 @@ export function migrateStateFile(statePath, { backup = true } = {}) {
|
|
|
552
602
|
|
|
553
603
|
// ── Evidence ────────────────────────────────────────────────────────────────
|
|
554
604
|
|
|
605
|
+
/**
|
|
606
|
+
* AR-1. Normalize and validate a commit citation.
|
|
607
|
+
*
|
|
608
|
+
* Accepts a full SHA or an abbreviated one (git's default short form is 7, but
|
|
609
|
+
* 4–40 hex characters are all unambiguous enough to store). A symbolic name such
|
|
610
|
+
* as a branch or tag is REJECTED: those move, so a record naming one cannot be
|
|
611
|
+
* checked later, which defeats the purpose of citing a revision at all.
|
|
612
|
+
*
|
|
613
|
+
* Returns null for an absent value so a v2-shaped record is unchanged.
|
|
614
|
+
*/
|
|
615
|
+
export function normalizeCommit(commit) {
|
|
616
|
+
if (commit === null || commit === undefined) return null;
|
|
617
|
+
const value = String(commit).trim();
|
|
618
|
+
if (value === '') return null;
|
|
619
|
+
if (!/^[0-9a-fA-F]{4,40}$/.test(value)) {
|
|
620
|
+
throw new StateError(
|
|
621
|
+
`commit must be a 4-40 character hex revision identifier, but was "${value}". `
|
|
622
|
+
+ 'A branch or tag name is not accepted: it moves, so the citation could not be '
|
|
623
|
+
+ 'checked later. Pass an abbreviated or full SHA.',
|
|
624
|
+
);
|
|
625
|
+
}
|
|
626
|
+
return value.toLowerCase();
|
|
627
|
+
}
|
|
628
|
+
|
|
555
629
|
/** Build an evidence record. `id` defaults to a fresh UUIDv4. */
|
|
556
630
|
export function createEvidence({
|
|
557
631
|
evidenceId,
|
|
@@ -569,12 +643,19 @@ export function createEvidence({
|
|
|
569
643
|
criteriaHash = null,
|
|
570
644
|
relevantFiles = [],
|
|
571
645
|
toolVersion = null,
|
|
646
|
+
commit = null,
|
|
572
647
|
createdAt = new Date(),
|
|
573
648
|
expiresAt = null,
|
|
574
649
|
freshnessPolicy = null,
|
|
575
650
|
source = 'automated',
|
|
576
651
|
id,
|
|
577
652
|
}) {
|
|
653
|
+
// AR-1. A gate record must be able to name the revision it attests, or a
|
|
654
|
+
// "gate-related fix claim" cannot be traced to the code it claims to cover.
|
|
655
|
+
// Validated rather than trusted: this value is persisted into state.json and
|
|
656
|
+
// read back by the Reviewer, so a malformed one would make a claim look
|
|
657
|
+
// verified while naming nothing.
|
|
658
|
+
const normalizedCommit = normalizeCommit(commit);
|
|
578
659
|
return {
|
|
579
660
|
evidenceId: evidenceId || id || undefined,
|
|
580
661
|
workItemId,
|
|
@@ -591,6 +672,7 @@ export function createEvidence({
|
|
|
591
672
|
criteriaHash: criteriaHash || hashCriteria([]),
|
|
592
673
|
relevantFiles,
|
|
593
674
|
toolVersion,
|
|
675
|
+
commit: normalizedCommit,
|
|
594
676
|
createdAt: timestamp(createdAt),
|
|
595
677
|
expiresAt: expiresAt ? timestamp(expiresAt) : null,
|
|
596
678
|
freshnessPolicy,
|
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
|
+
|
|
@@ -252,6 +252,7 @@ export async function runVerificationLoop({
|
|
|
252
252
|
relevantFiles = [],
|
|
253
253
|
criteria = [],
|
|
254
254
|
rootDir = process.cwd(),
|
|
255
|
+
commit = null,
|
|
255
256
|
policy,
|
|
256
257
|
budgets,
|
|
257
258
|
runCommandImpl = runCommand,
|
|
@@ -327,6 +328,7 @@ export async function runVerificationLoop({
|
|
|
327
328
|
inputTreeHash,
|
|
328
329
|
criteriaHash,
|
|
329
330
|
relevantFiles,
|
|
331
|
+
commit,
|
|
330
332
|
createdAt: startedAt,
|
|
331
333
|
source: 'automated',
|
|
332
334
|
});
|
|
@@ -461,7 +463,7 @@ function finalize({ status, attempts, tracker, inputTreeHash, criteriaHash, stop
|
|
|
461
463
|
export function manualConfirmation({
|
|
462
464
|
gate, workItemId, phase, projectPath, editorVersion, scope, acceptanceCriterionId = null,
|
|
463
465
|
relevantFiles = [], criteria = [], rootDir = process.cwd(), approvedBy = 'user', at = new Date(),
|
|
464
|
-
reason = null, expiresAt = null, environment = null, expiresInMs = null,
|
|
466
|
+
reason = null, expiresAt = null, environment = null, expiresInMs = null, commit = null,
|
|
465
467
|
} = {}) {
|
|
466
468
|
const inputTreeHash = computeInputTreeHash(rootDir, relevantFiles);
|
|
467
469
|
// v3 quality fields. `scope` is declared both as the free-text `result` line
|
|
@@ -504,6 +506,7 @@ export function manualConfirmation({
|
|
|
504
506
|
relevantFiles,
|
|
505
507
|
createdAt: at,
|
|
506
508
|
expiresAt: expiry,
|
|
509
|
+
commit,
|
|
507
510
|
source: 'manual-confirmation',
|
|
508
511
|
}),
|
|
509
512
|
// Present only when supplied, so a v2-shaped record is unchanged when the
|