@mitre/hdf-diff 3.0.0 → 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.
Files changed (91) hide show
  1. package/LICENSE.md +55 -0
  2. package/README.md +49 -17
  3. package/dist/index.d.ts +622 -16
  4. package/dist/index.d.ts.map +1 -1
  5. package/dist/index.js +2037 -18
  6. package/dist/index.js.map +1 -1
  7. package/package.json +19 -20
  8. package/dist/diff.d.ts +0 -49
  9. package/dist/diff.d.ts.map +0 -1
  10. package/dist/diff.js +0 -791
  11. package/dist/diff.js.map +0 -1
  12. package/dist/exit-codes.d.ts +0 -42
  13. package/dist/exit-codes.d.ts.map +0 -1
  14. package/dist/exit-codes.js +0 -74
  15. package/dist/exit-codes.js.map +0 -1
  16. package/dist/matching/cci-match.d.ts +0 -14
  17. package/dist/matching/cci-match.d.ts.map +0 -1
  18. package/dist/matching/cci-match.js +0 -102
  19. package/dist/matching/cci-match.js.map +0 -1
  20. package/dist/matching/exact-id.d.ts +0 -9
  21. package/dist/matching/exact-id.d.ts.map +0 -1
  22. package/dist/matching/exact-id.js +0 -88
  23. package/dist/matching/exact-id.js.map +0 -1
  24. package/dist/matching/fuzzy-match.d.ts +0 -26
  25. package/dist/matching/fuzzy-match.d.ts.map +0 -1
  26. package/dist/matching/fuzzy-match.js +0 -145
  27. package/dist/matching/fuzzy-match.js.map +0 -1
  28. package/dist/matching/index.d.ts +0 -36
  29. package/dist/matching/index.d.ts.map +0 -1
  30. package/dist/matching/index.js +0 -71
  31. package/dist/matching/index.js.map +0 -1
  32. package/dist/matching/mapped-id.d.ts +0 -13
  33. package/dist/matching/mapped-id.d.ts.map +0 -1
  34. package/dist/matching/mapped-id.js +0 -101
  35. package/dist/matching/mapped-id.js.map +0 -1
  36. package/dist/matching/types.d.ts +0 -34
  37. package/dist/matching/types.d.ts.map +0 -1
  38. package/dist/matching/types.js +0 -2
  39. package/dist/matching/types.js.map +0 -1
  40. package/dist/normalize.d.ts +0 -20
  41. package/dist/normalize.d.ts.map +0 -1
  42. package/dist/normalize.js +0 -120
  43. package/dist/normalize.js.map +0 -1
  44. package/dist/renderers/csv.d.ts +0 -15
  45. package/dist/renderers/csv.d.ts.map +0 -1
  46. package/dist/renderers/csv.js +0 -77
  47. package/dist/renderers/csv.js.map +0 -1
  48. package/dist/renderers/filter.d.ts +0 -11
  49. package/dist/renderers/filter.d.ts.map +0 -1
  50. package/dist/renderers/filter.js +0 -29
  51. package/dist/renderers/filter.js.map +0 -1
  52. package/dist/renderers/index.d.ts +0 -17
  53. package/dist/renderers/index.d.ts.map +0 -1
  54. package/dist/renderers/index.js +0 -29
  55. package/dist/renderers/index.js.map +0 -1
  56. package/dist/renderers/json.d.ts +0 -13
  57. package/dist/renderers/json.d.ts.map +0 -1
  58. package/dist/renderers/json.js +0 -41
  59. package/dist/renderers/json.js.map +0 -1
  60. package/dist/renderers/markdown.d.ts +0 -13
  61. package/dist/renderers/markdown.d.ts.map +0 -1
  62. package/dist/renderers/markdown.js +0 -133
  63. package/dist/renderers/markdown.js.map +0 -1
  64. package/dist/renderers/terminal.d.ts +0 -14
  65. package/dist/renderers/terminal.d.ts.map +0 -1
  66. package/dist/renderers/terminal.js +0 -173
  67. package/dist/renderers/terminal.js.map +0 -1
  68. package/dist/renderers/types.d.ts +0 -12
  69. package/dist/renderers/types.d.ts.map +0 -1
  70. package/dist/renderers/types.js +0 -2
  71. package/dist/renderers/types.js.map +0 -1
  72. package/dist/sbom.d.ts +0 -42
  73. package/dist/sbom.d.ts.map +0 -1
  74. package/dist/sbom.js +0 -203
  75. package/dist/sbom.js.map +0 -1
  76. package/dist/status.d.ts +0 -27
  77. package/dist/status.d.ts.map +0 -1
  78. package/dist/status.js +0 -137
  79. package/dist/status.js.map +0 -1
  80. package/dist/summary.d.ts +0 -6
  81. package/dist/summary.d.ts.map +0 -1
  82. package/dist/summary.js +0 -47
  83. package/dist/summary.js.map +0 -1
  84. package/dist/types.d.ts +0 -208
  85. package/dist/types.d.ts.map +0 -1
  86. package/dist/types.js +0 -2
  87. package/dist/types.js.map +0 -1
  88. package/dist/validate.d.ts +0 -20
  89. package/dist/validate.d.ts.map +0 -1
  90. package/dist/validate.js +0 -108
  91. 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