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
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { link, mkdir, open, readFile,
|
|
1
|
+
import { link, mkdir, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
|
|
2
2
|
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
3
|
import { canonicalJson, evaluateChangeWaiverState, loadChangeWaiverEvidence, } from './change-waiver.js';
|
|
4
|
-
import { assertEvidenceMergeReady, assertEvidenceMergeStartable, } from './evidence-merge-guard.js';
|
|
5
|
-
import { assertCoordinatedEvidenceRead, withEvidenceWriterLock, } from './evidence-writer-lock.js';
|
|
6
|
-
import { batchKey, effectiveBatches, loadChangeEvidence, } from './change-evidence.js';
|
|
4
|
+
import { assertEvidenceMergeReady, assertEvidenceMergeStartable, evidenceJournals, } from './evidence-merge-guard.js';
|
|
5
|
+
import { assertCoordinatedEvidenceRead, resolveEvidenceWriterCoordinatedRoot, withEvidenceWriterLock, } from './evidence-writer-lock.js';
|
|
6
|
+
import { batchKey, effectiveBatches, loadChangeEvidence, qualityIdentity, qualityLineage, qualityPayloadForIdentity, } from './change-evidence.js';
|
|
7
7
|
import { digest, exists, readText, safePath, within } from './files.js';
|
|
8
8
|
import { loadEvidenceOrder, validateEvidenceOrderLog, } from './order.js';
|
|
9
9
|
import { loadTddEvidence, } from './tdd.js';
|
|
@@ -15,7 +15,7 @@ const WAIVERS_PATH = `${EVIDENCE_DIR}/change-waivers.json`;
|
|
|
15
15
|
const JOURNAL_PATH = `${EVIDENCE_DIR}/.merge-transaction.json`;
|
|
16
16
|
const STAGING_PREFIX = `${EVIDENCE_DIR}/.merge-transaction.`;
|
|
17
17
|
const TARGETS = [ORDER_PATH, TDD_PATH, CHANGES_PATH, WAIVERS_PATH];
|
|
18
|
-
const TDD_PHASES = ['red', 'green', 'refactor', 'migrate', 'void'];
|
|
18
|
+
const TDD_PHASES = ['red', 'green', 'refactor', 'migrate', 'void', 'archive'];
|
|
19
19
|
const CHANGE_PHASES = ['impact', 'requirements', 'design', 'red', 'implementation', 'green', 'quality'];
|
|
20
20
|
const BATCH_PHASES = ['red', 'implementation', 'green'];
|
|
21
21
|
function json(value) {
|
|
@@ -46,6 +46,8 @@ function nonWaiverOrderKey(record) {
|
|
|
46
46
|
record.kind,
|
|
47
47
|
record.entityId,
|
|
48
48
|
record.phase,
|
|
49
|
+
record.oldTestId ?? null,
|
|
50
|
+
record.newTestId ?? null,
|
|
49
51
|
record.code ?? null,
|
|
50
52
|
record.requirementId ?? null,
|
|
51
53
|
record.detail ?? null,
|
|
@@ -76,7 +78,12 @@ function batchPhase(record, change) {
|
|
|
76
78
|
function payloadFor(history, record) {
|
|
77
79
|
if (record.kind === 'tdd') {
|
|
78
80
|
const cycle = history.tdd.cycles.find((entry) => entry.cycleId === record.entityId);
|
|
79
|
-
const value =
|
|
81
|
+
const value = record.phase === 'migrate'
|
|
82
|
+
? (cycle?.migrateHistory?.find((entry) => entry.order === record.sequence
|
|
83
|
+
|| ((entry.oldTestId ?? null) === (record.oldTestId ?? null)
|
|
84
|
+
&& (entry.newTestId ?? null) === (record.newTestId ?? null)))
|
|
85
|
+
?? (cycle?.migrate?.order === record.sequence ? cycle.migrate : undefined))
|
|
86
|
+
: cycle?.[record.phase];
|
|
80
87
|
return value ? { kind: 'tdd', identity: `${record.entityId}:${record.phase}`, value } : undefined;
|
|
81
88
|
}
|
|
82
89
|
if (record.phase === 'waiver') {
|
|
@@ -86,6 +93,10 @@ function payloadFor(history, record) {
|
|
|
86
93
|
const change = history.changes.changes.find((entry) => entry.changeId === record.entityId);
|
|
87
94
|
if (!change)
|
|
88
95
|
return undefined;
|
|
96
|
+
if (/^quality(?::[1-9]\d*)?$/.test(record.phase)) {
|
|
97
|
+
const value = qualityPayloadForIdentity(change, record.phase);
|
|
98
|
+
return value ? { kind: 'change', identity: `${record.entityId}:${record.phase}`, value } : undefined;
|
|
99
|
+
}
|
|
89
100
|
const value = batchPhase(record, change);
|
|
90
101
|
return value ? { kind: 'change', identity: `${record.entityId}:${record.phase}`, value } : undefined;
|
|
91
102
|
}
|
|
@@ -165,15 +176,18 @@ function validateInputEntities(history) {
|
|
|
165
176
|
}
|
|
166
177
|
const phaseSet = new Set();
|
|
167
178
|
for (const record of history.tdd.chain ?? []) {
|
|
168
|
-
const key =
|
|
179
|
+
const key = canonicalJson(record.phase === 'migrate'
|
|
180
|
+
? [record.cycleId, record.phase, record.oldTestId ?? null, record.newTestId ?? null]
|
|
181
|
+
: [record.cycleId, record.phase]);
|
|
169
182
|
if (phaseSet.has(key))
|
|
170
183
|
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Duplicate TDD chain identity.', TDD_PATH, key));
|
|
171
184
|
phaseSet.add(key);
|
|
172
185
|
}
|
|
173
186
|
for (const cycle of history.tdd.cycles) {
|
|
174
|
-
for (const phase of TDD_PHASES) {
|
|
187
|
+
for (const phase of TDD_PHASES.filter((entry) => entry !== 'migrate')) {
|
|
175
188
|
const value = cycle[phase];
|
|
176
|
-
|
|
189
|
+
const phaseIdentity = canonicalJson([cycle.cycleId, phase]);
|
|
190
|
+
if (value && !phaseSet.has(phaseIdentity)) {
|
|
177
191
|
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Missing TDD chain identity.', TDD_PATH, `${cycle.cycleId}:${phase}`));
|
|
178
192
|
}
|
|
179
193
|
if (value && !Number.isInteger(value.order)) {
|
|
@@ -187,16 +201,75 @@ function validateInputEntities(history) {
|
|
|
187
201
|
}
|
|
188
202
|
}
|
|
189
203
|
}
|
|
204
|
+
const migrations = cycle.migrateHistory?.length ? cycle.migrateHistory : (cycle.migrate ? [cycle.migrate] : []);
|
|
205
|
+
for (const migration of migrations) {
|
|
206
|
+
const identity = canonicalJson([cycle.cycleId, 'migrate', migration.oldTestId ?? null, migration.newTestId ?? null]);
|
|
207
|
+
if (!phaseSet.has(identity)) {
|
|
208
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Missing TDD chain identity.', TDD_PATH, `${cycle.cycleId}:migrate`));
|
|
209
|
+
}
|
|
210
|
+
if (!Number.isInteger(migration.order)) {
|
|
211
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'TDD phase lacks monotonic order evidence; migrate or regenerate it before merging.', TDD_PATH, `${cycle.cycleId}:migrate`));
|
|
212
|
+
}
|
|
213
|
+
if (migration.order !== undefined) {
|
|
214
|
+
const matches = history.order.records.filter((record) => record.sequence === migration.order && record.kind === 'tdd'
|
|
215
|
+
&& record.entityId === cycle.cycleId && record.phase === 'migrate'
|
|
216
|
+
&& (record.oldTestId ?? null) === (migration.oldTestId ?? null)
|
|
217
|
+
&& (record.newTestId ?? null) === (migration.newTestId ?? null));
|
|
218
|
+
if (matches.length !== 1) {
|
|
219
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'TDD phase does not pair to exactly one order record.', TDD_PATH, `${cycle.cycleId}:migrate`));
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
190
223
|
}
|
|
191
224
|
for (const key of phaseSet) {
|
|
192
|
-
const
|
|
225
|
+
const parsed = JSON.parse(key);
|
|
226
|
+
const [cycleId, phase, oldTestId, newTestId] = parsed;
|
|
193
227
|
const cycle = history.tdd.cycles.find((entry) => entry.cycleId === cycleId);
|
|
194
|
-
|
|
228
|
+
const present = phase === 'migrate'
|
|
229
|
+
? (cycle?.migrateHistory?.some((entry) => (entry.oldTestId ?? null) === (oldTestId ?? null) && (entry.newTestId ?? null) === (newTestId ?? null))
|
|
230
|
+
|| (cycle?.migrate && (cycle.migrate.oldTestId ?? null) === (oldTestId ?? null) && (cycle.migrate.newTestId ?? null) === (newTestId ?? null)))
|
|
231
|
+
: !!cycle?.[phase];
|
|
232
|
+
if (!present) {
|
|
195
233
|
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Extra TDD chain identity.', TDD_PATH, key));
|
|
196
234
|
}
|
|
197
235
|
}
|
|
198
236
|
for (const change of history.changes.changes) {
|
|
199
|
-
|
|
237
|
+
if (history.changes.schemaVersion === 1 && change.qualityHistory !== undefined) {
|
|
238
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Schema version 1 cannot contain Quality history.', CHANGES_PATH, change.changeId));
|
|
239
|
+
}
|
|
240
|
+
const historyShapeValid = change.qualityHistory === undefined
|
|
241
|
+
|| (Array.isArray(change.qualityHistory) && change.qualityHistory.length > 0);
|
|
242
|
+
if (!historyShapeValid) {
|
|
243
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality history must be a non-empty array when present.', CHANGES_PATH, change.changeId));
|
|
244
|
+
}
|
|
245
|
+
if (Array.isArray(change.qualityHistory) && change.qualityHistory.length > 0 && !change.phases.quality) {
|
|
246
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality history requires an authoritative Quality checkpoint.', CHANGES_PATH, change.changeId));
|
|
247
|
+
}
|
|
248
|
+
const lineage = Array.isArray(change.qualityHistory)
|
|
249
|
+
? [...change.qualityHistory, ...(change.phases.quality ? [change.phases.quality] : [])]
|
|
250
|
+
: [...(change.phases.quality ? [change.phases.quality] : [])];
|
|
251
|
+
const qualityIdentities = new Set();
|
|
252
|
+
let previousQualityOrder = 0;
|
|
253
|
+
for (const [index, value] of lineage.entries()) {
|
|
254
|
+
const identity = qualityIdentity(index + 1);
|
|
255
|
+
qualityIdentities.add(identity);
|
|
256
|
+
if (value.phase !== 'quality' || typeof value.recordedAt !== 'string'
|
|
257
|
+
|| !value.fingerprints || !Number.isInteger(value.order) || value.order <= previousQualityOrder) {
|
|
258
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality lineage checkpoint is malformed.', CHANGES_PATH, `${change.changeId}:${identity}`));
|
|
259
|
+
continue;
|
|
260
|
+
}
|
|
261
|
+
previousQualityOrder = value.order;
|
|
262
|
+
const matches = history.order.records.filter((record) => record.sequence === value.order && record.kind === 'change'
|
|
263
|
+
&& record.entityId === change.changeId && record.phase === identity);
|
|
264
|
+
if (matches.length !== 1) {
|
|
265
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality checkpoint does not pair to exactly one order record.', CHANGES_PATH, `${change.changeId}:${identity}`));
|
|
266
|
+
}
|
|
267
|
+
}
|
|
268
|
+
if (history.order.records.some((record) => record.kind === 'change' && record.entityId === change.changeId
|
|
269
|
+
&& /^quality(?::\d+)?$/.test(record.phase) && !qualityIdentities.has(record.phase))) {
|
|
270
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality order identity is orphaned or malformed.', CHANGES_PATH, change.changeId));
|
|
271
|
+
}
|
|
272
|
+
for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
|
|
200
273
|
const value = change.phases[phase];
|
|
201
274
|
if (value && !Number.isInteger(value.order)) {
|
|
202
275
|
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Change phase lacks monotonic order evidence; migrate or regenerate it before merging.', CHANGES_PATH, `${change.changeId}:${phase}`));
|
|
@@ -262,7 +335,8 @@ async function validateIncomingWorktree(root, base, incoming) {
|
|
|
262
335
|
* @design DES-EVIDENCE-HISTORY-MERGE-001
|
|
263
336
|
*/
|
|
264
337
|
async function planEvidenceMerge(root, incomingRoot) {
|
|
265
|
-
const
|
|
338
|
+
const baseReal = resolveEvidenceWriterCoordinatedRoot(root);
|
|
339
|
+
const incomingReal = resolveEvidenceWriterCoordinatedRoot(incomingRoot);
|
|
266
340
|
const nested = (parent, child) => {
|
|
267
341
|
const rel = relative(parent, child);
|
|
268
342
|
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
@@ -460,11 +534,16 @@ function mergeChanges(plan, diagnostics) {
|
|
|
460
534
|
const changes = structuredClone(plan.base.changes.changes);
|
|
461
535
|
const byId = new Map(changes.map((change) => [change.changeId, change]));
|
|
462
536
|
for (const change of changes) {
|
|
463
|
-
for (const phase of CHANGE_PHASES) {
|
|
537
|
+
for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
|
|
464
538
|
const item = change.phases[phase];
|
|
465
539
|
if (item)
|
|
466
540
|
change.phases[phase] = rewriteChangePhase(item, 'base', plan);
|
|
467
541
|
}
|
|
542
|
+
if (change.qualityHistory) {
|
|
543
|
+
change.qualityHistory = change.qualityHistory.map((item) => rewriteChangePhase(item, 'base', plan));
|
|
544
|
+
}
|
|
545
|
+
if (change.phases.quality)
|
|
546
|
+
change.phases.quality = rewriteChangePhase(change.phases.quality, 'base', plan);
|
|
468
547
|
for (const batch of change.tddBatches ?? []) {
|
|
469
548
|
for (const phase of BATCH_PHASES) {
|
|
470
549
|
const item = batch[phase];
|
|
@@ -477,11 +556,16 @@ function mergeChanges(plan, diagnostics) {
|
|
|
477
556
|
const target = byId.get(sourceChange.changeId);
|
|
478
557
|
if (!target) {
|
|
479
558
|
const clone = structuredClone(sourceChange);
|
|
480
|
-
for (const phase of CHANGE_PHASES) {
|
|
559
|
+
for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
|
|
481
560
|
const item = clone.phases[phase];
|
|
482
561
|
if (item)
|
|
483
562
|
clone.phases[phase] = rewriteChangePhase(item, 'incoming', plan);
|
|
484
563
|
}
|
|
564
|
+
if (clone.qualityHistory) {
|
|
565
|
+
clone.qualityHistory = clone.qualityHistory.map((item) => rewriteChangePhase(item, 'incoming', plan));
|
|
566
|
+
}
|
|
567
|
+
if (clone.phases.quality)
|
|
568
|
+
clone.phases.quality = rewriteChangePhase(clone.phases.quality, 'incoming', plan);
|
|
485
569
|
for (const batch of clone.tddBatches ?? []) {
|
|
486
570
|
for (const phase of BATCH_PHASES) {
|
|
487
571
|
const item = batch[phase];
|
|
@@ -497,7 +581,7 @@ function mergeChanges(plan, diagnostics) {
|
|
|
497
581
|
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Matching change ID has different requirement IDs.', CHANGES_PATH, sourceChange.changeId));
|
|
498
582
|
continue;
|
|
499
583
|
}
|
|
500
|
-
for (const phase of CHANGE_PHASES) {
|
|
584
|
+
for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
|
|
501
585
|
const left = target.phases[phase];
|
|
502
586
|
const right = sourceChange.phases[phase];
|
|
503
587
|
if (left && right && !semanticEqual(left, right)) {
|
|
@@ -506,6 +590,21 @@ function mergeChanges(plan, diagnostics) {
|
|
|
506
590
|
else if (!left && right)
|
|
507
591
|
target.phases[phase] = rewriteChangePhase(right, 'incoming', plan);
|
|
508
592
|
}
|
|
593
|
+
const leftLineage = qualityLineage(target);
|
|
594
|
+
const rightLineage = qualityLineage(sourceChange);
|
|
595
|
+
const shared = Math.min(leftLineage.length, rightLineage.length);
|
|
596
|
+
const prefix = Array.from({ length: shared }, (_, index) => semanticEqual(leftLineage[index], rightLineage[index])).every(Boolean);
|
|
597
|
+
if (!prefix) {
|
|
598
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Quality lineages diverge at the same ordinal.', CHANGES_PATH, sourceChange.changeId));
|
|
599
|
+
}
|
|
600
|
+
else if (rightLineage.length > leftLineage.length) {
|
|
601
|
+
const rewritten = rightLineage.map((item) => rewriteChangePhase(item, 'incoming', plan));
|
|
602
|
+
target.phases.quality = rewritten.at(-1);
|
|
603
|
+
if (rewritten.length > 1)
|
|
604
|
+
target.qualityHistory = rewritten.slice(0, -1);
|
|
605
|
+
else
|
|
606
|
+
delete target.qualityHistory;
|
|
607
|
+
}
|
|
509
608
|
for (const incomingBatch of sourceChange.tddBatches ?? []) {
|
|
510
609
|
const targetBatches = target.tddBatches ??= [];
|
|
511
610
|
const key = batchKey(incomingBatch.requirementIds);
|
|
@@ -527,7 +626,12 @@ function mergeChanges(plan, diagnostics) {
|
|
|
527
626
|
}
|
|
528
627
|
}
|
|
529
628
|
}
|
|
530
|
-
|
|
629
|
+
const hasHistory = changes.some((change) => (change.qualityHistory?.length ?? 0) > 0);
|
|
630
|
+
return {
|
|
631
|
+
...plan.base.changes,
|
|
632
|
+
schemaVersion: hasHistory || plan.base.changes.schemaVersion === 2 || plan.incoming.changes.schemaVersion === 2 ? 2 : 1,
|
|
633
|
+
changes,
|
|
634
|
+
};
|
|
531
635
|
}
|
|
532
636
|
function mergeWaivers(plan) {
|
|
533
637
|
const waivers = structuredClone(plan.base.waivers.waivers).map((record) => rewritePhase(record, 'base', plan));
|
|
@@ -606,16 +710,19 @@ function validateCandidateReferences(order, tdd, changes, waivers) {
|
|
|
606
710
|
diagnostics.push(mergeDiagnostic('TDD_ORDER_SEQUENCE', 'Refactor is not after Green in monotonic evidence order.', TDD_PATH, `${cycle.cycleId}:refactor`));
|
|
607
711
|
}
|
|
608
712
|
const latestNonMigrateOrder = cycle.refactor?.valid ? cycle.refactor.order : cycle.green?.order;
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
713
|
+
const migrations = cycle.migrateHistory?.length ? cycle.migrateHistory : (cycle.migrate ? [cycle.migrate] : []);
|
|
714
|
+
for (const migration of migrations) {
|
|
715
|
+
if (migration.order !== undefined && latestNonMigrateOrder !== undefined
|
|
716
|
+
&& migration.order <= latestNonMigrateOrder) {
|
|
717
|
+
diagnostics.push(mergeDiagnostic('TDD_ORDER_SEQUENCE', 'Migrate is not after Green/Refactor in monotonic evidence order.', TDD_PATH, `${cycle.cycleId}:migrate`));
|
|
718
|
+
}
|
|
612
719
|
}
|
|
613
720
|
}
|
|
614
721
|
for (const identity of chainKeys) {
|
|
615
722
|
diagnostics.push(mergeDiagnostic('TDD_CHAIN_ORPHAN', 'Rebuilt TDD chain entry has no phase payload.', TDD_PATH, identity));
|
|
616
723
|
}
|
|
617
724
|
for (const change of changes.changes) {
|
|
618
|
-
for (const phase of CHANGE_PHASES) {
|
|
725
|
+
for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
|
|
619
726
|
const value = change.phases[phase];
|
|
620
727
|
if (!value)
|
|
621
728
|
continue;
|
|
@@ -624,6 +731,22 @@ function validateCandidateReferences(order, tdd, changes, waivers) {
|
|
|
624
731
|
diagnostics.push(mergeDiagnostic('CHANGE_ORDER_MISMATCH', 'Rebuilt change phase does not match its order record.', CHANGES_PATH, `${change.changeId}:${phase}`));
|
|
625
732
|
}
|
|
626
733
|
}
|
|
734
|
+
const qualityIdentities = new Set();
|
|
735
|
+
let previousQualityOrder = 0;
|
|
736
|
+
for (const [index, value] of qualityLineage(change).entries()) {
|
|
737
|
+
const identity = qualityIdentity(index + 1);
|
|
738
|
+
qualityIdentities.add(identity);
|
|
739
|
+
const record = value.order === undefined ? undefined : orderAt.get(value.order);
|
|
740
|
+
if (!record || record.kind !== 'change' || record.entityId !== change.changeId
|
|
741
|
+
|| record.phase !== identity || value.order <= previousQualityOrder) {
|
|
742
|
+
diagnostics.push(mergeDiagnostic('CHANGE_QUALITY_HISTORY_MALFORMED', 'Rebuilt Quality checkpoint does not match its ordinal order record.', CHANGES_PATH, `${change.changeId}:${identity}`));
|
|
743
|
+
}
|
|
744
|
+
previousQualityOrder = value.order ?? previousQualityOrder;
|
|
745
|
+
}
|
|
746
|
+
if (order.records.some((record) => record.kind === 'change' && record.entityId === change.changeId
|
|
747
|
+
&& /^quality(?::\d+)?$/.test(record.phase) && !qualityIdentities.has(record.phase))) {
|
|
748
|
+
diagnostics.push(mergeDiagnostic('CHANGE_QUALITY_HISTORY_MALFORMED', 'Rebuilt Quality order record is orphaned.', CHANGES_PATH, change.changeId));
|
|
749
|
+
}
|
|
627
750
|
const fullSetKey = batchKey(change.requirementIds);
|
|
628
751
|
if (change.phases.requirements?.order !== undefined && change.phases.impact?.order !== undefined
|
|
629
752
|
&& change.phases.requirements.order <= change.phases.impact.order) {
|
|
@@ -706,8 +829,8 @@ function buildCandidates(plan) {
|
|
|
706
829
|
},
|
|
707
830
|
};
|
|
708
831
|
}
|
|
709
|
-
async function
|
|
710
|
-
const handle = await open(path, 'r');
|
|
832
|
+
async function fsyncFile(path) {
|
|
833
|
+
const handle = await open(path, 'r+');
|
|
711
834
|
try {
|
|
712
835
|
await handle.sync();
|
|
713
836
|
}
|
|
@@ -715,14 +838,19 @@ async function fsyncPath(path) {
|
|
|
715
838
|
await handle.close();
|
|
716
839
|
}
|
|
717
840
|
}
|
|
718
|
-
async function
|
|
841
|
+
export async function fsyncEvidenceMergeDirectory(path, openDirectory = open) {
|
|
842
|
+
let handle;
|
|
719
843
|
try {
|
|
720
|
-
await
|
|
844
|
+
handle = await openDirectory(path, 'r');
|
|
845
|
+
await handle.sync();
|
|
721
846
|
}
|
|
722
847
|
catch (cause) {
|
|
723
848
|
if (!['EISDIR', 'EPERM', 'EACCES', 'EINVAL'].includes(cause.code ?? ''))
|
|
724
849
|
throw cause;
|
|
725
850
|
}
|
|
851
|
+
finally {
|
|
852
|
+
await handle?.close();
|
|
853
|
+
}
|
|
726
854
|
}
|
|
727
855
|
async function removeIfPresent(path) {
|
|
728
856
|
await rm(path, { force: true });
|
|
@@ -743,7 +871,7 @@ async function restoreJournal(root, journal) {
|
|
|
743
871
|
if (target.existed) {
|
|
744
872
|
const staging = `${absolute}.${journal.transactionId}.restore`;
|
|
745
873
|
await writeFile(staging, Buffer.from(target.originalBase64, 'base64'));
|
|
746
|
-
await
|
|
874
|
+
await fsyncFile(staging);
|
|
747
875
|
await rename(staging, absolute);
|
|
748
876
|
}
|
|
749
877
|
else {
|
|
@@ -751,9 +879,9 @@ async function restoreJournal(root, journal) {
|
|
|
751
879
|
}
|
|
752
880
|
await removeIfPresent(await safePath(root, target.temporaryPath));
|
|
753
881
|
}
|
|
754
|
-
await
|
|
882
|
+
await fsyncEvidenceMergeDirectory(evidenceDirectory);
|
|
755
883
|
await removeIfPresent(await safePath(root, JOURNAL_PATH));
|
|
756
|
-
await
|
|
884
|
+
await fsyncEvidenceMergeDirectory(evidenceDirectory);
|
|
757
885
|
}
|
|
758
886
|
/** @id CODE-EVIDENCE-HISTORY-MERGE-003
|
|
759
887
|
* @implements REQ-EVIDENCE-HISTORY-MERGE-004
|
|
@@ -784,7 +912,7 @@ async function applyCandidates(root, candidates, faultAt) {
|
|
|
784
912
|
if (faultAt === 'journal:staging')
|
|
785
913
|
throw new Error('Injected evidence merge failure at journal:staging.');
|
|
786
914
|
await writeFile(stagingJournal, json(journal), { flag: 'wx' });
|
|
787
|
-
await
|
|
915
|
+
await fsyncFile(stagingJournal);
|
|
788
916
|
if (faultAt === 'journal:publication') {
|
|
789
917
|
throw new Error('EVIDENCE_MERGE_RECOVERY_REQUIRED: injected failure at journal:publication.');
|
|
790
918
|
}
|
|
@@ -799,7 +927,7 @@ async function applyCandidates(root, candidates, faultAt) {
|
|
|
799
927
|
}
|
|
800
928
|
throw cause;
|
|
801
929
|
}
|
|
802
|
-
await
|
|
930
|
+
await fsyncEvidenceMergeDirectory(dirname(journalAbsolute));
|
|
803
931
|
try {
|
|
804
932
|
for (let index = 0; index < targets.length; index++) {
|
|
805
933
|
if (faultAt === `temporary:${index}`)
|
|
@@ -807,7 +935,7 @@ async function applyCandidates(root, candidates, faultAt) {
|
|
|
807
935
|
const target = targets[index];
|
|
808
936
|
const temporary = await safePath(root, target.temporaryPath);
|
|
809
937
|
await writeFile(temporary, Buffer.from(target.candidateBase64, 'base64'), { flag: 'wx' });
|
|
810
|
-
await
|
|
938
|
+
await fsyncFile(temporary);
|
|
811
939
|
}
|
|
812
940
|
for (let index = 0; index < targets.length; index++) {
|
|
813
941
|
if (faultAt === `replace:${index}`)
|
|
@@ -815,15 +943,15 @@ async function applyCandidates(root, candidates, faultAt) {
|
|
|
815
943
|
const target = targets[index];
|
|
816
944
|
await rename(await safePath(root, target.temporaryPath), await safePath(root, target.path));
|
|
817
945
|
}
|
|
818
|
-
await
|
|
946
|
+
await fsyncEvidenceMergeDirectory(dirname(journalAbsolute));
|
|
819
947
|
journal.state = 'committed';
|
|
820
948
|
const commitStaging = await safePath(root, `${STAGING_PREFIX}${transactionId}.commit.json`);
|
|
821
949
|
await writeFile(commitStaging, json(journal), { flag: 'wx' });
|
|
822
|
-
await
|
|
950
|
+
await fsyncFile(commitStaging);
|
|
823
951
|
if (faultAt === 'commit-marker')
|
|
824
952
|
throw new Error('Injected evidence merge failure at commit-marker.');
|
|
825
953
|
await rename(commitStaging, journalAbsolute);
|
|
826
|
-
await
|
|
954
|
+
await fsyncEvidenceMergeDirectory(dirname(journalAbsolute));
|
|
827
955
|
if (faultAt === 'cleanup')
|
|
828
956
|
throw new Error('EVIDENCE_MERGE_RECOVERY_REQUIRED: injected failure at cleanup.');
|
|
829
957
|
await removeIfPresent(journalAbsolute);
|
|
@@ -968,6 +1096,13 @@ export async function recoverEvidenceMerge(root) {
|
|
|
968
1096
|
return withEvidenceWriterLock(root, 'evidence merge --recover', () => recoverEvidenceMergeUnlocked(root));
|
|
969
1097
|
}
|
|
970
1098
|
async function recoverEvidenceMergeUnlocked(root) {
|
|
1099
|
+
const journals = await evidenceJournals(root);
|
|
1100
|
+
if (journals.merge && journals.qualityRefresh) {
|
|
1101
|
+
throw new Error('EVIDENCE_MERGE_RECOVERY_UNSAFE: merge and Quality-refresh journals coexist.');
|
|
1102
|
+
}
|
|
1103
|
+
if (journals.qualityRefresh) {
|
|
1104
|
+
throw new Error('CHANGE_QUALITY_REFRESH_RECOVERY_REQUIRED: run change quality-recover.');
|
|
1105
|
+
}
|
|
971
1106
|
const journalAbsolute = await safePath(root, JOURNAL_PATH);
|
|
972
1107
|
const evidenceDir = await safePath(root, EVIDENCE_DIR);
|
|
973
1108
|
let staging = [];
|
|
@@ -1028,20 +1163,20 @@ async function recoverEvidenceMergeUnlocked(root) {
|
|
|
1028
1163
|
if (digest(actual) !== target.candidateSha256) {
|
|
1029
1164
|
const stagingPath = `${absolute}.${journal.transactionId}.recover`;
|
|
1030
1165
|
await writeFile(stagingPath, Buffer.from(target.candidateBase64, 'base64'));
|
|
1031
|
-
await
|
|
1166
|
+
await fsyncFile(stagingPath);
|
|
1032
1167
|
await rename(stagingPath, absolute);
|
|
1033
|
-
await
|
|
1168
|
+
await fsyncEvidenceMergeDirectory(evidenceDir);
|
|
1034
1169
|
}
|
|
1035
1170
|
const verified = await readFile(absolute, 'utf8');
|
|
1036
1171
|
if (digest(verified) !== target.candidateSha256) {
|
|
1037
1172
|
throw unsafeRecovery('Candidate verification failed after rewrite.', inventory);
|
|
1038
1173
|
}
|
|
1039
1174
|
}
|
|
1040
|
-
await
|
|
1175
|
+
await fsyncEvidenceMergeDirectory(evidenceDir);
|
|
1041
1176
|
await removeIfPresent(journalAbsolute);
|
|
1042
1177
|
for (const entry of staging)
|
|
1043
1178
|
await removeIfPresent(resolve(evidenceDir, entry));
|
|
1044
|
-
await
|
|
1179
|
+
await fsyncEvidenceMergeDirectory(evidenceDir);
|
|
1045
1180
|
return { recovered: true, action: 'rolled-forward', discardedStaging: staging.length > 0 };
|
|
1046
1181
|
}
|
|
1047
1182
|
//# sourceMappingURL=evidence-merge.js.map
|