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.
Files changed (63) hide show
  1. package/.github/plugin/marketplace.json +2 -2
  2. package/.github/skills/sdd-change/SKILL.md +28 -45
  3. package/CHANGELOG.md +88 -0
  4. package/README-ja.md +103 -3
  5. package/README.md +113 -3
  6. package/dist/packages/analysis/src/approval.d.ts +51 -0
  7. package/dist/packages/analysis/src/approval.js +341 -2
  8. package/dist/packages/analysis/src/approval.js.map +1 -1
  9. package/dist/packages/analysis/src/attestation.js +4 -0
  10. package/dist/packages/analysis/src/attestation.js.map +1 -1
  11. package/dist/packages/analysis/src/change-evidence.d.ts +56 -13
  12. package/dist/packages/analysis/src/change-evidence.js +142 -81
  13. package/dist/packages/analysis/src/change-evidence.js.map +1 -1
  14. package/dist/packages/analysis/src/change-waiver.d.ts +37 -13
  15. package/dist/packages/analysis/src/change-waiver.js +425 -174
  16. package/dist/packages/analysis/src/change-waiver.js.map +1 -1
  17. package/dist/packages/analysis/src/change.d.ts +1 -0
  18. package/dist/packages/analysis/src/change.js +176 -23
  19. package/dist/packages/analysis/src/change.js.map +1 -1
  20. package/dist/packages/analysis/src/evidence-merge-guard.d.ts +5 -0
  21. package/dist/packages/analysis/src/evidence-merge-guard.js +15 -0
  22. package/dist/packages/analysis/src/evidence-merge-guard.js.map +1 -1
  23. package/dist/packages/analysis/src/evidence-merge.d.ts +2 -0
  24. package/dist/packages/analysis/src/evidence-merge.js +173 -38
  25. package/dist/packages/analysis/src/evidence-merge.js.map +1 -1
  26. package/dist/packages/analysis/src/evidence-writer-lock.d.ts +9 -2
  27. package/dist/packages/analysis/src/evidence-writer-lock.js +75 -21
  28. package/dist/packages/analysis/src/evidence-writer-lock.js.map +1 -1
  29. package/dist/packages/analysis/src/files.js +5 -0
  30. package/dist/packages/analysis/src/files.js.map +1 -1
  31. package/dist/packages/analysis/src/gate.d.ts +1 -1
  32. package/dist/packages/analysis/src/gate.js +17 -4
  33. package/dist/packages/analysis/src/gate.js.map +1 -1
  34. package/dist/packages/analysis/src/index.d.ts +1 -0
  35. package/dist/packages/analysis/src/index.js +1 -0
  36. package/dist/packages/analysis/src/index.js.map +1 -1
  37. package/dist/packages/analysis/src/order.d.ts +16 -1
  38. package/dist/packages/analysis/src/order.js +6 -0
  39. package/dist/packages/analysis/src/order.js.map +1 -1
  40. package/dist/packages/analysis/src/quality-refresh.d.ts +18 -0
  41. package/dist/packages/analysis/src/quality-refresh.js +312 -0
  42. package/dist/packages/analysis/src/quality-refresh.js.map +1 -0
  43. package/dist/packages/analysis/src/tdd.d.ts +84 -3
  44. package/dist/packages/analysis/src/tdd.js +589 -50
  45. package/dist/packages/analysis/src/tdd.js.map +1 -1
  46. package/dist/packages/analysis/src/trace.d.ts +24 -0
  47. package/dist/packages/analysis/src/trace.js +95 -3
  48. package/dist/packages/analysis/src/trace.js.map +1 -1
  49. package/dist/packages/analysis/src/workflow-waiver.d.ts +6 -2
  50. package/dist/packages/analysis/src/workflow-waiver.js +9 -5
  51. package/dist/packages/analysis/src/workflow-waiver.js.map +1 -1
  52. package/dist/packages/analysis/src/workflow.d.ts +1 -1
  53. package/dist/packages/analysis/src/workflow.js +4 -4
  54. package/dist/packages/analysis/src/workflow.js.map +1 -1
  55. package/dist/packages/cli/src/install.js +27 -4
  56. package/dist/packages/cli/src/install.js.map +1 -1
  57. package/dist/packages/cli/src/main.js +77 -18
  58. package/dist/packages/cli/src/main.js.map +1 -1
  59. package/dist/packages/domain/src/design.js +8 -1
  60. package/dist/packages/domain/src/design.js.map +1 -1
  61. package/dist/packages/domain/src/types.d.ts +1 -0
  62. package/package.json +7 -2
  63. 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 testFingerprint(root, test) {
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 digest(text.slice(start, declaration.end).trim());
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 digest(text.slice(start, next < 0 ? text.length : searchFrom + next).trim());
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 effective = effectiveLatestCycle(evidence, order, validlyVoided, testId, cycle);
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.migrate)
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 latestPhase = cycle.refactor?.valid ? cycle.refactor : cycle.green;
153
- const storedFingerprint = latestPhase.testFingerprint;
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' ? cycle.void?.order : phase === 'migrate' ? cycle.migrate?.order : cycle[phase]?.order;
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 && !voidLinkage(evidence, order, entry).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.testId)
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 = `${record.cycleId}:${record.phase}`;
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', 'migrate', 'void']) {
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 = `${cycle.cycleId ?? 'missing'}:${phase}`;
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.testId, 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 list = cyclesByTest.get(cycle.testId);
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(cycle.testId, [cycle]);
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.testId)
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
- if (cycle.migrate) {
675
- if (!cycle.cycleId || !Number.isInteger(cycle.migrate.order)) {
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
- else {
679
- const record = evidenceOrderRecord(order.records, 'tdd', cycle.cycleId, 'migrate');
680
- if (!record || record.sequence !== cycle.migrate.order) {
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 (cycle.migrate.order !== undefined && latestNonMigrateOrder !== undefined && cycle.migrate.order <= latestNonMigrateOrder) {
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.migrate.approver) {
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 (!cycle.green.scoped || !cycle.green.resultObserved || cycle.green.testStatus !== 'passed' || !cycle.green.reportSha256 || !cycle.green.sourceFingerprint || !cycle.green.executionId) {
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 actualLatest = latestCycles.get(cycle.testId);
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
- || (actualLatest !== undefined && validlyVoidedCycles.has(actualLatest)
710
- && effectiveLatestCycle(evidence, order, validlyVoidedCycles, cycle.testId, actualLatest) === cycle);
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 === cycle.testId);
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
- if (cycle.migrate?.order !== undefined)
721
- candidates.push({ order: cycle.migrate.order, fingerprint: cycle.migrate.toFingerprint });
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', `${cycle.testId} changed after its latest passing TDD phase.`, cycle.testPath));
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 && (!cycle.refactor.scoped || !cycle.refactor.resultObserved || cycle.refactor.testStatus !== 'passed' || !cycle.refactor.reportSha256 || !cycle.refactor.sourceFingerprint || !cycle.refactor.executionId)) {
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