@mitre/hdf-diff 3.0.1 → 3.1.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +55 -0
- package/README.md +49 -17
- package/dist/index.d.ts +622 -16
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2037 -18
- package/dist/index.js.map +1 -1
- package/package.json +19 -19
- package/dist/diff.d.ts +0 -49
- package/dist/diff.d.ts.map +0 -1
- package/dist/diff.js +0 -791
- package/dist/diff.js.map +0 -1
- package/dist/exit-codes.d.ts +0 -42
- package/dist/exit-codes.d.ts.map +0 -1
- package/dist/exit-codes.js +0 -74
- package/dist/exit-codes.js.map +0 -1
- package/dist/matching/cci-match.d.ts +0 -14
- package/dist/matching/cci-match.d.ts.map +0 -1
- package/dist/matching/cci-match.js +0 -102
- package/dist/matching/cci-match.js.map +0 -1
- package/dist/matching/exact-id.d.ts +0 -9
- package/dist/matching/exact-id.d.ts.map +0 -1
- package/dist/matching/exact-id.js +0 -88
- package/dist/matching/exact-id.js.map +0 -1
- package/dist/matching/fuzzy-match.d.ts +0 -26
- package/dist/matching/fuzzy-match.d.ts.map +0 -1
- package/dist/matching/fuzzy-match.js +0 -145
- package/dist/matching/fuzzy-match.js.map +0 -1
- package/dist/matching/index.d.ts +0 -36
- package/dist/matching/index.d.ts.map +0 -1
- package/dist/matching/index.js +0 -71
- package/dist/matching/index.js.map +0 -1
- package/dist/matching/mapped-id.d.ts +0 -13
- package/dist/matching/mapped-id.d.ts.map +0 -1
- package/dist/matching/mapped-id.js +0 -101
- package/dist/matching/mapped-id.js.map +0 -1
- package/dist/matching/types.d.ts +0 -34
- package/dist/matching/types.d.ts.map +0 -1
- package/dist/matching/types.js +0 -2
- package/dist/matching/types.js.map +0 -1
- package/dist/normalize.d.ts +0 -20
- package/dist/normalize.d.ts.map +0 -1
- package/dist/normalize.js +0 -120
- package/dist/normalize.js.map +0 -1
- package/dist/renderers/csv.d.ts +0 -15
- package/dist/renderers/csv.d.ts.map +0 -1
- package/dist/renderers/csv.js +0 -77
- package/dist/renderers/csv.js.map +0 -1
- package/dist/renderers/filter.d.ts +0 -11
- package/dist/renderers/filter.d.ts.map +0 -1
- package/dist/renderers/filter.js +0 -29
- package/dist/renderers/filter.js.map +0 -1
- package/dist/renderers/index.d.ts +0 -17
- package/dist/renderers/index.d.ts.map +0 -1
- package/dist/renderers/index.js +0 -29
- package/dist/renderers/index.js.map +0 -1
- package/dist/renderers/json.d.ts +0 -13
- package/dist/renderers/json.d.ts.map +0 -1
- package/dist/renderers/json.js +0 -41
- package/dist/renderers/json.js.map +0 -1
- package/dist/renderers/markdown.d.ts +0 -13
- package/dist/renderers/markdown.d.ts.map +0 -1
- package/dist/renderers/markdown.js +0 -133
- package/dist/renderers/markdown.js.map +0 -1
- package/dist/renderers/terminal.d.ts +0 -14
- package/dist/renderers/terminal.d.ts.map +0 -1
- package/dist/renderers/terminal.js +0 -173
- package/dist/renderers/terminal.js.map +0 -1
- package/dist/renderers/types.d.ts +0 -12
- package/dist/renderers/types.d.ts.map +0 -1
- package/dist/renderers/types.js +0 -2
- package/dist/renderers/types.js.map +0 -1
- package/dist/sbom.d.ts +0 -42
- package/dist/sbom.d.ts.map +0 -1
- package/dist/sbom.js +0 -203
- package/dist/sbom.js.map +0 -1
- package/dist/status.d.ts +0 -27
- package/dist/status.d.ts.map +0 -1
- package/dist/status.js +0 -137
- package/dist/status.js.map +0 -1
- package/dist/summary.d.ts +0 -6
- package/dist/summary.d.ts.map +0 -1
- package/dist/summary.js +0 -47
- package/dist/summary.js.map +0 -1
- package/dist/types.d.ts +0 -208
- package/dist/types.d.ts.map +0 -1
- package/dist/types.js +0 -2
- package/dist/types.js.map +0 -1
- package/dist/validate.d.ts +0 -20
- package/dist/validate.d.ts.map +0 -1
- package/dist/validate.js +0 -19
- package/dist/validate.js.map +0 -1
package/dist/index.js
CHANGED
|
@@ -1,19 +1,2038 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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, "|");
|
|
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
|