@mitre/hdf-diff 3.0.1 → 3.1.0-rc.1
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/LICENSE.md +55 -0
- package/README.md +49 -17
- package/dist/index.d.ts +622 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2037 -18
- package/dist/index.js.map +1 -1
- package/package.json +19 -19
- package/dist/diff.d.ts +0 -49
- package/dist/diff.d.ts.map +0 -1
- package/dist/diff.js +0 -791
- package/dist/diff.js.map +0 -1
- package/dist/exit-codes.d.ts +0 -42
- package/dist/exit-codes.d.ts.map +0 -1
- package/dist/exit-codes.js +0 -74
- package/dist/exit-codes.js.map +0 -1
- package/dist/matching/cci-match.d.ts +0 -14
- package/dist/matching/cci-match.d.ts.map +0 -1
- package/dist/matching/cci-match.js +0 -102
- package/dist/matching/cci-match.js.map +0 -1
- package/dist/matching/exact-id.d.ts +0 -9
- package/dist/matching/exact-id.d.ts.map +0 -1
- package/dist/matching/exact-id.js +0 -88
- package/dist/matching/exact-id.js.map +0 -1
- package/dist/matching/fuzzy-match.d.ts +0 -26
- package/dist/matching/fuzzy-match.d.ts.map +0 -1
- package/dist/matching/fuzzy-match.js +0 -145
- package/dist/matching/fuzzy-match.js.map +0 -1
- package/dist/matching/index.d.ts +0 -36
- package/dist/matching/index.d.ts.map +0 -1
- package/dist/matching/index.js +0 -71
- package/dist/matching/index.js.map +0 -1
- package/dist/matching/mapped-id.d.ts +0 -13
- package/dist/matching/mapped-id.d.ts.map +0 -1
- package/dist/matching/mapped-id.js +0 -101
- package/dist/matching/mapped-id.js.map +0 -1
- package/dist/matching/types.d.ts +0 -34
- package/dist/matching/types.d.ts.map +0 -1
- package/dist/matching/types.js +0 -2
- package/dist/matching/types.js.map +0 -1
- package/dist/normalize.d.ts +0 -20
- package/dist/normalize.d.ts.map +0 -1
- package/dist/normalize.js +0 -120
- package/dist/normalize.js.map +0 -1
- package/dist/renderers/csv.d.ts +0 -15
- package/dist/renderers/csv.d.ts.map +0 -1
- package/dist/renderers/csv.js +0 -77
- package/dist/renderers/csv.js.map +0 -1
- package/dist/renderers/filter.d.ts +0 -11
- package/dist/renderers/filter.d.ts.map +0 -1
- package/dist/renderers/filter.js +0 -29
- package/dist/renderers/filter.js.map +0 -1
- package/dist/renderers/index.d.ts +0 -17
- package/dist/renderers/index.d.ts.map +0 -1
- package/dist/renderers/index.js +0 -29
- package/dist/renderers/index.js.map +0 -1
- package/dist/renderers/json.d.ts +0 -13
- package/dist/renderers/json.d.ts.map +0 -1
- package/dist/renderers/json.js +0 -41
- package/dist/renderers/json.js.map +0 -1
- package/dist/renderers/markdown.d.ts +0 -13
- package/dist/renderers/markdown.d.ts.map +0 -1
- package/dist/renderers/markdown.js +0 -133
- package/dist/renderers/markdown.js.map +0 -1
- package/dist/renderers/terminal.d.ts +0 -14
- package/dist/renderers/terminal.d.ts.map +0 -1
- package/dist/renderers/terminal.js +0 -173
- package/dist/renderers/terminal.js.map +0 -1
- package/dist/renderers/types.d.ts +0 -12
- package/dist/renderers/types.d.ts.map +0 -1
- package/dist/renderers/types.js +0 -2
- package/dist/renderers/types.js.map +0 -1
- package/dist/sbom.d.ts +0 -42
- package/dist/sbom.d.ts.map +0 -1
- package/dist/sbom.js +0 -203
- package/dist/sbom.js.map +0 -1
- package/dist/status.d.ts +0 -27
- package/dist/status.d.ts.map +0 -1
- package/dist/status.js +0 -137
- package/dist/status.js.map +0 -1
- package/dist/summary.d.ts +0 -6
- package/dist/summary.d.ts.map +0 -1
- package/dist/summary.js +0 -47
- package/dist/summary.js.map +0 -1
- package/dist/types.d.ts +0 -208
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/validate.d.ts +0 -20
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js +0 -19
- package/dist/validate.js.map +0 -1
package/dist/diff.js
DELETED
|
@@ -1,791 +0,0 @@
|
|
|
1
|
-
import { computeEffectiveStatus, classifyChangeReasons, classifyDiffStatus } from './status.js';
|
|
2
|
-
import { computeSummary } from './summary.js';
|
|
3
|
-
import { normalizeToV2 } from './normalize.js';
|
|
4
|
-
import { matchRequirements } from './matching/index.js';
|
|
5
|
-
import { validateComparison } from './validate.js';
|
|
6
|
-
import { diffSboms } from './sbom.js';
|
|
7
|
-
const DEFAULT_TRACKED_FIELDS = ['impact', 'severity', 'tags'];
|
|
8
|
-
/**
|
|
9
|
-
* Extract a dataSource label from a document, if available.
|
|
10
|
-
* Looks for `dataSource.name` in the raw (pre-normalized) document.
|
|
11
|
-
*/
|
|
12
|
-
function extractDataSourceLabel(doc) {
|
|
13
|
-
const ds = doc['dataSource'];
|
|
14
|
-
if (ds && typeof ds['name'] === 'string') {
|
|
15
|
-
return ds['name'];
|
|
16
|
-
}
|
|
17
|
-
return undefined;
|
|
18
|
-
}
|
|
19
|
-
/**
|
|
20
|
-
* Build MatchOptions from DiffOptions, extracting the matching-related fields.
|
|
21
|
-
*/
|
|
22
|
-
function buildMatchOptions(options) {
|
|
23
|
-
return {
|
|
24
|
-
strategy: options?.matchStrategy,
|
|
25
|
-
fallbackStrategies: options?.fallbackStrategies,
|
|
26
|
-
mappingTable: options?.mappingTable,
|
|
27
|
-
minConfidence: options?.minConfidence,
|
|
28
|
-
};
|
|
29
|
-
}
|
|
30
|
-
/**
|
|
31
|
-
* Determine the primary strategy name for the matching config output.
|
|
32
|
-
*/
|
|
33
|
-
function resolveStrategyName(options) {
|
|
34
|
-
return options?.matchStrategy ?? 'exactId';
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Compare two HDF results documents and produce a structured comparison.
|
|
38
|
-
*
|
|
39
|
-
* Requirements are matched using a configurable matching strategy (default: exact ID).
|
|
40
|
-
* Baselines are matched by `name`.
|
|
41
|
-
*
|
|
42
|
-
* For fleet mode, `newResults` can be an array of documents, each compared
|
|
43
|
-
* pairwise against `oldResults` (the reference).
|
|
44
|
-
*/
|
|
45
|
-
export function diffHdf(oldResults, newResults, options) {
|
|
46
|
-
const trackedFields = options?.trackedFields ?? DEFAULT_TRACKED_FIELDS;
|
|
47
|
-
let comparisonMode = options?.comparisonMode ?? 'temporal';
|
|
48
|
-
const matchOpts = buildMatchOptions(options);
|
|
49
|
-
// Auto-detect baseline evolution mode when both inputs are baseline documents
|
|
50
|
-
// (have requirements[] but no baselines[], targets[], or statistics[])
|
|
51
|
-
if (!options?.comparisonMode && !Array.isArray(newResults)) {
|
|
52
|
-
if (isBaselineDocument(oldResults) && isBaselineDocument(newResults)) {
|
|
53
|
-
comparisonMode = 'baselineEvolution';
|
|
54
|
-
}
|
|
55
|
-
else if (isSystemDocument(oldResults) && isSystemDocument(newResults)) {
|
|
56
|
-
comparisonMode = 'systemDrift';
|
|
57
|
-
}
|
|
58
|
-
}
|
|
59
|
-
// Validate array inputs up front (applies to all modes)
|
|
60
|
-
if (Array.isArray(newResults)) {
|
|
61
|
-
if (newResults.length === 0) {
|
|
62
|
-
throw new Error('newResults array must not be empty');
|
|
63
|
-
}
|
|
64
|
-
if (comparisonMode !== 'fleet' && newResults.length > 1) {
|
|
65
|
-
throw new Error(`Mode '${comparisonMode}' expects a single document, got ${newResults.length}. Use 'fleet' mode for multiple documents.`);
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
// Baseline evolution mode: compare two baseline documents
|
|
69
|
-
if (comparisonMode === 'baselineEvolution') {
|
|
70
|
-
const newDoc = Array.isArray(newResults) ? newResults[0] : newResults;
|
|
71
|
-
return diffBaselines(oldResults, newDoc, options);
|
|
72
|
-
}
|
|
73
|
-
// System drift mode: compare two system documents
|
|
74
|
-
if (comparisonMode === 'systemDrift') {
|
|
75
|
-
const newDoc = Array.isArray(newResults) ? newResults[0] : newResults;
|
|
76
|
-
return diffSystems(oldResults, newDoc, options);
|
|
77
|
-
}
|
|
78
|
-
// Fleet mode: compare reference against each system
|
|
79
|
-
if (comparisonMode === 'fleet') {
|
|
80
|
-
return diffFleet(oldResults, newResults, trackedFields, matchOpts);
|
|
81
|
-
}
|
|
82
|
-
// Non-fleet modes: newResults must be a single document
|
|
83
|
-
const newDoc = Array.isArray(newResults) ? newResults[0] : newResults;
|
|
84
|
-
// Normalize v1 (InSpec exec-json) to v2 if needed
|
|
85
|
-
const oldNormalized = normalizeToV2(oldResults);
|
|
86
|
-
const newNormalized = normalizeToV2(newDoc);
|
|
87
|
-
const oldTimestamp = oldNormalized['timestamp'];
|
|
88
|
-
const newTimestamp = newNormalized['timestamp'];
|
|
89
|
-
// Build sources metadata based on mode
|
|
90
|
-
const sources = buildSources(comparisonMode, oldResults, newDoc, oldTimestamp, newTimestamp);
|
|
91
|
-
// Compute baseline and requirement diffs
|
|
92
|
-
const { baselineDiffs, requirementDiffs } = comparePair(oldNormalized, newNormalized, oldTimestamp, newTimestamp, trackedFields, matchOpts);
|
|
93
|
-
// Sort by id
|
|
94
|
-
requirementDiffs.sort((a, b) => a.id.localeCompare(b.id));
|
|
95
|
-
// Extract drift: unchanged requirements with metadata changes
|
|
96
|
-
const drift = extractDrift(requirementDiffs);
|
|
97
|
-
const comparison = {
|
|
98
|
-
formatVersion: '1.0.0',
|
|
99
|
-
comparisonMode,
|
|
100
|
-
timestamp: new Date().toISOString(),
|
|
101
|
-
sources,
|
|
102
|
-
matching: {
|
|
103
|
-
primaryStrategy: resolveStrategyName(options),
|
|
104
|
-
},
|
|
105
|
-
summary: computeSummary(requirementDiffs),
|
|
106
|
-
baselineDiffs,
|
|
107
|
-
requirementDiffs,
|
|
108
|
-
drift,
|
|
109
|
-
};
|
|
110
|
-
if (options?.validateOutput) {
|
|
111
|
-
const result = validateComparison(comparison);
|
|
112
|
-
// Safety net: diffHdf always produces valid output by construction.
|
|
113
|
-
// This branch is unreachable in normal operation but guards against future regressions.
|
|
114
|
-
/* c8 ignore start */
|
|
115
|
-
if (!result.valid) {
|
|
116
|
-
throw new Error(`Output validation failed: ${result.errors?.join(', ')}`);
|
|
117
|
-
}
|
|
118
|
-
/* c8 ignore stop */
|
|
119
|
-
}
|
|
120
|
-
return comparison;
|
|
121
|
-
}
|
|
122
|
-
/**
|
|
123
|
-
* Build source metadata entries based on comparison mode.
|
|
124
|
-
*/
|
|
125
|
-
function buildSources(mode, oldDoc, newDoc, oldTimestamp, newTimestamp) {
|
|
126
|
-
switch (mode) {
|
|
127
|
-
case 'baseline':
|
|
128
|
-
return [
|
|
129
|
-
{ role: 'golden', label: 'Golden baseline', assessmentTimestamp: oldTimestamp },
|
|
130
|
-
{ role: 'new', label: 'Current scan', assessmentTimestamp: newTimestamp },
|
|
131
|
-
];
|
|
132
|
-
case 'multiSource': {
|
|
133
|
-
const oldLabel = extractDataSourceLabel(oldDoc) ?? 'Old evaluation';
|
|
134
|
-
const newLabel = extractDataSourceLabel(newDoc) ?? 'New evaluation';
|
|
135
|
-
return [
|
|
136
|
-
{ role: 'old', label: oldLabel, assessmentTimestamp: oldTimestamp },
|
|
137
|
-
{ role: 'new', label: newLabel, assessmentTimestamp: newTimestamp },
|
|
138
|
-
];
|
|
139
|
-
}
|
|
140
|
-
case 'temporal':
|
|
141
|
-
default:
|
|
142
|
-
return [
|
|
143
|
-
{ role: 'old', label: 'Old evaluation', assessmentTimestamp: oldTimestamp },
|
|
144
|
-
{ role: 'new', label: 'New evaluation', assessmentTimestamp: newTimestamp },
|
|
145
|
-
];
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* Fleet mode: compare a reference document against one or more system documents.
|
|
150
|
-
* Each system is compared pairwise against the reference; all diffs are collected
|
|
151
|
-
* into a single result with `sourceIndex` set on each RequirementDiff.
|
|
152
|
-
*/
|
|
153
|
-
function diffFleet(referenceDoc, newResults, trackedFields, matchOpts) {
|
|
154
|
-
const systems = Array.isArray(newResults) ? newResults : [newResults];
|
|
155
|
-
const refNormalized = normalizeToV2(referenceDoc);
|
|
156
|
-
const refTimestamp = refNormalized['timestamp'];
|
|
157
|
-
// Build sources, compare each system against the reference, and collect diffs
|
|
158
|
-
// in a single pass — each system document is normalized exactly once.
|
|
159
|
-
const sources = [
|
|
160
|
-
{ role: 'reference', label: 'Reference', assessmentTimestamp: refTimestamp },
|
|
161
|
-
];
|
|
162
|
-
const allRequirementDiffs = [];
|
|
163
|
-
const allBaselineDiffs = [];
|
|
164
|
-
// Deduplicate baseline diffs by name: first-wins. In fleet mode, every system
|
|
165
|
-
// is compared against the same reference, so the first occurrence of a
|
|
166
|
-
// BaselineDiff for a given baseline name is representative. BaselineDiff does
|
|
167
|
-
// not carry a sourceIndex in v1 of the schema, so per-system baseline tracking
|
|
168
|
-
// is deferred to a future version.
|
|
169
|
-
const seenBaselineNames = new Set();
|
|
170
|
-
for (let i = 0; i < systems.length; i++) {
|
|
171
|
-
const sysNormalized = normalizeToV2(systems[i]);
|
|
172
|
-
const sysTimestamp = sysNormalized['timestamp'];
|
|
173
|
-
const sourceIndex = i + 1;
|
|
174
|
-
// Build the source entry for this system
|
|
175
|
-
sources.push({
|
|
176
|
-
role: 'system',
|
|
177
|
-
label: `System ${sourceIndex}`,
|
|
178
|
-
assessmentTimestamp: sysTimestamp,
|
|
179
|
-
});
|
|
180
|
-
const { baselineDiffs, requirementDiffs } = comparePair(refNormalized, sysNormalized, refTimestamp, sysTimestamp, trackedFields, matchOpts);
|
|
181
|
-
// Tag each requirement diff with its source index
|
|
182
|
-
for (const rd of requirementDiffs) {
|
|
183
|
-
rd.sourceIndex = sourceIndex;
|
|
184
|
-
allRequirementDiffs.push(rd);
|
|
185
|
-
}
|
|
186
|
-
// Collect baseline diffs (first-wins dedup; see comment above)
|
|
187
|
-
for (const bd of baselineDiffs) {
|
|
188
|
-
if (!seenBaselineNames.has(bd.name)) {
|
|
189
|
-
seenBaselineNames.add(bd.name);
|
|
190
|
-
allBaselineDiffs.push(bd);
|
|
191
|
-
}
|
|
192
|
-
}
|
|
193
|
-
}
|
|
194
|
-
// Sort by id, then by sourceIndex
|
|
195
|
-
allRequirementDiffs.sort((a, b) => {
|
|
196
|
-
const idCmp = a.id.localeCompare(b.id);
|
|
197
|
-
if (idCmp !== 0)
|
|
198
|
-
return idCmp;
|
|
199
|
-
/* c8 ignore next -- sourceIndex is always set in fleet mode; ?? 0 is defensive typing */
|
|
200
|
-
return (a.sourceIndex ?? 0) - (b.sourceIndex ?? 0);
|
|
201
|
-
});
|
|
202
|
-
// Extract drift: unchanged requirements with metadata changes
|
|
203
|
-
const drift = extractDrift(allRequirementDiffs);
|
|
204
|
-
return {
|
|
205
|
-
formatVersion: '1.0.0',
|
|
206
|
-
comparisonMode: 'fleet',
|
|
207
|
-
timestamp: new Date().toISOString(),
|
|
208
|
-
sources,
|
|
209
|
-
matching: {
|
|
210
|
-
primaryStrategy: matchOpts.strategy ?? 'exactId',
|
|
211
|
-
},
|
|
212
|
-
summary: computeSummary(allRequirementDiffs),
|
|
213
|
-
baselineDiffs: allBaselineDiffs,
|
|
214
|
-
requirementDiffs: allRequirementDiffs,
|
|
215
|
-
drift,
|
|
216
|
-
};
|
|
217
|
-
}
|
|
218
|
-
/**
|
|
219
|
-
* Extract drift entries from requirement diffs.
|
|
220
|
-
*
|
|
221
|
-
* Drift = requirements whose effective status is unchanged but whose metadata
|
|
222
|
-
* changed (tags, descriptions, impact score, etc.). These are the "silent"
|
|
223
|
-
* changes that don't affect pass/fail outcome but still matter for auditing.
|
|
224
|
-
*
|
|
225
|
-
* Returns shallow copies so drift entries are independent of requirementDiffs.
|
|
226
|
-
*/
|
|
227
|
-
function extractDrift(requirementDiffs) {
|
|
228
|
-
return requirementDiffs
|
|
229
|
-
.filter(r => r.state === 'unchanged' && r.changeReasons.length > 0)
|
|
230
|
-
.map(r => ({ ...r }));
|
|
231
|
-
}
|
|
232
|
-
/**
|
|
233
|
-
* Core pairwise comparison between two normalized HDF documents.
|
|
234
|
-
* Returns baseline diffs and requirement diffs without sorting or wrapping.
|
|
235
|
-
*
|
|
236
|
-
* Uses the pluggable matching system to pair requirements between evaluations.
|
|
237
|
-
*/
|
|
238
|
-
function comparePair(oldNormalized, newNormalized, oldTimestamp, newTimestamp, trackedFields, matchOpts) {
|
|
239
|
-
const oldBaselines = oldNormalized['baselines'] ?? [];
|
|
240
|
-
const newBaselines = newNormalized['baselines'] ?? [];
|
|
241
|
-
// Build baseline maps by name
|
|
242
|
-
const oldBaselineMap = new Map();
|
|
243
|
-
for (const b of oldBaselines) {
|
|
244
|
-
oldBaselineMap.set(b.name, b);
|
|
245
|
-
}
|
|
246
|
-
const newBaselineMap = new Map();
|
|
247
|
-
for (const b of newBaselines) {
|
|
248
|
-
newBaselineMap.set(b.name, b);
|
|
249
|
-
}
|
|
250
|
-
// Compute baseline diffs
|
|
251
|
-
const allBaselineNames = new Set([...oldBaselineMap.keys(), ...newBaselineMap.keys()]);
|
|
252
|
-
const baselineDiffs = [];
|
|
253
|
-
for (const name of allBaselineNames) {
|
|
254
|
-
const oldB = oldBaselineMap.get(name);
|
|
255
|
-
const newB = newBaselineMap.get(name);
|
|
256
|
-
if (oldB && newB) {
|
|
257
|
-
const versionChanged = oldB.version !== newB.version;
|
|
258
|
-
baselineDiffs.push({
|
|
259
|
-
name,
|
|
260
|
-
oldVersion: oldB.version,
|
|
261
|
-
newVersion: newB.version,
|
|
262
|
-
state: versionChanged ? 'updated' : 'unchanged',
|
|
263
|
-
});
|
|
264
|
-
}
|
|
265
|
-
else if (oldB && !newB) {
|
|
266
|
-
baselineDiffs.push({
|
|
267
|
-
name,
|
|
268
|
-
oldVersion: oldB.version,
|
|
269
|
-
state: 'absent',
|
|
270
|
-
});
|
|
271
|
-
}
|
|
272
|
-
else if (!oldB && newB) {
|
|
273
|
-
baselineDiffs.push({
|
|
274
|
-
name,
|
|
275
|
-
newVersion: newB.version,
|
|
276
|
-
state: 'new',
|
|
277
|
-
});
|
|
278
|
-
}
|
|
279
|
-
}
|
|
280
|
-
// Collect all requirements across all baselines
|
|
281
|
-
const oldReqs = [];
|
|
282
|
-
for (const baseline of oldBaselines) {
|
|
283
|
-
for (const req of baseline.requirements) {
|
|
284
|
-
oldReqs.push(req);
|
|
285
|
-
}
|
|
286
|
-
}
|
|
287
|
-
const newReqs = [];
|
|
288
|
-
for (const baseline of newBaselines) {
|
|
289
|
-
for (const req of baseline.requirements) {
|
|
290
|
-
newReqs.push(req);
|
|
291
|
-
}
|
|
292
|
-
}
|
|
293
|
-
// Use the matching system to pair requirements
|
|
294
|
-
const matchResult = matchRequirements(oldReqs, newReqs, matchOpts);
|
|
295
|
-
// Build requirement diffs from match results
|
|
296
|
-
const requirementDiffs = [];
|
|
297
|
-
// Matched pairs
|
|
298
|
-
for (const pair of matchResult.matched) {
|
|
299
|
-
const oldReq = pair.oldReq;
|
|
300
|
-
const newReq = pair.newReq;
|
|
301
|
-
/* c8 ignore next -- id is always a string on RequirementLike; ?? is defensive typing */
|
|
302
|
-
const id = (newReq.id ?? oldReq.id);
|
|
303
|
-
const oldStatus = computeEffectiveStatus(oldReq, oldTimestamp);
|
|
304
|
-
const newStatus = computeEffectiveStatus(newReq, newTimestamp);
|
|
305
|
-
const diffState = classifyDiffStatus(oldStatus, newStatus);
|
|
306
|
-
const changeReasons = classifyChangeReasons(oldReq, newReq, oldTimestamp, newTimestamp);
|
|
307
|
-
const fieldChanges = computeFieldChanges(oldReq, newReq, trackedFields);
|
|
308
|
-
requirementDiffs.push({
|
|
309
|
-
id,
|
|
310
|
-
title: newReq.title ?? oldReq.title,
|
|
311
|
-
state: diffState,
|
|
312
|
-
oldEffectiveStatus: oldStatus,
|
|
313
|
-
newEffectiveStatus: newStatus,
|
|
314
|
-
changeReasons,
|
|
315
|
-
oldImpact: oldReq.impact,
|
|
316
|
-
newImpact: newReq.impact,
|
|
317
|
-
fieldChanges,
|
|
318
|
-
before: oldReq,
|
|
319
|
-
after: newReq,
|
|
320
|
-
matchStrategy: pair.strategy,
|
|
321
|
-
matchConfidence: pair.confidence,
|
|
322
|
-
});
|
|
323
|
-
}
|
|
324
|
-
// Unmatched old requirements (absent)
|
|
325
|
-
for (const req of matchResult.unmatchedOld) {
|
|
326
|
-
const oldReq = req;
|
|
327
|
-
const id = oldReq.id;
|
|
328
|
-
const oldStatus = computeEffectiveStatus(oldReq, oldTimestamp);
|
|
329
|
-
requirementDiffs.push({
|
|
330
|
-
id,
|
|
331
|
-
title: oldReq.title,
|
|
332
|
-
state: 'absent',
|
|
333
|
-
oldEffectiveStatus: oldStatus,
|
|
334
|
-
changeReasons: [],
|
|
335
|
-
oldImpact: oldReq.impact,
|
|
336
|
-
fieldChanges: [],
|
|
337
|
-
before: oldReq,
|
|
338
|
-
after: null,
|
|
339
|
-
});
|
|
340
|
-
}
|
|
341
|
-
// Unmatched new requirements (new)
|
|
342
|
-
for (const req of matchResult.unmatchedNew) {
|
|
343
|
-
const newReq = req;
|
|
344
|
-
const id = newReq.id;
|
|
345
|
-
const newStatus = computeEffectiveStatus(newReq, newTimestamp);
|
|
346
|
-
requirementDiffs.push({
|
|
347
|
-
id,
|
|
348
|
-
title: newReq.title,
|
|
349
|
-
state: 'new',
|
|
350
|
-
newEffectiveStatus: newStatus,
|
|
351
|
-
changeReasons: [],
|
|
352
|
-
newImpact: newReq.impact,
|
|
353
|
-
fieldChanges: [],
|
|
354
|
-
before: null,
|
|
355
|
-
after: newReq,
|
|
356
|
-
});
|
|
357
|
-
}
|
|
358
|
-
return { baselineDiffs, requirementDiffs };
|
|
359
|
-
}
|
|
360
|
-
/**
|
|
361
|
-
* Compute field-level changes for tracked fields between two requirements.
|
|
362
|
-
* Uses JSON Patch-like op/path format.
|
|
363
|
-
*/
|
|
364
|
-
function computeFieldChanges(oldReq, newReq, trackedFields) {
|
|
365
|
-
const changes = [];
|
|
366
|
-
for (const field of trackedFields) {
|
|
367
|
-
const oldVal = oldReq[field];
|
|
368
|
-
const newVal = newReq[field];
|
|
369
|
-
// Deep comparison for objects/arrays
|
|
370
|
-
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
371
|
-
if (oldVal === undefined && newVal !== undefined) {
|
|
372
|
-
changes.push({
|
|
373
|
-
op: 'add',
|
|
374
|
-
path: field,
|
|
375
|
-
newValue: newVal,
|
|
376
|
-
});
|
|
377
|
-
}
|
|
378
|
-
else if (oldVal !== undefined && newVal === undefined) {
|
|
379
|
-
changes.push({
|
|
380
|
-
op: 'remove',
|
|
381
|
-
path: field,
|
|
382
|
-
oldValue: oldVal,
|
|
383
|
-
});
|
|
384
|
-
}
|
|
385
|
-
else {
|
|
386
|
-
changes.push({
|
|
387
|
-
op: 'replace',
|
|
388
|
-
path: field,
|
|
389
|
-
oldValue: oldVal,
|
|
390
|
-
newValue: newVal,
|
|
391
|
-
});
|
|
392
|
-
}
|
|
393
|
-
}
|
|
394
|
-
}
|
|
395
|
-
return changes;
|
|
396
|
-
}
|
|
397
|
-
/**
|
|
398
|
-
* Detect whether a document is a baseline (not results).
|
|
399
|
-
* Baselines have `requirements` at the top level and lack `baselines`, `targets`, and `statistics`.
|
|
400
|
-
*/
|
|
401
|
-
function isBaselineDocument(doc) {
|
|
402
|
-
return (Array.isArray(doc['requirements']) &&
|
|
403
|
-
!Array.isArray(doc['baselines']) &&
|
|
404
|
-
!Array.isArray(doc['targets']) &&
|
|
405
|
-
doc['statistics'] === undefined);
|
|
406
|
-
}
|
|
407
|
-
/** Default tracked fields for baseline evolution comparisons. */
|
|
408
|
-
const BASELINE_TRACKED_FIELDS = ['title', 'impact', 'descriptions', 'tags'];
|
|
409
|
-
/**
|
|
410
|
-
* Compare two HDF baseline documents and produce a structured comparison
|
|
411
|
-
* showing requirement changes between baseline versions.
|
|
412
|
-
*
|
|
413
|
-
* Unlike diffHdf (which compares results/evaluations), this compares baseline
|
|
414
|
-
* definitions — requirements without results. There is no status-based classification
|
|
415
|
-
* (fixed/regressed); only metadata changes (title, impact, descriptions, tags) are tracked.
|
|
416
|
-
*/
|
|
417
|
-
export function diffBaselines(oldBaseline, newBaseline, options) {
|
|
418
|
-
const trackedFields = options?.trackedFields ?? BASELINE_TRACKED_FIELDS;
|
|
419
|
-
const matchOpts = buildMatchOptions(options);
|
|
420
|
-
// Extract requirements from baseline documents
|
|
421
|
-
const oldReqs = oldBaseline['requirements'] ?? [];
|
|
422
|
-
const newReqs = newBaseline['requirements'] ?? [];
|
|
423
|
-
// Use the matching system to pair requirements
|
|
424
|
-
const matchResult = matchRequirements(oldReqs, newReqs, matchOpts);
|
|
425
|
-
// Build requirement diffs from match results
|
|
426
|
-
const requirementDiffs = [];
|
|
427
|
-
// Matched pairs
|
|
428
|
-
for (const pair of matchResult.matched) {
|
|
429
|
-
const oldReq = pair.oldReq;
|
|
430
|
-
const newReq = pair.newReq;
|
|
431
|
-
const id = (newReq.id ?? oldReq.id);
|
|
432
|
-
const fieldChanges = computeFieldChanges(oldReq, newReq, trackedFields);
|
|
433
|
-
// For baseline evolution, state is determined by metadata changes only
|
|
434
|
-
const state = fieldChanges.length > 0 ? 'updated' : 'unchanged';
|
|
435
|
-
// Determine change reasons for baseline evolution
|
|
436
|
-
const changeReasons = [];
|
|
437
|
-
if (oldReq.impact !== newReq.impact) {
|
|
438
|
-
changeReasons.push('impactChanged');
|
|
439
|
-
}
|
|
440
|
-
const oldTags = JSON.stringify(oldReq['tags'] ?? {});
|
|
441
|
-
const newTags = JSON.stringify(newReq['tags'] ?? {});
|
|
442
|
-
const oldDescs = JSON.stringify(oldReq['descriptions'] ?? []);
|
|
443
|
-
const newDescs = JSON.stringify(newReq['descriptions'] ?? []);
|
|
444
|
-
const oldTitle = oldReq['title'];
|
|
445
|
-
const newTitle = newReq['title'];
|
|
446
|
-
if (oldTags !== newTags || oldDescs !== newDescs || oldTitle !== newTitle) {
|
|
447
|
-
changeReasons.push('metadataChanged');
|
|
448
|
-
}
|
|
449
|
-
requirementDiffs.push({
|
|
450
|
-
id,
|
|
451
|
-
title: newReq.title ?? oldReq.title,
|
|
452
|
-
state,
|
|
453
|
-
changeReasons,
|
|
454
|
-
oldImpact: oldReq.impact,
|
|
455
|
-
newImpact: newReq.impact,
|
|
456
|
-
fieldChanges,
|
|
457
|
-
before: oldReq,
|
|
458
|
-
after: newReq,
|
|
459
|
-
matchStrategy: pair.strategy,
|
|
460
|
-
matchConfidence: pair.confidence,
|
|
461
|
-
});
|
|
462
|
-
}
|
|
463
|
-
// Unmatched old requirements (absent)
|
|
464
|
-
for (const req of matchResult.unmatchedOld) {
|
|
465
|
-
const oldReq = req;
|
|
466
|
-
requirementDiffs.push({
|
|
467
|
-
id: oldReq.id,
|
|
468
|
-
title: oldReq.title,
|
|
469
|
-
state: 'absent',
|
|
470
|
-
changeReasons: [],
|
|
471
|
-
oldImpact: oldReq.impact,
|
|
472
|
-
fieldChanges: [],
|
|
473
|
-
before: oldReq,
|
|
474
|
-
after: null,
|
|
475
|
-
});
|
|
476
|
-
}
|
|
477
|
-
// Unmatched new requirements (new)
|
|
478
|
-
for (const req of matchResult.unmatchedNew) {
|
|
479
|
-
const newReq = req;
|
|
480
|
-
requirementDiffs.push({
|
|
481
|
-
id: newReq.id,
|
|
482
|
-
title: newReq.title,
|
|
483
|
-
state: 'new',
|
|
484
|
-
changeReasons: [],
|
|
485
|
-
newImpact: newReq.impact,
|
|
486
|
-
fieldChanges: [],
|
|
487
|
-
before: null,
|
|
488
|
-
after: newReq,
|
|
489
|
-
});
|
|
490
|
-
}
|
|
491
|
-
// Sort by id
|
|
492
|
-
requirementDiffs.sort((a, b) => a.id.localeCompare(b.id));
|
|
493
|
-
// Build baseline diff from top-level baseline metadata
|
|
494
|
-
const oldName = oldBaseline['name'] ?? '';
|
|
495
|
-
const newName = newBaseline['name'] ?? '';
|
|
496
|
-
const oldVersion = oldBaseline['version'];
|
|
497
|
-
const newVersion = newBaseline['version'];
|
|
498
|
-
const baselineDiffs = [];
|
|
499
|
-
const baselineName = newName || oldName;
|
|
500
|
-
if (baselineName) {
|
|
501
|
-
const versionChanged = oldVersion !== newVersion;
|
|
502
|
-
baselineDiffs.push({
|
|
503
|
-
name: baselineName,
|
|
504
|
-
oldVersion,
|
|
505
|
-
newVersion,
|
|
506
|
-
state: versionChanged ? 'updated' : 'unchanged',
|
|
507
|
-
});
|
|
508
|
-
}
|
|
509
|
-
// Build sources
|
|
510
|
-
const sources = [
|
|
511
|
-
{ role: 'old', label: oldVersion ? `${baselineName} ${oldVersion}` : baselineName || 'Old baseline' },
|
|
512
|
-
{ role: 'new', label: newVersion ? `${baselineName} ${newVersion}` : baselineName || 'New baseline' },
|
|
513
|
-
];
|
|
514
|
-
const comparison = {
|
|
515
|
-
formatVersion: '1.0.0',
|
|
516
|
-
comparisonMode: 'baselineEvolution',
|
|
517
|
-
timestamp: new Date().toISOString(),
|
|
518
|
-
sources,
|
|
519
|
-
matching: {
|
|
520
|
-
primaryStrategy: resolveStrategyName(options),
|
|
521
|
-
},
|
|
522
|
-
summary: computeSummary(requirementDiffs),
|
|
523
|
-
baselineDiffs,
|
|
524
|
-
requirementDiffs,
|
|
525
|
-
};
|
|
526
|
-
if (options?.validateOutput) {
|
|
527
|
-
const result = validateComparison(comparison);
|
|
528
|
-
/* c8 ignore start */
|
|
529
|
-
if (!result.valid) {
|
|
530
|
-
throw new Error(`Output validation failed: ${result.errors?.join(', ')}`);
|
|
531
|
-
}
|
|
532
|
-
/* c8 ignore stop */
|
|
533
|
-
}
|
|
534
|
-
return comparison;
|
|
535
|
-
}
|
|
536
|
-
/**
|
|
537
|
-
* Detect whether a document is a system document (has components[] but no baselines/requirements).
|
|
538
|
-
*/
|
|
539
|
-
function isSystemDocument(doc) {
|
|
540
|
-
return (Array.isArray(doc['components']) &&
|
|
541
|
-
!Array.isArray(doc['baselines']) &&
|
|
542
|
-
!Array.isArray(doc['requirements']) &&
|
|
543
|
-
doc['statistics'] === undefined);
|
|
544
|
-
}
|
|
545
|
-
/** Fields tracked for system-level field changes. */
|
|
546
|
-
const SYSTEM_TOP_LEVEL_FIELDS = ['authorizationStatus', 'categorizationLevel', 'description'];
|
|
547
|
-
/** Fields tracked for component-level field changes. */
|
|
548
|
-
const COMPONENT_TRACKED_FIELDS = [
|
|
549
|
-
'type', 'description', 'baselineRefs', 'inputOverrides', 'sbomRef', 'targetSelector',
|
|
550
|
-
];
|
|
551
|
-
/**
|
|
552
|
-
* Compare two HDF system documents and produce a structured comparison
|
|
553
|
-
* showing component-level changes between system versions.
|
|
554
|
-
*
|
|
555
|
-
* Components are matched by componentId (UUID) when available, falling back
|
|
556
|
-
* to exact name matching. Top-level system fields, data flows, and embedded
|
|
557
|
-
* SBOMs are also compared.
|
|
558
|
-
*/
|
|
559
|
-
export function diffSystems(oldSystem, newSystem, options) {
|
|
560
|
-
const oldComponents = oldSystem['components'] ?? [];
|
|
561
|
-
const newComponents = newSystem['components'] ?? [];
|
|
562
|
-
// Match components: prefer componentId, fall back to name
|
|
563
|
-
const pairs = matchComponents(oldComponents, newComponents);
|
|
564
|
-
const componentDiffs = [];
|
|
565
|
-
for (const { oldComp, newComp, name } of pairs) {
|
|
566
|
-
if (oldComp && newComp) {
|
|
567
|
-
const fieldChanges = computeComponentFieldChanges(oldComp, newComp, COMPONENT_TRACKED_FIELDS);
|
|
568
|
-
const state = fieldChanges.length > 0 ? 'updated' : 'unchanged';
|
|
569
|
-
componentDiffs.push({ name, state, before: oldComp, after: newComp, fieldChanges });
|
|
570
|
-
}
|
|
571
|
-
else if (oldComp && !newComp) {
|
|
572
|
-
componentDiffs.push({ name, state: 'absent', before: oldComp, after: null, fieldChanges: [] });
|
|
573
|
-
}
|
|
574
|
-
else if (!oldComp && newComp) {
|
|
575
|
-
componentDiffs.push({ name, state: 'new', before: null, after: newComp, fieldChanges: [] });
|
|
576
|
-
}
|
|
577
|
-
}
|
|
578
|
-
componentDiffs.sort((a, b) => a.name.localeCompare(b.name));
|
|
579
|
-
// Compare top-level system fields
|
|
580
|
-
const systemFieldChanges = computeComponentFieldChanges(oldSystem, newSystem, SYSTEM_TOP_LEVEL_FIELDS);
|
|
581
|
-
// Build summary counts based on component diffs
|
|
582
|
-
const counts = { new: 0, absent: 0, unchanged: 0, updated: 0 };
|
|
583
|
-
for (const cd of componentDiffs) {
|
|
584
|
-
counts[cd.state]++;
|
|
585
|
-
}
|
|
586
|
-
const oldName = oldSystem['name'] ?? '';
|
|
587
|
-
const newName = newSystem['name'] ?? '';
|
|
588
|
-
const systemName = newName || oldName;
|
|
589
|
-
const sources = [
|
|
590
|
-
{ role: 'old', label: systemName ? `${systemName} (old)` : 'Old system' },
|
|
591
|
-
{ role: 'new', label: systemName ? `${systemName} (new)` : 'New system' },
|
|
592
|
-
];
|
|
593
|
-
const comparison = {
|
|
594
|
-
formatVersion: '1.0.0',
|
|
595
|
-
comparisonMode: 'systemDrift',
|
|
596
|
-
timestamp: new Date().toISOString(),
|
|
597
|
-
sources,
|
|
598
|
-
summary: {
|
|
599
|
-
total: pairs.length,
|
|
600
|
-
matchedCount: counts.unchanged + counts.updated,
|
|
601
|
-
unmatchedOldCount: counts.absent,
|
|
602
|
-
unmatchedNewCount: counts.new,
|
|
603
|
-
new: counts.new,
|
|
604
|
-
absent: counts.absent,
|
|
605
|
-
unchanged: counts.unchanged,
|
|
606
|
-
updated: counts.updated,
|
|
607
|
-
fixed: 0,
|
|
608
|
-
regressed: 0,
|
|
609
|
-
},
|
|
610
|
-
baselineDiffs: [],
|
|
611
|
-
requirementDiffs: [],
|
|
612
|
-
componentDiffs,
|
|
613
|
-
};
|
|
614
|
-
// Extensions: system field changes + data flow changes
|
|
615
|
-
const extensions = {};
|
|
616
|
-
if (systemFieldChanges.length > 0) {
|
|
617
|
-
extensions['systemFieldChanges'] = systemFieldChanges;
|
|
618
|
-
}
|
|
619
|
-
const dataFlowChanges = diffDataFlows(oldSystem, newSystem);
|
|
620
|
-
if (dataFlowChanges.length > 0) {
|
|
621
|
-
extensions['dataFlowChanges'] = dataFlowChanges;
|
|
622
|
-
}
|
|
623
|
-
if (Object.keys(extensions).length > 0) {
|
|
624
|
-
comparison.extensions = extensions;
|
|
625
|
-
}
|
|
626
|
-
// Diff embedded SBOMs across matched components
|
|
627
|
-
const allPackageDiffs = diffEmbeddedSboms(pairs);
|
|
628
|
-
if (allPackageDiffs.length > 0) {
|
|
629
|
-
comparison.packageDiffs = allPackageDiffs;
|
|
630
|
-
}
|
|
631
|
-
if (options?.validateOutput) {
|
|
632
|
-
const result = validateComparison(comparison);
|
|
633
|
-
/* c8 ignore start */
|
|
634
|
-
if (!result.valid) {
|
|
635
|
-
throw new Error(`Output validation failed: ${result.errors?.join(', ')}`);
|
|
636
|
-
}
|
|
637
|
-
/* c8 ignore stop */
|
|
638
|
-
}
|
|
639
|
-
return comparison;
|
|
640
|
-
}
|
|
641
|
-
/**
|
|
642
|
-
* Match old and new components by componentId (when available) or name.
|
|
643
|
-
*/
|
|
644
|
-
function matchComponents(oldComponents, newComponents) {
|
|
645
|
-
const matched = new Set(); // indices of matched new components
|
|
646
|
-
const pairs = [];
|
|
647
|
-
// First pass: match by componentId
|
|
648
|
-
const newById = new Map();
|
|
649
|
-
for (let i = 0; i < newComponents.length; i++) {
|
|
650
|
-
const id = newComponents[i]['componentId'];
|
|
651
|
-
if (id)
|
|
652
|
-
newById.set(id, i);
|
|
653
|
-
}
|
|
654
|
-
const oldMatched = new Set();
|
|
655
|
-
for (let i = 0; i < oldComponents.length; i++) {
|
|
656
|
-
const oldId = oldComponents[i]['componentId'];
|
|
657
|
-
if (oldId && newById.has(oldId)) {
|
|
658
|
-
const ni = newById.get(oldId);
|
|
659
|
-
const newComp = newComponents[ni];
|
|
660
|
-
pairs.push({
|
|
661
|
-
name: newComp['name'] || oldComponents[i]['name'] || oldId,
|
|
662
|
-
oldComp: oldComponents[i],
|
|
663
|
-
newComp,
|
|
664
|
-
});
|
|
665
|
-
matched.add(ni);
|
|
666
|
-
oldMatched.add(i);
|
|
667
|
-
}
|
|
668
|
-
}
|
|
669
|
-
// Second pass: match remaining by name
|
|
670
|
-
const newByName = new Map();
|
|
671
|
-
for (let i = 0; i < newComponents.length; i++) {
|
|
672
|
-
if (matched.has(i))
|
|
673
|
-
continue;
|
|
674
|
-
const name = newComponents[i]['name'];
|
|
675
|
-
if (name)
|
|
676
|
-
newByName.set(name, i);
|
|
677
|
-
}
|
|
678
|
-
for (let i = 0; i < oldComponents.length; i++) {
|
|
679
|
-
if (oldMatched.has(i))
|
|
680
|
-
continue;
|
|
681
|
-
const name = oldComponents[i]['name'];
|
|
682
|
-
if (name && newByName.has(name)) {
|
|
683
|
-
const ni = newByName.get(name);
|
|
684
|
-
pairs.push({ name, oldComp: oldComponents[i], newComp: newComponents[ni] });
|
|
685
|
-
matched.add(ni);
|
|
686
|
-
oldMatched.add(i);
|
|
687
|
-
newByName.delete(name);
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
// Unmatched old → absent
|
|
691
|
-
for (let i = 0; i < oldComponents.length; i++) {
|
|
692
|
-
if (oldMatched.has(i))
|
|
693
|
-
continue;
|
|
694
|
-
const name = oldComponents[i]['name'] || `component-${i}`;
|
|
695
|
-
pairs.push({ name, oldComp: oldComponents[i], newComp: undefined });
|
|
696
|
-
}
|
|
697
|
-
// Unmatched new → new
|
|
698
|
-
for (let i = 0; i < newComponents.length; i++) {
|
|
699
|
-
if (matched.has(i))
|
|
700
|
-
continue;
|
|
701
|
-
const name = newComponents[i]['name'] || `component-${i}`;
|
|
702
|
-
pairs.push({ name, oldComp: undefined, newComp: newComponents[i] });
|
|
703
|
-
}
|
|
704
|
-
return pairs;
|
|
705
|
-
}
|
|
706
|
-
/**
|
|
707
|
-
* Diff data flows between two system documents. Flows are keyed by from+to.
|
|
708
|
-
*/
|
|
709
|
-
function diffDataFlows(oldSystem, newSystem) {
|
|
710
|
-
const oldFlows = oldSystem['dataFlows'] ?? [];
|
|
711
|
-
const newFlows = newSystem['dataFlows'] ?? [];
|
|
712
|
-
if (oldFlows.length === 0 && newFlows.length === 0)
|
|
713
|
-
return [];
|
|
714
|
-
const flowKey = (f) => {
|
|
715
|
-
const from = f['from'] ?? '';
|
|
716
|
-
const to = typeof f['to'] === 'string' ? f['to'] : JSON.stringify(f['to']);
|
|
717
|
-
return `${from}→${to}`;
|
|
718
|
-
};
|
|
719
|
-
const oldMap = new Map();
|
|
720
|
-
for (const f of oldFlows)
|
|
721
|
-
oldMap.set(flowKey(f), f);
|
|
722
|
-
const newMap = new Map();
|
|
723
|
-
for (const f of newFlows)
|
|
724
|
-
newMap.set(flowKey(f), f);
|
|
725
|
-
const allKeys = new Set([...oldMap.keys(), ...newMap.keys()]);
|
|
726
|
-
const changes = [];
|
|
727
|
-
for (const key of allKeys) {
|
|
728
|
-
const oldF = oldMap.get(key);
|
|
729
|
-
const newF = newMap.get(key);
|
|
730
|
-
if (oldF && newF) {
|
|
731
|
-
if (JSON.stringify(oldF) !== JSON.stringify(newF)) {
|
|
732
|
-
changes.push({ state: 'updated', flow: newF });
|
|
733
|
-
}
|
|
734
|
-
}
|
|
735
|
-
else if (oldF && !newF) {
|
|
736
|
-
changes.push({ state: 'removed', flow: oldF });
|
|
737
|
-
}
|
|
738
|
-
else if (!oldF && newF) {
|
|
739
|
-
changes.push({ state: 'added', flow: newF });
|
|
740
|
-
}
|
|
741
|
-
}
|
|
742
|
-
return changes;
|
|
743
|
-
}
|
|
744
|
-
/**
|
|
745
|
-
* Diff embedded SBOMs across matched component pairs.
|
|
746
|
-
*/
|
|
747
|
-
function diffEmbeddedSboms(pairs) {
|
|
748
|
-
const allDiffs = [];
|
|
749
|
-
for (const { oldComp, newComp } of pairs) {
|
|
750
|
-
if (!oldComp || !newComp)
|
|
751
|
-
continue;
|
|
752
|
-
const oldSbom = oldComp['sbom'];
|
|
753
|
-
const newSbom = newComp['sbom'];
|
|
754
|
-
if (!oldSbom || !newSbom)
|
|
755
|
-
continue;
|
|
756
|
-
if (typeof oldSbom !== 'object' || typeof newSbom !== 'object')
|
|
757
|
-
continue;
|
|
758
|
-
try {
|
|
759
|
-
const result = diffSboms(JSON.stringify(oldSbom), JSON.stringify(newSbom));
|
|
760
|
-
allDiffs.push(...result.packageDiffs);
|
|
761
|
-
}
|
|
762
|
-
catch {
|
|
763
|
-
// Skip SBOM diff if formats are incompatible
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
return allDiffs;
|
|
767
|
-
}
|
|
768
|
-
/**
|
|
769
|
-
* Compute field-level changes for component or system fields.
|
|
770
|
-
* Uses JSON Patch-like op/path format.
|
|
771
|
-
*/
|
|
772
|
-
function computeComponentFieldChanges(oldObj, newObj, trackedFields) {
|
|
773
|
-
const changes = [];
|
|
774
|
-
for (const field of trackedFields) {
|
|
775
|
-
const oldVal = oldObj[field];
|
|
776
|
-
const newVal = newObj[field];
|
|
777
|
-
if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) {
|
|
778
|
-
if (oldVal === undefined && newVal !== undefined) {
|
|
779
|
-
changes.push({ op: 'add', path: field, newValue: newVal });
|
|
780
|
-
}
|
|
781
|
-
else if (oldVal !== undefined && newVal === undefined) {
|
|
782
|
-
changes.push({ op: 'remove', path: field, oldValue: oldVal });
|
|
783
|
-
}
|
|
784
|
-
else {
|
|
785
|
-
changes.push({ op: 'replace', path: field, oldValue: oldVal, newValue: newVal });
|
|
786
|
-
}
|
|
787
|
-
}
|
|
788
|
-
}
|
|
789
|
-
return changes;
|
|
790
|
-
}
|
|
791
|
-
//# sourceMappingURL=diff.js.map
|