musubix3 0.1.20 → 0.1.21
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/.github/plugin/marketplace.json +2 -2
- package/.github/skills/sdd-change/SKILL.md +28 -45
- package/CHANGELOG.md +88 -0
- package/README-ja.md +103 -3
- package/README.md +113 -3
- package/dist/packages/analysis/src/approval.d.ts +51 -0
- package/dist/packages/analysis/src/approval.js +341 -2
- package/dist/packages/analysis/src/approval.js.map +1 -1
- package/dist/packages/analysis/src/attestation.js +4 -0
- package/dist/packages/analysis/src/attestation.js.map +1 -1
- package/dist/packages/analysis/src/change-evidence.d.ts +56 -13
- package/dist/packages/analysis/src/change-evidence.js +142 -81
- package/dist/packages/analysis/src/change-evidence.js.map +1 -1
- package/dist/packages/analysis/src/change-waiver.d.ts +37 -13
- package/dist/packages/analysis/src/change-waiver.js +425 -174
- package/dist/packages/analysis/src/change-waiver.js.map +1 -1
- package/dist/packages/analysis/src/change.d.ts +1 -0
- package/dist/packages/analysis/src/change.js +176 -23
- package/dist/packages/analysis/src/change.js.map +1 -1
- package/dist/packages/analysis/src/evidence-merge-guard.d.ts +5 -0
- package/dist/packages/analysis/src/evidence-merge-guard.js +15 -0
- package/dist/packages/analysis/src/evidence-merge-guard.js.map +1 -1
- package/dist/packages/analysis/src/evidence-merge.d.ts +2 -0
- package/dist/packages/analysis/src/evidence-merge.js +173 -38
- package/dist/packages/analysis/src/evidence-merge.js.map +1 -1
- package/dist/packages/analysis/src/evidence-writer-lock.d.ts +9 -2
- package/dist/packages/analysis/src/evidence-writer-lock.js +75 -21
- package/dist/packages/analysis/src/evidence-writer-lock.js.map +1 -1
- package/dist/packages/analysis/src/files.js +5 -0
- package/dist/packages/analysis/src/files.js.map +1 -1
- package/dist/packages/analysis/src/gate.d.ts +1 -1
- package/dist/packages/analysis/src/gate.js +17 -4
- package/dist/packages/analysis/src/gate.js.map +1 -1
- package/dist/packages/analysis/src/index.d.ts +1 -0
- package/dist/packages/analysis/src/index.js +1 -0
- package/dist/packages/analysis/src/index.js.map +1 -1
- package/dist/packages/analysis/src/order.d.ts +16 -1
- package/dist/packages/analysis/src/order.js +6 -0
- package/dist/packages/analysis/src/order.js.map +1 -1
- package/dist/packages/analysis/src/quality-refresh.d.ts +18 -0
- package/dist/packages/analysis/src/quality-refresh.js +312 -0
- package/dist/packages/analysis/src/quality-refresh.js.map +1 -0
- package/dist/packages/analysis/src/tdd.d.ts +84 -3
- package/dist/packages/analysis/src/tdd.js +589 -50
- package/dist/packages/analysis/src/tdd.js.map +1 -1
- package/dist/packages/analysis/src/trace.d.ts +24 -0
- package/dist/packages/analysis/src/trace.js +95 -3
- package/dist/packages/analysis/src/trace.js.map +1 -1
- package/dist/packages/analysis/src/workflow-waiver.d.ts +6 -2
- package/dist/packages/analysis/src/workflow-waiver.js +9 -5
- package/dist/packages/analysis/src/workflow-waiver.js.map +1 -1
- package/dist/packages/analysis/src/workflow.d.ts +1 -1
- package/dist/packages/analysis/src/workflow.js +4 -4
- package/dist/packages/analysis/src/workflow.js.map +1 -1
- package/dist/packages/cli/src/install.js +27 -4
- package/dist/packages/cli/src/install.js.map +1 -1
- package/dist/packages/cli/src/main.js +77 -18
- package/dist/packages/cli/src/main.js.map +1 -1
- package/dist/packages/domain/src/design.js +8 -1
- package/dist/packages/domain/src/design.js.map +1 -1
- package/dist/packages/domain/src/types.d.ts +1 -0
- package/package.json +7 -2
- package/plugin.json +1 -1
|
@@ -8,7 +8,108 @@ import { buildTrace } from './trace.js';
|
|
|
8
8
|
import { adapterInvocation, clearAdapterOutput, mergeAdapterArgs, normalizeAdapterReport, readAdapterOutput } from './adapters.js';
|
|
9
9
|
import { appendEvidenceOrder, evidenceOrderRecord, inspectEvidenceOrder } from './order.js';
|
|
10
10
|
import { requireApproval, resolveRequirementDomain } from './approval.js';
|
|
11
|
+
import { batchFor, changeRecordPhasePrecondition, currentTddOrderWindow, effectiveBatches, loadChangeEvidence, } from './change-evidence.js';
|
|
11
12
|
import { withEvidenceWriterLock } from './evidence-writer-lock.js';
|
|
13
|
+
// TDD cycle-validity re-derivations. Relocated here (unchanged bodies) from
|
|
14
|
+
// change-evidence.ts: they need TddCycle/TddEvidence, which are defined in
|
|
15
|
+
// this file, and change-evidence.ts must stay free of any dependency on
|
|
16
|
+
// this file so that this file's own (new, CHANGE-0049) runtime dependency
|
|
17
|
+
// on change-evidence.ts (for changeRecordPhasePrecondition/loadChangeEvidence)
|
|
18
|
+
// does not create a module-dependency cycle between the two files.
|
|
19
|
+
/** @id CODE-CHANGE-REQUIREMENT-BATCHES-004
|
|
20
|
+
* @implements REQ-CHANGE-REQUIREMENT-BATCHES-005
|
|
21
|
+
* @design DES-CHANGE-REQUIREMENT-BATCHES-002
|
|
22
|
+
*/
|
|
23
|
+
export function voidedCycleOrdersInCurrentWindow(change, requirementId, tdd, validlyVoided) {
|
|
24
|
+
const window = currentTddOrderWindow(change, requirementId);
|
|
25
|
+
if (!window)
|
|
26
|
+
return [];
|
|
27
|
+
return (tdd?.cycles ?? [])
|
|
28
|
+
.filter((cycle) => cycle.requirementId === requirementId
|
|
29
|
+
&& validlyVoided.has(cycle)
|
|
30
|
+
&& Number.isInteger(cycle.red.order)
|
|
31
|
+
&& cycle.red.order > window.after
|
|
32
|
+
&& cycle.red.order <= window.through
|
|
33
|
+
&& Number.isInteger(cycle.void?.order))
|
|
34
|
+
.map((cycle) => cycle.void.order)
|
|
35
|
+
.sort((a, b) => a - b);
|
|
36
|
+
}
|
|
37
|
+
/** @id CODE-CHANGE-EVIDENCE-WAIVER-029
|
|
38
|
+
* @implements REQ-CHANGE-EVIDENCE-WAIVER-018
|
|
39
|
+
* @design DES-CHANGE-EVIDENCE-WAIVER-006
|
|
40
|
+
* Excludes validly archived cycles from the order window alongside validly
|
|
41
|
+
* voided ones (Issue #64): a cleaned-up dangling cycle (e.g. one `tdd
|
|
42
|
+
* archive`d because the forced-failing Red it required never applied) must
|
|
43
|
+
* never be selected as the "current" cycle for a requirement.
|
|
44
|
+
*/
|
|
45
|
+
function currentTddCycle(change, requirementId, tdd, validlyVoided, validlyArchived = new Set()) {
|
|
46
|
+
const window = currentTddOrderWindow(change, requirementId);
|
|
47
|
+
if (!window)
|
|
48
|
+
return undefined;
|
|
49
|
+
return (tdd?.cycles ?? [])
|
|
50
|
+
.filter((cycle) => cycle.requirementId === requirementId
|
|
51
|
+
&& !validlyVoided.has(cycle)
|
|
52
|
+
&& !validlyArchived.has(cycle)
|
|
53
|
+
&& Number.isInteger(cycle.red.order)
|
|
54
|
+
&& cycle.red.order > window.after
|
|
55
|
+
&& cycle.red.order <= window.through)
|
|
56
|
+
.reduce((selected, cycle) => !selected || cycle.red.order >= selected.red.order ? cycle : selected, undefined);
|
|
57
|
+
}
|
|
58
|
+
function tddCyclesInCurrentWindow(change, requirementId, tdd, validlyVoided) {
|
|
59
|
+
const window = currentTddOrderWindow(change, requirementId);
|
|
60
|
+
if (!window)
|
|
61
|
+
return [];
|
|
62
|
+
return (tdd?.cycles ?? []).filter((cycle) => cycle.requirementId === requirementId
|
|
63
|
+
&& !validlyVoided.has(cycle)
|
|
64
|
+
&& Number.isInteger(cycle.red.order)
|
|
65
|
+
&& cycle.red.order > window.after
|
|
66
|
+
&& cycle.red.order <= window.through);
|
|
67
|
+
}
|
|
68
|
+
export function hasValidTddCycle(change, requirementId, tdd, validlyVoided = new Set()) {
|
|
69
|
+
const batch = batchFor(effectiveBatches(change), requirementId);
|
|
70
|
+
const implementation = batch?.implementation;
|
|
71
|
+
const green = batch?.green;
|
|
72
|
+
return tddCyclesInCurrentWindow(change, requirementId, tdd, validlyVoided).some((cycle) => !!implementation
|
|
73
|
+
&& green
|
|
74
|
+
&& Number.isInteger(implementation.order)
|
|
75
|
+
&& Number.isInteger(green.order)
|
|
76
|
+
&& Number.isInteger(cycle.green?.order)
|
|
77
|
+
&& cycle.red.valid
|
|
78
|
+
&& cycle.green?.valid
|
|
79
|
+
&& cycle.green.order > implementation.order
|
|
80
|
+
&& cycle.green.order <= green.order);
|
|
81
|
+
}
|
|
82
|
+
export function redUnprovenCondition(change, requirementId, tdd, validlyVoided = new Set()) {
|
|
83
|
+
const batch = batchFor(effectiveBatches(change), requirementId);
|
|
84
|
+
return !!batch?.red && !hasValidTddCycle(change, requirementId, tdd, validlyVoided);
|
|
85
|
+
}
|
|
86
|
+
export function greenUnprovenCondition(change, requirementId, tdd, validlyVoided = new Set()) {
|
|
87
|
+
const batch = batchFor(effectiveBatches(change), requirementId);
|
|
88
|
+
return !!batch?.green && !hasValidTddCycle(change, requirementId, tdd, validlyVoided);
|
|
89
|
+
}
|
|
90
|
+
export function completenessTddUnsatisfiedCondition(change, requirementId, tdd, validlyVoided = new Set()) {
|
|
91
|
+
return !hasValidTddCycle(change, requirementId, tdd, validlyVoided);
|
|
92
|
+
}
|
|
93
|
+
/** @id CODE-CHANGE-EVIDENCE-WAIVER-028
|
|
94
|
+
* @implements REQ-CHANGE-EVIDENCE-WAIVER-018
|
|
95
|
+
* @design DES-CHANGE-EVIDENCE-WAIVER-006
|
|
96
|
+
* Accepts an optional `validlyArchived` set, mirroring `validlyVoided`, so
|
|
97
|
+
* archived TDD cycles are excluded from both the "lacks order evidence" scan
|
|
98
|
+
* and `currentTddCycle`'s window selection (Issue #64).
|
|
99
|
+
*/
|
|
100
|
+
export function orderMigrationRequiredRequirementCondition(change, requirementId, tdd, validlyVoided = new Set(), validlyArchived = new Set()) {
|
|
101
|
+
const batch = batchFor(effectiveBatches(change), requirementId);
|
|
102
|
+
if (!batch?.red)
|
|
103
|
+
return false;
|
|
104
|
+
const cycles = (tdd?.cycles ?? []).filter((cycle) => cycle.requirementId === requirementId && !validlyVoided.has(cycle) && !validlyArchived.has(cycle));
|
|
105
|
+
if (cycles.some((cycle) => !Number.isInteger(cycle.red.order)))
|
|
106
|
+
return true;
|
|
107
|
+
const window = currentTddOrderWindow(change, requirementId);
|
|
108
|
+
if (!window)
|
|
109
|
+
return false;
|
|
110
|
+
const current = currentTddCycle(change, requirementId, tdd, validlyVoided, validlyArchived);
|
|
111
|
+
return !!current && !Number.isInteger(current.green?.order);
|
|
112
|
+
}
|
|
12
113
|
function render(value, testId, testPath, reportPath) {
|
|
13
114
|
return value.replaceAll('{testId}', testId).replaceAll('{testPath}', testPath).replaceAll('{reportPath}', reportPath);
|
|
14
115
|
}
|
|
@@ -31,6 +132,57 @@ export function parseMusubixTestReport(text) {
|
|
|
31
132
|
function chainRecordSha256(record) {
|
|
32
133
|
return digest(JSON.stringify(record));
|
|
33
134
|
}
|
|
135
|
+
function escapeRegExp(value) {
|
|
136
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
137
|
+
}
|
|
138
|
+
function migrationMode(record) {
|
|
139
|
+
return record.mode ?? 'fingerprint';
|
|
140
|
+
}
|
|
141
|
+
function migrationScope(record) {
|
|
142
|
+
return migrationMode(record) === 'identifier'
|
|
143
|
+
? {
|
|
144
|
+
...(record.oldTestId !== undefined ? { oldTestId: record.oldTestId } : {}),
|
|
145
|
+
...(record.newTestId !== undefined ? { newTestId: record.newTestId } : {}),
|
|
146
|
+
}
|
|
147
|
+
: undefined;
|
|
148
|
+
}
|
|
149
|
+
function migrationRecords(cycle) {
|
|
150
|
+
if (cycle.migrateHistory?.length)
|
|
151
|
+
return cycle.migrateHistory;
|
|
152
|
+
return cycle.migrate ? [cycle.migrate] : [];
|
|
153
|
+
}
|
|
154
|
+
function latestMigration(cycle) {
|
|
155
|
+
return migrationRecords(cycle).at(-1);
|
|
156
|
+
}
|
|
157
|
+
function currentEffectiveTestId(cycle) {
|
|
158
|
+
const identifierMigration = [...migrationRecords(cycle)].reverse().find((record) => migrationMode(record) === 'identifier');
|
|
159
|
+
return identifierMigration?.newTestId ?? cycle.testId;
|
|
160
|
+
}
|
|
161
|
+
function currentEffectiveFingerprint(cycle) {
|
|
162
|
+
const migrate = latestMigration(cycle);
|
|
163
|
+
if (migrate)
|
|
164
|
+
return migrate.toFingerprint;
|
|
165
|
+
if (cycle.refactor?.valid)
|
|
166
|
+
return cycle.refactor.testFingerprint;
|
|
167
|
+
return cycle.green?.testFingerprint;
|
|
168
|
+
}
|
|
169
|
+
function currentEffectiveSourceFingerprint(cycle) {
|
|
170
|
+
if (cycle.refactor?.valid)
|
|
171
|
+
return cycle.refactor.sourceFingerprint;
|
|
172
|
+
return cycle.green?.sourceFingerprint;
|
|
173
|
+
}
|
|
174
|
+
function chainIdentity(record) {
|
|
175
|
+
if (record.phase === 'migrate') {
|
|
176
|
+
return JSON.stringify([
|
|
177
|
+
record.cycleId,
|
|
178
|
+
record.phase,
|
|
179
|
+
record.mode ?? 'fingerprint',
|
|
180
|
+
record.oldTestId ?? null,
|
|
181
|
+
record.newTestId ?? null,
|
|
182
|
+
]);
|
|
183
|
+
}
|
|
184
|
+
return JSON.stringify([record.cycleId, record.phase]);
|
|
185
|
+
}
|
|
34
186
|
function appendChainRecord(evidence, cycle, phase, phaseEvidence) {
|
|
35
187
|
if (!cycle.cycleId)
|
|
36
188
|
throw new Error('TDD cycle ID is required for append-only evidence.');
|
|
@@ -49,6 +201,13 @@ function appendChainRecord(evidence, cycle, phase, phaseEvidence) {
|
|
|
49
201
|
testPath: cycle.testPath,
|
|
50
202
|
commandName: cycle.commandName,
|
|
51
203
|
phase,
|
|
204
|
+
...(phase === 'migrate' && phaseEvidence && typeof phaseEvidence === 'object'
|
|
205
|
+
? {
|
|
206
|
+
mode: phaseEvidence.mode,
|
|
207
|
+
oldTestId: phaseEvidence.oldTestId,
|
|
208
|
+
newTestId: phaseEvidence.newTestId,
|
|
209
|
+
}
|
|
210
|
+
: {}),
|
|
52
211
|
phaseEvidenceSha256: digest(JSON.stringify(phaseEvidence)),
|
|
53
212
|
previousSha256: previous?.recordSha256 ?? null,
|
|
54
213
|
};
|
|
@@ -75,7 +234,7 @@ function collectStatements(node, out) {
|
|
|
75
234
|
out.push(node);
|
|
76
235
|
ts.forEachChild(node, (child) => collectStatements(child, out));
|
|
77
236
|
}
|
|
78
|
-
async function
|
|
237
|
+
async function testDeclarationText(root, test) {
|
|
79
238
|
const text = await readText(root, test.path);
|
|
80
239
|
const lines = text.split(/\r?\n/);
|
|
81
240
|
const start = lines.slice(0, Math.max(0, test.line - 1)).join('\n').length + (test.line > 1 ? 1 : 0);
|
|
@@ -85,12 +244,15 @@ async function testFingerprint(root, test) {
|
|
|
85
244
|
collectStatements(source, statements);
|
|
86
245
|
const declaration = statements.find((statement) => statement.getStart(source) >= start);
|
|
87
246
|
if (declaration)
|
|
88
|
-
return
|
|
247
|
+
return text.slice(start, declaration.end).trim();
|
|
89
248
|
}
|
|
90
249
|
const lineEnd = text.indexOf('\n', start);
|
|
91
250
|
const searchFrom = lineEnd < 0 ? text.length : lineEnd + 1;
|
|
92
251
|
const next = text.slice(searchFrom).search(/^[ \t]*(?:\/\*+|\/\/|#).*?@id\s+TEST-/m);
|
|
93
|
-
return
|
|
252
|
+
return text.slice(start, next < 0 ? text.length : searchFrom + next).trim();
|
|
253
|
+
}
|
|
254
|
+
async function testFingerprint(root, test) {
|
|
255
|
+
return digest(await testDeclarationText(root, test));
|
|
94
256
|
}
|
|
95
257
|
// The pre-REQ-TDD-FINGERPRINT-SCOPING-001 algorithm (top-level statements
|
|
96
258
|
// only). Retained solely so `migrateTddFingerprint` can prove a stored
|
|
@@ -116,6 +278,43 @@ export async function legacyTestFingerprint(root, test) {
|
|
|
116
278
|
const next = text.slice(searchFrom).search(/^[ \t]*(?:\/\*+|\/\/|#).*?@id\s+TEST-/m);
|
|
117
279
|
return digest(text.slice(start, next < 0 ? text.length : searchFrom + next).trim());
|
|
118
280
|
}
|
|
281
|
+
/** @id CODE-TDD-IDENTIFIER-MIGRATION-002
|
|
282
|
+
* @implements REQ-TDD-IDENTIFIER-MIGRATION-002 REQ-TDD-IDENTIFIER-MIGRATION-003
|
|
283
|
+
* @design DES-TDD-IDENTIFIER-MIGRATION-002
|
|
284
|
+
*/
|
|
285
|
+
async function normalizedIdentifierMigrationFingerprint(root, test, oldTestId, newTestId) {
|
|
286
|
+
let declaration = await testDeclarationText(root, test);
|
|
287
|
+
const idPattern = new RegExp(`@id\\s+${escapeRegExp(newTestId)}\\b`, 'g');
|
|
288
|
+
const idMatches = declaration.match(idPattern) ?? [];
|
|
289
|
+
if (idMatches.length !== 1) {
|
|
290
|
+
throw new Error(`${newTestId} must contain exactly one authoritative @id annotation in its declaration.`);
|
|
291
|
+
}
|
|
292
|
+
declaration = declaration.replace(idPattern, `@id ${oldTestId}`);
|
|
293
|
+
if (!isSource(test.path))
|
|
294
|
+
return digest(declaration);
|
|
295
|
+
const source = ts.createSourceFile(test.path, declaration, ts.ScriptTarget.Latest, true);
|
|
296
|
+
const literals = [];
|
|
297
|
+
const visit = (node) => {
|
|
298
|
+
if ((ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) && node.text.includes(newTestId)) {
|
|
299
|
+
literals.push({ start: node.getStart(source), end: node.end, text: node.text });
|
|
300
|
+
}
|
|
301
|
+
ts.forEachChild(node, visit);
|
|
302
|
+
};
|
|
303
|
+
visit(source);
|
|
304
|
+
if (literals.length !== 1) {
|
|
305
|
+
throw new Error(`${newTestId} must differ from ${oldTestId} only in its @id annotation and one adapter-matched identity string.`);
|
|
306
|
+
}
|
|
307
|
+
const [literal] = literals;
|
|
308
|
+
if (!literal)
|
|
309
|
+
throw new Error('Missing adapter identity literal.');
|
|
310
|
+
const replacement = declaration.slice(literal.start, literal.end).replace(new RegExp(escapeRegExp(newTestId), 'g'), oldTestId);
|
|
311
|
+
declaration = `${declaration.slice(0, literal.start)}${replacement}${declaration.slice(literal.end)}`;
|
|
312
|
+
return digest(declaration.trim());
|
|
313
|
+
}
|
|
314
|
+
/** @id CODE-TDD-CYCLE-ARCHIVE-006
|
|
315
|
+
* @implements REQ-TDD-CYCLE-ARCHIVE-012
|
|
316
|
+
* @design DES-TDD-CYCLE-ARCHIVE-005
|
|
317
|
+
*/
|
|
119
318
|
export async function migrateTddFingerprint(root, testId, approver) {
|
|
120
319
|
return withEvidenceWriterLock(root, 'tdd migrate', () => migrateTddFingerprintUnlocked(root, testId, approver));
|
|
121
320
|
}
|
|
@@ -129,13 +328,21 @@ async function migrateTddFingerprintUnlocked(root, testId, approver) {
|
|
|
129
328
|
if (!cycle)
|
|
130
329
|
throw new Error(`No TDD cycle found for ${testId}.`);
|
|
131
330
|
const order = await inspectEvidenceOrder(root);
|
|
331
|
+
/** @id CODE-TDD-CYCLE-ARCHIVE-005
|
|
332
|
+
* @implements REQ-TDD-CYCLE-ARCHIVE-012
|
|
333
|
+
* @design DES-TDD-CYCLE-ARCHIVE-005
|
|
334
|
+
*/
|
|
335
|
+
if (archiveLinkage(evidence, order, cycle).valid) {
|
|
336
|
+
throw new Error(`${testId}'s latest cycle is archived and cannot be migrated; record a fresh Red-Green cycle instead.`);
|
|
337
|
+
}
|
|
132
338
|
/** @id CODE-TDD-CYCLE-VOID-006
|
|
133
|
-
* @implements REQ-TDD-CYCLE-VOID-012
|
|
134
|
-
* @design DES-TDD-CYCLE-VOID-006
|
|
339
|
+
* @implements REQ-TDD-CYCLE-VOID-012 REQ-TDD-CYCLE-ARCHIVE-012
|
|
340
|
+
* @design DES-TDD-CYCLE-VOID-006 DES-TDD-CYCLE-ARCHIVE-005
|
|
135
341
|
*/
|
|
136
342
|
if (voidLinkage(evidence, order, cycle).valid) {
|
|
137
343
|
const validlyVoided = new Set(evidence.cycles.filter((entry) => voidLinkage(evidence, order, entry).valid));
|
|
138
|
-
const
|
|
344
|
+
const validlyArchived = new Set(evidence.cycles.filter((entry) => archiveLinkage(evidence, order, entry).valid));
|
|
345
|
+
const effective = effectiveLatestCycle(evidence, order, validlyVoided, validlyArchived, testId, cycle);
|
|
139
346
|
if (effective)
|
|
140
347
|
cycle = effective;
|
|
141
348
|
}
|
|
@@ -143,14 +350,15 @@ async function migrateTddFingerprintUnlocked(root, testId, approver) {
|
|
|
143
350
|
throw new Error(`${testId} lacks a cycle ID; regenerate its evidence before migrating.`);
|
|
144
351
|
if (!cycle.green?.valid)
|
|
145
352
|
throw new Error(`${testId} has no valid Green phase to migrate.`);
|
|
146
|
-
if (cycle.
|
|
353
|
+
if (migrationRecords(cycle).length > 0)
|
|
147
354
|
throw new Error(`${testId} has already been migrated.`);
|
|
148
355
|
const trace = await buildTrace(root);
|
|
149
356
|
const test = trace.nodes.find((node) => node.kind === 'test' && node.id === testId);
|
|
150
357
|
if (!test)
|
|
151
358
|
throw new Error(`Annotated test ID not found: ${testId}`);
|
|
152
|
-
const
|
|
153
|
-
|
|
359
|
+
const storedFingerprint = currentEffectiveFingerprint(cycle);
|
|
360
|
+
if (!storedFingerprint)
|
|
361
|
+
throw new Error(`${testId} has no effective fingerprint to migrate.`);
|
|
154
362
|
const legacyCurrent = await legacyTestFingerprint(root, test);
|
|
155
363
|
if (legacyCurrent !== storedFingerprint) {
|
|
156
364
|
return {
|
|
@@ -165,6 +373,7 @@ async function migrateTddFingerprintUnlocked(root, testId, approver) {
|
|
|
165
373
|
fromFingerprint: storedFingerprint,
|
|
166
374
|
toFingerprint,
|
|
167
375
|
approver,
|
|
376
|
+
mode: 'fingerprint',
|
|
168
377
|
recordedAt: new Date().toISOString(),
|
|
169
378
|
};
|
|
170
379
|
record.order = (await appendEvidenceOrder(root, { kind: 'tdd', entityId: cycle.cycleId, phase: 'migrate' })).sequence;
|
|
@@ -173,6 +382,105 @@ async function migrateTddFingerprintUnlocked(root, testId, approver) {
|
|
|
173
382
|
await writeJson(root, '.musubix/evidence/tdd.json', evidence);
|
|
174
383
|
return { migrated: true, testId, fromFingerprint: storedFingerprint, toFingerprint };
|
|
175
384
|
}
|
|
385
|
+
/** @id CODE-TDD-IDENTIFIER-MIGRATION-001
|
|
386
|
+
* @implements REQ-TDD-IDENTIFIER-MIGRATION-001 REQ-TDD-IDENTIFIER-MIGRATION-002 REQ-TDD-IDENTIFIER-MIGRATION-003 REQ-TDD-IDENTIFIER-MIGRATION-004
|
|
387
|
+
* @design DES-TDD-IDENTIFIER-MIGRATION-001 DES-TDD-IDENTIFIER-MIGRATION-002 DES-TDD-IDENTIFIER-MIGRATION-003
|
|
388
|
+
*/
|
|
389
|
+
export async function migrateTddIdentifier(root, oldTestId, newTestId, approver) {
|
|
390
|
+
return withEvidenceWriterLock(root, 'tdd migrate', () => migrateTddIdentifierUnlocked(root, oldTestId, newTestId, approver));
|
|
391
|
+
}
|
|
392
|
+
async function migrateTddIdentifierUnlocked(root, oldTestId, newTestId, approver) {
|
|
393
|
+
if (!approver?.trim())
|
|
394
|
+
throw new Error('An approver is required to migrate TDD identifier evidence.');
|
|
395
|
+
if (oldTestId === newTestId)
|
|
396
|
+
throw new Error('Identifier migration requires old and new test IDs that must differ.');
|
|
397
|
+
const evidence = await loadTddEvidence(root);
|
|
398
|
+
if (!evidence)
|
|
399
|
+
throw new Error('No TDD evidence found.');
|
|
400
|
+
let cycle = evidence.cycles.filter((entry) => entry.testId === oldTestId).at(-1);
|
|
401
|
+
if (!cycle)
|
|
402
|
+
throw new Error(`No TDD cycle found for ${oldTestId}.`);
|
|
403
|
+
const order = await inspectEvidenceOrder(root);
|
|
404
|
+
if (archiveLinkage(evidence, order, cycle).valid) {
|
|
405
|
+
throw new Error(`${oldTestId}'s latest cycle is archived and cannot be migrated; record a fresh Red-Green cycle instead.`);
|
|
406
|
+
}
|
|
407
|
+
if (voidLinkage(evidence, order, cycle).valid) {
|
|
408
|
+
const validlyVoided = new Set(evidence.cycles.filter((entry) => voidLinkage(evidence, order, entry).valid));
|
|
409
|
+
const validlyArchived = new Set(evidence.cycles.filter((entry) => archiveLinkage(evidence, order, entry).valid));
|
|
410
|
+
const effective = effectiveLatestCycle(evidence, order, validlyVoided, validlyArchived, oldTestId, cycle);
|
|
411
|
+
if (effective)
|
|
412
|
+
cycle = effective;
|
|
413
|
+
}
|
|
414
|
+
if (!cycle.cycleId)
|
|
415
|
+
throw new Error(`${oldTestId} lacks a cycle ID; regenerate its evidence before migrating.`);
|
|
416
|
+
if (!cycle.green?.valid)
|
|
417
|
+
throw new Error(`${oldTestId} has no valid Green phase to migrate.`);
|
|
418
|
+
const previousIdentifierMigration = [...migrationRecords(cycle)].reverse().find((record) => migrationMode(record) === 'identifier');
|
|
419
|
+
if (previousIdentifierMigration?.newTestId === newTestId) {
|
|
420
|
+
throw new Error(`${oldTestId} is already relinked to ${newTestId}.`);
|
|
421
|
+
}
|
|
422
|
+
if (previousIdentifierMigration) {
|
|
423
|
+
throw new Error(`${oldTestId} is already relinked to ${previousIdentifierMigration.newTestId}; conflicting relinks are not allowed.`);
|
|
424
|
+
}
|
|
425
|
+
if (evidence.cycles.some((entry) => entry !== cycle && currentEffectiveTestId(entry) === newTestId)) {
|
|
426
|
+
throw new Error(`${newTestId} already has a recorded cycle history.`);
|
|
427
|
+
}
|
|
428
|
+
const trace = await buildTrace(root);
|
|
429
|
+
if (trace.nodes.some((node) => node.kind === 'test' && node.id === oldTestId)) {
|
|
430
|
+
throw new Error(`${oldTestId} still has a surviving authoritative declaration in the current trace.`);
|
|
431
|
+
}
|
|
432
|
+
const matches = trace.nodes.filter((node) => node.kind === 'test' && node.id === newTestId);
|
|
433
|
+
if (matches.length !== 1)
|
|
434
|
+
throw new Error(`${newTestId} must have exactly one surviving authoritative declaration.`);
|
|
435
|
+
const [test] = matches;
|
|
436
|
+
if (!test)
|
|
437
|
+
throw new Error(`Annotated test ID not found: ${newTestId}`);
|
|
438
|
+
if (test.path !== cycle.testPath)
|
|
439
|
+
throw new Error(`${newTestId} must remain in the same test file path as ${oldTestId}.`);
|
|
440
|
+
const expectedSourceFingerprint = currentEffectiveSourceFingerprint(cycle);
|
|
441
|
+
const currentSource = await sourceFingerprint(root, test.path);
|
|
442
|
+
if (expectedSourceFingerprint !== currentSource) {
|
|
443
|
+
throw new Error(`${oldTestId}'s non-test sourceFingerprint changed after Green; record a fresh Red-Green cycle instead.`);
|
|
444
|
+
}
|
|
445
|
+
const baselineFingerprint = currentEffectiveFingerprint(cycle);
|
|
446
|
+
if (!baselineFingerprint)
|
|
447
|
+
throw new Error(`${oldTestId} has no effective fingerprint to relink.`);
|
|
448
|
+
const normalizedFingerprint = await normalizedIdentifierMigrationFingerprint(root, test, oldTestId, newTestId);
|
|
449
|
+
if (normalizedFingerprint !== baselineFingerprint) {
|
|
450
|
+
throw new Error(`${newTestId} is not a pure rename of ${oldTestId}; declaration fingerprint changed beyond rename-only tokens.`);
|
|
451
|
+
}
|
|
452
|
+
const toFingerprint = await testFingerprint(root, test);
|
|
453
|
+
const record = {
|
|
454
|
+
phase: 'migrate',
|
|
455
|
+
mode: 'identifier',
|
|
456
|
+
fromFingerprint: baselineFingerprint,
|
|
457
|
+
toFingerprint,
|
|
458
|
+
oldTestId,
|
|
459
|
+
newTestId,
|
|
460
|
+
approver,
|
|
461
|
+
recordedAt: new Date().toISOString(),
|
|
462
|
+
};
|
|
463
|
+
record.order = (await appendEvidenceOrder(root, {
|
|
464
|
+
kind: 'tdd',
|
|
465
|
+
entityId: cycle.cycleId,
|
|
466
|
+
phase: 'migrate',
|
|
467
|
+
oldTestId,
|
|
468
|
+
newTestId,
|
|
469
|
+
})).sequence;
|
|
470
|
+
cycle.migrateHistory = [...migrationRecords(cycle), record];
|
|
471
|
+
if (!cycle.migrate)
|
|
472
|
+
cycle.migrate = record;
|
|
473
|
+
appendChainRecord(evidence, cycle, 'migrate', record);
|
|
474
|
+
await writeJson(root, '.musubix/evidence/tdd.json', evidence);
|
|
475
|
+
return {
|
|
476
|
+
migrated: true,
|
|
477
|
+
testId: oldTestId,
|
|
478
|
+
oldTestId,
|
|
479
|
+
newTestId,
|
|
480
|
+
fromFingerprint: baselineFingerprint,
|
|
481
|
+
toFingerprint,
|
|
482
|
+
};
|
|
483
|
+
}
|
|
176
484
|
export async function loadTddEvidence(root) {
|
|
177
485
|
const path = '.musubix/evidence/tdd.json';
|
|
178
486
|
if (!await exists(within(root, path)))
|
|
@@ -190,13 +498,19 @@ export async function loadTddEvidence(root) {
|
|
|
190
498
|
function phaseLinkageValid(order, chain, cycle, phase, phaseEvidence) {
|
|
191
499
|
if (!order.valid || !cycle.cycleId || !chain)
|
|
192
500
|
return false;
|
|
193
|
-
const phaseOrder = phase === 'void'
|
|
501
|
+
const phaseOrder = phase === 'void'
|
|
502
|
+
? cycle.void?.order
|
|
503
|
+
: phase === 'archive'
|
|
504
|
+
? cycle.archive?.order
|
|
505
|
+
: phase === 'migrate'
|
|
506
|
+
? cycle.migrate?.order
|
|
507
|
+
: cycle[phase]?.order;
|
|
194
508
|
if (phaseOrder === undefined)
|
|
195
509
|
return false;
|
|
196
510
|
const orderRecord = evidenceOrderRecord(order.records, 'tdd', cycle.cycleId, phase);
|
|
197
511
|
if (!orderRecord || orderRecord.sequence !== phaseOrder)
|
|
198
512
|
return false;
|
|
199
|
-
if (phase === 'void' && orderRecord.testId !== cycle.testId)
|
|
513
|
+
if ((phase === 'void' || phase === 'archive') && orderRecord.testId !== cycle.testId)
|
|
200
514
|
return false;
|
|
201
515
|
const matches = chain.filter((record) => record.phase === phase && record.cycleId === cycle.cycleId && record.testId === cycle.testId);
|
|
202
516
|
if (matches.length !== 1)
|
|
@@ -209,6 +523,32 @@ function phaseLinkageValid(order, chain, cycle, phase, phaseEvidence) {
|
|
|
209
523
|
&& record.previousSha256 === expectedPrevious
|
|
210
524
|
&& record.phaseEvidenceSha256 === digest(JSON.stringify(phaseEvidence));
|
|
211
525
|
}
|
|
526
|
+
function migrationLinkageValid(order, chain, cycle, record) {
|
|
527
|
+
if (!order.valid || !cycle.cycleId || record.order === undefined || !chain)
|
|
528
|
+
return false;
|
|
529
|
+
const orderRecord = evidenceOrderRecord(order.records, 'tdd', cycle.cycleId, 'migrate', migrationScope(record));
|
|
530
|
+
if (!orderRecord || orderRecord.sequence !== record.order)
|
|
531
|
+
return false;
|
|
532
|
+
if (migrationMode(record) === 'identifier'
|
|
533
|
+
&& (orderRecord.oldTestId !== record.oldTestId || orderRecord.newTestId !== record.newTestId)) {
|
|
534
|
+
return false;
|
|
535
|
+
}
|
|
536
|
+
const matches = chain.filter((entry) => entry.phase === 'migrate'
|
|
537
|
+
&& entry.cycleId === cycle.cycleId
|
|
538
|
+
&& entry.testId === cycle.testId
|
|
539
|
+
&& (entry.mode ?? 'fingerprint') === migrationMode(record)
|
|
540
|
+
&& (entry.oldTestId ?? null) === (record.oldTestId ?? null)
|
|
541
|
+
&& (entry.newTestId ?? null) === (record.newTestId ?? null));
|
|
542
|
+
if (matches.length !== 1)
|
|
543
|
+
return false;
|
|
544
|
+
const chainRecord = matches[0];
|
|
545
|
+
const index = chain.indexOf(chainRecord);
|
|
546
|
+
const expectedPrevious = index === 0 ? null : chain[index - 1].recordSha256;
|
|
547
|
+
const { recordSha256, ...payload } = chainRecord;
|
|
548
|
+
return recordSha256 === chainRecordSha256(payload)
|
|
549
|
+
&& chainRecord.previousSha256 === expectedPrevious
|
|
550
|
+
&& chainRecord.phaseEvidenceSha256 === digest(JSON.stringify(record));
|
|
551
|
+
}
|
|
212
552
|
/** @id CODE-TDD-CYCLE-VOID-002
|
|
213
553
|
* @implements REQ-TDD-CYCLE-VOID-005, REQ-TDD-CYCLE-VOID-006, REQ-TDD-CYCLE-VOID-007
|
|
214
554
|
* @design DES-TDD-CYCLE-VOID-002
|
|
@@ -243,9 +583,77 @@ function voidLinkage(evidence, order, cycle) {
|
|
|
243
583
|
}
|
|
244
584
|
return { valid: false, reason: `${cycle.testId}'s void evidence is malformed: ${reason}.` };
|
|
245
585
|
}
|
|
586
|
+
/** @id CODE-TDD-CYCLE-ARCHIVE-002
|
|
587
|
+
* @implements REQ-TDD-CYCLE-ARCHIVE-006 REQ-TDD-CYCLE-ARCHIVE-007 REQ-TDD-CYCLE-ARCHIVE-013
|
|
588
|
+
* @design DES-TDD-CYCLE-ARCHIVE-003
|
|
589
|
+
*/
|
|
590
|
+
function archiveLinkage(evidence, order, cycle) {
|
|
591
|
+
if (!cycle.archive)
|
|
592
|
+
return { valid: false };
|
|
593
|
+
const matchingOrderRecords = !cycle.cycleId
|
|
594
|
+
? []
|
|
595
|
+
: [...order.records.values()].filter((record) => record.kind === 'tdd' && record.entityId === cycle.cycleId && record.phase === 'archive');
|
|
596
|
+
// Chain records sharing this cycle's (cycleId, phase) identity regardless of
|
|
597
|
+
// testId: a conflicting record for a different testId must not be masked by
|
|
598
|
+
// phaseLinkageValid's testId-scoped match (REQ-TDD-CYCLE-ARCHIVE-007).
|
|
599
|
+
const identityChainRecords = (evidence.chain ?? []).filter((record) => record.phase === 'archive' && record.cycleId === cycle.cycleId);
|
|
600
|
+
if (cycle.archive.testId === cycle.testId
|
|
601
|
+
&& cycle.archive.cycleId === cycle.cycleId
|
|
602
|
+
&& matchingOrderRecords.length === 1
|
|
603
|
+
&& identityChainRecords.length === 1
|
|
604
|
+
&& phaseLinkageValid(order, evidence.chain, cycle, 'archive', cycle.archive)) {
|
|
605
|
+
return { valid: true };
|
|
606
|
+
}
|
|
607
|
+
let reason = 'an invalid monotonic evidence order log';
|
|
608
|
+
if (order.valid) {
|
|
609
|
+
if (cycle.archive.testId !== cycle.testId || cycle.archive.cycleId !== cycle.cycleId) {
|
|
610
|
+
reason = 'a payload identity mismatch';
|
|
611
|
+
}
|
|
612
|
+
else if (!cycle.cycleId || cycle.archive.order === undefined) {
|
|
613
|
+
reason = 'a missing order record reference';
|
|
614
|
+
}
|
|
615
|
+
else if (matchingOrderRecords.length === 0) {
|
|
616
|
+
reason = 'a missing or mismatched order record';
|
|
617
|
+
}
|
|
618
|
+
else if (matchingOrderRecords.length > 1) {
|
|
619
|
+
reason = 'a duplicate order record';
|
|
620
|
+
}
|
|
621
|
+
else if (matchingOrderRecords[0].sequence !== cycle.archive.order) {
|
|
622
|
+
reason = 'a missing or mismatched order record';
|
|
623
|
+
}
|
|
624
|
+
else if (matchingOrderRecords[0].testId !== cycle.testId) {
|
|
625
|
+
reason = 'an order record testId mismatch';
|
|
626
|
+
}
|
|
627
|
+
else if (!evidence.chain) {
|
|
628
|
+
reason = 'a missing hash chain';
|
|
629
|
+
}
|
|
630
|
+
else if (identityChainRecords.length > 1) {
|
|
631
|
+
reason = 'a conflicting chain record';
|
|
632
|
+
}
|
|
633
|
+
else {
|
|
634
|
+
const matches = evidence.chain.filter((record) => record.phase === 'archive' && record.cycleId === cycle.cycleId && record.testId === cycle.testId);
|
|
635
|
+
reason = matches.length === 0
|
|
636
|
+
? 'a missing chain record'
|
|
637
|
+
: matches.length > 1
|
|
638
|
+
? 'a duplicate chain record'
|
|
639
|
+
: 'a broken predecessor or payload hash link';
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return { valid: false, reason: `${cycle.testId}'s archive evidence is malformed: ${reason}.` };
|
|
643
|
+
}
|
|
246
644
|
export function validlyVoidedTddCycles(evidence, order) {
|
|
247
645
|
return new Set((evidence?.cycles ?? []).filter((cycle) => voidLinkage(evidence, order, cycle).valid));
|
|
248
646
|
}
|
|
647
|
+
/** @id CODE-CHANGE-EVIDENCE-WAIVER-027
|
|
648
|
+
* @implements REQ-CHANGE-EVIDENCE-WAIVER-018
|
|
649
|
+
* @design DES-CHANGE-EVIDENCE-WAIVER-006
|
|
650
|
+
* Mirrors `validlyVoidedTddCycles` for archived cycles so callers can exclude
|
|
651
|
+
* validly archived evidence from change order-window checks the same way
|
|
652
|
+
* validly voided evidence is already excluded (Issue #64).
|
|
653
|
+
*/
|
|
654
|
+
export function validlyArchivedTddCycles(evidence, order) {
|
|
655
|
+
return new Set((evidence?.cycles ?? []).filter((cycle) => archiveLinkage(evidence, order, cycle).valid));
|
|
656
|
+
}
|
|
249
657
|
/** Resolves the effective latest cycle for `testId` when its actual latest
|
|
250
658
|
* cycle (`voidedCycle`) is validly voided, per REQ-TDD-CYCLE-VOID-010: the
|
|
251
659
|
* eligible, non-voided candidate with the greatest verified Green order
|
|
@@ -255,7 +663,7 @@ export function validlyVoidedTddCycles(evidence, order) {
|
|
|
255
663
|
* @implements REQ-TDD-CYCLE-VOID-010
|
|
256
664
|
* @design DES-TDD-CYCLE-VOID-004
|
|
257
665
|
*/
|
|
258
|
-
function effectiveLatestCycle(evidence, order, validlyVoidedCycles, testId, voidedCycle) {
|
|
666
|
+
function effectiveLatestCycle(evidence, order, validlyVoidedCycles, validlyArchivedCycles, testId, voidedCycle) {
|
|
259
667
|
if (!voidedCycle.cycleId)
|
|
260
668
|
return undefined;
|
|
261
669
|
const voidRecord = evidenceOrderRecord(order.records, 'tdd', voidedCycle.cycleId, 'void');
|
|
@@ -269,6 +677,8 @@ function effectiveLatestCycle(evidence, order, validlyVoidedCycles, testId, void
|
|
|
269
677
|
continue;
|
|
270
678
|
if (validlyVoidedCycles.has(candidate))
|
|
271
679
|
continue;
|
|
680
|
+
if (validlyArchivedCycles.has(candidate))
|
|
681
|
+
continue;
|
|
272
682
|
if (!candidate.cycleId)
|
|
273
683
|
continue;
|
|
274
684
|
if (!phaseLinkageValid(order, evidence.chain, candidate, 'green', candidate.green))
|
|
@@ -282,12 +692,19 @@ function effectiveLatestCycle(evidence, order, validlyVoidedCycles, testId, void
|
|
|
282
692
|
return best?.cycle;
|
|
283
693
|
}
|
|
284
694
|
/** @id CODE-TDD-CYCLE-VOID-001
|
|
285
|
-
* @implements REQ-TDD-CYCLE-VOID-001, REQ-TDD-CYCLE-VOID-002, REQ-TDD-CYCLE-VOID-003, REQ-TDD-CYCLE-VOID-004, REQ-TDD-CYCLE-VOID-011
|
|
286
|
-
* @design DES-TDD-CYCLE-VOID-001, DES-TDD-CYCLE-VOID-005
|
|
695
|
+
* @implements REQ-TDD-CYCLE-VOID-001, REQ-TDD-CYCLE-VOID-002, REQ-TDD-CYCLE-VOID-003, REQ-TDD-CYCLE-VOID-004, REQ-TDD-CYCLE-VOID-011, REQ-TDD-CYCLE-ARCHIVE-004
|
|
696
|
+
* @design DES-TDD-CYCLE-VOID-001, DES-TDD-CYCLE-VOID-005, DES-TDD-CYCLE-ARCHIVE-002
|
|
287
697
|
*/
|
|
288
698
|
export async function voidTddCycle(root, testId, approver, reason) {
|
|
289
699
|
return withEvidenceWriterLock(root, 'tdd void', () => voidTddCycleUnlocked(root, testId, approver, reason));
|
|
290
700
|
}
|
|
701
|
+
/** @id CODE-TDD-CYCLE-ARCHIVE-001
|
|
702
|
+
* @implements REQ-TDD-CYCLE-ARCHIVE-001 REQ-TDD-CYCLE-ARCHIVE-002 REQ-TDD-CYCLE-ARCHIVE-003 REQ-TDD-CYCLE-ARCHIVE-004 REQ-TDD-CYCLE-ARCHIVE-005
|
|
703
|
+
* @design DES-TDD-CYCLE-ARCHIVE-001 DES-TDD-CYCLE-ARCHIVE-002
|
|
704
|
+
*/
|
|
705
|
+
export async function archiveTddCycle(root, testId, approver, reason) {
|
|
706
|
+
return withEvidenceWriterLock(root, 'tdd archive', () => archiveTddCycleUnlocked(root, testId, approver, reason));
|
|
707
|
+
}
|
|
291
708
|
async function voidTddCycleUnlocked(root, testId, approver, reason) {
|
|
292
709
|
if (!approver?.trim())
|
|
293
710
|
throw new Error('An approver is required to void a TDD cycle.');
|
|
@@ -299,10 +716,12 @@ async function voidTddCycleUnlocked(root, testId, approver, reason) {
|
|
|
299
716
|
const cycle = evidence.cycles.filter((entry) => entry.testId === testId).at(-1);
|
|
300
717
|
if (!cycle)
|
|
301
718
|
throw new Error(`No TDD cycle found for ${testId}.`);
|
|
302
|
-
if (cycle.green?.valid)
|
|
303
|
-
throw new Error(`${testId}'s latest cycle has a valid Green phase; only a dangling cycle can be voided.`);
|
|
304
719
|
if (cycle.void)
|
|
305
720
|
throw new Error(`${testId}'s latest cycle is already voided.`);
|
|
721
|
+
if (cycle.archive)
|
|
722
|
+
throw new Error(`${testId}'s latest cycle already carries a void or archive marker.`);
|
|
723
|
+
if (cycle.green?.valid)
|
|
724
|
+
throw new Error(`${testId}'s latest cycle has a valid Green phase; only a dangling cycle can be voided.`);
|
|
306
725
|
if (!cycle.cycleId)
|
|
307
726
|
throw new Error(`${testId} lacks a cycle ID; regenerate its evidence before voiding.`);
|
|
308
727
|
if (!evidence.chain && evidence.cycles.some((entry) => entry !== cycle)) {
|
|
@@ -310,7 +729,7 @@ async function voidTddCycleUnlocked(root, testId, approver, reason) {
|
|
|
310
729
|
}
|
|
311
730
|
const order = await inspectEvidenceOrder(root);
|
|
312
731
|
const earlierCycles = evidence.cycles.slice(0, evidence.cycles.indexOf(cycle)).filter((entry) => entry.testId === testId);
|
|
313
|
-
const eligibleFallback = earlierCycles.some((entry) => entry.red.valid && entry.green?.valid && !
|
|
732
|
+
const eligibleFallback = earlierCycles.some((entry) => entry.red.valid && entry.green?.valid && !entry.void && !entry.archive);
|
|
314
733
|
if (!eligibleFallback) {
|
|
315
734
|
return {
|
|
316
735
|
voided: false,
|
|
@@ -325,11 +744,72 @@ async function voidTddCycleUnlocked(root, testId, approver, reason) {
|
|
|
325
744
|
await writeJson(root, '.musubix/evidence/tdd.json', evidence);
|
|
326
745
|
return { voided: true, testId, cycleId: cycle.cycleId };
|
|
327
746
|
}
|
|
747
|
+
async function archiveTddCycleUnlocked(root, testId, approver, reason) {
|
|
748
|
+
if (!approver?.trim())
|
|
749
|
+
throw new Error('An approver is required to archive a TDD cycle.');
|
|
750
|
+
if (!reason?.trim())
|
|
751
|
+
throw new Error('A reason is required to archive a TDD cycle.');
|
|
752
|
+
const evidence = await loadTddEvidence(root);
|
|
753
|
+
if (!evidence)
|
|
754
|
+
throw new Error('No TDD evidence found.');
|
|
755
|
+
const cycle = evidence.cycles.filter((entry) => entry.testId === testId).at(-1);
|
|
756
|
+
if (!cycle)
|
|
757
|
+
throw new Error(`No TDD cycle found for ${testId}.`);
|
|
758
|
+
if (cycle.void || cycle.archive)
|
|
759
|
+
throw new Error(`${testId}'s latest cycle already carries a void or archive marker.`);
|
|
760
|
+
if (!cycle.cycleId)
|
|
761
|
+
throw new Error(`${testId} lacks a cycle ID; regenerate its evidence before archiving.`);
|
|
762
|
+
if (!evidence.chain && evidence.cycles.some((entry) => entry !== cycle)) {
|
|
763
|
+
throw new Error('Existing TDD evidence lacks an append-only hash chain; regenerate it before recording new phases.');
|
|
764
|
+
}
|
|
765
|
+
const record = {
|
|
766
|
+
phase: 'archive',
|
|
767
|
+
approver,
|
|
768
|
+
reason,
|
|
769
|
+
testId: cycle.testId,
|
|
770
|
+
cycleId: cycle.cycleId,
|
|
771
|
+
recordedAt: new Date().toISOString(),
|
|
772
|
+
};
|
|
773
|
+
record.order = (await appendEvidenceOrder(root, {
|
|
774
|
+
kind: 'tdd',
|
|
775
|
+
entityId: cycle.cycleId,
|
|
776
|
+
phase: 'archive',
|
|
777
|
+
testId: cycle.testId,
|
|
778
|
+
})).sequence;
|
|
779
|
+
cycle.archive = record;
|
|
780
|
+
appendChainRecord(evidence, cycle, 'archive', record);
|
|
781
|
+
await writeJson(root, '.musubix/evidence/tdd.json', evidence);
|
|
782
|
+
return { archived: true, testId, cycleId: cycle.cycleId };
|
|
783
|
+
}
|
|
328
784
|
export async function runTddPhase(root, phase, testId, requirementId, commandName, runner = runProcess) {
|
|
329
785
|
return withEvidenceWriterLock(root, `tdd ${phase}`, () => runTddPhaseUnlocked(root, phase, testId, requirementId, commandName, runner));
|
|
330
786
|
}
|
|
331
787
|
async function runTddPhaseUnlocked(root, phase, testId, requirementId, commandName, runner) {
|
|
332
788
|
const config = await loadConfig(root);
|
|
789
|
+
// Hoisted so both this check and the TDD_ADOPTION_PROJECT_WIDE warning
|
|
790
|
+
// logic below share one computation (same `.musubix/changes/CHANGE-\d+\.md`
|
|
791
|
+
// existence check gate.ts's own `hasChangeDocuments` formula uses).
|
|
792
|
+
const hasChangeDocuments = (await files(root)).some((path) => /^\.musubix\/changes\/CHANGE-\d+\.md$/.test(path));
|
|
793
|
+
/** @id CODE-TDD-GREEN-REQUIREMENT-SCOPING-003
|
|
794
|
+
* @implements REQ-TDD-GREEN-REQUIREMENT-SCOPING-003
|
|
795
|
+
* @design DES-TDD-GREEN-REQUIREMENT-SCOPING-002
|
|
796
|
+
* Runs strictly before every other existing `tdd red`/`tdd green`
|
|
797
|
+
* validation (the `red`-only design-approval check immediately below and
|
|
798
|
+
* REQ-002's cycle-matching check after `buildTrace`), and before any
|
|
799
|
+
* preflight command, test-command execution, or evidence/order-log write,
|
|
800
|
+
* so a staged-change phase-ordering mistake is rejected before it can
|
|
801
|
+
* corrupt the append-only TDD evidence chain (Issue #67).
|
|
802
|
+
*/
|
|
803
|
+
if (hasChangeDocuments && (phase === 'red' || phase === 'green')) {
|
|
804
|
+
const changeEvidence = await loadChangeEvidence(root);
|
|
805
|
+
const precondition = changeRecordPhasePrecondition(changeEvidence, phase, requirementId);
|
|
806
|
+
if (!precondition.satisfied) {
|
|
807
|
+
const instruction = precondition.changeId
|
|
808
|
+
? `change-record ${precondition.changeId} ${precondition.missingPhase} --requirement ${requirementId}`
|
|
809
|
+
: `a staged change must record design --requirement ${requirementId} first`;
|
|
810
|
+
throw new Error(`CHANGE_RECORD_PHASE_PRECONDITION: tdd ${phase} for ${requirementId} requires ${instruction} before it can be recorded.`);
|
|
811
|
+
}
|
|
812
|
+
}
|
|
333
813
|
if (phase === 'red') {
|
|
334
814
|
const domain = await resolveRequirementDomain(root, config.approval, requirementId);
|
|
335
815
|
await requireApproval(root, 'design', config.approval, domain);
|
|
@@ -474,12 +954,11 @@ async function runTddPhaseUnlocked(root, phase, testId, requirementId, commandNa
|
|
|
474
954
|
.filter((edge) => edge.relation === 'verifies' && edge.to === requirement.id)
|
|
475
955
|
.map((edge) => edge.from);
|
|
476
956
|
return !evidence.cycles.some((cycle) => cycle.requirementId === requirement.id
|
|
477
|
-
&& verifiedTests.includes(cycle
|
|
957
|
+
&& verifiedTests.includes(currentEffectiveTestId(cycle))
|
|
478
958
|
&& cycle.red.valid
|
|
479
959
|
&& cycle.green?.valid);
|
|
480
960
|
})
|
|
481
961
|
.map((requirement) => requirement.id);
|
|
482
|
-
const hasChangeDocuments = (await files(root)).some((path) => /^\.musubix\/changes\/CHANGE-\d+\.md$/.test(path));
|
|
483
962
|
const alreadyRequired = config.requiredChecks.includes('tdd') || hasChangeDocuments;
|
|
484
963
|
const uncoveredText = uncoveredIds.length
|
|
485
964
|
? `Other uncovered mandatory requirements: ${uncoveredIds.join(', ')}.`
|
|
@@ -528,10 +1007,14 @@ async function runTddPhaseUnlocked(root, phase, testId, requirementId, commandNa
|
|
|
528
1007
|
await writeJson(root, '.musubix/evidence/tdd.json', evidence);
|
|
529
1008
|
return result;
|
|
530
1009
|
}
|
|
1010
|
+
/** @id CODE-TDD-CYCLE-ARCHIVE-004
|
|
1011
|
+
* @implements REQ-TDD-CYCLE-ARCHIVE-006 REQ-TDD-CYCLE-ARCHIVE-007 REQ-TDD-CYCLE-ARCHIVE-008 REQ-TDD-CYCLE-ARCHIVE-009 REQ-TDD-CYCLE-ARCHIVE-010 REQ-TDD-CYCLE-ARCHIVE-013
|
|
1012
|
+
* @design DES-TDD-CYCLE-ARCHIVE-003 DES-TDD-CYCLE-ARCHIVE-004 DES-TDD-CYCLE-ARCHIVE-005
|
|
1013
|
+
*/
|
|
531
1014
|
export async function validateTddEvidence(root) {
|
|
532
1015
|
const evidence = await loadTddEvidence(root);
|
|
533
1016
|
if (!evidence?.cycles.length)
|
|
534
|
-
return { present: false, valid: false, diagnostics: [], cycles: 0, voided: [] };
|
|
1017
|
+
return { present: false, valid: false, diagnostics: [], cycles: 0, voided: [], archived: [] };
|
|
535
1018
|
const diagnostics = [];
|
|
536
1019
|
const order = await inspectEvidenceOrder(root);
|
|
537
1020
|
diagnostics.push(...order.diagnostics);
|
|
@@ -553,17 +1036,17 @@ export async function validateTddEvidence(root) {
|
|
|
553
1036
|
if (recordSha256 !== chainRecordSha256(payload)) {
|
|
554
1037
|
diagnostics.push(error('TDD_CHAIN_HASH_MISMATCH', `TDD chain record ${record.sequence} has an invalid SHA-256.`));
|
|
555
1038
|
}
|
|
556
|
-
const key =
|
|
1039
|
+
const key = chainIdentity(record);
|
|
557
1040
|
if (records.has(key))
|
|
558
1041
|
diagnostics.push(error('TDD_CHAIN_PHASE_DUPLICATE', `${key} appears more than once in the TDD chain.`));
|
|
559
1042
|
records.set(key, record);
|
|
560
1043
|
}
|
|
561
1044
|
for (const cycle of evidence.cycles) {
|
|
562
|
-
for (const phase of ['red', 'green', 'refactor', '
|
|
1045
|
+
for (const phase of ['red', 'green', 'refactor', 'void', 'archive']) {
|
|
563
1046
|
const phaseEvidence = cycle[phase];
|
|
564
1047
|
if (!phaseEvidence)
|
|
565
1048
|
continue;
|
|
566
|
-
const key =
|
|
1049
|
+
const key = chainIdentity({ cycleId: cycle.cycleId ?? 'missing', phase });
|
|
567
1050
|
const record = records.get(key);
|
|
568
1051
|
if (!record) {
|
|
569
1052
|
diagnostics.push(error('TDD_CHAIN_PHASE_MISSING', `${cycle.testId}:${phase} is absent from the TDD hash chain.`, cycle.testPath));
|
|
@@ -578,6 +1061,28 @@ export async function validateTddEvidence(root) {
|
|
|
578
1061
|
}
|
|
579
1062
|
records.delete(key);
|
|
580
1063
|
}
|
|
1064
|
+
for (const migration of migrationRecords(cycle)) {
|
|
1065
|
+
const key = chainIdentity({
|
|
1066
|
+
cycleId: cycle.cycleId ?? 'missing',
|
|
1067
|
+
phase: 'migrate',
|
|
1068
|
+
mode: migrationMode(migration),
|
|
1069
|
+
...(migration.oldTestId !== undefined ? { oldTestId: migration.oldTestId } : {}),
|
|
1070
|
+
...(migration.newTestId !== undefined ? { newTestId: migration.newTestId } : {}),
|
|
1071
|
+
});
|
|
1072
|
+
const record = records.get(key);
|
|
1073
|
+
if (!record) {
|
|
1074
|
+
diagnostics.push(error('TDD_CHAIN_PHASE_MISSING', `${cycle.testId}:migrate is absent from the TDD hash chain.`, cycle.testPath));
|
|
1075
|
+
continue;
|
|
1076
|
+
}
|
|
1077
|
+
if (record.requirementId !== cycle.requirementId
|
|
1078
|
+
|| record.testId !== cycle.testId
|
|
1079
|
+
|| record.testPath !== cycle.testPath
|
|
1080
|
+
|| record.commandName !== cycle.commandName
|
|
1081
|
+
|| record.phaseEvidenceSha256 !== digest(JSON.stringify(migration))) {
|
|
1082
|
+
diagnostics.push(error('TDD_CHAIN_PAYLOAD_MISMATCH', `${cycle.testId}:migrate does not match its immutable TDD chain record.`, cycle.testPath));
|
|
1083
|
+
}
|
|
1084
|
+
records.delete(key);
|
|
1085
|
+
}
|
|
581
1086
|
}
|
|
582
1087
|
for (const record of records.values()) {
|
|
583
1088
|
diagnostics.push(error('TDD_CHAIN_ORPHAN', `TDD chain record ${record.sequence} has no matching cycle phase.`));
|
|
@@ -585,7 +1090,7 @@ export async function validateTddEvidence(root) {
|
|
|
585
1090
|
}
|
|
586
1091
|
const latestCycles = new Map();
|
|
587
1092
|
for (const cycle of evidence.cycles)
|
|
588
|
-
latestCycles.set(cycle
|
|
1093
|
+
latestCycles.set(currentEffectiveTestId(cycle), cycle);
|
|
589
1094
|
/** @id CODE-TDD-SUPERSEDED-CYCLE-SCOPING-001
|
|
590
1095
|
* @implements REQ-TDD-SUPERSEDED-CYCLE-SCOPING-001
|
|
591
1096
|
*/
|
|
@@ -593,11 +1098,12 @@ export async function validateTddEvidence(root) {
|
|
|
593
1098
|
{
|
|
594
1099
|
const cyclesByTest = new Map();
|
|
595
1100
|
for (const cycle of evidence.cycles) {
|
|
596
|
-
const
|
|
1101
|
+
const identity = currentEffectiveTestId(cycle);
|
|
1102
|
+
const list = cyclesByTest.get(identity);
|
|
597
1103
|
if (list)
|
|
598
1104
|
list.push(cycle);
|
|
599
1105
|
else
|
|
600
|
-
cyclesByTest.set(
|
|
1106
|
+
cyclesByTest.set(identity, [cycle]);
|
|
601
1107
|
}
|
|
602
1108
|
for (const cycles of cyclesByTest.values()) {
|
|
603
1109
|
for (let index = 0; index < cycles.length; index++) {
|
|
@@ -613,6 +1119,8 @@ export async function validateTddEvidence(root) {
|
|
|
613
1119
|
*/
|
|
614
1120
|
const validlyVoidedCycles = new Set();
|
|
615
1121
|
const voided = [];
|
|
1122
|
+
const validlyArchivedCycles = new Set();
|
|
1123
|
+
const archived = [];
|
|
616
1124
|
for (const cycle of evidence.cycles) {
|
|
617
1125
|
if (!cycle.void)
|
|
618
1126
|
continue;
|
|
@@ -635,13 +1143,29 @@ export async function validateTddEvidence(root) {
|
|
|
635
1143
|
diagnostics.push(error('TDD_VOID_EVIDENCE_MALFORMED', linkage.reason, cycle.testPath));
|
|
636
1144
|
}
|
|
637
1145
|
}
|
|
1146
|
+
for (const cycle of evidence.cycles) {
|
|
1147
|
+
if (!cycle.archive)
|
|
1148
|
+
continue;
|
|
1149
|
+
const linkage = archiveLinkage(evidence, order, cycle);
|
|
1150
|
+
if (linkage.valid) {
|
|
1151
|
+
validlyArchivedCycles.add(cycle);
|
|
1152
|
+
archived.push({
|
|
1153
|
+
testId: cycle.testId,
|
|
1154
|
+
cycleId: cycle.cycleId,
|
|
1155
|
+
archive: { approver: cycle.archive.approver, reason: cycle.archive.reason, recordedAt: cycle.archive.recordedAt },
|
|
1156
|
+
});
|
|
1157
|
+
}
|
|
1158
|
+
else {
|
|
1159
|
+
diagnostics.push(error('TDD_ARCHIVE_EVIDENCE_MALFORMED', linkage.reason, cycle.testPath));
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
638
1162
|
const trace = await buildTrace(root, false);
|
|
639
1163
|
for (const requirement of trace.nodes.filter((node) => node.kind === 'requirement' && node.mandatory)) {
|
|
640
1164
|
const verifiedTests = trace.edges
|
|
641
1165
|
.filter((edge) => edge.relation === 'verifies' && edge.to === requirement.id)
|
|
642
1166
|
.map((edge) => edge.from);
|
|
643
1167
|
const covered = evidence.cycles.some((cycle) => cycle.requirementId === requirement.id
|
|
644
|
-
&& verifiedTests.includes(cycle
|
|
1168
|
+
&& verifiedTests.includes(currentEffectiveTestId(cycle))
|
|
645
1169
|
&& cycle.red.valid
|
|
646
1170
|
&& cycle.green?.valid);
|
|
647
1171
|
if (!covered) {
|
|
@@ -671,45 +1195,57 @@ export async function validateTddEvidence(root) {
|
|
|
671
1195
|
if (cycle.refactor?.order !== undefined && cycle.green?.order !== undefined && cycle.refactor.order <= cycle.green.order) {
|
|
672
1196
|
diagnostics.push(error('TDD_ORDER_SEQUENCE', `${cycle.testId}:refactor is not after Green in monotonic evidence order.`, cycle.testPath));
|
|
673
1197
|
}
|
|
674
|
-
|
|
675
|
-
|
|
1198
|
+
const migrations = migrationRecords(cycle);
|
|
1199
|
+
for (const migration of migrations) {
|
|
1200
|
+
if (!cycle.cycleId || !Number.isInteger(migration.order)) {
|
|
676
1201
|
diagnostics.push(error('TDD_ORDER_MIGRATION_REQUIRED', `${cycle.testId}:migrate lacks monotonic order evidence; archive legacy evidence and regenerate the complete cycle instead of editing append-only records.`, cycle.testPath));
|
|
1202
|
+
continue;
|
|
677
1203
|
}
|
|
678
|
-
|
|
679
|
-
const
|
|
680
|
-
if (!
|
|
1204
|
+
if (!migrationLinkageValid(order, evidence.chain, cycle, migration)) {
|
|
1205
|
+
const orderRecord = evidenceOrderRecord(order.records, 'tdd', cycle.cycleId, 'migrate', migrationScope(migration));
|
|
1206
|
+
if (!orderRecord || orderRecord.sequence !== migration.order) {
|
|
681
1207
|
diagnostics.push(error('TDD_ORDER_MISMATCH', `${cycle.testId}:migrate does not match the monotonic evidence order log.`, cycle.testPath));
|
|
682
1208
|
}
|
|
1209
|
+
else {
|
|
1210
|
+
diagnostics.push(error('TDD_CHAIN_PAYLOAD_MISMATCH', `${cycle.testId}:migrate does not match its immutable TDD chain record.`, cycle.testPath));
|
|
1211
|
+
}
|
|
683
1212
|
}
|
|
684
1213
|
const latestNonMigrateOrder = cycle.refactor?.valid ? cycle.refactor.order : cycle.green?.order;
|
|
685
|
-
if (
|
|
1214
|
+
if (migration.order !== undefined && latestNonMigrateOrder !== undefined && migration.order <= latestNonMigrateOrder) {
|
|
686
1215
|
diagnostics.push(error('TDD_ORDER_SEQUENCE', `${cycle.testId}:migrate is not after Green/Refactor in monotonic evidence order.`, cycle.testPath));
|
|
687
1216
|
}
|
|
688
|
-
if (!cycle.
|
|
1217
|
+
if (!validlyArchivedCycles.has(cycle) && !migration.approver) {
|
|
689
1218
|
diagnostics.push(error('TDD_LEGACY_OR_UNSCOPED_EVIDENCE', `${cycle.testId}:migrate lacks a recorded human approver.`, cycle.testPath));
|
|
690
1219
|
}
|
|
1220
|
+
if (migrationMode(migration) === 'identifier' && (!migration.oldTestId || !migration.newTestId)) {
|
|
1221
|
+
diagnostics.push(error('TDD_MIGRATE_EVIDENCE_MALFORMED', `${cycle.testId}:migrate lacks old/new test ID linkage.`, cycle.testPath));
|
|
1222
|
+
}
|
|
691
1223
|
}
|
|
692
|
-
if (!supersededCycles.has(cycle) && !validlyVoidedCycles.has(cycle)
|
|
1224
|
+
if (!supersededCycles.has(cycle) && !validlyVoidedCycles.has(cycle) && !validlyArchivedCycles.has(cycle)
|
|
693
1225
|
&& (!cycle.red.scoped || !cycle.red.resultObserved || cycle.red.testStatus !== 'failed' || !cycle.red.reportSha256 || !cycle.red.sourceFingerprint || !cycle.red.executionId)) {
|
|
694
1226
|
diagnostics.push(error('TDD_LEGACY_OR_UNSCOPED_EVIDENCE', `${cycle.testId} lacks test-scoped execution provenance; archive the legacy cycle and regenerate it from a clean Red baseline: move .musubix/evidence/tdd.json aside and re-record every cycle with tdd red/green/refactor. There is no partial prune command; hand-editing the evidence is not supported.`, cycle.testPath));
|
|
695
1227
|
}
|
|
696
|
-
if (!supersededCycles.has(cycle) && !validlyVoidedCycles.has(cycle) && !cycle.red.valid)
|
|
1228
|
+
if (!supersededCycles.has(cycle) && !validlyVoidedCycles.has(cycle) && !validlyArchivedCycles.has(cycle) && !cycle.red.valid)
|
|
697
1229
|
diagnostics.push(error('TDD_RED_MISSING', `${cycle.testId} has no valid failing Red phase; archive it and regenerate the complete cycle by moving .musubix/evidence/tdd.json aside and re-recording every cycle.`, cycle.testPath));
|
|
698
|
-
if (!supersededCycles.has(cycle) && !validlyVoidedCycles.has(cycle) && !cycle.green?.valid)
|
|
1230
|
+
if (!supersededCycles.has(cycle) && !validlyVoidedCycles.has(cycle) && !validlyArchivedCycles.has(cycle) && !cycle.green?.valid)
|
|
699
1231
|
diagnostics.push(error('TDD_GREEN_MISSING', `${cycle.testId} has no valid passing Green phase; archive it and regenerate the complete cycle by moving .musubix/evidence/tdd.json aside and re-recording every cycle.`, cycle.testPath));
|
|
700
1232
|
if (cycle.green?.valid) {
|
|
701
|
-
if (!
|
|
1233
|
+
if (!validlyArchivedCycles.has(cycle)
|
|
1234
|
+
&& (!cycle.green.scoped || !cycle.green.resultObserved || cycle.green.testStatus !== 'passed' || !cycle.green.reportSha256 || !cycle.green.sourceFingerprint || !cycle.green.executionId)) {
|
|
702
1235
|
diagnostics.push(error('TDD_LEGACY_OR_UNSCOPED_EVIDENCE', `${cycle.testId} Green lacks test-scoped execution provenance.`, cycle.testPath));
|
|
703
1236
|
}
|
|
704
|
-
const
|
|
1237
|
+
const effectiveTestId = currentEffectiveTestId(cycle);
|
|
1238
|
+
const actualLatest = latestCycles.get(effectiveTestId);
|
|
705
1239
|
// Effective-latest resolution for TDD_TEST_STALE (REQ-TDD-CYCLE-VOID-010)
|
|
706
1240
|
// is implemented by the shared `effectiveLatestCycle` helper, annotated
|
|
707
1241
|
// as CODE-TDD-CYCLE-VOID-004 above.
|
|
708
1242
|
const isStaleTarget = actualLatest === cycle
|
|
709
|
-
|
|
710
|
-
|
|
1243
|
+
? !validlyArchivedCycles.has(cycle)
|
|
1244
|
+
: (actualLatest !== undefined && validlyVoidedCycles.has(actualLatest)
|
|
1245
|
+
&& effectiveLatestCycle(evidence, order, validlyVoidedCycles, validlyArchivedCycles, effectiveTestId, actualLatest) === cycle
|
|
1246
|
+
&& !validlyArchivedCycles.has(cycle));
|
|
711
1247
|
if (isStaleTarget) {
|
|
712
|
-
const test = trace.nodes.find((node) => node.kind === 'test' && node.id ===
|
|
1248
|
+
const test = trace.nodes.find((node) => node.kind === 'test' && node.id === effectiveTestId);
|
|
713
1249
|
const current = test ? await testFingerprint(root, test) : undefined;
|
|
714
1250
|
const candidates = [];
|
|
715
1251
|
if (cycle.green.order !== undefined)
|
|
@@ -717,23 +1253,26 @@ export async function validateTddEvidence(root) {
|
|
|
717
1253
|
if (cycle.refactor?.valid && cycle.refactor.order !== undefined) {
|
|
718
1254
|
candidates.push({ order: cycle.refactor.order, fingerprint: cycle.refactor.testFingerprint });
|
|
719
1255
|
}
|
|
720
|
-
|
|
721
|
-
|
|
1256
|
+
for (const migration of migrations) {
|
|
1257
|
+
if (migration.order !== undefined)
|
|
1258
|
+
candidates.push({ order: migration.order, fingerprint: migration.toFingerprint });
|
|
1259
|
+
}
|
|
722
1260
|
const latest = candidates.sort((a, b) => b.order - a.order)[0];
|
|
723
1261
|
if (latest && current !== latest.fingerprint)
|
|
724
|
-
diagnostics.push(error('TDD_TEST_STALE', `${
|
|
1262
|
+
diagnostics.push(error('TDD_TEST_STALE', `${effectiveTestId} changed after its latest passing TDD phase.`, cycle.testPath));
|
|
725
1263
|
}
|
|
726
|
-
if (cycle.red.sourceFingerprint === cycle.green.sourceFingerprint) {
|
|
1264
|
+
if (!validlyArchivedCycles.has(cycle) && cycle.red.sourceFingerprint === cycle.green.sourceFingerprint) {
|
|
727
1265
|
diagnostics.push(error('TDD_GREEN_WITHOUT_SOURCE_CHANGE', `${cycle.testId} has no non-test project change between Red and Green.`, cycle.testPath));
|
|
728
1266
|
}
|
|
729
1267
|
}
|
|
730
|
-
if (cycle.green && cycle.green.commandSha256 !== cycle.red.commandSha256) {
|
|
1268
|
+
if (!validlyArchivedCycles.has(cycle) && cycle.green && cycle.green.commandSha256 !== cycle.red.commandSha256) {
|
|
731
1269
|
diagnostics.push(error('TDD_COMMAND_CHANGED', `${cycle.testId} used a different command between Red and Green.`, cycle.testPath));
|
|
732
1270
|
}
|
|
733
|
-
if (cycle.refactor && cycle.refactor.commandSha256 !== cycle.red.commandSha256) {
|
|
1271
|
+
if (!validlyArchivedCycles.has(cycle) && cycle.refactor && cycle.refactor.commandSha256 !== cycle.red.commandSha256) {
|
|
734
1272
|
diagnostics.push(error('TDD_COMMAND_CHANGED', `${cycle.testId} used a different command during Refactor.`, cycle.testPath));
|
|
735
1273
|
}
|
|
736
|
-
if (cycle.refactor?.valid &&
|
|
1274
|
+
if (cycle.refactor?.valid && !validlyArchivedCycles.has(cycle)
|
|
1275
|
+
&& (!cycle.refactor.scoped || !cycle.refactor.resultObserved || cycle.refactor.testStatus !== 'passed' || !cycle.refactor.reportSha256 || !cycle.refactor.sourceFingerprint || !cycle.refactor.executionId)) {
|
|
737
1276
|
diagnostics.push(error('TDD_LEGACY_OR_UNSCOPED_EVIDENCE', `${cycle.testId} Refactor lacks test-scoped execution provenance.`, cycle.testPath));
|
|
738
1277
|
}
|
|
739
1278
|
}
|
|
@@ -754,6 +1293,6 @@ export async function validateTddEvidence(root) {
|
|
|
754
1293
|
hashes.set(key, cycle);
|
|
755
1294
|
}
|
|
756
1295
|
}
|
|
757
|
-
return { present: true, valid: !diagnostics.length, diagnostics, cycles: evidence.cycles.length, voided };
|
|
1296
|
+
return { present: true, valid: !diagnostics.length, diagnostics, cycles: evidence.cycles.length, voided, archived };
|
|
758
1297
|
}
|
|
759
1298
|
//# sourceMappingURL=tdd.js.map
|