@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/index.js CHANGED
@@ -1,19 +1,2038 @@
1
- // Diff engine
2
- export { diffHdf, diffBaselines, diffSystems } from './diff.js';
3
- // Status utilities
4
- export { computeEffectiveStatus, classifyChangeReasons, classifyDiffStatus, } from './status.js';
5
- // Summary
6
- export { computeSummary } from './summary.js';
7
- // Normalization (v1 → v2)
8
- export { isV1Format, normalizeToV2 } from './normalize.js';
9
- // Matching strategies
10
- export { matchRequirements, createExactIdStrategy, createMappedIdStrategy, createCciMatchStrategy, createFuzzyTitleStrategy, tokenize, jaccardSimilarity, } from './matching/index.js';
11
- // Schema validation
12
- export { validateComparison } from './validate.js';
13
- // Exit codes
14
- export { EXIT_IDENTICAL, EXIT_DIFFERENCES, EXIT_ERROR, EXIT_DETAILED_IDENTICAL, EXIT_DETAILED_ERROR, EXIT_DETAILED_FIXES_ONLY, EXIT_DETAILED_REGRESSIONS_ONLY, EXIT_DETAILED_MIXED, EXIT_DETAILED_BASELINE_CHANGED, EXIT_DETAILED_DRIFT_ONLY, computeExitCode, computeDetailedExitCode, } from './exit-codes.js';
15
- // Renderers
16
- export { render, renderJson, renderMarkdown, renderTerminal, renderCsv } from './renderers/index.js';
17
- // SBOM comparison
18
- export { diffSboms } from './sbom.js';
1
+ import { validateComparison as validateComparison$1 } from "@mitre/hdf-validators";
2
+ //#region src/status.ts
3
+ /** Status severity ranking — higher index = worse */
4
+ const STATUS_SEVERITY = [
5
+ "notApplicable",
6
+ "notReviewed",
7
+ "passed",
8
+ "failed",
9
+ "error"
10
+ ];
11
+ /** Statuses that count as "passing" for fixed/regressed classification */
12
+ const PASSING_STATUSES = new Set(["passed"]);
13
+ /** Statuses that count as "failing" for fixed/regressed classification */
14
+ const FAILING_STATUSES = new Set([
15
+ "failed",
16
+ "error",
17
+ "notReviewed"
18
+ ]);
19
+ /**
20
+ * Determine the effective status of a requirement from its results and overrides.
21
+ *
22
+ * Priority:
23
+ * 1. impact === 0 → notApplicable (regardless of results)
24
+ * 2. effectiveStatus field set (and no statusOverrides) → use it
25
+ * 3. Non-expired statusOverrides → use first non-expired
26
+ * 4. Aggregate results using worst-wins
27
+ * 5. Empty results → notReviewed
28
+ */
29
+ function computeEffectiveStatus(requirement, referenceTimestamp) {
30
+ if (requirement["impact"] === 0) return "notApplicable";
31
+ const overrides = requirement["statusOverrides"];
32
+ if (overrides && overrides.length > 0) {
33
+ const refTime = referenceTimestamp ? new Date(referenceTimestamp).getTime() : Date.now();
34
+ for (const override of overrides) if (new Date(override.expiresAt).getTime() > refTime && override.status) return override.status;
35
+ }
36
+ const effectiveStatus = requirement["effectiveStatus"];
37
+ if (effectiveStatus && (!overrides || overrides.length === 0)) return effectiveStatus;
38
+ const results = requirement["results"];
39
+ if (!results || results.length === 0) return "notReviewed";
40
+ let worstIndex = -1;
41
+ let worstStatus = "notReviewed";
42
+ for (const result of results) {
43
+ const idx = STATUS_SEVERITY.indexOf(result.status);
44
+ if (idx > worstIndex) {
45
+ worstIndex = idx;
46
+ worstStatus = result.status;
47
+ }
48
+ }
49
+ return worstStatus;
50
+ }
51
+ /**
52
+ * Classify why the status changed between two requirements.
53
+ * Returns an array of change reasons (a status change can have multiple causes).
54
+ */
55
+ function classifyChangeReasons(oldReq, newReq, oldTimestamp, newTimestamp) {
56
+ const reasons = [];
57
+ const oldResults = oldReq["results"] ?? [];
58
+ const newResults = newReq["results"] ?? [];
59
+ const oldResultStatuses = oldResults.map((r) => r.status).sort();
60
+ const newResultStatuses = newResults.map((r) => r.status).sort();
61
+ if (JSON.stringify(oldResultStatuses) !== JSON.stringify(newResultStatuses)) reasons.push("resultChanged");
62
+ const oldOverrides = oldReq["statusOverrides"] ?? [];
63
+ const newOverrides = newReq["statusOverrides"] ?? [];
64
+ if (newOverrides.length > oldOverrides.length) reasons.push("overrideAdded");
65
+ else if (newOverrides.length < oldOverrides.length) reasons.push("overrideRemoved");
66
+ if (oldTimestamp && newTimestamp && oldOverrides.length > 0) {
67
+ const oldTime = new Date(oldTimestamp).getTime();
68
+ const newTime = new Date(newTimestamp).getTime();
69
+ for (const override of oldOverrides) {
70
+ const expiresAt = new Date(override.expiresAt).getTime();
71
+ if (expiresAt > oldTime && expiresAt <= newTime) {
72
+ reasons.push("overrideExpired");
73
+ break;
74
+ }
75
+ }
76
+ }
77
+ if (oldReq["impact"] !== newReq["impact"]) reasons.push("impactChanged");
78
+ if (oldReq["disposition"] !== newReq["disposition"]) reasons.push("dispositionChanged");
79
+ if (oldReq["effectiveImpact"] !== newReq["effectiveImpact"]) reasons.push("effectiveImpactChanged");
80
+ const oldTags = JSON.stringify(oldReq["tags"] ?? {});
81
+ const newTags = JSON.stringify(newReq["tags"] ?? {});
82
+ const oldDescs = JSON.stringify(oldReq["descriptions"] ?? []);
83
+ const newDescs = JSON.stringify(newReq["descriptions"] ?? []);
84
+ const oldTitle = oldReq["title"];
85
+ const newTitle = newReq["title"];
86
+ if (oldTags !== newTags || oldDescs !== newDescs || oldTitle !== newTitle) reasons.push("metadataChanged");
87
+ return reasons;
88
+ }
89
+ /**
90
+ * Classify the overall diff status based on old and new effective statuses.
91
+ *
92
+ * - If old is failing and new is passing → 'fixed'
93
+ * - If old is passing and new is failing → 'regressed'
94
+ * - If statuses are equal → 'unchanged'
95
+ * - Otherwise → 'updated'
96
+ */
97
+ function classifyDiffStatus(oldEffectiveStatus, newEffectiveStatus) {
98
+ if (oldEffectiveStatus === newEffectiveStatus) return "unchanged";
99
+ const oldIsFailing = FAILING_STATUSES.has(oldEffectiveStatus);
100
+ const newIsPassing = PASSING_STATUSES.has(newEffectiveStatus);
101
+ const oldIsPassing = PASSING_STATUSES.has(oldEffectiveStatus);
102
+ const newIsFailing = FAILING_STATUSES.has(newEffectiveStatus);
103
+ if (oldIsFailing && newIsPassing) return "fixed";
104
+ if (oldIsPassing && newIsFailing) return "regressed";
105
+ return "updated";
106
+ }
107
+ //#endregion
108
+ //#region src/summary.ts
109
+ /**
110
+ * Compute summary counts from an array of RequirementDiff entries.
111
+ */
112
+ function computeSummary(requirements) {
113
+ const summary = {
114
+ fixed: 0,
115
+ regressed: 0,
116
+ new: 0,
117
+ absent: 0,
118
+ unchanged: 0,
119
+ updated: 0,
120
+ total: requirements.length,
121
+ matchedCount: 0,
122
+ unmatchedOldCount: 0,
123
+ unmatchedNewCount: 0
124
+ };
125
+ for (const req of requirements) switch (req.state) {
126
+ case "fixed":
127
+ summary.fixed++;
128
+ summary.matchedCount++;
129
+ break;
130
+ case "regressed":
131
+ summary.regressed++;
132
+ summary.matchedCount++;
133
+ break;
134
+ case "new":
135
+ summary.new++;
136
+ summary.unmatchedNewCount++;
137
+ break;
138
+ case "absent":
139
+ summary.absent++;
140
+ summary.unmatchedOldCount++;
141
+ break;
142
+ case "unchanged":
143
+ summary.unchanged++;
144
+ summary.matchedCount++;
145
+ break;
146
+ case "updated":
147
+ summary.updated++;
148
+ summary.matchedCount++;
149
+ break;
150
+ }
151
+ return summary;
152
+ }
153
+ //#endregion
154
+ //#region src/normalize.ts
155
+ /**
156
+ * Detect whether a document is v1 (InSpec exec-json) format.
157
+ * V1 has `profiles` at the top level; v2 has `baselines`.
158
+ */
159
+ function isV1Format(doc) {
160
+ return Array.isArray(doc["profiles"]) && !Array.isArray(doc["baselines"]);
161
+ }
162
+ /**
163
+ * Normalize a document to v2-like structure. If already v2, returns as-is.
164
+ * If v1, converts profiles→baselines, controls→requirements, and snake_case→camelCase.
165
+ */
166
+ function normalizeToV2(doc) {
167
+ if (!isV1Format(doc)) return doc;
168
+ return {
169
+ baselines: doc["profiles"].map(normalizeProfile),
170
+ statistics: doc["statistics"],
171
+ timestamp: doc["timestamp"]
172
+ };
173
+ }
174
+ function normalizeProfile(profile) {
175
+ const requirements = (profile.controls ?? []).map(normalizeControl);
176
+ return {
177
+ name: profile.name,
178
+ title: profile.title,
179
+ version: profile.version,
180
+ checksum: profile.sha256 ? {
181
+ algorithm: "sha256",
182
+ value: profile.sha256
183
+ } : void 0,
184
+ groups: profile.groups ?? [],
185
+ supports: profile.supports ?? [],
186
+ inputs: profile.attributes ?? [],
187
+ requirements
188
+ };
189
+ }
190
+ function normalizeControl(control) {
191
+ const results = (control.results ?? []).map(normalizeResult);
192
+ return {
193
+ id: control.id,
194
+ title: control.title,
195
+ descriptions: control.desc ? [{
196
+ label: "default",
197
+ data: control.desc
198
+ }] : [],
199
+ impact: control.impact,
200
+ tags: control.tags ?? {},
201
+ refs: control.refs ?? [],
202
+ code: control.code,
203
+ sourceLocation: control.source_location ?? control.sourceLocation,
204
+ results
205
+ };
206
+ }
207
+ /**
208
+ * Map InSpec v1 result status values to HDF v2 Result_Status enum values.
209
+ *
210
+ * InSpec v1 uses: "passed", "failed", "skipped", "error"
211
+ * HDF v2 uses: "passed", "failed", "notApplicable", "notReviewed", "error"
212
+ *
213
+ * "skipped" in InSpec means the test was not executed (typically because a
214
+ * `describe.one_of` condition was not met, or `only_if` excluded it).
215
+ * This maps to "notReviewed" in HDF v2 — the requirement was not assessed.
216
+ */
217
+ function normalizeResultStatus(v1Status) {
218
+ switch (v1Status) {
219
+ case "skipped": return "notReviewed";
220
+ default: return v1Status;
221
+ }
222
+ }
223
+ /**
224
+ * Normalize a timestamp string to RFC 3339 / ISO 8601 date-time format.
225
+ *
226
+ * InSpec v1 uses formats like "2017-09-22 14:12:15 -0400" which are not valid
227
+ * RFC 3339. This function attempts to parse and re-format such timestamps.
228
+ * If parsing fails, the original string is returned as-is.
229
+ */
230
+ function normalizeTimestamp(timestamp) {
231
+ if (timestamp.includes("T")) return timestamp;
232
+ const parsed = new Date(timestamp);
233
+ if (!isNaN(parsed.getTime())) return parsed.toISOString();
234
+ return timestamp;
235
+ }
236
+ function normalizeResult(result) {
237
+ const rawStartTime = result.start_time ?? result.startTime ?? "";
238
+ const normalized = {
239
+ status: normalizeResultStatus(result.status),
240
+ codeDesc: result.code_desc ?? result.codeDesc ?? "",
241
+ startTime: rawStartTime ? normalizeTimestamp(rawStartTime) : rawStartTime
242
+ };
243
+ const runTime = result.run_time ?? result.runTime;
244
+ if (runTime !== void 0) normalized["runTime"] = runTime;
245
+ if (result.message !== void 0) normalized["message"] = result.message;
246
+ return normalized;
247
+ }
248
+ //#endregion
249
+ //#region src/matching/exact-id.ts
250
+ /**
251
+ * Create an exact ID matching strategy.
252
+ *
253
+ * Matches requirements by their `id` field with exact string equality.
254
+ * Confidence is always 1.0 for matches.
255
+ */
256
+ function createExactIdStrategy() {
257
+ return {
258
+ name: "exactId",
259
+ match(oldReqs, newReqs) {
260
+ const result = {
261
+ matched: [],
262
+ unmatchedOld: [],
263
+ unmatchedNew: []
264
+ };
265
+ const newById = /* @__PURE__ */ new Map();
266
+ const duplicateNewIds = /* @__PURE__ */ new Set();
267
+ for (const req of newReqs) {
268
+ const id = req["id"];
269
+ if (typeof id === "string") {
270
+ if (newById.has(id)) {
271
+ duplicateNewIds.add(id);
272
+ newById.delete(id);
273
+ } else if (!duplicateNewIds.has(id)) newById.set(id, req);
274
+ }
275
+ }
276
+ const oldById = /* @__PURE__ */ new Map();
277
+ const duplicateOldIds = /* @__PURE__ */ new Set();
278
+ for (const req of oldReqs) {
279
+ const id = req["id"];
280
+ if (typeof id === "string") {
281
+ if (oldById.has(id)) {
282
+ duplicateOldIds.add(id);
283
+ oldById.delete(id);
284
+ } else if (!duplicateOldIds.has(id)) oldById.set(id, req);
285
+ }
286
+ }
287
+ const matchedNewIds = /* @__PURE__ */ new Set();
288
+ for (const oldReq of oldReqs) {
289
+ const id = oldReq["id"];
290
+ if (typeof id !== "string" || duplicateOldIds.has(id)) {
291
+ result.unmatchedOld.push(oldReq);
292
+ continue;
293
+ }
294
+ const newReq = newById.get(id);
295
+ if (newReq) {
296
+ result.matched.push({
297
+ oldReq,
298
+ newReq,
299
+ strategy: "exactId",
300
+ confidence: 1
301
+ });
302
+ matchedNewIds.add(id);
303
+ } else result.unmatchedOld.push(oldReq);
304
+ }
305
+ for (const req of newReqs) {
306
+ const id = req["id"];
307
+ if (typeof id === "string") {
308
+ if (duplicateNewIds.has(id) || !matchedNewIds.has(id)) result.unmatchedNew.push(req);
309
+ } else result.unmatchedNew.push(req);
310
+ }
311
+ return result;
312
+ }
313
+ };
314
+ }
315
+ //#endregion
316
+ //#region src/matching/mapped-id.ts
317
+ /**
318
+ * Create a mapped ID matching strategy.
319
+ *
320
+ * Translates old requirement IDs using a mapping table before matching.
321
+ * Only matches requirements whose old ID appears in the mapping table and
322
+ * whose mapped new ID exists in the new requirements.
323
+ *
324
+ * Confidence is 0.95 for mapped matches (slightly less than exact ID
325
+ * because the match depends on the accuracy of the mapping table).
326
+ */
327
+ function createMappedIdStrategy(mapping) {
328
+ return {
329
+ name: "mappedId",
330
+ match(oldReqs, newReqs) {
331
+ const result = {
332
+ matched: [],
333
+ unmatchedOld: [],
334
+ unmatchedNew: []
335
+ };
336
+ const newById = /* @__PURE__ */ new Map();
337
+ const duplicateNewIds = /* @__PURE__ */ new Set();
338
+ for (const req of newReqs) {
339
+ const id = req["id"];
340
+ if (typeof id === "string") {
341
+ if (newById.has(id)) {
342
+ duplicateNewIds.add(id);
343
+ newById.delete(id);
344
+ } else if (!duplicateNewIds.has(id)) newById.set(id, req);
345
+ }
346
+ }
347
+ const oldById = /* @__PURE__ */ new Map();
348
+ const duplicateOldIds = /* @__PURE__ */ new Set();
349
+ for (const req of oldReqs) {
350
+ const id = req["id"];
351
+ if (typeof id === "string") {
352
+ if (oldById.has(id)) {
353
+ duplicateOldIds.add(id);
354
+ oldById.delete(id);
355
+ } else if (!duplicateOldIds.has(id)) oldById.set(id, req);
356
+ }
357
+ }
358
+ const matchedNewIds = /* @__PURE__ */ new Set();
359
+ for (const oldReq of oldReqs) {
360
+ const oldId = oldReq["id"];
361
+ if (typeof oldId !== "string" || duplicateOldIds.has(oldId)) {
362
+ result.unmatchedOld.push(oldReq);
363
+ continue;
364
+ }
365
+ const mappedNewId = mapping[oldId];
366
+ if (mappedNewId === void 0) {
367
+ result.unmatchedOld.push(oldReq);
368
+ continue;
369
+ }
370
+ if (duplicateNewIds.has(mappedNewId)) {
371
+ result.unmatchedOld.push(oldReq);
372
+ continue;
373
+ }
374
+ const newReq = newById.get(mappedNewId);
375
+ if (newReq && !matchedNewIds.has(mappedNewId)) {
376
+ result.matched.push({
377
+ oldReq,
378
+ newReq,
379
+ strategy: "mappedId",
380
+ confidence: .95
381
+ });
382
+ matchedNewIds.add(mappedNewId);
383
+ } else result.unmatchedOld.push(oldReq);
384
+ }
385
+ for (const req of newReqs) {
386
+ const id = req["id"];
387
+ if (typeof id === "string") {
388
+ if (duplicateNewIds.has(id) || !matchedNewIds.has(id)) result.unmatchedNew.push(req);
389
+ } else result.unmatchedNew.push(req);
390
+ }
391
+ return result;
392
+ }
393
+ };
394
+ }
395
+ //#endregion
396
+ //#region src/matching/cci-match.ts
397
+ /**
398
+ * Extract CCI identifiers from a requirement's tags.
399
+ * Looks for `tags.cci` as an array of strings.
400
+ */
401
+ function extractCcis(req) {
402
+ const tags = req["tags"];
403
+ if (!tags) return [];
404
+ const cci = tags["cci"];
405
+ if (!Array.isArray(cci)) return [];
406
+ return cci.filter((c) => typeof c === "string");
407
+ }
408
+ /**
409
+ * Create a CCI-based matching strategy.
410
+ *
411
+ * Matches requirements that share the same CCI identifier in `tags.cci`.
412
+ * Only produces a match when exactly one old requirement and exactly one
413
+ * new requirement share a given CCI (unambiguous). Ambiguous CCIs (shared
414
+ * by multiple old or multiple new requirements) are skipped, and those
415
+ * requirements are left unmatched.
416
+ *
417
+ * Confidence is 0.8 for unambiguous matches.
418
+ */
419
+ function createCciMatchStrategy() {
420
+ return {
421
+ name: "cciMatch",
422
+ match(oldReqs, newReqs) {
423
+ const result = {
424
+ matched: [],
425
+ unmatchedOld: [],
426
+ unmatchedNew: []
427
+ };
428
+ const oldCciMap = /* @__PURE__ */ new Map();
429
+ for (let i = 0; i < oldReqs.length; i++) for (const cci of extractCcis(oldReqs[i])) {
430
+ const list = oldCciMap.get(cci);
431
+ if (list) list.push(i);
432
+ else oldCciMap.set(cci, [i]);
433
+ }
434
+ const newCciMap = /* @__PURE__ */ new Map();
435
+ for (let i = 0; i < newReqs.length; i++) for (const cci of extractCcis(newReqs[i])) {
436
+ const list = newCciMap.get(cci);
437
+ if (list) list.push(i);
438
+ else newCciMap.set(cci, [i]);
439
+ }
440
+ const matchedOldIndices = /* @__PURE__ */ new Set();
441
+ const matchedNewIndices = /* @__PURE__ */ new Set();
442
+ const allCcis = new Set([...oldCciMap.keys(), ...newCciMap.keys()]);
443
+ for (const cci of allCcis) {
444
+ const oldIndices = oldCciMap.get(cci) ?? [];
445
+ const newIndices = newCciMap.get(cci) ?? [];
446
+ if (oldIndices.length !== 1 || newIndices.length !== 1) continue;
447
+ const oldIdx = oldIndices[0];
448
+ const newIdx = newIndices[0];
449
+ if (matchedOldIndices.has(oldIdx) || matchedNewIndices.has(newIdx)) continue;
450
+ result.matched.push({
451
+ oldReq: oldReqs[oldIdx],
452
+ newReq: newReqs[newIdx],
453
+ strategy: "cciMatch",
454
+ confidence: .8
455
+ });
456
+ matchedOldIndices.add(oldIdx);
457
+ matchedNewIndices.add(newIdx);
458
+ }
459
+ for (let i = 0; i < oldReqs.length; i++) if (!matchedOldIndices.has(i)) result.unmatchedOld.push(oldReqs[i]);
460
+ for (let i = 0; i < newReqs.length; i++) if (!matchedNewIndices.has(i)) result.unmatchedNew.push(newReqs[i]);
461
+ return result;
462
+ }
463
+ };
464
+ }
465
+ //#endregion
466
+ //#region src/matching/fuzzy-match.ts
467
+ /** Common English stop words to exclude from tokenization */
468
+ const STOP_WORDS = new Set([
469
+ "a",
470
+ "an",
471
+ "the",
472
+ "is",
473
+ "are",
474
+ "was",
475
+ "were",
476
+ "be",
477
+ "been",
478
+ "being",
479
+ "have",
480
+ "has",
481
+ "had",
482
+ "do",
483
+ "does",
484
+ "did",
485
+ "will",
486
+ "would",
487
+ "could",
488
+ "should",
489
+ "may",
490
+ "might",
491
+ "shall",
492
+ "can",
493
+ "to",
494
+ "of",
495
+ "in",
496
+ "for",
497
+ "on",
498
+ "with",
499
+ "at",
500
+ "by",
501
+ "from",
502
+ "as",
503
+ "into",
504
+ "through",
505
+ "during",
506
+ "before",
507
+ "after",
508
+ "above",
509
+ "below",
510
+ "between",
511
+ "out",
512
+ "off",
513
+ "over",
514
+ "under",
515
+ "again",
516
+ "further",
517
+ "then",
518
+ "once",
519
+ "and",
520
+ "but",
521
+ "or",
522
+ "nor",
523
+ "not",
524
+ "no",
525
+ "so",
526
+ "if",
527
+ "it",
528
+ "its",
529
+ "that",
530
+ "this",
531
+ "these",
532
+ "those",
533
+ "each",
534
+ "every",
535
+ "all",
536
+ "both",
537
+ "few",
538
+ "more",
539
+ "most",
540
+ "other",
541
+ "some",
542
+ "such",
543
+ "than",
544
+ "too",
545
+ "very",
546
+ "just",
547
+ "about"
548
+ ]);
549
+ /**
550
+ * Tokenize a title string into a set of meaningful tokens.
551
+ *
552
+ * - Lowercases the string
553
+ * - Splits on whitespace and punctuation
554
+ * - Filters out common stop words
555
+ * - Returns unique tokens as a Set
556
+ */
557
+ function tokenize(text) {
558
+ if (!text) return /* @__PURE__ */ new Set();
559
+ const tokens = text.toLowerCase().split(/[\s\-_/\\,.;:!?()[\]{}"']+/).filter((t) => t.length > 0 && !STOP_WORDS.has(t));
560
+ return new Set(tokens);
561
+ }
562
+ /**
563
+ * Compute Jaccard similarity between two sets.
564
+ * Returns |intersection| / |union|, or 0 if both sets are empty.
565
+ */
566
+ function jaccardSimilarity(a, b) {
567
+ if (a.size === 0 && b.size === 0) return 0;
568
+ let intersectionSize = 0;
569
+ const [smaller, larger] = a.size <= b.size ? [a, b] : [b, a];
570
+ for (const item of smaller) if (larger.has(item)) intersectionSize++;
571
+ const unionSize = a.size + b.size - intersectionSize;
572
+ /* c8 ignore next */
573
+ if (unionSize === 0) return 0;
574
+ return intersectionSize / unionSize;
575
+ }
576
+ /**
577
+ * Default minimum confidence threshold for fuzzy title matching.
578
+ */
579
+ const DEFAULT_MIN_CONFIDENCE = .6;
580
+ /**
581
+ * Create a fuzzy title matching strategy.
582
+ *
583
+ * Matches requirements by token-based Jaccard similarity on the `title` field.
584
+ * Uses greedy best-match: for each unmatched old requirement, finds the
585
+ * best-matching unmatched new requirement above the confidence threshold.
586
+ *
587
+ * @param minConfidence Minimum Jaccard similarity to accept a match (default: 0.6)
588
+ */
589
+ function createFuzzyTitleStrategy(minConfidence) {
590
+ const threshold = minConfidence ?? DEFAULT_MIN_CONFIDENCE;
591
+ return {
592
+ name: "fuzzyTitle",
593
+ match(oldReqs, newReqs) {
594
+ const result = {
595
+ matched: [],
596
+ unmatchedOld: [],
597
+ unmatchedNew: []
598
+ };
599
+ const oldTokens = [];
600
+ for (let i = 0; i < oldReqs.length; i++) {
601
+ const title = oldReqs[i]["title"];
602
+ if (typeof title === "string" && title.length > 0) {
603
+ const tokens = tokenize(title);
604
+ if (tokens.size > 0) oldTokens.push({
605
+ idx: i,
606
+ tokens
607
+ });
608
+ }
609
+ }
610
+ const newTokens = [];
611
+ for (let i = 0; i < newReqs.length; i++) {
612
+ const title = newReqs[i]["title"];
613
+ if (typeof title === "string" && title.length > 0) {
614
+ const tokens = tokenize(title);
615
+ if (tokens.size > 0) newTokens.push({
616
+ idx: i,
617
+ tokens
618
+ });
619
+ }
620
+ }
621
+ const matchedOldIndices = /* @__PURE__ */ new Set();
622
+ const matchedNewIndices = /* @__PURE__ */ new Set();
623
+ const candidates = [];
624
+ for (const old of oldTokens) for (const nw of newTokens) {
625
+ const sim = jaccardSimilarity(old.tokens, nw.tokens);
626
+ if (sim >= threshold) candidates.push({
627
+ oldIdx: old.idx,
628
+ newIdx: nw.idx,
629
+ similarity: sim
630
+ });
631
+ }
632
+ candidates.sort((a, b) => b.similarity - a.similarity);
633
+ for (const candidate of candidates) {
634
+ if (matchedOldIndices.has(candidate.oldIdx) || matchedNewIndices.has(candidate.newIdx)) continue;
635
+ result.matched.push({
636
+ oldReq: oldReqs[candidate.oldIdx],
637
+ newReq: newReqs[candidate.newIdx],
638
+ strategy: "fuzzyTitle",
639
+ confidence: candidate.similarity
640
+ });
641
+ matchedOldIndices.add(candidate.oldIdx);
642
+ matchedNewIndices.add(candidate.newIdx);
643
+ }
644
+ for (let i = 0; i < oldReqs.length; i++) if (!matchedOldIndices.has(i)) result.unmatchedOld.push(oldReqs[i]);
645
+ for (let i = 0; i < newReqs.length; i++) if (!matchedNewIndices.has(i)) result.unmatchedNew.push(newReqs[i]);
646
+ return result;
647
+ }
648
+ };
649
+ }
650
+ //#endregion
651
+ //#region src/matching/index.ts
652
+ /**
653
+ * Create a strategy instance by name, using the provided options for
654
+ * strategies that require configuration.
655
+ */
656
+ function createStrategy(name, options) {
657
+ switch (name) {
658
+ case "exactId": return createExactIdStrategy();
659
+ case "mappedId": return createMappedIdStrategy(options.mappingTable ?? {});
660
+ case "cciMatch": return createCciMatchStrategy();
661
+ case "fuzzyTitle": return createFuzzyTitleStrategy(options.minConfidence);
662
+ default: throw new Error(`Unknown matching strategy: '${name}'`);
663
+ }
664
+ }
665
+ /**
666
+ * Match requirements between two evaluations using a primary strategy
667
+ * and optional fallback strategies.
668
+ *
669
+ * The registry applies strategies in order:
670
+ * 1. Primary strategy matches what it can
671
+ * 2. Unmatched requirements pass to the next fallback strategy
672
+ * 3. Process continues until all strategies are exhausted or all
673
+ * requirements are matched
674
+ *
675
+ * @param oldReqs Requirements from the old evaluation
676
+ * @param newReqs Requirements from the new evaluation
677
+ * @param options Matching configuration
678
+ * @returns Combined match result from all strategies
679
+ */
680
+ function matchRequirements(oldReqs, newReqs, options) {
681
+ const opts = options ?? {};
682
+ const strategies = [opts.strategy ?? "exactId", ...opts.fallbackStrategies ?? []].map((name) => createStrategy(name, opts));
683
+ const allMatched = [];
684
+ let currentUnmatchedOld = oldReqs;
685
+ let currentUnmatchedNew = newReqs;
686
+ for (const strategy of strategies) {
687
+ if (currentUnmatchedOld.length === 0 || currentUnmatchedNew.length === 0) break;
688
+ const result = strategy.match(currentUnmatchedOld, currentUnmatchedNew);
689
+ allMatched.push(...result.matched);
690
+ currentUnmatchedOld = result.unmatchedOld;
691
+ currentUnmatchedNew = result.unmatchedNew;
692
+ }
693
+ return {
694
+ matched: allMatched,
695
+ unmatchedOld: currentUnmatchedOld,
696
+ unmatchedNew: currentUnmatchedNew
697
+ };
698
+ }
699
+ //#endregion
700
+ //#region src/validate.ts
701
+ /**
702
+ * Validate a document against the hdf-comparison schema.
703
+ *
704
+ * Delegates to @mitre/hdf-validators which loads schemas from embedded
705
+ * bundled JSON (no filesystem access, no hardcoded version URLs).
706
+ *
707
+ * @param doc - The document to validate (typically the output of `diffHdf()`)
708
+ * @returns Validation result with `valid` boolean and optional `errors` array
709
+ */
710
+ function validateComparison(doc) {
711
+ const result = validateComparison$1(doc);
712
+ if (result.valid) return { valid: true };
713
+ return {
714
+ valid: false,
715
+ errors: result.errors.map((e) => e.field === "(root)" ? e.message : `${e.field}: ${e.message}`)
716
+ };
717
+ }
718
+ //#endregion
719
+ //#region src/sbom.ts
720
+ /**
721
+ * Compare two SBOM documents (CycloneDX or SPDX JSON strings) and return
722
+ * package-level diffs.
723
+ *
724
+ * @param oldJson - JSON string of the old SBOM document
725
+ * @param newJson - JSON string of the new SBOM document
726
+ * @returns Structured diff result with per-package state and aggregate counts
727
+ * @throws Error if either input is not valid JSON or not a recognized SBOM format
728
+ */
729
+ function diffSboms(oldJson, newJson) {
730
+ const oldParsed = JSON.parse(oldJson);
731
+ const newParsed = JSON.parse(newJson);
732
+ const oldPackages = extractPackages(oldParsed);
733
+ const newPackages = extractPackages(newParsed);
734
+ const oldMap = buildPackageMap(oldPackages);
735
+ const newMap = buildPackageMap(newPackages);
736
+ const diffs = [];
737
+ const seen = /* @__PURE__ */ new Set();
738
+ for (const [key, oldPkg] of oldMap) {
739
+ seen.add(key);
740
+ const newPkg = newMap.get(key);
741
+ if (newPkg) if (oldPkg.version !== newPkg.version) diffs.push({
742
+ purl: newPkg.purl || newPkg.name,
743
+ name: newPkg.name,
744
+ state: "updated",
745
+ oldVersion: oldPkg.version,
746
+ newVersion: newPkg.version,
747
+ licenses: newPkg.licenses.length > 0 ? newPkg.licenses : void 0
748
+ });
749
+ else diffs.push({
750
+ purl: oldPkg.purl || oldPkg.name,
751
+ name: oldPkg.name,
752
+ state: "unchanged"
753
+ });
754
+ else diffs.push({
755
+ purl: oldPkg.purl || oldPkg.name,
756
+ name: oldPkg.name,
757
+ state: "removed",
758
+ oldVersion: oldPkg.version
759
+ });
760
+ }
761
+ for (const [key, newPkg] of newMap) if (!seen.has(key)) diffs.push({
762
+ purl: newPkg.purl || newPkg.name,
763
+ name: newPkg.name,
764
+ state: "added",
765
+ newVersion: newPkg.version,
766
+ licenses: newPkg.licenses.length > 0 ? newPkg.licenses : void 0
767
+ });
768
+ diffs.sort((a, b) => a.name.localeCompare(b.name));
769
+ let added = 0;
770
+ let removed = 0;
771
+ let updated = 0;
772
+ let unchanged = 0;
773
+ for (const d of diffs) switch (d.state) {
774
+ case "added":
775
+ added++;
776
+ break;
777
+ case "removed":
778
+ removed++;
779
+ break;
780
+ case "updated":
781
+ updated++;
782
+ break;
783
+ case "unchanged":
784
+ unchanged++;
785
+ break;
786
+ }
787
+ return {
788
+ packageDiffs: diffs,
789
+ added,
790
+ removed,
791
+ updated,
792
+ unchanged
793
+ };
794
+ }
795
+ /**
796
+ * Extract packages from an SBOM document, auto-detecting the format.
797
+ */
798
+ function extractPackages(doc) {
799
+ if (doc["bomFormat"] === "CycloneDX") return extractCycloneDXPackages(doc);
800
+ if (typeof doc["spdxVersion"] === "string") return extractSPDXPackages(doc);
801
+ throw new Error("Unrecognized SBOM format: expected CycloneDX (bomFormat) or SPDX (spdxVersion)");
802
+ }
803
+ /**
804
+ * Extract packages from a CycloneDX document.
805
+ * Reads components[] with purl, name, version fields.
806
+ */
807
+ function extractCycloneDXPackages(doc) {
808
+ const components = doc["components"];
809
+ if (!Array.isArray(components)) return [];
810
+ return components.map((comp) => {
811
+ const licenses = extractCycloneDXLicenses(comp);
812
+ return {
813
+ purl: comp["purl"] ?? "",
814
+ name: comp["name"] ?? "",
815
+ version: comp["version"] ?? "",
816
+ licenses
817
+ };
818
+ });
819
+ }
820
+ /**
821
+ * Extract license strings from a CycloneDX component.
822
+ * CycloneDX licenses can be in `licenses[].license.id` or `licenses[].license.name`.
823
+ */
824
+ function extractCycloneDXLicenses(comp) {
825
+ const licensesArr = comp["licenses"];
826
+ if (!Array.isArray(licensesArr)) return [];
827
+ const result = [];
828
+ for (const entry of licensesArr) {
829
+ const license = entry["license"];
830
+ if (license) {
831
+ const id = license["id"];
832
+ const name = license["name"];
833
+ if (id) result.push(id);
834
+ else if (name) result.push(name);
835
+ }
836
+ }
837
+ return result;
838
+ }
839
+ /**
840
+ * Extract packages from an SPDX document.
841
+ * Reads packages[] with name, versionInfo, and externalRefs (for PURL).
842
+ */
843
+ function extractSPDXPackages(doc) {
844
+ const packages = doc["packages"];
845
+ if (!Array.isArray(packages)) return [];
846
+ return packages.map((pkg) => {
847
+ const purl = extractSPDXPurl(pkg);
848
+ const license = pkg["licenseConcluded"] ?? pkg["licenseDeclared"] ?? "";
849
+ return {
850
+ purl,
851
+ name: pkg["name"] ?? "",
852
+ version: pkg["versionInfo"] ?? "",
853
+ licenses: license && license !== "NOASSERTION" ? [license] : []
854
+ };
855
+ });
856
+ }
857
+ /**
858
+ * Extract a PURL from an SPDX package's externalRefs array.
859
+ */
860
+ function extractSPDXPurl(pkg) {
861
+ const refs = pkg["externalRefs"];
862
+ if (!Array.isArray(refs)) return "";
863
+ for (const ref of refs) if (ref["referenceType"] === "purl") return ref["referenceLocator"] ?? "";
864
+ return "";
865
+ }
866
+ /**
867
+ * Build a lookup map of packages indexed by name (version-independent key).
868
+ */
869
+ function buildPackageMap(packages) {
870
+ const map = /* @__PURE__ */ new Map();
871
+ for (const pkg of packages) {
872
+ const key = pkg.name || pkg.purl;
873
+ if (key) map.set(key, pkg);
874
+ }
875
+ return map;
876
+ }
877
+ //#endregion
878
+ //#region src/diff.ts
879
+ const DEFAULT_TRACKED_FIELDS = [
880
+ "impact",
881
+ "severity",
882
+ "tags"
883
+ ];
884
+ /**
885
+ * Extract a dataSource label from a document, if available.
886
+ * Looks for `dataSource.name` in the raw (pre-normalized) document.
887
+ */
888
+ function extractDataSourceLabel(doc) {
889
+ const ds = doc["dataSource"];
890
+ if (ds && typeof ds["name"] === "string") return ds["name"];
891
+ }
892
+ /**
893
+ * Build MatchOptions from DiffOptions, extracting the matching-related fields.
894
+ */
895
+ function buildMatchOptions(options) {
896
+ return {
897
+ strategy: options?.matchStrategy,
898
+ fallbackStrategies: options?.fallbackStrategies,
899
+ mappingTable: options?.mappingTable,
900
+ minConfidence: options?.minConfidence
901
+ };
902
+ }
903
+ /**
904
+ * Determine the primary strategy name for the matching config output.
905
+ */
906
+ function resolveStrategyName(options) {
907
+ return options?.matchStrategy ?? "exactId";
908
+ }
909
+ /**
910
+ * Compare two HDF results documents and produce a structured comparison.
911
+ *
912
+ * Requirements are matched using a configurable matching strategy (default: exact ID).
913
+ * Baselines are matched by `name`.
914
+ *
915
+ * For fleet mode, `newResults` can be an array of documents, each compared
916
+ * pairwise against `oldResults` (the reference).
917
+ */
918
+ function diffHdf(oldResults, newResults, options) {
919
+ const trackedFields = options?.trackedFields ?? DEFAULT_TRACKED_FIELDS;
920
+ let comparisonMode = options?.comparisonMode ?? "temporal";
921
+ const matchOpts = buildMatchOptions(options);
922
+ if (!options?.comparisonMode && !Array.isArray(newResults)) {
923
+ if (isBaselineDocument(oldResults) && isBaselineDocument(newResults)) comparisonMode = "baselineEvolution";
924
+ else if (isSystemDocument(oldResults) && isSystemDocument(newResults)) comparisonMode = "systemDrift";
925
+ }
926
+ if (Array.isArray(newResults)) {
927
+ if (newResults.length === 0) throw new Error("newResults array must not be empty");
928
+ if (comparisonMode !== "fleet" && newResults.length > 1) throw new Error(`Mode '${comparisonMode}' expects a single document, got ${newResults.length}. Use 'fleet' mode for multiple documents.`);
929
+ }
930
+ if (comparisonMode === "baselineEvolution") return diffBaselines(oldResults, Array.isArray(newResults) ? newResults[0] : newResults, options);
931
+ if (comparisonMode === "systemDrift") return diffSystems(oldResults, Array.isArray(newResults) ? newResults[0] : newResults, options);
932
+ if (comparisonMode === "fleet") return diffFleet(oldResults, newResults, trackedFields, matchOpts);
933
+ const newDoc = Array.isArray(newResults) ? newResults[0] : newResults;
934
+ const oldNormalized = normalizeToV2(oldResults);
935
+ const newNormalized = normalizeToV2(newDoc);
936
+ const oldTimestamp = oldNormalized["timestamp"];
937
+ const newTimestamp = newNormalized["timestamp"];
938
+ const sources = buildSources(comparisonMode, oldResults, newDoc, oldTimestamp, newTimestamp);
939
+ const { baselineDiffs, requirementDiffs } = comparePair(oldNormalized, newNormalized, oldTimestamp, newTimestamp, trackedFields, matchOpts);
940
+ requirementDiffs.sort((a, b) => a.id.localeCompare(b.id));
941
+ const drift = extractDrift(requirementDiffs);
942
+ const comparison = {
943
+ formatVersion: "1.0.0",
944
+ comparisonMode,
945
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
946
+ sources,
947
+ matching: { primaryStrategy: resolveStrategyName(options) },
948
+ summary: computeSummary(requirementDiffs),
949
+ baselineDiffs,
950
+ requirementDiffs,
951
+ drift
952
+ };
953
+ if (options?.validateOutput) {
954
+ const result = validateComparison(comparison);
955
+ /* c8 ignore start */
956
+ if (!result.valid) throw new Error(`Output validation failed: ${result.errors?.join(", ")}`);
957
+ }
958
+ return comparison;
959
+ }
960
+ /**
961
+ * Build source metadata entries based on comparison mode.
962
+ */
963
+ function buildSources(mode, oldDoc, newDoc, oldTimestamp, newTimestamp) {
964
+ switch (mode) {
965
+ case "baseline": return [{
966
+ role: "golden",
967
+ label: "Golden baseline",
968
+ assessmentTimestamp: oldTimestamp
969
+ }, {
970
+ role: "new",
971
+ label: "Current scan",
972
+ assessmentTimestamp: newTimestamp
973
+ }];
974
+ case "multiSource": {
975
+ const oldLabel = extractDataSourceLabel(oldDoc) ?? "Old evaluation";
976
+ const newLabel = extractDataSourceLabel(newDoc) ?? "New evaluation";
977
+ return [{
978
+ role: "old",
979
+ label: oldLabel,
980
+ assessmentTimestamp: oldTimestamp
981
+ }, {
982
+ role: "new",
983
+ label: newLabel,
984
+ assessmentTimestamp: newTimestamp
985
+ }];
986
+ }
987
+ default: return [{
988
+ role: "old",
989
+ label: "Old evaluation",
990
+ assessmentTimestamp: oldTimestamp
991
+ }, {
992
+ role: "new",
993
+ label: "New evaluation",
994
+ assessmentTimestamp: newTimestamp
995
+ }];
996
+ }
997
+ }
998
+ /**
999
+ * Fleet mode: compare a reference document against one or more system documents.
1000
+ * Each system is compared pairwise against the reference; all diffs are collected
1001
+ * into a single result with `sourceIndex` set on each RequirementDiff.
1002
+ */
1003
+ function diffFleet(referenceDoc, newResults, trackedFields, matchOpts) {
1004
+ const systems = Array.isArray(newResults) ? newResults : [newResults];
1005
+ const refNormalized = normalizeToV2(referenceDoc);
1006
+ const refTimestamp = refNormalized["timestamp"];
1007
+ const sources = [{
1008
+ role: "reference",
1009
+ label: "Reference",
1010
+ assessmentTimestamp: refTimestamp
1011
+ }];
1012
+ const allRequirementDiffs = [];
1013
+ const allBaselineDiffs = [];
1014
+ const seenBaselineNames = /* @__PURE__ */ new Set();
1015
+ for (let i = 0; i < systems.length; i++) {
1016
+ const sysNormalized = normalizeToV2(systems[i]);
1017
+ const sysTimestamp = sysNormalized["timestamp"];
1018
+ const sourceIndex = i + 1;
1019
+ sources.push({
1020
+ role: "system",
1021
+ label: `System ${sourceIndex}`,
1022
+ assessmentTimestamp: sysTimestamp
1023
+ });
1024
+ const { baselineDiffs, requirementDiffs } = comparePair(refNormalized, sysNormalized, refTimestamp, sysTimestamp, trackedFields, matchOpts);
1025
+ for (const rd of requirementDiffs) {
1026
+ rd.sourceIndex = sourceIndex;
1027
+ allRequirementDiffs.push(rd);
1028
+ }
1029
+ for (const bd of baselineDiffs) if (!seenBaselineNames.has(bd.name)) {
1030
+ seenBaselineNames.add(bd.name);
1031
+ allBaselineDiffs.push(bd);
1032
+ }
1033
+ }
1034
+ allRequirementDiffs.sort((a, b) => {
1035
+ const idCmp = a.id.localeCompare(b.id);
1036
+ if (idCmp !== 0) return idCmp;
1037
+ /* c8 ignore next -- sourceIndex is always set in fleet mode; ?? 0 is defensive typing */
1038
+ return (a.sourceIndex ?? 0) - (b.sourceIndex ?? 0);
1039
+ });
1040
+ const drift = extractDrift(allRequirementDiffs);
1041
+ return {
1042
+ formatVersion: "1.0.0",
1043
+ comparisonMode: "fleet",
1044
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1045
+ sources,
1046
+ matching: { primaryStrategy: matchOpts.strategy ?? "exactId" },
1047
+ summary: computeSummary(allRequirementDiffs),
1048
+ baselineDiffs: allBaselineDiffs,
1049
+ requirementDiffs: allRequirementDiffs,
1050
+ drift
1051
+ };
1052
+ }
1053
+ /**
1054
+ * Extract drift entries from requirement diffs.
1055
+ *
1056
+ * Drift = requirements whose effective status is unchanged but whose metadata
1057
+ * changed (tags, descriptions, impact score, etc.). These are the "silent"
1058
+ * changes that don't affect pass/fail outcome but still matter for auditing.
1059
+ *
1060
+ * Returns shallow copies so drift entries are independent of requirementDiffs.
1061
+ */
1062
+ function extractDrift(requirementDiffs) {
1063
+ return requirementDiffs.filter((r) => r.state === "unchanged" && r.changeReasons.length > 0).map((r) => ({ ...r }));
1064
+ }
1065
+ /**
1066
+ * Core pairwise comparison between two normalized HDF documents.
1067
+ * Returns baseline diffs and requirement diffs without sorting or wrapping.
1068
+ *
1069
+ * Uses the pluggable matching system to pair requirements between evaluations.
1070
+ */
1071
+ function comparePair(oldNormalized, newNormalized, oldTimestamp, newTimestamp, trackedFields, matchOpts) {
1072
+ const oldBaselines = oldNormalized["baselines"] ?? [];
1073
+ const newBaselines = newNormalized["baselines"] ?? [];
1074
+ const oldBaselineMap = /* @__PURE__ */ new Map();
1075
+ for (const b of oldBaselines) oldBaselineMap.set(b.name, b);
1076
+ const newBaselineMap = /* @__PURE__ */ new Map();
1077
+ for (const b of newBaselines) newBaselineMap.set(b.name, b);
1078
+ const allBaselineNames = new Set([...oldBaselineMap.keys(), ...newBaselineMap.keys()]);
1079
+ const baselineDiffs = [];
1080
+ for (const name of allBaselineNames) {
1081
+ const oldB = oldBaselineMap.get(name);
1082
+ const newB = newBaselineMap.get(name);
1083
+ if (oldB && newB) {
1084
+ const versionChanged = oldB.version !== newB.version;
1085
+ baselineDiffs.push({
1086
+ name,
1087
+ oldVersion: oldB.version,
1088
+ newVersion: newB.version,
1089
+ state: versionChanged ? "updated" : "unchanged"
1090
+ });
1091
+ } else if (oldB && !newB) baselineDiffs.push({
1092
+ name,
1093
+ oldVersion: oldB.version,
1094
+ state: "absent"
1095
+ });
1096
+ else if (!oldB && newB) baselineDiffs.push({
1097
+ name,
1098
+ newVersion: newB.version,
1099
+ state: "new"
1100
+ });
1101
+ }
1102
+ const oldReqs = [];
1103
+ for (const baseline of oldBaselines) for (const req of baseline.requirements) oldReqs.push(req);
1104
+ const newReqs = [];
1105
+ for (const baseline of newBaselines) for (const req of baseline.requirements) newReqs.push(req);
1106
+ const matchResult = matchRequirements(oldReqs, newReqs, matchOpts);
1107
+ const requirementDiffs = [];
1108
+ for (const pair of matchResult.matched) {
1109
+ const oldReq = pair.oldReq;
1110
+ const newReq = pair.newReq;
1111
+ /* c8 ignore next -- id is always a string on RequirementLike; ?? is defensive typing */
1112
+ const id = newReq.id ?? oldReq.id;
1113
+ const oldStatus = computeEffectiveStatus(oldReq, oldTimestamp);
1114
+ const newStatus = computeEffectiveStatus(newReq, newTimestamp);
1115
+ const diffState = classifyDiffStatus(oldStatus, newStatus);
1116
+ const changeReasons = classifyChangeReasons(oldReq, newReq, oldTimestamp, newTimestamp);
1117
+ const fieldChanges = computeFieldChanges(oldReq, newReq, trackedFields);
1118
+ requirementDiffs.push({
1119
+ id,
1120
+ title: newReq.title ?? oldReq.title,
1121
+ state: diffState,
1122
+ oldEffectiveStatus: oldStatus,
1123
+ newEffectiveStatus: newStatus,
1124
+ changeReasons,
1125
+ oldImpact: oldReq.impact,
1126
+ newImpact: newReq.impact,
1127
+ fieldChanges,
1128
+ before: oldReq,
1129
+ after: newReq,
1130
+ matchStrategy: pair.strategy,
1131
+ matchConfidence: pair.confidence
1132
+ });
1133
+ }
1134
+ for (const req of matchResult.unmatchedOld) {
1135
+ const oldReq = req;
1136
+ const id = oldReq.id;
1137
+ const oldStatus = computeEffectiveStatus(oldReq, oldTimestamp);
1138
+ requirementDiffs.push({
1139
+ id,
1140
+ title: oldReq.title,
1141
+ state: "absent",
1142
+ oldEffectiveStatus: oldStatus,
1143
+ changeReasons: [],
1144
+ oldImpact: oldReq.impact,
1145
+ fieldChanges: [],
1146
+ before: oldReq,
1147
+ after: null
1148
+ });
1149
+ }
1150
+ for (const req of matchResult.unmatchedNew) {
1151
+ const newReq = req;
1152
+ const id = newReq.id;
1153
+ const newStatus = computeEffectiveStatus(newReq, newTimestamp);
1154
+ requirementDiffs.push({
1155
+ id,
1156
+ title: newReq.title,
1157
+ state: "new",
1158
+ newEffectiveStatus: newStatus,
1159
+ changeReasons: [],
1160
+ newImpact: newReq.impact,
1161
+ fieldChanges: [],
1162
+ before: null,
1163
+ after: newReq
1164
+ });
1165
+ }
1166
+ return {
1167
+ baselineDiffs,
1168
+ requirementDiffs
1169
+ };
1170
+ }
1171
+ /**
1172
+ * Compute field-level changes for tracked fields between two requirements.
1173
+ * Uses JSON Patch-like op/path format.
1174
+ */
1175
+ function computeFieldChanges(oldReq, newReq, trackedFields) {
1176
+ const changes = [];
1177
+ for (const field of trackedFields) {
1178
+ const oldVal = oldReq[field];
1179
+ const newVal = newReq[field];
1180
+ if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) if (oldVal === void 0 && newVal !== void 0) changes.push({
1181
+ op: "add",
1182
+ path: field,
1183
+ newValue: newVal
1184
+ });
1185
+ else if (oldVal !== void 0 && newVal === void 0) changes.push({
1186
+ op: "remove",
1187
+ path: field,
1188
+ oldValue: oldVal
1189
+ });
1190
+ else changes.push({
1191
+ op: "replace",
1192
+ path: field,
1193
+ oldValue: oldVal,
1194
+ newValue: newVal
1195
+ });
1196
+ }
1197
+ return changes;
1198
+ }
1199
+ /**
1200
+ * Detect whether a document is a baseline (not results).
1201
+ * Baselines have `requirements` at the top level and lack `baselines`, `targets`, and `statistics`.
1202
+ */
1203
+ function isBaselineDocument(doc) {
1204
+ return Array.isArray(doc["requirements"]) && !Array.isArray(doc["baselines"]) && !Array.isArray(doc["targets"]) && doc["statistics"] === void 0;
1205
+ }
1206
+ /** Default tracked fields for baseline evolution comparisons. */
1207
+ const BASELINE_TRACKED_FIELDS = [
1208
+ "title",
1209
+ "impact",
1210
+ "descriptions",
1211
+ "tags"
1212
+ ];
1213
+ /**
1214
+ * Compare two HDF baseline documents and produce a structured comparison
1215
+ * showing requirement changes between baseline versions.
1216
+ *
1217
+ * Unlike diffHdf (which compares results/evaluations), this compares baseline
1218
+ * definitions — requirements without results. There is no status-based classification
1219
+ * (fixed/regressed); only metadata changes (title, impact, descriptions, tags) are tracked.
1220
+ */
1221
+ function diffBaselines(oldBaseline, newBaseline, options) {
1222
+ const trackedFields = options?.trackedFields ?? BASELINE_TRACKED_FIELDS;
1223
+ const matchOpts = buildMatchOptions(options);
1224
+ const matchResult = matchRequirements(oldBaseline["requirements"] ?? [], newBaseline["requirements"] ?? [], matchOpts);
1225
+ const requirementDiffs = [];
1226
+ for (const pair of matchResult.matched) {
1227
+ const oldReq = pair.oldReq;
1228
+ const newReq = pair.newReq;
1229
+ const id = newReq.id ?? oldReq.id;
1230
+ const fieldChanges = computeFieldChanges(oldReq, newReq, trackedFields);
1231
+ const state = fieldChanges.length > 0 ? "updated" : "unchanged";
1232
+ const changeReasons = [];
1233
+ if (oldReq.impact !== newReq.impact) changeReasons.push("impactChanged");
1234
+ const oldTags = JSON.stringify(oldReq["tags"] ?? {});
1235
+ const newTags = JSON.stringify(newReq["tags"] ?? {});
1236
+ const oldDescs = JSON.stringify(oldReq["descriptions"] ?? []);
1237
+ const newDescs = JSON.stringify(newReq["descriptions"] ?? []);
1238
+ const oldTitle = oldReq["title"];
1239
+ const newTitle = newReq["title"];
1240
+ if (oldTags !== newTags || oldDescs !== newDescs || oldTitle !== newTitle) changeReasons.push("metadataChanged");
1241
+ requirementDiffs.push({
1242
+ id,
1243
+ title: newReq.title ?? oldReq.title,
1244
+ state,
1245
+ changeReasons,
1246
+ oldImpact: oldReq.impact,
1247
+ newImpact: newReq.impact,
1248
+ fieldChanges,
1249
+ before: oldReq,
1250
+ after: newReq,
1251
+ matchStrategy: pair.strategy,
1252
+ matchConfidence: pair.confidence
1253
+ });
1254
+ }
1255
+ for (const req of matchResult.unmatchedOld) {
1256
+ const oldReq = req;
1257
+ requirementDiffs.push({
1258
+ id: oldReq.id,
1259
+ title: oldReq.title,
1260
+ state: "absent",
1261
+ changeReasons: [],
1262
+ oldImpact: oldReq.impact,
1263
+ fieldChanges: [],
1264
+ before: oldReq,
1265
+ after: null
1266
+ });
1267
+ }
1268
+ for (const req of matchResult.unmatchedNew) {
1269
+ const newReq = req;
1270
+ requirementDiffs.push({
1271
+ id: newReq.id,
1272
+ title: newReq.title,
1273
+ state: "new",
1274
+ changeReasons: [],
1275
+ newImpact: newReq.impact,
1276
+ fieldChanges: [],
1277
+ before: null,
1278
+ after: newReq
1279
+ });
1280
+ }
1281
+ requirementDiffs.sort((a, b) => a.id.localeCompare(b.id));
1282
+ const oldName = oldBaseline["name"] ?? "";
1283
+ const newName = newBaseline["name"] ?? "";
1284
+ const oldVersion = oldBaseline["version"];
1285
+ const newVersion = newBaseline["version"];
1286
+ const baselineDiffs = [];
1287
+ const baselineName = newName || oldName;
1288
+ if (baselineName) {
1289
+ const versionChanged = oldVersion !== newVersion;
1290
+ baselineDiffs.push({
1291
+ name: baselineName,
1292
+ oldVersion,
1293
+ newVersion,
1294
+ state: versionChanged ? "updated" : "unchanged"
1295
+ });
1296
+ }
1297
+ const sources = [{
1298
+ role: "old",
1299
+ label: oldVersion ? `${baselineName} ${oldVersion}` : baselineName || "Old baseline"
1300
+ }, {
1301
+ role: "new",
1302
+ label: newVersion ? `${baselineName} ${newVersion}` : baselineName || "New baseline"
1303
+ }];
1304
+ const comparison = {
1305
+ formatVersion: "1.0.0",
1306
+ comparisonMode: "baselineEvolution",
1307
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1308
+ sources,
1309
+ matching: { primaryStrategy: resolveStrategyName(options) },
1310
+ summary: computeSummary(requirementDiffs),
1311
+ baselineDiffs,
1312
+ requirementDiffs
1313
+ };
1314
+ if (options?.validateOutput) {
1315
+ const result = validateComparison(comparison);
1316
+ /* c8 ignore start */
1317
+ if (!result.valid) throw new Error(`Output validation failed: ${result.errors?.join(", ")}`);
1318
+ }
1319
+ return comparison;
1320
+ }
1321
+ /**
1322
+ * Detect whether a document is a system document (has components[] but no baselines/requirements).
1323
+ */
1324
+ function isSystemDocument(doc) {
1325
+ return Array.isArray(doc["components"]) && !Array.isArray(doc["baselines"]) && !Array.isArray(doc["requirements"]) && doc["statistics"] === void 0;
1326
+ }
1327
+ /** Fields tracked for system-level field changes. */
1328
+ const SYSTEM_TOP_LEVEL_FIELDS = [
1329
+ "authorizationStatus",
1330
+ "categorizationLevel",
1331
+ "description"
1332
+ ];
1333
+ /** Fields tracked for component-level field changes. */
1334
+ const COMPONENT_TRACKED_FIELDS = [
1335
+ "type",
1336
+ "description",
1337
+ "baselineRefs",
1338
+ "inputOverrides",
1339
+ "sbomRef",
1340
+ "targetSelector"
1341
+ ];
1342
+ /**
1343
+ * Compare two HDF system documents and produce a structured comparison
1344
+ * showing component-level changes between system versions.
1345
+ *
1346
+ * Components are matched by componentId (UUID) when available, falling back
1347
+ * to exact name matching. Top-level system fields, data flows, and embedded
1348
+ * SBOMs are also compared.
1349
+ */
1350
+ function diffSystems(oldSystem, newSystem, options) {
1351
+ const pairs = matchComponents(oldSystem["components"] ?? [], newSystem["components"] ?? []);
1352
+ const componentDiffs = [];
1353
+ for (const { oldComp, newComp, name } of pairs) if (oldComp && newComp) {
1354
+ const fieldChanges = computeComponentFieldChanges(oldComp, newComp, COMPONENT_TRACKED_FIELDS);
1355
+ const state = fieldChanges.length > 0 ? "updated" : "unchanged";
1356
+ componentDiffs.push({
1357
+ name,
1358
+ state,
1359
+ before: oldComp,
1360
+ after: newComp,
1361
+ fieldChanges
1362
+ });
1363
+ } else if (oldComp && !newComp) componentDiffs.push({
1364
+ name,
1365
+ state: "absent",
1366
+ before: oldComp,
1367
+ after: null,
1368
+ fieldChanges: []
1369
+ });
1370
+ else if (!oldComp && newComp) componentDiffs.push({
1371
+ name,
1372
+ state: "new",
1373
+ before: null,
1374
+ after: newComp,
1375
+ fieldChanges: []
1376
+ });
1377
+ componentDiffs.sort((a, b) => a.name.localeCompare(b.name));
1378
+ const systemFieldChanges = computeComponentFieldChanges(oldSystem, newSystem, SYSTEM_TOP_LEVEL_FIELDS);
1379
+ const counts = {
1380
+ new: 0,
1381
+ absent: 0,
1382
+ unchanged: 0,
1383
+ updated: 0
1384
+ };
1385
+ for (const cd of componentDiffs) counts[cd.state]++;
1386
+ const oldName = oldSystem["name"] ?? "";
1387
+ const systemName = (newSystem["name"] ?? "") || oldName;
1388
+ const sources = [{
1389
+ role: "old",
1390
+ label: systemName ? `${systemName} (old)` : "Old system"
1391
+ }, {
1392
+ role: "new",
1393
+ label: systemName ? `${systemName} (new)` : "New system"
1394
+ }];
1395
+ const comparison = {
1396
+ formatVersion: "1.0.0",
1397
+ comparisonMode: "systemDrift",
1398
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1399
+ sources,
1400
+ summary: {
1401
+ total: pairs.length,
1402
+ matchedCount: counts.unchanged + counts.updated,
1403
+ unmatchedOldCount: counts.absent,
1404
+ unmatchedNewCount: counts.new,
1405
+ new: counts.new,
1406
+ absent: counts.absent,
1407
+ unchanged: counts.unchanged,
1408
+ updated: counts.updated,
1409
+ fixed: 0,
1410
+ regressed: 0
1411
+ },
1412
+ baselineDiffs: [],
1413
+ requirementDiffs: [],
1414
+ componentDiffs
1415
+ };
1416
+ const extensions = {};
1417
+ if (systemFieldChanges.length > 0) extensions["systemFieldChanges"] = systemFieldChanges;
1418
+ const dataFlowChanges = diffDataFlows(oldSystem, newSystem);
1419
+ if (dataFlowChanges.length > 0) extensions["dataFlowChanges"] = dataFlowChanges;
1420
+ if (Object.keys(extensions).length > 0) comparison.extensions = extensions;
1421
+ const allPackageDiffs = diffEmbeddedSboms(pairs);
1422
+ if (allPackageDiffs.length > 0) comparison.packageDiffs = allPackageDiffs;
1423
+ if (options?.validateOutput) {
1424
+ const result = validateComparison(comparison);
1425
+ /* c8 ignore start */
1426
+ if (!result.valid) throw new Error(`Output validation failed: ${result.errors?.join(", ")}`);
1427
+ }
1428
+ return comparison;
1429
+ }
1430
+ /**
1431
+ * Match old and new components by componentId (when available) or name.
1432
+ */
1433
+ function matchComponents(oldComponents, newComponents) {
1434
+ const matched = /* @__PURE__ */ new Set();
1435
+ const pairs = [];
1436
+ const newById = /* @__PURE__ */ new Map();
1437
+ for (let i = 0; i < newComponents.length; i++) {
1438
+ const id = newComponents[i]["componentId"];
1439
+ if (id) newById.set(id, i);
1440
+ }
1441
+ const oldMatched = /* @__PURE__ */ new Set();
1442
+ for (let i = 0; i < oldComponents.length; i++) {
1443
+ const oldId = oldComponents[i]["componentId"];
1444
+ if (oldId && newById.has(oldId)) {
1445
+ const ni = newById.get(oldId);
1446
+ const newComp = newComponents[ni];
1447
+ pairs.push({
1448
+ name: newComp["name"] || oldComponents[i]["name"] || oldId,
1449
+ oldComp: oldComponents[i],
1450
+ newComp
1451
+ });
1452
+ matched.add(ni);
1453
+ oldMatched.add(i);
1454
+ }
1455
+ }
1456
+ const newByName = /* @__PURE__ */ new Map();
1457
+ for (let i = 0; i < newComponents.length; i++) {
1458
+ if (matched.has(i)) continue;
1459
+ const name = newComponents[i]["name"];
1460
+ if (name) newByName.set(name, i);
1461
+ }
1462
+ for (let i = 0; i < oldComponents.length; i++) {
1463
+ if (oldMatched.has(i)) continue;
1464
+ const name = oldComponents[i]["name"];
1465
+ if (name && newByName.has(name)) {
1466
+ const ni = newByName.get(name);
1467
+ pairs.push({
1468
+ name,
1469
+ oldComp: oldComponents[i],
1470
+ newComp: newComponents[ni]
1471
+ });
1472
+ matched.add(ni);
1473
+ oldMatched.add(i);
1474
+ newByName.delete(name);
1475
+ }
1476
+ }
1477
+ for (let i = 0; i < oldComponents.length; i++) {
1478
+ if (oldMatched.has(i)) continue;
1479
+ const name = oldComponents[i]["name"] || `component-${i}`;
1480
+ pairs.push({
1481
+ name,
1482
+ oldComp: oldComponents[i],
1483
+ newComp: void 0
1484
+ });
1485
+ }
1486
+ for (let i = 0; i < newComponents.length; i++) {
1487
+ if (matched.has(i)) continue;
1488
+ const name = newComponents[i]["name"] || `component-${i}`;
1489
+ pairs.push({
1490
+ name,
1491
+ oldComp: void 0,
1492
+ newComp: newComponents[i]
1493
+ });
1494
+ }
1495
+ return pairs;
1496
+ }
1497
+ /**
1498
+ * Diff data flows between two system documents. Flows are keyed by from+to.
1499
+ */
1500
+ function diffDataFlows(oldSystem, newSystem) {
1501
+ const oldFlows = oldSystem["dataFlows"] ?? [];
1502
+ const newFlows = newSystem["dataFlows"] ?? [];
1503
+ if (oldFlows.length === 0 && newFlows.length === 0) return [];
1504
+ const flowKey = (f) => {
1505
+ return `${f["from"] ?? ""}→${typeof f["to"] === "string" ? f["to"] : JSON.stringify(f["to"])}`;
1506
+ };
1507
+ const oldMap = /* @__PURE__ */ new Map();
1508
+ for (const f of oldFlows) oldMap.set(flowKey(f), f);
1509
+ const newMap = /* @__PURE__ */ new Map();
1510
+ for (const f of newFlows) newMap.set(flowKey(f), f);
1511
+ const allKeys = new Set([...oldMap.keys(), ...newMap.keys()]);
1512
+ const changes = [];
1513
+ for (const key of allKeys) {
1514
+ const oldF = oldMap.get(key);
1515
+ const newF = newMap.get(key);
1516
+ if (oldF && newF) {
1517
+ if (JSON.stringify(oldF) !== JSON.stringify(newF)) changes.push({
1518
+ state: "updated",
1519
+ flow: newF
1520
+ });
1521
+ } else if (oldF && !newF) changes.push({
1522
+ state: "removed",
1523
+ flow: oldF
1524
+ });
1525
+ else if (!oldF && newF) changes.push({
1526
+ state: "added",
1527
+ flow: newF
1528
+ });
1529
+ }
1530
+ return changes;
1531
+ }
1532
+ /**
1533
+ * Diff embedded SBOMs across matched component pairs.
1534
+ */
1535
+ function diffEmbeddedSboms(pairs) {
1536
+ const allDiffs = [];
1537
+ for (const { oldComp, newComp } of pairs) {
1538
+ if (!oldComp || !newComp) continue;
1539
+ const oldSbom = oldComp["sbom"];
1540
+ const newSbom = newComp["sbom"];
1541
+ if (!oldSbom || !newSbom) continue;
1542
+ if (typeof oldSbom !== "object" || typeof newSbom !== "object") continue;
1543
+ try {
1544
+ const result = diffSboms(JSON.stringify(oldSbom), JSON.stringify(newSbom));
1545
+ allDiffs.push(...result.packageDiffs);
1546
+ } catch {}
1547
+ }
1548
+ return allDiffs;
1549
+ }
1550
+ /**
1551
+ * Compute field-level changes for component or system fields.
1552
+ * Uses JSON Patch-like op/path format.
1553
+ */
1554
+ function computeComponentFieldChanges(oldObj, newObj, trackedFields) {
1555
+ const changes = [];
1556
+ for (const field of trackedFields) {
1557
+ const oldVal = oldObj[field];
1558
+ const newVal = newObj[field];
1559
+ if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) if (oldVal === void 0 && newVal !== void 0) changes.push({
1560
+ op: "add",
1561
+ path: field,
1562
+ newValue: newVal
1563
+ });
1564
+ else if (oldVal !== void 0 && newVal === void 0) changes.push({
1565
+ op: "remove",
1566
+ path: field,
1567
+ oldValue: oldVal
1568
+ });
1569
+ else changes.push({
1570
+ op: "replace",
1571
+ path: field,
1572
+ oldValue: oldVal,
1573
+ newValue: newVal
1574
+ });
1575
+ }
1576
+ return changes;
1577
+ }
1578
+ //#endregion
1579
+ //#region src/exit-codes.ts
1580
+ /** GNU diff compatible exit codes (--exit-code mode) */
1581
+ const EXIT_IDENTICAL = 0;
1582
+ const EXIT_DIFFERENCES = 1;
1583
+ const EXIT_ERROR = 2;
1584
+ /** Detailed exit codes (--detailed-exitcode mode) */
1585
+ const EXIT_DETAILED_IDENTICAL = 0;
1586
+ const EXIT_DETAILED_ERROR = 1;
1587
+ const EXIT_DETAILED_FIXES_ONLY = 10;
1588
+ const EXIT_DETAILED_REGRESSIONS_ONLY = 11;
1589
+ const EXIT_DETAILED_MIXED = 12;
1590
+ const EXIT_DETAILED_BASELINE_CHANGED = 13;
1591
+ const EXIT_DETAILED_DRIFT_ONLY = 14;
1592
+ /**
1593
+ * Compute the GNU diff compatible exit code from a comparison summary.
1594
+ *
1595
+ * Returns:
1596
+ * 0 = identical (no differences found)
1597
+ * 1 = differences found (any kind)
1598
+ *
1599
+ * Note: error (exit code 2) is not computed from the summary — callers
1600
+ * should return EXIT_ERROR directly when I/O or parse errors occur.
1601
+ */
1602
+ function computeExitCode(summary) {
1603
+ if (hasDifferences(summary)) return 1;
1604
+ return 0;
1605
+ }
1606
+ /**
1607
+ * Compute the detailed exit code from a comparison summary.
1608
+ *
1609
+ * Returns:
1610
+ * 0 = identical (no differences found)
1611
+ * 10 = differences found, fixes only (security posture improved)
1612
+ * 11 = differences found, regressions only (security posture degraded)
1613
+ * 12 = differences found, mixed fixes and regressions
1614
+ * 13 = differences found, only new/absent controls (baseline changed)
1615
+ * 14 = differences found, only metadata drift (no status changes)
1616
+ *
1617
+ * Note: error (exit code 1) is not computed from the summary — callers
1618
+ * should return EXIT_DETAILED_ERROR directly when I/O or parse errors occur.
1619
+ *
1620
+ * Priority order: mixed(12) > regressions(11) > fixes(10) > baseline(13) > drift(14)
1621
+ */
1622
+ function computeDetailedExitCode(summary) {
1623
+ if (!hasDifferences(summary)) return 0;
1624
+ if (summary.regressed > 0 && summary.fixed > 0) return 12;
1625
+ if (summary.regressed > 0) return 11;
1626
+ if (summary.fixed > 0) return 10;
1627
+ if (summary.new > 0 || summary.absent > 0) return 13;
1628
+ return 14;
1629
+ }
1630
+ /**
1631
+ * Returns true if the summary indicates any differences at all.
1632
+ */
1633
+ function hasDifferences(summary) {
1634
+ return summary.total !== summary.unchanged;
1635
+ }
1636
+ //#endregion
1637
+ //#region src/renderers/filter.ts
1638
+ /**
1639
+ * Filter requirement diffs based on render options.
1640
+ *
1641
+ * Supports filtering by:
1642
+ * - `filterStates`: Only include requirements matching the given states
1643
+ * - `filterSeverity`: Only include requirements matching the given severity tag
1644
+ */
1645
+ function filterRequirements(diffs, options) {
1646
+ let filtered = diffs;
1647
+ if (options?.filterStates && options.filterStates.length > 0) {
1648
+ const states = new Set(options.filterStates);
1649
+ filtered = filtered.filter((r) => states.has(r.state));
1650
+ }
1651
+ if (options?.filterSeverity) {
1652
+ const severity = options.filterSeverity.toLowerCase();
1653
+ filtered = filtered.filter((r) => {
1654
+ const before = r.before;
1655
+ const tags = r.after?.["tags"] ?? before?.["tags"];
1656
+ if (!tags) return false;
1657
+ return typeof tags["severity"] === "string" && tags["severity"].toLowerCase() === severity;
1658
+ });
1659
+ }
1660
+ return filtered;
1661
+ }
1662
+ //#endregion
1663
+ //#region src/renderers/json.ts
1664
+ /**
1665
+ * Strip `before` and `after` from a RequirementDiff, keeping only
1666
+ * the summary fields (id, state, title, statuses, changeReasons, fieldChanges).
1667
+ */
1668
+ function stripSnapshots(req) {
1669
+ const { before, after, ...rest } = req;
1670
+ return rest;
1671
+ }
1672
+ /**
1673
+ * Render an HdfComparison as a JSON string.
1674
+ *
1675
+ * - `detail: 'summary'` -- only `{ formatVersion, comparisonMode, summary }`
1676
+ * - `detail: 'control'` -- full document but `before`/`after` stripped from requirementDiffs
1677
+ * - `detail: 'full'` -- `JSON.stringify(comparison, null, 2)` (the complete document)
1678
+ *
1679
+ * Default detail level: `'control'`.
1680
+ */
1681
+ function renderJson(comparison, options) {
1682
+ const detail = options?.detail ?? "control";
1683
+ if (detail === "summary") return JSON.stringify({
1684
+ formatVersion: comparison.formatVersion,
1685
+ comparisonMode: comparison.comparisonMode,
1686
+ summary: comparison.summary
1687
+ }, null, 2);
1688
+ if (detail === "full") {
1689
+ const filtered = filterRequirements(comparison.requirementDiffs, options);
1690
+ if (filtered.length !== comparison.requirementDiffs.length) return JSON.stringify({
1691
+ ...comparison,
1692
+ requirementDiffs: filtered
1693
+ }, null, 2);
1694
+ return JSON.stringify(comparison, null, 2);
1695
+ }
1696
+ const strippedDiffs = filterRequirements(comparison.requirementDiffs, options).map(stripSnapshots);
1697
+ return JSON.stringify({
1698
+ ...comparison,
1699
+ requirementDiffs: strippedDiffs
1700
+ }, null, 2);
1701
+ }
1702
+ //#endregion
1703
+ //#region src/renderers/markdown.ts
1704
+ /**
1705
+ * Render the summary table in markdown format.
1706
+ */
1707
+ function renderSummaryTable(comparison) {
1708
+ const { summary } = comparison;
1709
+ return [
1710
+ "## HDF Comparison Summary",
1711
+ "",
1712
+ "| Metric | Count |",
1713
+ "|--------|-------|",
1714
+ `| Fixed | ${summary.fixed} |`,
1715
+ `| Regressed | ${summary.regressed} |`,
1716
+ `| New | ${summary.new} |`,
1717
+ `| Absent | ${summary.absent} |`,
1718
+ `| Unchanged | ${summary.unchanged} |`,
1719
+ `| Updated | ${summary.updated} |`,
1720
+ `| **Total** | **${summary.total}** |`
1721
+ ].join("\n");
1722
+ }
1723
+ /**
1724
+ * Group requirement diffs by state.
1725
+ */
1726
+ function groupByState(diffs) {
1727
+ const groups = /* @__PURE__ */ new Map();
1728
+ for (const diff of diffs) {
1729
+ const existing = groups.get(diff.state);
1730
+ if (existing) existing.push(diff);
1731
+ else groups.set(diff.state, [diff]);
1732
+ }
1733
+ return groups;
1734
+ }
1735
+ /**
1736
+ * Escape a value for use in a markdown table cell.
1737
+ * Replaces pipe characters with their HTML entity.
1738
+ */
1739
+ function escapeCell(value) {
1740
+ return value.replace(/\|/g, "&#124;");
1741
+ }
1742
+ /**
1743
+ * Format field changes for a markdown cell.
1744
+ */
1745
+ function formatFieldChanges$1(req) {
1746
+ if (req.fieldChanges.length === 0) return "";
1747
+ return req.fieldChanges.map((fc) => {
1748
+ if (fc.op === "add") return `+${fc.path}: ${JSON.stringify(fc.newValue)}`;
1749
+ if (fc.op === "remove") return `-${fc.path}: ${JSON.stringify(fc.oldValue)}`;
1750
+ return `${fc.path}: ${JSON.stringify(fc.oldValue)} -> ${JSON.stringify(fc.newValue)}`;
1751
+ }).join("; ");
1752
+ }
1753
+ /**
1754
+ * Render a section for a single state group.
1755
+ */
1756
+ function renderStateSection(state, diffs, detail) {
1757
+ const lines = [`### ${state.charAt(0).toUpperCase() + state.slice(1)} (${diffs.length})`];
1758
+ if (diffs.length === 0) {
1759
+ lines.push("", "(none)");
1760
+ return lines.join("\n");
1761
+ }
1762
+ lines.push("");
1763
+ if (detail === "full") {
1764
+ lines.push("| ID | Title | Old Status | New Status | Change Reasons | Field Changes |", "|----|-------|------------|------------|----------------|---------------|");
1765
+ for (const req of diffs) {
1766
+ const id = escapeCell(req.id);
1767
+ const title = escapeCell(req.title ?? "");
1768
+ const oldStatus = escapeCell(req.oldEffectiveStatus ?? "");
1769
+ const newStatus = escapeCell(req.newEffectiveStatus ?? "");
1770
+ const reasons = escapeCell(req.changeReasons.join(", "));
1771
+ const fieldChanges = escapeCell(formatFieldChanges$1(req));
1772
+ lines.push(`| ${id} | ${title} | ${oldStatus} | ${newStatus} | ${reasons} | ${fieldChanges} |`);
1773
+ }
1774
+ } else {
1775
+ lines.push("| ID | Title | Old Status | New Status |", "|----|-------|------------|------------|");
1776
+ for (const req of diffs) {
1777
+ const id = escapeCell(req.id);
1778
+ const title = escapeCell(req.title ?? "");
1779
+ const oldStatus = escapeCell(req.oldEffectiveStatus ?? "");
1780
+ const newStatus = escapeCell(req.newEffectiveStatus ?? "");
1781
+ lines.push(`| ${id} | ${title} | ${oldStatus} | ${newStatus} |`);
1782
+ }
1783
+ }
1784
+ return lines.join("\n");
1785
+ }
1786
+ /**
1787
+ * Render an HdfComparison as a Markdown string.
1788
+ *
1789
+ * - `detail: 'summary'` -- summary table only
1790
+ * - `detail: 'control'` -- summary + per-requirement tables by state
1791
+ * - `detail: 'full'` -- summary + per-requirement tables with changeReasons and fieldChanges
1792
+ *
1793
+ * Default detail level: `'control'`.
1794
+ */
1795
+ function renderMarkdown(comparison, options) {
1796
+ const detail = options?.detail ?? "control";
1797
+ const parts = [renderSummaryTable(comparison)];
1798
+ if (detail === "summary") return parts.join("\n");
1799
+ const stateOrder = [
1800
+ "fixed",
1801
+ "regressed",
1802
+ "new",
1803
+ "absent",
1804
+ "updated",
1805
+ "unchanged"
1806
+ ];
1807
+ const grouped = groupByState(filterRequirements(comparison.requirementDiffs, options));
1808
+ const statesToShow = options?.filterStates && options.filterStates.length > 0 ? stateOrder.filter((s) => options.filterStates.includes(s)) : stateOrder;
1809
+ for (const state of statesToShow) {
1810
+ parts.push("");
1811
+ const diffs = grouped.get(state) ?? [];
1812
+ parts.push(renderStateSection(state, diffs, detail));
1813
+ }
1814
+ return parts.join("\n");
1815
+ }
1816
+ //#endregion
1817
+ //#region src/renderers/terminal.ts
1818
+ const RESET = "\x1B[0m";
1819
+ const GREEN = "\x1B[32m";
1820
+ const RED = "\x1B[31m";
1821
+ const YELLOW = "\x1B[33m";
1822
+ const BOLD = "\x1B[1m";
1823
+ const DIM = "\x1B[2m";
1824
+ /**
1825
+ * Get the symbol and color for a requirement state.
1826
+ */
1827
+ function getSymbolAndColor(state, useColor) {
1828
+ const identity = (s) => s;
1829
+ switch (state) {
1830
+ case "fixed":
1831
+ case "new": return {
1832
+ symbol: "+",
1833
+ colorFn: useColor ? (s) => `${GREEN}${s}${RESET}` : identity
1834
+ };
1835
+ case "regressed":
1836
+ case "absent": return {
1837
+ symbol: "-",
1838
+ colorFn: useColor ? (s) => `${RED}${s}${RESET}` : identity
1839
+ };
1840
+ case "updated": return {
1841
+ symbol: "~",
1842
+ colorFn: useColor ? (s) => `${YELLOW}${s}${RESET}` : identity
1843
+ };
1844
+ default: return {
1845
+ symbol: " ",
1846
+ colorFn: useColor ? (s) => `${DIM}${s}${RESET}` : identity
1847
+ };
1848
+ }
1849
+ }
1850
+ /**
1851
+ * Format the status transition for a requirement.
1852
+ */
1853
+ function formatStatusTransition(req) {
1854
+ if (req.state === "new") return "(new)";
1855
+ if (req.state === "absent") return "(absent)";
1856
+ const parts = [];
1857
+ if (req.oldEffectiveStatus && req.newEffectiveStatus) parts.push(`${req.oldEffectiveStatus} → ${req.newEffectiveStatus}`);
1858
+ if (req.state !== "unchanged") parts.push(`(${req.state})`);
1859
+ return parts.join(" ");
1860
+ }
1861
+ /**
1862
+ * Format field changes for full detail.
1863
+ */
1864
+ function formatFieldChangesForTerminal(req) {
1865
+ if (req.fieldChanges.length === 0) return "";
1866
+ return req.fieldChanges.map((fc) => {
1867
+ if (fc.op === "add") return `+${fc.path}: ${JSON.stringify(fc.newValue)}`;
1868
+ if (fc.op === "remove") return `-${fc.path}: ${JSON.stringify(fc.oldValue)}`;
1869
+ return `${fc.path}: ${JSON.stringify(fc.oldValue)} → ${JSON.stringify(fc.newValue)}`;
1870
+ }).join("; ");
1871
+ }
1872
+ /**
1873
+ * Render a single requirement line.
1874
+ */
1875
+ function renderRequirementLine(req, detail, useColor) {
1876
+ const { symbol, colorFn } = getSymbolAndColor(req.state, useColor);
1877
+ const title = req.title ?? "";
1878
+ const transition = formatStatusTransition(req);
1879
+ let line = ` ${symbol} ${req.id} ${title} ${transition}`;
1880
+ if (detail === "full") {
1881
+ if (req.changeReasons.length > 0) line += ` [${req.changeReasons.join(", ")}]`;
1882
+ const fieldChanges = formatFieldChangesForTerminal(req);
1883
+ if (fieldChanges) line += ` ${fieldChanges}`;
1884
+ }
1885
+ return colorFn(line);
1886
+ }
1887
+ /**
1888
+ * Build the summary line.
1889
+ */
1890
+ function buildSummaryLine(comparison, useColor) {
1891
+ const { summary } = comparison;
1892
+ const line = `Summary: ${[
1893
+ `${summary.fixed} fixed`,
1894
+ `${summary.regressed} regressed`,
1895
+ `${summary.new} new`,
1896
+ `${summary.absent} absent`,
1897
+ `${summary.unchanged} unchanged`,
1898
+ `${summary.updated} updated`,
1899
+ `(${summary.total} total)`
1900
+ ].join(", ")}`;
1901
+ if (useColor) return `${BOLD}${line}${RESET}`;
1902
+ return line;
1903
+ }
1904
+ /**
1905
+ * Build the header line.
1906
+ */
1907
+ function buildHeaderLine(comparison, useColor) {
1908
+ const mode = comparison.comparisonMode;
1909
+ const oldSource = comparison.sources.find((s) => s.role === "old" || s.role === "golden" || s.role === "reference");
1910
+ const newSource = comparison.sources.find((s) => s.role === "new" || s.role === "system");
1911
+ let header = `HDF Comparison: ${mode}`;
1912
+ const oldTimestamp = oldSource?.assessmentTimestamp;
1913
+ const newTimestamp = newSource?.assessmentTimestamp;
1914
+ if (oldTimestamp && newTimestamp) {
1915
+ const oldDate = oldTimestamp.split("T")[0];
1916
+ const newDate = newTimestamp.split("T")[0];
1917
+ header += ` (${oldDate} → ${newDate})`;
1918
+ }
1919
+ if (useColor) return `${BOLD}${header}${RESET}`;
1920
+ return header;
1921
+ }
1922
+ /**
1923
+ * Render an HdfComparison for terminal display with optional ANSI colors.
1924
+ *
1925
+ * - `detail: 'summary'` -- just the summary line
1926
+ * - `detail: 'control'` -- requirement list + summary (excludes unchanged)
1927
+ * - `detail: 'full'` -- all requirements including unchanged, with changeReasons and fieldChanges
1928
+ *
1929
+ * When `color: false`, no ANSI escape codes are emitted.
1930
+ * Default detail level: `'control'`. Default color: `true`.
1931
+ */
1932
+ function renderTerminal(comparison, options) {
1933
+ const detail = options?.detail ?? "control";
1934
+ const useColor = options?.color ?? true;
1935
+ const lines = [];
1936
+ lines.push(buildHeaderLine(comparison, useColor));
1937
+ lines.push("");
1938
+ if (detail === "summary") {
1939
+ lines.push(buildSummaryLine(comparison, useColor));
1940
+ return lines.join("\n");
1941
+ }
1942
+ const filtered = filterRequirements(comparison.requirementDiffs, options);
1943
+ for (const req of filtered) {
1944
+ if (detail === "control" && req.state === "unchanged") continue;
1945
+ lines.push(renderRequirementLine(req, detail, useColor));
1946
+ }
1947
+ lines.push("");
1948
+ lines.push(buildSummaryLine(comparison, useColor));
1949
+ return lines.join("\n");
1950
+ }
1951
+ //#endregion
1952
+ //#region src/renderers/csv.ts
1953
+ /**
1954
+ * Escape a value for CSV output.
1955
+ *
1956
+ * Per RFC 4180:
1957
+ * - Fields containing commas, double quotes, or newlines are enclosed in double quotes.
1958
+ * - Double quotes within a field are escaped by doubling them.
1959
+ */
1960
+ function escapeCsvField(value) {
1961
+ if (value.includes(",") || value.includes("\"") || value.includes("\n") || value.includes("\r")) return `"${value.replace(/"/g, "\"\"")}"`;
1962
+ return value;
1963
+ }
1964
+ /**
1965
+ * Format field changes as a human-readable string.
1966
+ */
1967
+ function formatFieldChanges(req) {
1968
+ if (req.fieldChanges.length === 0) return "";
1969
+ return req.fieldChanges.map((fc) => {
1970
+ if (fc.op === "add") return `+${fc.path}: ${JSON.stringify(fc.newValue)}`;
1971
+ if (fc.op === "remove") return `-${fc.path}: ${JSON.stringify(fc.oldValue)}`;
1972
+ return `${fc.path}: ${JSON.stringify(fc.oldValue)} -> ${JSON.stringify(fc.newValue)}`;
1973
+ }).join("; ");
1974
+ }
1975
+ /**
1976
+ * Render an HdfComparison as a CSV string.
1977
+ *
1978
+ * One row per requirement. Columns:
1979
+ * - ID, Title, State, Old Status, New Status, Impact (Old), Impact (New), Change Reasons
1980
+ *
1981
+ * When `detail: 'full'`, an additional Field Changes column is included.
1982
+ *
1983
+ * Header row is always included. Standard CSV escaping (RFC 4180).
1984
+ * Default detail level: `'control'`.
1985
+ */
1986
+ function renderCsv(comparison, options) {
1987
+ const detail = options?.detail ?? "control";
1988
+ const filtered = filterRequirements(comparison.requirementDiffs, options);
1989
+ const headers = [
1990
+ "ID",
1991
+ "Title",
1992
+ "State",
1993
+ "Old Status",
1994
+ "New Status",
1995
+ "Impact (Old)",
1996
+ "Impact (New)",
1997
+ "Change Reasons"
1998
+ ];
1999
+ if (detail === "full") headers.push("Field Changes");
2000
+ const lines = [headers.join(",")];
2001
+ for (const req of filtered) {
2002
+ const row = [
2003
+ escapeCsvField(req.id),
2004
+ escapeCsvField(req.title ?? ""),
2005
+ escapeCsvField(req.state),
2006
+ escapeCsvField(req.oldEffectiveStatus ?? ""),
2007
+ escapeCsvField(req.newEffectiveStatus ?? ""),
2008
+ escapeCsvField(req.oldImpact !== void 0 ? String(req.oldImpact) : ""),
2009
+ escapeCsvField(req.newImpact !== void 0 ? String(req.newImpact) : ""),
2010
+ escapeCsvField(req.changeReasons.join(", "))
2011
+ ];
2012
+ if (detail === "full") row.push(escapeCsvField(formatFieldChanges(req)));
2013
+ lines.push(row.join(","));
2014
+ }
2015
+ return lines.join("\n");
2016
+ }
2017
+ //#endregion
2018
+ //#region src/renderers/index.ts
2019
+ /**
2020
+ * Convenience function to render a comparison in any supported format.
2021
+ *
2022
+ * @param comparison - The HdfComparison document to render
2023
+ * @param format - Output format: 'json', 'markdown', 'terminal', or 'csv'
2024
+ * @param options - Rendering options (detail level, filters, color)
2025
+ * @returns The rendered string
2026
+ */
2027
+ function render(comparison, format, options) {
2028
+ switch (format) {
2029
+ case "json": return renderJson(comparison, options);
2030
+ case "markdown": return renderMarkdown(comparison, options);
2031
+ case "terminal": return renderTerminal(comparison, options);
2032
+ case "csv": return renderCsv(comparison, options);
2033
+ }
2034
+ }
2035
+ //#endregion
2036
+ export { EXIT_DETAILED_BASELINE_CHANGED, EXIT_DETAILED_DRIFT_ONLY, EXIT_DETAILED_ERROR, EXIT_DETAILED_FIXES_ONLY, EXIT_DETAILED_IDENTICAL, EXIT_DETAILED_MIXED, EXIT_DETAILED_REGRESSIONS_ONLY, EXIT_DIFFERENCES, EXIT_ERROR, EXIT_IDENTICAL, classifyChangeReasons, classifyDiffStatus, computeDetailedExitCode, computeEffectiveStatus, computeExitCode, computeSummary, createCciMatchStrategy, createExactIdStrategy, createFuzzyTitleStrategy, createMappedIdStrategy, diffBaselines, diffHdf, diffSboms, diffSystems, isV1Format, jaccardSimilarity, matchRequirements, normalizeToV2, render, renderCsv, renderJson, renderMarkdown, renderTerminal, tokenize, validateComparison };
2037
+
19
2038
  //# sourceMappingURL=index.js.map