musubix3 0.1.19 → 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 +126 -0
- package/README-ja.md +171 -5
- package/README.md +185 -8
- package/dist/packages/analysis/src/adapters.d.ts +2 -2
- package/dist/packages/analysis/src/adapters.js +33 -3
- package/dist/packages/analysis/src/adapters.js.map +1 -1
- package/dist/packages/analysis/src/approval-record.js +4 -0
- package/dist/packages/analysis/src/approval-record.js.map +1 -1
- 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 +65 -8
- package/dist/packages/analysis/src/change-evidence.js +173 -46
- package/dist/packages/analysis/src/change-evidence.js.map +1 -1
- package/dist/packages/analysis/src/change-waiver.d.ts +46 -13
- package/dist/packages/analysis/src/change-waiver.js +481 -152
- 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 +194 -66
- package/dist/packages/analysis/src/change.js.map +1 -1
- package/dist/packages/analysis/src/evidence-merge-guard.d.ts +9 -0
- package/dist/packages/analysis/src/evidence-merge-guard.js +62 -0
- package/dist/packages/analysis/src/evidence-merge-guard.js.map +1 -0
- package/dist/packages/analysis/src/evidence-merge.d.ts +55 -0
- package/dist/packages/analysis/src/evidence-merge.js +1182 -0
- package/dist/packages/analysis/src/evidence-merge.js.map +1 -0
- package/dist/packages/analysis/src/evidence-writer-lock.d.ts +189 -0
- package/dist/packages/analysis/src/evidence-writer-lock.js +843 -0
- package/dist/packages/analysis/src/evidence-writer-lock.js.map +1 -0
- package/dist/packages/analysis/src/files.js +11 -0
- package/dist/packages/analysis/src/files.js.map +1 -1
- package/dist/packages/analysis/src/formal.js +7 -0
- package/dist/packages/analysis/src/formal.js.map +1 -1
- package/dist/packages/analysis/src/gate.d.ts +1 -1
- package/dist/packages/analysis/src/gate.js +23 -6
- package/dist/packages/analysis/src/gate.js.map +1 -1
- package/dist/packages/analysis/src/graph.js +6 -0
- package/dist/packages/analysis/src/graph.js.map +1 -1
- package/dist/packages/analysis/src/index.d.ts +3 -0
- package/dist/packages/analysis/src/index.js +3 -0
- package/dist/packages/analysis/src/index.js.map +1 -1
- package/dist/packages/analysis/src/knowledge.js +4 -0
- package/dist/packages/analysis/src/knowledge.js.map +1 -1
- package/dist/packages/analysis/src/model-correspondence.js +5 -1
- package/dist/packages/analysis/src/model-correspondence.js.map +1 -1
- package/dist/packages/analysis/src/mutation.js +4 -0
- package/dist/packages/analysis/src/mutation.js.map +1 -1
- package/dist/packages/analysis/src/order.d.ts +16 -1
- package/dist/packages/analysis/src/order.js +10 -0
- package/dist/packages/analysis/src/order.js.map +1 -1
- package/dist/packages/analysis/src/performance.js +5 -1
- package/dist/packages/analysis/src/performance.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 +86 -3
- package/dist/packages/analysis/src/tdd.js +604 -52
- 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 +101 -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 +103 -21
- package/dist/packages/analysis/src/workflow.js.map +1 -1
- package/dist/packages/cli/src/install.js +51 -6
- package/dist/packages/cli/src/install.js.map +1 -1
- package/dist/packages/cli/src/main.js +252 -79
- 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
|
@@ -0,0 +1,1182 @@
|
|
|
1
|
+
import { link, mkdir, open, readFile, rename, rm, unlink, writeFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname, isAbsolute, relative, resolve, sep } from 'node:path';
|
|
3
|
+
import { canonicalJson, evaluateChangeWaiverState, loadChangeWaiverEvidence, } from './change-waiver.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
|
+
import { digest, exists, readText, safePath, within } from './files.js';
|
|
8
|
+
import { loadEvidenceOrder, validateEvidenceOrderLog, } from './order.js';
|
|
9
|
+
import { loadTddEvidence, } from './tdd.js';
|
|
10
|
+
const EVIDENCE_DIR = '.musubix/evidence';
|
|
11
|
+
const ORDER_PATH = `${EVIDENCE_DIR}/order.json`;
|
|
12
|
+
const TDD_PATH = `${EVIDENCE_DIR}/tdd.json`;
|
|
13
|
+
const CHANGES_PATH = `${EVIDENCE_DIR}/changes.json`;
|
|
14
|
+
const WAIVERS_PATH = `${EVIDENCE_DIR}/change-waivers.json`;
|
|
15
|
+
const JOURNAL_PATH = `${EVIDENCE_DIR}/.merge-transaction.json`;
|
|
16
|
+
const STAGING_PREFIX = `${EVIDENCE_DIR}/.merge-transaction.`;
|
|
17
|
+
const TARGETS = [ORDER_PATH, TDD_PATH, CHANGES_PATH, WAIVERS_PATH];
|
|
18
|
+
const TDD_PHASES = ['red', 'green', 'refactor', 'migrate', 'void', 'archive'];
|
|
19
|
+
const CHANGE_PHASES = ['impact', 'requirements', 'design', 'red', 'implementation', 'green', 'quality'];
|
|
20
|
+
const BATCH_PHASES = ['red', 'implementation', 'green'];
|
|
21
|
+
function json(value) {
|
|
22
|
+
return `${JSON.stringify(value, null, 2)}\n`;
|
|
23
|
+
}
|
|
24
|
+
function mergeDiagnostic(code, message, file, identity) {
|
|
25
|
+
return {
|
|
26
|
+
code,
|
|
27
|
+
message,
|
|
28
|
+
...(file !== undefined ? { file } : {}),
|
|
29
|
+
...(identity !== undefined ? { identity } : {}),
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
function reconstructedFree(value) {
|
|
33
|
+
if (Array.isArray(value))
|
|
34
|
+
return value.map(reconstructedFree);
|
|
35
|
+
if (!value || typeof value !== 'object')
|
|
36
|
+
return value;
|
|
37
|
+
return Object.fromEntries(Object.entries(value)
|
|
38
|
+
.filter(([key]) => !['sequence', 'order', 'previousSha256', 'recordSha256', 'payloadSha256', 'phaseEvidenceSha256'].includes(key))
|
|
39
|
+
.map(([key, entry]) => [key, reconstructedFree(entry)]));
|
|
40
|
+
}
|
|
41
|
+
function semanticEqual(left, right) {
|
|
42
|
+
return canonicalJson(reconstructedFree(left)) === canonicalJson(reconstructedFree(right));
|
|
43
|
+
}
|
|
44
|
+
function nonWaiverOrderKey(record) {
|
|
45
|
+
return canonicalJson([
|
|
46
|
+
record.kind,
|
|
47
|
+
record.entityId,
|
|
48
|
+
record.phase,
|
|
49
|
+
record.oldTestId ?? null,
|
|
50
|
+
record.newTestId ?? null,
|
|
51
|
+
record.code ?? null,
|
|
52
|
+
record.requirementId ?? null,
|
|
53
|
+
record.detail ?? null,
|
|
54
|
+
]);
|
|
55
|
+
}
|
|
56
|
+
function waiverIdentity(record) {
|
|
57
|
+
return canonicalJson([
|
|
58
|
+
record.changeId,
|
|
59
|
+
record.code,
|
|
60
|
+
record.requirementId ?? null,
|
|
61
|
+
record.detail ?? null,
|
|
62
|
+
record.approver,
|
|
63
|
+
record.reason,
|
|
64
|
+
record.recordedAt,
|
|
65
|
+
record.snapshotVersion,
|
|
66
|
+
record.snapshotHash,
|
|
67
|
+
]);
|
|
68
|
+
}
|
|
69
|
+
function batchPhase(record, change) {
|
|
70
|
+
const [phase, key] = record.phase.split(':', 2);
|
|
71
|
+
if (!BATCH_PHASES.includes(phase))
|
|
72
|
+
return undefined;
|
|
73
|
+
if (key === undefined)
|
|
74
|
+
return change.phases[phase];
|
|
75
|
+
const batch = change.tddBatches?.find((entry) => batchKey(entry.requirementIds) === key);
|
|
76
|
+
return batch?.[phase];
|
|
77
|
+
}
|
|
78
|
+
function payloadFor(history, record) {
|
|
79
|
+
if (record.kind === 'tdd') {
|
|
80
|
+
const cycle = history.tdd.cycles.find((entry) => entry.cycleId === record.entityId);
|
|
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];
|
|
87
|
+
return value ? { kind: 'tdd', identity: `${record.entityId}:${record.phase}`, value } : undefined;
|
|
88
|
+
}
|
|
89
|
+
if (record.phase === 'waiver') {
|
|
90
|
+
const waiver = history.waivers.waivers.find((entry) => entry.order === record.sequence);
|
|
91
|
+
return waiver ? { kind: 'waiver', identity: waiverIdentity(waiver), value: waiver } : undefined;
|
|
92
|
+
}
|
|
93
|
+
const change = history.changes.changes.find((entry) => entry.changeId === record.entityId);
|
|
94
|
+
if (!change)
|
|
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
|
+
}
|
|
100
|
+
const value = batchPhase(record, change);
|
|
101
|
+
return value ? { kind: 'change', identity: `${record.entityId}:${record.phase}`, value } : undefined;
|
|
102
|
+
}
|
|
103
|
+
function orderKey(entry) {
|
|
104
|
+
if (entry.record.kind === 'change' && entry.record.phase === 'waiver') {
|
|
105
|
+
return entry.payload?.kind === 'waiver'
|
|
106
|
+
? `paired-waiver:${entry.payload.identity}`
|
|
107
|
+
: `unpaired-waiver:${entry.source}:${entry.record.sequence}`;
|
|
108
|
+
}
|
|
109
|
+
return nonWaiverOrderKey(entry.record);
|
|
110
|
+
}
|
|
111
|
+
async function readRequired(root, path) {
|
|
112
|
+
try {
|
|
113
|
+
return await readText(root, path);
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
throw new Error(`Required evidence file is missing: ${path}`);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
async function loadHistory(root) {
|
|
120
|
+
const rawOrder = await readRequired(root, ORDER_PATH);
|
|
121
|
+
const rawTdd = await readRequired(root, TDD_PATH);
|
|
122
|
+
const rawChanges = await readRequired(root, CHANGES_PATH);
|
|
123
|
+
const waiverFilePresent = await exists(within(root, WAIVERS_PATH));
|
|
124
|
+
const rawWaivers = waiverFilePresent ? await readText(root, WAIVERS_PATH) : undefined;
|
|
125
|
+
const order = await loadEvidenceOrder(root);
|
|
126
|
+
const tdd = await loadTddEvidence(root);
|
|
127
|
+
const changes = await loadChangeEvidence(root);
|
|
128
|
+
const loadedWaivers = await loadChangeWaiverEvidence(root);
|
|
129
|
+
if (!order || !tdd || !changes)
|
|
130
|
+
throw new Error('Required evidence history is incomplete.');
|
|
131
|
+
const orderValidation = validateEvidenceOrderLog(order);
|
|
132
|
+
if (!orderValidation.valid)
|
|
133
|
+
throw new Error(`Invalid evidence order: ${orderValidation.diagnostics.map((item) => item.code).join(', ')}`);
|
|
134
|
+
if (loadedWaivers?.malformed)
|
|
135
|
+
throw new Error('Invalid change waiver evidence.');
|
|
136
|
+
return {
|
|
137
|
+
root,
|
|
138
|
+
order,
|
|
139
|
+
tdd,
|
|
140
|
+
changes,
|
|
141
|
+
waivers: loadedWaivers ?? { schemaVersion: 1, waivers: [] },
|
|
142
|
+
waiverFilePresent,
|
|
143
|
+
raw: {
|
|
144
|
+
[ORDER_PATH]: rawOrder,
|
|
145
|
+
[TDD_PATH]: rawTdd,
|
|
146
|
+
[CHANGES_PATH]: rawChanges,
|
|
147
|
+
...(rawWaivers !== undefined ? { [WAIVERS_PATH]: rawWaivers } : {}),
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function validateInputEntities(history) {
|
|
152
|
+
const diagnostics = [];
|
|
153
|
+
const cycles = new Set();
|
|
154
|
+
for (const cycle of history.tdd.cycles) {
|
|
155
|
+
if (!cycle.cycleId || cycles.has(cycle.cycleId)) {
|
|
156
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Duplicate or missing TDD cycle ID.', TDD_PATH, cycle.cycleId ?? 'missing'));
|
|
157
|
+
}
|
|
158
|
+
else
|
|
159
|
+
cycles.add(cycle.cycleId);
|
|
160
|
+
}
|
|
161
|
+
const changes = new Set();
|
|
162
|
+
for (const change of history.changes.changes) {
|
|
163
|
+
if (changes.has(change.changeId)) {
|
|
164
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Duplicate change ID.', CHANGES_PATH, change.changeId));
|
|
165
|
+
}
|
|
166
|
+
else
|
|
167
|
+
changes.add(change.changeId);
|
|
168
|
+
const batchKeys = new Set();
|
|
169
|
+
for (const batch of change.tddBatches ?? []) {
|
|
170
|
+
const key = batchKey(batch.requirementIds);
|
|
171
|
+
if (batchKeys.has(key)) {
|
|
172
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Duplicate persisted requirement batch key.', CHANGES_PATH, `${change.changeId}:${key}`));
|
|
173
|
+
}
|
|
174
|
+
batchKeys.add(key);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
const phaseSet = new Set();
|
|
178
|
+
for (const record of history.tdd.chain ?? []) {
|
|
179
|
+
const key = canonicalJson(record.phase === 'migrate'
|
|
180
|
+
? [record.cycleId, record.phase, record.oldTestId ?? null, record.newTestId ?? null]
|
|
181
|
+
: [record.cycleId, record.phase]);
|
|
182
|
+
if (phaseSet.has(key))
|
|
183
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Duplicate TDD chain identity.', TDD_PATH, key));
|
|
184
|
+
phaseSet.add(key);
|
|
185
|
+
}
|
|
186
|
+
for (const cycle of history.tdd.cycles) {
|
|
187
|
+
for (const phase of TDD_PHASES.filter((entry) => entry !== 'migrate')) {
|
|
188
|
+
const value = cycle[phase];
|
|
189
|
+
const phaseIdentity = canonicalJson([cycle.cycleId, phase]);
|
|
190
|
+
if (value && !phaseSet.has(phaseIdentity)) {
|
|
191
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Missing TDD chain identity.', TDD_PATH, `${cycle.cycleId}:${phase}`));
|
|
192
|
+
}
|
|
193
|
+
if (value && !Number.isInteger(value.order)) {
|
|
194
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'TDD phase lacks monotonic order evidence; migrate or regenerate it before merging.', TDD_PATH, `${cycle.cycleId}:${phase}`));
|
|
195
|
+
}
|
|
196
|
+
if (value?.order !== undefined) {
|
|
197
|
+
const matches = history.order.records.filter((record) => record.sequence === value.order && record.kind === 'tdd'
|
|
198
|
+
&& record.entityId === cycle.cycleId && record.phase === phase);
|
|
199
|
+
if (matches.length !== 1) {
|
|
200
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'TDD phase does not pair to exactly one order record.', TDD_PATH, `${cycle.cycleId}:${phase}`));
|
|
201
|
+
}
|
|
202
|
+
}
|
|
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
|
+
}
|
|
223
|
+
}
|
|
224
|
+
for (const key of phaseSet) {
|
|
225
|
+
const parsed = JSON.parse(key);
|
|
226
|
+
const [cycleId, phase, oldTestId, newTestId] = parsed;
|
|
227
|
+
const cycle = history.tdd.cycles.find((entry) => entry.cycleId === cycleId);
|
|
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) {
|
|
233
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Extra TDD chain identity.', TDD_PATH, key));
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
for (const change of history.changes.changes) {
|
|
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')) {
|
|
273
|
+
const value = change.phases[phase];
|
|
274
|
+
if (value && !Number.isInteger(value.order)) {
|
|
275
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Change phase lacks monotonic order evidence; migrate or regenerate it before merging.', CHANGES_PATH, `${change.changeId}:${phase}`));
|
|
276
|
+
}
|
|
277
|
+
if (!value?.order)
|
|
278
|
+
continue;
|
|
279
|
+
const matches = history.order.records.filter((record) => record.sequence === value.order && record.kind === 'change'
|
|
280
|
+
&& record.entityId === change.changeId && record.phase === phase);
|
|
281
|
+
if (matches.length !== 1) {
|
|
282
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Change phase does not pair to exactly one order record.', CHANGES_PATH, `${change.changeId}:${phase}`));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
for (const batch of change.tddBatches ?? []) {
|
|
286
|
+
const key = batchKey(batch.requirementIds);
|
|
287
|
+
for (const phase of BATCH_PHASES) {
|
|
288
|
+
const value = batch[phase];
|
|
289
|
+
if (value && !Number.isInteger(value.order)) {
|
|
290
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Change batch phase lacks monotonic order evidence; migrate or regenerate it before merging.', CHANGES_PATH, `${change.changeId}:${phase}:${key}`));
|
|
291
|
+
}
|
|
292
|
+
if (!value?.order)
|
|
293
|
+
continue;
|
|
294
|
+
const matches = history.order.records.filter((record) => record.sequence === value.order && record.kind === 'change'
|
|
295
|
+
&& record.entityId === change.changeId && record.phase === `${phase}:${key}`);
|
|
296
|
+
if (matches.length !== 1) {
|
|
297
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Change batch phase does not pair to exactly one order record.', CHANGES_PATH, `${change.changeId}:${phase}:${key}`));
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const waiver of history.waivers.waivers) {
|
|
303
|
+
const matches = history.order.records.filter((record) => record.sequence === waiver.order && record.kind === 'change'
|
|
304
|
+
&& record.entityId === waiver.changeId && record.phase === 'waiver'
|
|
305
|
+
&& record.code === waiver.code && record.requirementId === waiver.requirementId
|
|
306
|
+
&& record.detail === waiver.detail);
|
|
307
|
+
if (matches.length !== 1) {
|
|
308
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Waiver does not pair to exactly one order record.', WAIVERS_PATH, waiverIdentity(waiver)));
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
return diagnostics;
|
|
312
|
+
}
|
|
313
|
+
async function validateIncomingWorktree(root, base, incoming) {
|
|
314
|
+
const diagnostics = [];
|
|
315
|
+
const newChangeIds = new Set();
|
|
316
|
+
for (const change of incoming.changes.changes) {
|
|
317
|
+
const baseChange = base.changes.changes.find((entry) => entry.changeId === change.changeId);
|
|
318
|
+
if (!baseChange || !semanticEqual(baseChange, change))
|
|
319
|
+
newChangeIds.add(change.changeId);
|
|
320
|
+
}
|
|
321
|
+
for (const waiver of incoming.waivers.waivers) {
|
|
322
|
+
if (!base.waivers.waivers.some((entry) => waiverIdentity(entry) === waiverIdentity(waiver))) {
|
|
323
|
+
newChangeIds.add(waiver.changeId);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
for (const changeId of newChangeIds) {
|
|
327
|
+
if (!await exists(within(root, `.musubix/changes/${changeId}.md`))) {
|
|
328
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_WORKTREE_INCOMPLETE', `Incoming evidence requires .musubix/changes/${changeId}.md in the current worktree.`, `.musubix/changes/${changeId}.md`, changeId));
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
return diagnostics;
|
|
332
|
+
}
|
|
333
|
+
/** @id CODE-EVIDENCE-HISTORY-MERGE-001
|
|
334
|
+
* @implements REQ-EVIDENCE-HISTORY-MERGE-001 REQ-EVIDENCE-HISTORY-MERGE-003
|
|
335
|
+
* @design DES-EVIDENCE-HISTORY-MERGE-001
|
|
336
|
+
*/
|
|
337
|
+
async function planEvidenceMerge(root, incomingRoot) {
|
|
338
|
+
const baseReal = resolveEvidenceWriterCoordinatedRoot(root);
|
|
339
|
+
const incomingReal = resolveEvidenceWriterCoordinatedRoot(incomingRoot);
|
|
340
|
+
const nested = (parent, child) => {
|
|
341
|
+
const rel = relative(parent, child);
|
|
342
|
+
return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
|
|
343
|
+
};
|
|
344
|
+
if (nested(baseReal, incomingReal) || nested(incomingReal, baseReal)) {
|
|
345
|
+
throw new Error('Incoming evidence root must be path-disjoint from the current root.');
|
|
346
|
+
}
|
|
347
|
+
const [base, incoming] = await Promise.all([loadHistory(baseReal), loadHistory(incomingReal)]);
|
|
348
|
+
const diagnostics = [
|
|
349
|
+
...validateInputEntities(base),
|
|
350
|
+
...validateInputEntities(incoming),
|
|
351
|
+
...await validateIncomingWorktree(baseReal, base, incoming),
|
|
352
|
+
];
|
|
353
|
+
const entries = base.order.records.map((record) => {
|
|
354
|
+
const payload = payloadFor(base, record);
|
|
355
|
+
return { source: 'base', record, ...(payload ? { payload } : {}) };
|
|
356
|
+
});
|
|
357
|
+
const baseOrderMap = new Map(base.order.records.map((record) => [record.sequence, record.sequence]));
|
|
358
|
+
const incomingOrderMap = new Map();
|
|
359
|
+
const byKey = new Map(entries.map((entry) => [orderKey(entry), entry]));
|
|
360
|
+
let deduplicated = 0;
|
|
361
|
+
let retainedIncomingPayload = false;
|
|
362
|
+
let previousMapped = 0;
|
|
363
|
+
for (const record of incoming.order.records) {
|
|
364
|
+
const payload = payloadFor(incoming, record);
|
|
365
|
+
const entry = { source: 'incoming', record, ...(payload ? { payload } : {}) };
|
|
366
|
+
const key = orderKey(entry);
|
|
367
|
+
const existing = byKey.get(key);
|
|
368
|
+
let mapped;
|
|
369
|
+
if (existing) {
|
|
370
|
+
if (existing.record.testId !== record.testId) {
|
|
371
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Matching order identity has a different testId.', ORDER_PATH, `${record.kind}:${record.entityId}:${record.phase}`));
|
|
372
|
+
}
|
|
373
|
+
else if (existing.payload && payload && !semanticEqual(existing.payload.value, payload.value)) {
|
|
374
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Matching logical evidence has divergent payloads.', record.kind === 'tdd' ? TDD_PATH : CHANGES_PATH, payload.identity));
|
|
375
|
+
}
|
|
376
|
+
else if (!existing.payload && payload) {
|
|
377
|
+
existing.payload = payload;
|
|
378
|
+
retainedIncomingPayload = true;
|
|
379
|
+
}
|
|
380
|
+
mapped = existing.record.sequence;
|
|
381
|
+
deduplicated += 1;
|
|
382
|
+
}
|
|
383
|
+
else {
|
|
384
|
+
mapped = entries.length + 1;
|
|
385
|
+
entries.push(entry);
|
|
386
|
+
byKey.set(key, entry);
|
|
387
|
+
if (payload)
|
|
388
|
+
retainedIncomingPayload = true;
|
|
389
|
+
}
|
|
390
|
+
incomingOrderMap.set(record.sequence, mapped);
|
|
391
|
+
if (mapped <= previousMapped) {
|
|
392
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Deduplication would invert incoming chronology.', ORDER_PATH, `${record.kind}:${record.entityId}:${record.phase}`));
|
|
393
|
+
}
|
|
394
|
+
previousMapped = mapped;
|
|
395
|
+
}
|
|
396
|
+
return {
|
|
397
|
+
base,
|
|
398
|
+
incoming,
|
|
399
|
+
entries,
|
|
400
|
+
baseOrderMap,
|
|
401
|
+
incomingOrderMap,
|
|
402
|
+
preserved: base.order.records.length,
|
|
403
|
+
deduplicated,
|
|
404
|
+
appended: entries.length - base.order.records.length,
|
|
405
|
+
retainedIncomingPayload,
|
|
406
|
+
diagnostics,
|
|
407
|
+
};
|
|
408
|
+
}
|
|
409
|
+
function mappedOrder(source, order, plan) {
|
|
410
|
+
if (order === undefined)
|
|
411
|
+
return undefined;
|
|
412
|
+
return (source === 'base' ? plan.baseOrderMap : plan.incomingOrderMap).get(order);
|
|
413
|
+
}
|
|
414
|
+
function rewritePhase(value, source, plan) {
|
|
415
|
+
const clone = structuredClone(value);
|
|
416
|
+
const order = mappedOrder(source, clone.order, plan);
|
|
417
|
+
if (order === undefined)
|
|
418
|
+
delete clone.order;
|
|
419
|
+
else
|
|
420
|
+
clone.order = order;
|
|
421
|
+
return clone;
|
|
422
|
+
}
|
|
423
|
+
function buildOrder(plan) {
|
|
424
|
+
const records = [];
|
|
425
|
+
for (const entry of plan.entries) {
|
|
426
|
+
const previousSha256 = records.at(-1)?.recordSha256 ?? null;
|
|
427
|
+
const { recordSha256: _recordSha256, ...original } = entry.record;
|
|
428
|
+
const payload = {
|
|
429
|
+
...original,
|
|
430
|
+
sequence: records.length + 1,
|
|
431
|
+
previousSha256,
|
|
432
|
+
};
|
|
433
|
+
records.push({ ...payload, recordSha256: digest(JSON.stringify(payload)) });
|
|
434
|
+
}
|
|
435
|
+
return { ...plan.base.order, schemaVersion: 1, records };
|
|
436
|
+
}
|
|
437
|
+
function cycleHeaderEqual(left, right) {
|
|
438
|
+
return left.requirementId === right.requirementId
|
|
439
|
+
&& left.testId === right.testId
|
|
440
|
+
&& left.testPath === right.testPath
|
|
441
|
+
&& left.commandName === right.commandName;
|
|
442
|
+
}
|
|
443
|
+
function mergeTdd(plan, diagnostics) {
|
|
444
|
+
const cycles = [];
|
|
445
|
+
const byId = new Map();
|
|
446
|
+
const owners = new Map();
|
|
447
|
+
for (const source of ['base', 'incoming']) {
|
|
448
|
+
const history = source === 'base' ? plan.base : plan.incoming;
|
|
449
|
+
for (const sourceCycle of history.tdd.cycles) {
|
|
450
|
+
const cycleId = sourceCycle.cycleId;
|
|
451
|
+
let target = byId.get(cycleId);
|
|
452
|
+
if (!target) {
|
|
453
|
+
target = structuredClone(sourceCycle);
|
|
454
|
+
cycles.push(target);
|
|
455
|
+
byId.set(cycleId, target);
|
|
456
|
+
for (const phase of TDD_PHASES)
|
|
457
|
+
if (sourceCycle[phase])
|
|
458
|
+
owners.set(`${cycleId}:${phase}`, source);
|
|
459
|
+
continue;
|
|
460
|
+
}
|
|
461
|
+
if (!cycleHeaderEqual(target, sourceCycle)) {
|
|
462
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Matching cycle ID has different header fields.', TDD_PATH, cycleId));
|
|
463
|
+
continue;
|
|
464
|
+
}
|
|
465
|
+
for (const phase of TDD_PHASES) {
|
|
466
|
+
const incomingPhase = sourceCycle[phase];
|
|
467
|
+
const existingPhase = target[phase];
|
|
468
|
+
if (incomingPhase && existingPhase && !semanticEqual(existingPhase, incomingPhase)) {
|
|
469
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Matching cycle phase has divergent payloads.', TDD_PATH, `${cycleId}:${phase}`));
|
|
470
|
+
}
|
|
471
|
+
else if (incomingPhase && !existingPhase) {
|
|
472
|
+
target[phase] = structuredClone(incomingPhase);
|
|
473
|
+
owners.set(`${cycleId}:${phase}`, source);
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
for (const cycle of cycles) {
|
|
479
|
+
for (const phase of TDD_PHASES) {
|
|
480
|
+
const value = cycle[phase];
|
|
481
|
+
if (!value)
|
|
482
|
+
continue;
|
|
483
|
+
cycle[phase] = rewritePhase(value, owners.get(`${cycle.cycleId}:${phase}`) ?? 'base', plan);
|
|
484
|
+
}
|
|
485
|
+
}
|
|
486
|
+
const phaseEntries = [];
|
|
487
|
+
for (const cycle of cycles) {
|
|
488
|
+
for (const phase of TDD_PHASES) {
|
|
489
|
+
const value = cycle[phase];
|
|
490
|
+
if (value?.order !== undefined) {
|
|
491
|
+
const owner = owners.get(`${cycle.cycleId}:${phase}`) ?? 'base';
|
|
492
|
+
const source = owner === 'base' ? plan.base : plan.incoming;
|
|
493
|
+
const template = source.tdd.chain?.find((entry) => entry.cycleId === cycle.cycleId && entry.phase === phase);
|
|
494
|
+
phaseEntries.push({ cycle, phase, value, order: value.order, ...(template ? { template } : {}) });
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
phaseEntries.sort((left, right) => left.order - right.order);
|
|
499
|
+
const chain = [];
|
|
500
|
+
for (const entry of phaseEntries) {
|
|
501
|
+
const { recordSha256: _recordSha256, sequence: _sequence, previousSha256: _previousSha256, phaseEvidenceSha256: _phaseEvidenceSha256, cycleId: _cycleId, requirementId: _requirementId, testId: _testId, testPath: _testPath, commandName: _commandName, phase: _phase, ...unknown } = entry.template ?? {};
|
|
502
|
+
const payload = {
|
|
503
|
+
sequence: chain.length + 1,
|
|
504
|
+
cycleId: entry.cycle.cycleId,
|
|
505
|
+
requirementId: entry.cycle.requirementId,
|
|
506
|
+
testId: entry.cycle.testId,
|
|
507
|
+
testPath: entry.cycle.testPath,
|
|
508
|
+
commandName: entry.cycle.commandName,
|
|
509
|
+
phase: entry.phase,
|
|
510
|
+
phaseEvidenceSha256: digest(JSON.stringify(entry.value)),
|
|
511
|
+
previousSha256: chain.at(-1)?.recordSha256 ?? null,
|
|
512
|
+
...unknown,
|
|
513
|
+
};
|
|
514
|
+
chain.push({ ...payload, recordSha256: digest(JSON.stringify(payload)) });
|
|
515
|
+
}
|
|
516
|
+
return { ...plan.base.tdd, schemaVersion: 1, cycles, chain };
|
|
517
|
+
}
|
|
518
|
+
function rewriteChangePhase(value, source, plan) {
|
|
519
|
+
return rewritePhase(value, source, plan);
|
|
520
|
+
}
|
|
521
|
+
function mergeBatch(target, incoming, plan, diagnostics, changeId) {
|
|
522
|
+
for (const phase of BATCH_PHASES) {
|
|
523
|
+
const left = target[phase];
|
|
524
|
+
const right = incoming[phase];
|
|
525
|
+
if (left && right && !semanticEqual(left, right)) {
|
|
526
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Matching change batch phase has divergent payloads.', CHANGES_PATH, `${changeId}:${phase}:${batchKey(target.requirementIds)}`));
|
|
527
|
+
}
|
|
528
|
+
else if (!left && right) {
|
|
529
|
+
target[phase] = rewriteChangePhase(right, 'incoming', plan);
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
function mergeChanges(plan, diagnostics) {
|
|
534
|
+
const changes = structuredClone(plan.base.changes.changes);
|
|
535
|
+
const byId = new Map(changes.map((change) => [change.changeId, change]));
|
|
536
|
+
for (const change of changes) {
|
|
537
|
+
for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
|
|
538
|
+
const item = change.phases[phase];
|
|
539
|
+
if (item)
|
|
540
|
+
change.phases[phase] = rewriteChangePhase(item, 'base', plan);
|
|
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);
|
|
547
|
+
for (const batch of change.tddBatches ?? []) {
|
|
548
|
+
for (const phase of BATCH_PHASES) {
|
|
549
|
+
const item = batch[phase];
|
|
550
|
+
if (item)
|
|
551
|
+
batch[phase] = rewriteChangePhase(item, 'base', plan);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
for (const sourceChange of plan.incoming.changes.changes) {
|
|
556
|
+
const target = byId.get(sourceChange.changeId);
|
|
557
|
+
if (!target) {
|
|
558
|
+
const clone = structuredClone(sourceChange);
|
|
559
|
+
for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
|
|
560
|
+
const item = clone.phases[phase];
|
|
561
|
+
if (item)
|
|
562
|
+
clone.phases[phase] = rewriteChangePhase(item, 'incoming', plan);
|
|
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);
|
|
569
|
+
for (const batch of clone.tddBatches ?? []) {
|
|
570
|
+
for (const phase of BATCH_PHASES) {
|
|
571
|
+
const item = batch[phase];
|
|
572
|
+
if (item)
|
|
573
|
+
batch[phase] = rewriteChangePhase(item, 'incoming', plan);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
changes.push(clone);
|
|
577
|
+
byId.set(clone.changeId, clone);
|
|
578
|
+
continue;
|
|
579
|
+
}
|
|
580
|
+
if (canonicalJson([...target.requirementIds].sort()) !== canonicalJson([...sourceChange.requirementIds].sort())) {
|
|
581
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Matching change ID has different requirement IDs.', CHANGES_PATH, sourceChange.changeId));
|
|
582
|
+
continue;
|
|
583
|
+
}
|
|
584
|
+
for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
|
|
585
|
+
const left = target.phases[phase];
|
|
586
|
+
const right = sourceChange.phases[phase];
|
|
587
|
+
if (left && right && !semanticEqual(left, right)) {
|
|
588
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Matching change phase has divergent payloads.', CHANGES_PATH, `${sourceChange.changeId}:${phase}`));
|
|
589
|
+
}
|
|
590
|
+
else if (!left && right)
|
|
591
|
+
target.phases[phase] = rewriteChangePhase(right, 'incoming', plan);
|
|
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
|
+
}
|
|
608
|
+
for (const incomingBatch of sourceChange.tddBatches ?? []) {
|
|
609
|
+
const targetBatches = target.tddBatches ??= [];
|
|
610
|
+
const key = batchKey(incomingBatch.requirementIds);
|
|
611
|
+
const matches = targetBatches.filter((batch) => batchKey(batch.requirementIds) === key);
|
|
612
|
+
if (matches.length > 1) {
|
|
613
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CONFLICT', 'Ambiguous duplicate requirement batch key.', CHANGES_PATH, `${sourceChange.changeId}:${key}`));
|
|
614
|
+
}
|
|
615
|
+
else if (matches.length === 1) {
|
|
616
|
+
mergeBatch(matches[0], incomingBatch, plan, diagnostics, sourceChange.changeId);
|
|
617
|
+
}
|
|
618
|
+
else {
|
|
619
|
+
const clone = structuredClone(incomingBatch);
|
|
620
|
+
for (const phase of BATCH_PHASES) {
|
|
621
|
+
const item = clone[phase];
|
|
622
|
+
if (item)
|
|
623
|
+
clone[phase] = rewriteChangePhase(item, 'incoming', plan);
|
|
624
|
+
}
|
|
625
|
+
targetBatches.push(clone);
|
|
626
|
+
}
|
|
627
|
+
}
|
|
628
|
+
}
|
|
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
|
+
};
|
|
635
|
+
}
|
|
636
|
+
function mergeWaivers(plan) {
|
|
637
|
+
const waivers = structuredClone(plan.base.waivers.waivers).map((record) => rewritePhase(record, 'base', plan));
|
|
638
|
+
const identities = new Set(waivers.map(waiverIdentity));
|
|
639
|
+
for (const source of plan.incoming.waivers.waivers) {
|
|
640
|
+
const identity = waiverIdentity(source);
|
|
641
|
+
if (!identities.has(identity)) {
|
|
642
|
+
waivers.push(rewritePhase(structuredClone(source), 'incoming', plan));
|
|
643
|
+
identities.add(identity);
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
let previousSha256 = '0'.repeat(64);
|
|
647
|
+
for (const waiver of waivers) {
|
|
648
|
+
waiver.previousSha256 = previousSha256;
|
|
649
|
+
const { payloadSha256: _payloadSha256, ...payload } = waiver;
|
|
650
|
+
waiver.payloadSha256 = digest(canonicalJson(payload));
|
|
651
|
+
previousSha256 = waiver.payloadSha256;
|
|
652
|
+
}
|
|
653
|
+
return { ...plan.base.waivers, schemaVersion: 1, waivers };
|
|
654
|
+
}
|
|
655
|
+
function validateQualityOrder(changes) {
|
|
656
|
+
const diagnostics = [];
|
|
657
|
+
for (const change of changes.changes) {
|
|
658
|
+
const quality = change.phases.quality?.order;
|
|
659
|
+
if (quality === undefined)
|
|
660
|
+
continue;
|
|
661
|
+
for (const batch of effectiveBatches(change)) {
|
|
662
|
+
for (const phase of ['red', 'implementation']) {
|
|
663
|
+
const order = batch[phase]?.order;
|
|
664
|
+
if (order !== undefined && quality <= order) {
|
|
665
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_QUALITY_ORDER', `${change.changeId}:quality is not after ${phase}.`, CHANGES_PATH, `${change.changeId}:${phase}`));
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
return diagnostics;
|
|
671
|
+
}
|
|
672
|
+
function validateCandidateReferences(order, tdd, changes, waivers) {
|
|
673
|
+
const diagnostics = [];
|
|
674
|
+
const orderAt = new Map(order.records.map((record) => [record.sequence, record]));
|
|
675
|
+
const chainKeys = new Set();
|
|
676
|
+
for (const [index, record] of (tdd.chain ?? []).entries()) {
|
|
677
|
+
const key = `${record.cycleId}:${record.phase}`;
|
|
678
|
+
const expectedPrevious = index === 0 ? null : tdd.chain[index - 1].recordSha256;
|
|
679
|
+
const { recordSha256, ...payload } = record;
|
|
680
|
+
if (record.sequence !== index + 1 || record.previousSha256 !== expectedPrevious
|
|
681
|
+
|| recordSha256 !== digest(JSON.stringify(payload))) {
|
|
682
|
+
diagnostics.push(mergeDiagnostic('TDD_CHAIN_HASH_MISMATCH', 'Rebuilt TDD chain linkage or hash is invalid.', TDD_PATH, key));
|
|
683
|
+
}
|
|
684
|
+
if (chainKeys.has(key)) {
|
|
685
|
+
diagnostics.push(mergeDiagnostic('TDD_CHAIN_PHASE_DUPLICATE', 'Rebuilt TDD chain identity is duplicated.', TDD_PATH, key));
|
|
686
|
+
}
|
|
687
|
+
chainKeys.add(key);
|
|
688
|
+
}
|
|
689
|
+
for (const cycle of tdd.cycles) {
|
|
690
|
+
for (const phase of TDD_PHASES) {
|
|
691
|
+
const value = cycle[phase];
|
|
692
|
+
if (!value)
|
|
693
|
+
continue;
|
|
694
|
+
const identity = `${cycle.cycleId}:${phase}`;
|
|
695
|
+
const record = value.order === undefined ? undefined : orderAt.get(value.order);
|
|
696
|
+
if (!record || record.kind !== 'tdd' || record.entityId !== cycle.cycleId || record.phase !== phase) {
|
|
697
|
+
diagnostics.push(mergeDiagnostic('TDD_ORDER_MISMATCH', 'Rebuilt TDD phase does not match its order record.', TDD_PATH, identity));
|
|
698
|
+
}
|
|
699
|
+
const chain = tdd.chain?.find((entry) => entry.cycleId === cycle.cycleId && entry.phase === phase);
|
|
700
|
+
if (!chain || chain.phaseEvidenceSha256 !== digest(JSON.stringify(value))) {
|
|
701
|
+
diagnostics.push(mergeDiagnostic('TDD_CHAIN_PAYLOAD_MISMATCH', 'Rebuilt TDD phase does not match its chain payload hash.', TDD_PATH, identity));
|
|
702
|
+
}
|
|
703
|
+
chainKeys.delete(identity);
|
|
704
|
+
}
|
|
705
|
+
if (cycle.green?.order !== undefined && cycle.red.order !== undefined && cycle.green.order <= cycle.red.order) {
|
|
706
|
+
diagnostics.push(mergeDiagnostic('TDD_ORDER_SEQUENCE', 'Green is not after Red in monotonic evidence order.', TDD_PATH, `${cycle.cycleId}:green`));
|
|
707
|
+
}
|
|
708
|
+
if (cycle.refactor?.order !== undefined && cycle.green?.order !== undefined
|
|
709
|
+
&& cycle.refactor.order <= cycle.green.order) {
|
|
710
|
+
diagnostics.push(mergeDiagnostic('TDD_ORDER_SEQUENCE', 'Refactor is not after Green in monotonic evidence order.', TDD_PATH, `${cycle.cycleId}:refactor`));
|
|
711
|
+
}
|
|
712
|
+
const latestNonMigrateOrder = cycle.refactor?.valid ? cycle.refactor.order : cycle.green?.order;
|
|
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
|
+
}
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
for (const identity of chainKeys) {
|
|
722
|
+
diagnostics.push(mergeDiagnostic('TDD_CHAIN_ORPHAN', 'Rebuilt TDD chain entry has no phase payload.', TDD_PATH, identity));
|
|
723
|
+
}
|
|
724
|
+
for (const change of changes.changes) {
|
|
725
|
+
for (const phase of CHANGE_PHASES.filter((entry) => entry !== 'quality')) {
|
|
726
|
+
const value = change.phases[phase];
|
|
727
|
+
if (!value)
|
|
728
|
+
continue;
|
|
729
|
+
const record = value.order === undefined ? undefined : orderAt.get(value.order);
|
|
730
|
+
if (!record || record.kind !== 'change' || record.entityId !== change.changeId || record.phase !== phase) {
|
|
731
|
+
diagnostics.push(mergeDiagnostic('CHANGE_ORDER_MISMATCH', 'Rebuilt change phase does not match its order record.', CHANGES_PATH, `${change.changeId}:${phase}`));
|
|
732
|
+
}
|
|
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
|
+
}
|
|
750
|
+
const fullSetKey = batchKey(change.requirementIds);
|
|
751
|
+
if (change.phases.requirements?.order !== undefined && change.phases.impact?.order !== undefined
|
|
752
|
+
&& change.phases.requirements.order <= change.phases.impact.order) {
|
|
753
|
+
diagnostics.push(mergeDiagnostic('CHANGE_PHASE_ORDER', 'Requirements is not after impact.', CHANGES_PATH, `${change.changeId}:requirements`));
|
|
754
|
+
}
|
|
755
|
+
if (change.phases.design?.order !== undefined && change.phases.requirements?.order !== undefined
|
|
756
|
+
&& change.phases.design.order <= change.phases.requirements.order) {
|
|
757
|
+
diagnostics.push(mergeDiagnostic('CHANGE_PHASE_ORDER', 'Design is not after requirements.', CHANGES_PATH, `${change.changeId}:design`));
|
|
758
|
+
}
|
|
759
|
+
for (const batch of effectiveBatches(change)) {
|
|
760
|
+
const key = batchKey(batch.requirementIds);
|
|
761
|
+
for (const phase of BATCH_PHASES) {
|
|
762
|
+
const value = batch[phase];
|
|
763
|
+
if (!value)
|
|
764
|
+
continue;
|
|
765
|
+
const expectedPhase = key === fullSetKey ? phase : `${phase}:${key}`;
|
|
766
|
+
const record = value.order === undefined ? undefined : orderAt.get(value.order);
|
|
767
|
+
if (!record || record.kind !== 'change' || record.entityId !== change.changeId || record.phase !== expectedPhase) {
|
|
768
|
+
diagnostics.push(mergeDiagnostic('CHANGE_ORDER_MISMATCH', 'Rebuilt change batch phase does not match its order record.', CHANGES_PATH, `${change.changeId}:${phase}:${key}`));
|
|
769
|
+
}
|
|
770
|
+
}
|
|
771
|
+
const ordered = [
|
|
772
|
+
change.phases.design?.order,
|
|
773
|
+
batch.red?.order,
|
|
774
|
+
batch.implementation?.order,
|
|
775
|
+
batch.green?.order,
|
|
776
|
+
change.phases.quality?.order,
|
|
777
|
+
].filter((value) => value !== undefined);
|
|
778
|
+
if (ordered.some((value, index) => index > 0 && value <= ordered[index - 1])) {
|
|
779
|
+
diagnostics.push(mergeDiagnostic('CHANGE_PHASE_ORDER', 'Rebuilt change chronology is out of order.', CHANGES_PATH, `${change.changeId}:${key}`));
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
let previousSha256 = '0'.repeat(64);
|
|
784
|
+
for (const waiver of waivers.waivers) {
|
|
785
|
+
const identity = waiverIdentity(waiver);
|
|
786
|
+
const record = orderAt.get(waiver.order);
|
|
787
|
+
if (!record || record.kind !== 'change' || record.entityId !== waiver.changeId || record.phase !== 'waiver'
|
|
788
|
+
|| record.code !== waiver.code || record.requirementId !== waiver.requirementId || record.detail !== waiver.detail) {
|
|
789
|
+
diagnostics.push(mergeDiagnostic('CHANGE_WAIVER_EVIDENCE_MALFORMED', 'Rebuilt waiver does not match its order record.', WAIVERS_PATH, identity));
|
|
790
|
+
}
|
|
791
|
+
const { payloadSha256, ...payload } = waiver;
|
|
792
|
+
if (waiver.previousSha256 !== previousSha256 || payloadSha256 !== digest(canonicalJson(payload))) {
|
|
793
|
+
diagnostics.push(mergeDiagnostic('CHANGE_WAIVER_EVIDENCE_MALFORMED', 'Rebuilt waiver hash chain is invalid.', WAIVERS_PATH, identity));
|
|
794
|
+
}
|
|
795
|
+
previousSha256 = waiver.payloadSha256;
|
|
796
|
+
}
|
|
797
|
+
return diagnostics;
|
|
798
|
+
}
|
|
799
|
+
/** @id CODE-EVIDENCE-HISTORY-MERGE-002
|
|
800
|
+
* @implements REQ-EVIDENCE-HISTORY-MERGE-002 REQ-EVIDENCE-HISTORY-MERGE-003
|
|
801
|
+
* @design DES-EVIDENCE-HISTORY-MERGE-002
|
|
802
|
+
*/
|
|
803
|
+
function buildCandidates(plan) {
|
|
804
|
+
const diagnostics = plan.diagnostics;
|
|
805
|
+
const candidateDiagnosticStart = diagnostics.length;
|
|
806
|
+
const order = buildOrder(plan);
|
|
807
|
+
const tdd = mergeTdd(plan, diagnostics);
|
|
808
|
+
const changes = mergeChanges(plan, diagnostics);
|
|
809
|
+
const waivers = mergeWaivers(plan);
|
|
810
|
+
diagnostics.push(...validateQualityOrder(changes));
|
|
811
|
+
diagnostics.push(...validateCandidateReferences(order, tdd, changes, waivers));
|
|
812
|
+
const orderValidation = validateEvidenceOrderLog(order);
|
|
813
|
+
diagnostics.push(...orderValidation.diagnostics.map((item) => mergeDiagnostic(item.code, item.message, item.path ?? ORDER_PATH)));
|
|
814
|
+
if (diagnostics.length > candidateDiagnosticStart) {
|
|
815
|
+
diagnostics.push(mergeDiagnostic('EVIDENCE_MERGE_CANDIDATE_INVALID', 'The reconstructed evidence candidate is structurally invalid; see the accompanying diagnostics.'));
|
|
816
|
+
}
|
|
817
|
+
const includeWaivers = plan.base.waiverFilePresent || waivers.waivers.length > 0;
|
|
818
|
+
return {
|
|
819
|
+
order,
|
|
820
|
+
tdd,
|
|
821
|
+
changes,
|
|
822
|
+
waivers,
|
|
823
|
+
includeWaivers,
|
|
824
|
+
bytes: {
|
|
825
|
+
[ORDER_PATH]: json(order),
|
|
826
|
+
[TDD_PATH]: json(tdd),
|
|
827
|
+
[CHANGES_PATH]: json(changes),
|
|
828
|
+
...(includeWaivers ? { [WAIVERS_PATH]: json(waivers) } : {}),
|
|
829
|
+
},
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
async function fsyncFile(path) {
|
|
833
|
+
const handle = await open(path, 'r+');
|
|
834
|
+
try {
|
|
835
|
+
await handle.sync();
|
|
836
|
+
}
|
|
837
|
+
finally {
|
|
838
|
+
await handle.close();
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
export async function fsyncEvidenceMergeDirectory(path, openDirectory = open) {
|
|
842
|
+
let handle;
|
|
843
|
+
try {
|
|
844
|
+
handle = await openDirectory(path, 'r');
|
|
845
|
+
await handle.sync();
|
|
846
|
+
}
|
|
847
|
+
catch (cause) {
|
|
848
|
+
if (!['EISDIR', 'EPERM', 'EACCES', 'EINVAL'].includes(cause.code ?? ''))
|
|
849
|
+
throw cause;
|
|
850
|
+
}
|
|
851
|
+
finally {
|
|
852
|
+
await handle?.close();
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
async function removeIfPresent(path) {
|
|
856
|
+
await rm(path, { force: true });
|
|
857
|
+
}
|
|
858
|
+
async function originalBytes(root, path) {
|
|
859
|
+
const absolute = await safePath(root, path);
|
|
860
|
+
try {
|
|
861
|
+
return { existed: true, bytes: await readFile(absolute, 'utf8') };
|
|
862
|
+
}
|
|
863
|
+
catch {
|
|
864
|
+
return { existed: false, bytes: '' };
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
async function restoreJournal(root, journal) {
|
|
868
|
+
const evidenceDirectory = await safePath(root, EVIDENCE_DIR);
|
|
869
|
+
for (const target of journal.targets) {
|
|
870
|
+
const absolute = await safePath(root, target.path);
|
|
871
|
+
if (target.existed) {
|
|
872
|
+
const staging = `${absolute}.${journal.transactionId}.restore`;
|
|
873
|
+
await writeFile(staging, Buffer.from(target.originalBase64, 'base64'));
|
|
874
|
+
await fsyncFile(staging);
|
|
875
|
+
await rename(staging, absolute);
|
|
876
|
+
}
|
|
877
|
+
else {
|
|
878
|
+
await removeIfPresent(absolute);
|
|
879
|
+
}
|
|
880
|
+
await removeIfPresent(await safePath(root, target.temporaryPath));
|
|
881
|
+
}
|
|
882
|
+
await fsyncEvidenceMergeDirectory(evidenceDirectory);
|
|
883
|
+
await removeIfPresent(await safePath(root, JOURNAL_PATH));
|
|
884
|
+
await fsyncEvidenceMergeDirectory(evidenceDirectory);
|
|
885
|
+
}
|
|
886
|
+
/** @id CODE-EVIDENCE-HISTORY-MERGE-003
|
|
887
|
+
* @implements REQ-EVIDENCE-HISTORY-MERGE-004
|
|
888
|
+
* @design DES-EVIDENCE-HISTORY-MERGE-003
|
|
889
|
+
*/
|
|
890
|
+
async function applyCandidates(root, candidates, faultAt) {
|
|
891
|
+
const transactionId = crypto.randomUUID();
|
|
892
|
+
const targets = [];
|
|
893
|
+
for (const path of TARGETS) {
|
|
894
|
+
const candidate = candidates.bytes[path];
|
|
895
|
+
if (candidate === undefined)
|
|
896
|
+
continue;
|
|
897
|
+
const original = await originalBytes(root, path);
|
|
898
|
+
targets.push({
|
|
899
|
+
path,
|
|
900
|
+
existed: original.existed,
|
|
901
|
+
originalBase64: Buffer.from(original.bytes).toString('base64'),
|
|
902
|
+
originalSha256: digest(original.bytes),
|
|
903
|
+
candidateBase64: Buffer.from(candidate).toString('base64'),
|
|
904
|
+
candidateSha256: digest(candidate),
|
|
905
|
+
temporaryPath: `${path}.${transactionId}.merge`,
|
|
906
|
+
});
|
|
907
|
+
}
|
|
908
|
+
const journal = { schemaVersion: 1, transactionId, state: 'prepared', targets };
|
|
909
|
+
const journalAbsolute = await safePath(root, JOURNAL_PATH);
|
|
910
|
+
const stagingJournal = await safePath(root, `${STAGING_PREFIX}${transactionId}.json`);
|
|
911
|
+
await mkdir(dirname(journalAbsolute), { recursive: true });
|
|
912
|
+
if (faultAt === 'journal:staging')
|
|
913
|
+
throw new Error('Injected evidence merge failure at journal:staging.');
|
|
914
|
+
await writeFile(stagingJournal, json(journal), { flag: 'wx' });
|
|
915
|
+
await fsyncFile(stagingJournal);
|
|
916
|
+
if (faultAt === 'journal:publication') {
|
|
917
|
+
throw new Error('EVIDENCE_MERGE_RECOVERY_REQUIRED: injected failure at journal:publication.');
|
|
918
|
+
}
|
|
919
|
+
try {
|
|
920
|
+
await link(stagingJournal, journalAbsolute);
|
|
921
|
+
await unlink(stagingJournal);
|
|
922
|
+
}
|
|
923
|
+
catch (cause) {
|
|
924
|
+
await removeIfPresent(stagingJournal);
|
|
925
|
+
if (cause.code === 'EEXIST') {
|
|
926
|
+
throw new Error('EVIDENCE_MERGE_RECOVERY_REQUIRED: another merge transaction was published.');
|
|
927
|
+
}
|
|
928
|
+
throw cause;
|
|
929
|
+
}
|
|
930
|
+
await fsyncEvidenceMergeDirectory(dirname(journalAbsolute));
|
|
931
|
+
try {
|
|
932
|
+
for (let index = 0; index < targets.length; index++) {
|
|
933
|
+
if (faultAt === `temporary:${index}`)
|
|
934
|
+
throw new Error(`Injected evidence merge failure at temporary:${index}.`);
|
|
935
|
+
const target = targets[index];
|
|
936
|
+
const temporary = await safePath(root, target.temporaryPath);
|
|
937
|
+
await writeFile(temporary, Buffer.from(target.candidateBase64, 'base64'), { flag: 'wx' });
|
|
938
|
+
await fsyncFile(temporary);
|
|
939
|
+
}
|
|
940
|
+
for (let index = 0; index < targets.length; index++) {
|
|
941
|
+
if (faultAt === `replace:${index}`)
|
|
942
|
+
throw new Error(`Injected evidence merge failure at replace:${index}.`);
|
|
943
|
+
const target = targets[index];
|
|
944
|
+
await rename(await safePath(root, target.temporaryPath), await safePath(root, target.path));
|
|
945
|
+
}
|
|
946
|
+
await fsyncEvidenceMergeDirectory(dirname(journalAbsolute));
|
|
947
|
+
journal.state = 'committed';
|
|
948
|
+
const commitStaging = await safePath(root, `${STAGING_PREFIX}${transactionId}.commit.json`);
|
|
949
|
+
await writeFile(commitStaging, json(journal), { flag: 'wx' });
|
|
950
|
+
await fsyncFile(commitStaging);
|
|
951
|
+
if (faultAt === 'commit-marker')
|
|
952
|
+
throw new Error('Injected evidence merge failure at commit-marker.');
|
|
953
|
+
await rename(commitStaging, journalAbsolute);
|
|
954
|
+
await fsyncEvidenceMergeDirectory(dirname(journalAbsolute));
|
|
955
|
+
if (faultAt === 'cleanup')
|
|
956
|
+
throw new Error('EVIDENCE_MERGE_RECOVERY_REQUIRED: injected failure at cleanup.');
|
|
957
|
+
await removeIfPresent(journalAbsolute);
|
|
958
|
+
}
|
|
959
|
+
catch (cause) {
|
|
960
|
+
const injected = cause instanceof Error && cause.message.startsWith('Injected evidence merge failure');
|
|
961
|
+
if (injected && journal.state === 'prepared') {
|
|
962
|
+
await restoreJournal(root, journal);
|
|
963
|
+
throw cause;
|
|
964
|
+
}
|
|
965
|
+
throw new Error(`EVIDENCE_MERGE_RECOVERY_REQUIRED: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
966
|
+
}
|
|
967
|
+
}
|
|
968
|
+
function reportFor(plan, candidates) {
|
|
969
|
+
const diagnostics = [...plan.diagnostics];
|
|
970
|
+
const valid = diagnostics.length === 0;
|
|
971
|
+
const files = TARGETS.map((path) => ({
|
|
972
|
+
path,
|
|
973
|
+
changed: candidates.bytes[path] !== undefined && candidates.bytes[path] !== plan.base.raw[path],
|
|
974
|
+
}));
|
|
975
|
+
return {
|
|
976
|
+
valid,
|
|
977
|
+
preserved: plan.preserved,
|
|
978
|
+
deduplicated: plan.deduplicated,
|
|
979
|
+
appended: plan.appended,
|
|
980
|
+
revalidationRequired: plan.appended > 0 || plan.retainedIncomingPayload,
|
|
981
|
+
diagnostics,
|
|
982
|
+
followUpDiagnostics: [],
|
|
983
|
+
staleWaivers: [],
|
|
984
|
+
supersededScopes: [],
|
|
985
|
+
files,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
/** @id CODE-EVIDENCE-HISTORY-MERGE-004
|
|
989
|
+
* @implements REQ-EVIDENCE-HISTORY-MERGE-001 REQ-EVIDENCE-HISTORY-MERGE-004 REQ-EVIDENCE-HISTORY-MERGE-005
|
|
990
|
+
* @design DES-EVIDENCE-HISTORY-MERGE-004
|
|
991
|
+
*/
|
|
992
|
+
export async function mergeEvidenceHistories(root, incoming, options = {}) {
|
|
993
|
+
if (options.dryRun) {
|
|
994
|
+
await assertCoordinatedEvidenceRead(root);
|
|
995
|
+
await assertCoordinatedEvidenceRead(incoming);
|
|
996
|
+
return mergeEvidenceHistoriesUnlocked(root, incoming, options);
|
|
997
|
+
}
|
|
998
|
+
return withEvidenceWriterLock(root, 'evidence merge', async () => {
|
|
999
|
+
await assertCoordinatedEvidenceRead(incoming);
|
|
1000
|
+
return mergeEvidenceHistoriesUnlocked(root, incoming, options);
|
|
1001
|
+
});
|
|
1002
|
+
}
|
|
1003
|
+
async function mergeEvidenceHistoriesUnlocked(root, incoming, options) {
|
|
1004
|
+
await assertEvidenceMergeStartable(root);
|
|
1005
|
+
const plan = await planEvidenceMerge(resolve(root), resolve(incoming));
|
|
1006
|
+
const candidates = buildCandidates(plan);
|
|
1007
|
+
const report = reportFor(plan, candidates);
|
|
1008
|
+
if (report.valid) {
|
|
1009
|
+
const [baseWaiverState, mergedWaiverState] = await Promise.all([
|
|
1010
|
+
evaluateChangeWaiverState(plan.base.root, plan.base.changes, plan.base.tdd, validateEvidenceOrderLog(plan.base.order), plan.base.waivers),
|
|
1011
|
+
evaluateChangeWaiverState(plan.base.root, candidates.changes, candidates.tdd, validateEvidenceOrderLog(candidates.order), candidates.waivers),
|
|
1012
|
+
]);
|
|
1013
|
+
report.staleWaivers = mergedWaiverState.stale;
|
|
1014
|
+
report.followUpDiagnostics = mergedWaiverState.stale.map((scope) => ({
|
|
1015
|
+
code: 'EVIDENCE_MERGE_WAIVER_STALE',
|
|
1016
|
+
severity: 'warning',
|
|
1017
|
+
message: `Merged waiver ${scope.changeId}:${scope.code} is stale and inactive.`,
|
|
1018
|
+
path: WAIVERS_PATH,
|
|
1019
|
+
changeId: scope.changeId,
|
|
1020
|
+
...(scope.requirementId !== undefined ? { requirementId: scope.requirementId } : {}),
|
|
1021
|
+
...(scope.detail !== undefined ? { detail: scope.detail } : {}),
|
|
1022
|
+
}));
|
|
1023
|
+
for (const [scope, selected] of mergedWaiverState.authoritative) {
|
|
1024
|
+
if (baseWaiverState.authoritative.get(scope) === selected)
|
|
1025
|
+
continue;
|
|
1026
|
+
const [changeId, code, requirementId, detail] = JSON.parse(scope);
|
|
1027
|
+
report.supersededScopes.push({
|
|
1028
|
+
changeId,
|
|
1029
|
+
code,
|
|
1030
|
+
...(requirementId !== null ? { requirementId } : {}),
|
|
1031
|
+
...(detail !== null ? { detail } : {}),
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
if (!report.valid || options.dryRun)
|
|
1036
|
+
return report;
|
|
1037
|
+
await applyCandidates(plan.base.root, candidates, options.faultAt);
|
|
1038
|
+
return report;
|
|
1039
|
+
}
|
|
1040
|
+
export { assertEvidenceMergeReady, assertEvidenceMergeStartable };
|
|
1041
|
+
function unsafeRecovery(reason, inventory) {
|
|
1042
|
+
return new Error(`EVIDENCE_MERGE_RECOVERY_UNSAFE: ${reason} Journal: ${JOURNAL_PATH}. `
|
|
1043
|
+
+ `Merge-owned files: ${inventory.length ? inventory.join(', ') : 'none found'}. `
|
|
1044
|
+
+ 'Manual remediation: back up .musubix/evidence; restore or verify order.json, tdd.json, changes.json, '
|
|
1045
|
+
+ 'and change-waivers.json from a trusted source; quarantine the listed merge files; rerun structural validation.');
|
|
1046
|
+
}
|
|
1047
|
+
function validateJournal(journal) {
|
|
1048
|
+
if (journal.schemaVersion !== 1 || typeof journal.transactionId !== 'string' || !journal.transactionId
|
|
1049
|
+
|| !Array.isArray(journal.targets) || !['prepared', 'committed'].includes(journal.state)) {
|
|
1050
|
+
throw new Error('EVIDENCE_MERGE_RECOVERY_UNSAFE: published merge journal is invalid.');
|
|
1051
|
+
}
|
|
1052
|
+
const paths = new Set();
|
|
1053
|
+
for (const target of journal.targets) {
|
|
1054
|
+
if (!TARGETS.includes(target.path) || paths.has(target.path)
|
|
1055
|
+
|| typeof target.existed !== 'boolean'
|
|
1056
|
+
|| typeof target.originalBase64 !== 'string' || typeof target.originalSha256 !== 'string'
|
|
1057
|
+
|| typeof target.candidateBase64 !== 'string' || typeof target.candidateSha256 !== 'string'
|
|
1058
|
+
|| typeof target.temporaryPath !== 'string'
|
|
1059
|
+
|| !target.temporaryPath.startsWith(`${target.path}.${journal.transactionId}.`)
|
|
1060
|
+
|| digest(Buffer.from(target.originalBase64, 'base64')) !== target.originalSha256
|
|
1061
|
+
|| digest(Buffer.from(target.candidateBase64, 'base64')) !== target.candidateSha256) {
|
|
1062
|
+
throw new Error('EVIDENCE_MERGE_RECOVERY_UNSAFE: published merge journal target is invalid.');
|
|
1063
|
+
}
|
|
1064
|
+
paths.add(target.path);
|
|
1065
|
+
}
|
|
1066
|
+
for (const required of [ORDER_PATH, TDD_PATH, CHANGES_PATH]) {
|
|
1067
|
+
if (!paths.has(required)) {
|
|
1068
|
+
throw new Error('EVIDENCE_MERGE_RECOVERY_UNSAFE: published merge journal is missing a required target.');
|
|
1069
|
+
}
|
|
1070
|
+
}
|
|
1071
|
+
}
|
|
1072
|
+
function validateJournalCandidates(journal) {
|
|
1073
|
+
try {
|
|
1074
|
+
const values = new Map(journal.targets.map((target) => [
|
|
1075
|
+
target.path,
|
|
1076
|
+
JSON.parse(Buffer.from(target.candidateBase64, 'base64').toString('utf8')),
|
|
1077
|
+
]));
|
|
1078
|
+
const order = values.get(ORDER_PATH);
|
|
1079
|
+
const tdd = values.get(TDD_PATH);
|
|
1080
|
+
const changes = values.get(CHANGES_PATH);
|
|
1081
|
+
const waivers = values.get(WAIVERS_PATH) ?? { schemaVersion: 1, waivers: [] };
|
|
1082
|
+
const diagnostics = [
|
|
1083
|
+
...validateEvidenceOrderLog(order).diagnostics.map((item) => mergeDiagnostic(item.code, item.message, item.path ?? ORDER_PATH)),
|
|
1084
|
+
...validateQualityOrder(changes),
|
|
1085
|
+
...validateCandidateReferences(order, tdd, changes, waivers),
|
|
1086
|
+
];
|
|
1087
|
+
if (diagnostics.length) {
|
|
1088
|
+
throw new Error(diagnostics.map((item) => `${item.code}:${item.identity ?? item.file ?? 'unknown'}`).join(', '));
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
catch (cause) {
|
|
1092
|
+
throw new Error(`EVIDENCE_MERGE_RECOVERY_UNSAFE: journaled candidate evidence is invalid: ${cause instanceof Error ? cause.message : String(cause)}`);
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
export async function recoverEvidenceMerge(root) {
|
|
1096
|
+
return withEvidenceWriterLock(root, 'evidence merge --recover', () => recoverEvidenceMergeUnlocked(root));
|
|
1097
|
+
}
|
|
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
|
+
}
|
|
1106
|
+
const journalAbsolute = await safePath(root, JOURNAL_PATH);
|
|
1107
|
+
const evidenceDir = await safePath(root, EVIDENCE_DIR);
|
|
1108
|
+
let staging = [];
|
|
1109
|
+
try {
|
|
1110
|
+
staging = (await (await import('node:fs/promises')).readdir(evidenceDir))
|
|
1111
|
+
.filter((entry) => entry.startsWith('.merge-transaction.') && entry !== '.merge-transaction.json');
|
|
1112
|
+
}
|
|
1113
|
+
catch (cause) {
|
|
1114
|
+
if (cause.code !== 'ENOENT')
|
|
1115
|
+
throw cause;
|
|
1116
|
+
}
|
|
1117
|
+
if (!await exists(journalAbsolute)) {
|
|
1118
|
+
for (const entry of staging)
|
|
1119
|
+
await removeIfPresent(resolve(evidenceDir, entry));
|
|
1120
|
+
return { recovered: false, action: 'nothing-to-recover', discardedStaging: staging.length > 0 };
|
|
1121
|
+
}
|
|
1122
|
+
let journal;
|
|
1123
|
+
try {
|
|
1124
|
+
journal = JSON.parse(await readFile(journalAbsolute, 'utf8'));
|
|
1125
|
+
}
|
|
1126
|
+
catch {
|
|
1127
|
+
throw unsafeRecovery('Published merge journal is unreadable.', [JOURNAL_PATH, ...staging]);
|
|
1128
|
+
}
|
|
1129
|
+
const inventory = [
|
|
1130
|
+
JOURNAL_PATH,
|
|
1131
|
+
...staging.map((entry) => `${EVIDENCE_DIR}/${entry}`),
|
|
1132
|
+
...(Array.isArray(journal.targets)
|
|
1133
|
+
? journal.targets.flatMap((target) => typeof target?.temporaryPath === 'string' ? [target.temporaryPath] : [])
|
|
1134
|
+
: []),
|
|
1135
|
+
];
|
|
1136
|
+
try {
|
|
1137
|
+
validateJournal(journal);
|
|
1138
|
+
}
|
|
1139
|
+
catch (cause) {
|
|
1140
|
+
throw unsafeRecovery(cause instanceof Error ? cause.message : String(cause), inventory);
|
|
1141
|
+
}
|
|
1142
|
+
if (journal.state === 'prepared') {
|
|
1143
|
+
await restoreJournal(root, journal);
|
|
1144
|
+
for (const entry of staging)
|
|
1145
|
+
await removeIfPresent(resolve(evidenceDir, entry));
|
|
1146
|
+
return { recovered: true, action: 'rolled-back', discardedStaging: staging.length > 0 };
|
|
1147
|
+
}
|
|
1148
|
+
try {
|
|
1149
|
+
validateJournalCandidates(journal);
|
|
1150
|
+
}
|
|
1151
|
+
catch (cause) {
|
|
1152
|
+
throw unsafeRecovery(cause instanceof Error ? cause.message : String(cause), inventory);
|
|
1153
|
+
}
|
|
1154
|
+
for (const target of journal.targets) {
|
|
1155
|
+
const absolute = await safePath(root, target.path);
|
|
1156
|
+
let actual = '';
|
|
1157
|
+
try {
|
|
1158
|
+
actual = await readFile(absolute, 'utf8');
|
|
1159
|
+
}
|
|
1160
|
+
catch {
|
|
1161
|
+
actual = '';
|
|
1162
|
+
}
|
|
1163
|
+
if (digest(actual) !== target.candidateSha256) {
|
|
1164
|
+
const stagingPath = `${absolute}.${journal.transactionId}.recover`;
|
|
1165
|
+
await writeFile(stagingPath, Buffer.from(target.candidateBase64, 'base64'));
|
|
1166
|
+
await fsyncFile(stagingPath);
|
|
1167
|
+
await rename(stagingPath, absolute);
|
|
1168
|
+
await fsyncEvidenceMergeDirectory(evidenceDir);
|
|
1169
|
+
}
|
|
1170
|
+
const verified = await readFile(absolute, 'utf8');
|
|
1171
|
+
if (digest(verified) !== target.candidateSha256) {
|
|
1172
|
+
throw unsafeRecovery('Candidate verification failed after rewrite.', inventory);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
await fsyncEvidenceMergeDirectory(evidenceDir);
|
|
1176
|
+
await removeIfPresent(journalAbsolute);
|
|
1177
|
+
for (const entry of staging)
|
|
1178
|
+
await removeIfPresent(resolve(evidenceDir, entry));
|
|
1179
|
+
await fsyncEvidenceMergeDirectory(evidenceDir);
|
|
1180
|
+
return { recovered: true, action: 'rolled-forward', discardedStaging: staging.length > 0 };
|
|
1181
|
+
}
|
|
1182
|
+
//# sourceMappingURL=evidence-merge.js.map
|