@mitre/hdf-diff 3.2.0 → 3.3.0

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/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { validateComparison as validateComparison$1 } from "@mitre/hdf-validators";
2
+ import { parseCvssVector } from "@mitre/hdf-utilities";
2
3
  //#region src/status.ts
3
4
  /** Status severity ranking — higher index = worse */
4
5
  const STATUS_SEVERITY = [
@@ -406,6 +407,65 @@ function extractCcis$1(req) {
406
407
  return cci.filter((c) => typeof c === "string");
407
408
  }
408
409
  /**
410
+ * Extract CWE identifiers from a requirement.
411
+ *
412
+ * Prefers the structured `req.cwe[]` field (introduced in Evaluated_Requirement
413
+ * Wave 1) when present and non-empty. Falls back to `tags.cwe` for compatibility
414
+ * with HDF files emitted before the structured field existed. `tags.cwe` may be
415
+ * either an array of strings or a single string.
416
+ *
417
+ * Exported for reuse by sibling matchers (e.g. srg-cci-tiebreak) and to make
418
+ * the preference order directly testable.
419
+ */
420
+ function extractCwes$1(req) {
421
+ const cwe = req["cwe"];
422
+ if (Array.isArray(cwe)) {
423
+ const filtered = cwe.filter((c) => typeof c === "string");
424
+ if (filtered.length > 0) return filtered;
425
+ }
426
+ const tags = req["tags"];
427
+ if (!tags) return [];
428
+ const tagCwe = tags["cwe"];
429
+ if (Array.isArray(tagCwe)) return tagCwe.filter((c) => typeof c === "string");
430
+ if (typeof tagCwe === "string") return [tagCwe];
431
+ return [];
432
+ }
433
+ /**
434
+ * Build a key → [requirement-indices] map for unambiguous-pair lookup.
435
+ */
436
+ function indexByKey(reqs, keys) {
437
+ const map = /* @__PURE__ */ new Map();
438
+ for (let i = 0; i < reqs.length; i++) for (const key of keys(reqs[i])) {
439
+ const list = map.get(key);
440
+ if (list) list.push(i);
441
+ else map.set(key, [i]);
442
+ }
443
+ return map;
444
+ }
445
+ /**
446
+ * Pair indices by an unambiguous shared key (exactly 1 old + 1 new per key),
447
+ * skipping any indices already claimed in `matchedOld` / `matchedNew`.
448
+ */
449
+ function pairUnambiguous(oldMap, newMap, matchedOld, matchedNew) {
450
+ const pairs = [];
451
+ const allKeys = new Set([...oldMap.keys(), ...newMap.keys()]);
452
+ for (const key of allKeys) {
453
+ const oldIndices = oldMap.get(key) ?? [];
454
+ const newIndices = newMap.get(key) ?? [];
455
+ if (oldIndices.length !== 1 || newIndices.length !== 1) continue;
456
+ const oldIdx = oldIndices[0];
457
+ const newIdx = newIndices[0];
458
+ if (matchedOld.has(oldIdx) || matchedNew.has(newIdx)) continue;
459
+ pairs.push({
460
+ oldIdx,
461
+ newIdx
462
+ });
463
+ matchedOld.add(oldIdx);
464
+ matchedNew.add(newIdx);
465
+ }
466
+ return pairs;
467
+ }
468
+ /**
409
469
  * Create a CCI-based matching strategy.
410
470
  *
411
471
  * Matches requirements that share the same CCI identifier in `tags.cci`.
@@ -414,51 +474,44 @@ function extractCcis$1(req) {
414
474
  * by multiple old or multiple new requirements) are skipped, and those
415
475
  * requirements are left unmatched.
416
476
  *
417
- * Confidence is 0.8 for unambiguous matches.
477
+ * As a fallback, also pairs unambiguous CWE matches (preferring the
478
+ * structured `req.cwe[]` field over the legacy `tags.cwe`). This catches
479
+ * CVE-ecosystem findings where CCI is absent but CWE is present.
480
+ *
481
+ * Confidence is 0.8 for CCI matches and 0.6 for CWE-fallback matches.
418
482
  */
419
483
  function createCciMatchStrategy() {
420
484
  return {
421
485
  name: "cciMatch",
422
486
  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$1(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$1(newReqs[i])) {
436
- const list = newCciMap.get(cci);
437
- if (list) list.push(i);
438
- else newCciMap.set(cci, [i]);
439
- }
440
487
  const matchedOldIndices = /* @__PURE__ */ new Set();
441
488
  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;
489
+ const matched = [];
490
+ const oldCciMap = indexByKey(oldReqs, extractCcis$1);
491
+ const newCciMap = indexByKey(newReqs, extractCcis$1);
492
+ for (const { oldIdx, newIdx } of pairUnambiguous(oldCciMap, newCciMap, matchedOldIndices, matchedNewIndices)) matched.push({
493
+ oldReq: oldReqs[oldIdx],
494
+ newReq: newReqs[newIdx],
495
+ strategy: "cciMatch",
496
+ confidence: .8
497
+ });
498
+ const oldCweMap = indexByKey(oldReqs, (r) => extractCwes$1(r));
499
+ const newCweMap = indexByKey(newReqs, (r) => extractCwes$1(r));
500
+ for (const { oldIdx, newIdx } of pairUnambiguous(oldCweMap, newCweMap, matchedOldIndices, matchedNewIndices)) matched.push({
501
+ oldReq: oldReqs[oldIdx],
502
+ newReq: newReqs[newIdx],
503
+ strategy: "cciMatch",
504
+ confidence: .6
505
+ });
506
+ const unmatchedOld = [];
507
+ for (let i = 0; i < oldReqs.length; i++) if (!matchedOldIndices.has(i)) unmatchedOld.push(oldReqs[i]);
508
+ const unmatchedNew = [];
509
+ for (let i = 0; i < newReqs.length; i++) if (!matchedNewIndices.has(i)) unmatchedNew.push(newReqs[i]);
510
+ return {
511
+ matched,
512
+ unmatchedOld,
513
+ unmatchedNew
514
+ };
462
515
  }
463
516
  };
464
517
  }
@@ -756,6 +809,28 @@ function extractCcis(req) {
756
809
  return new Set(cci.filter((c) => typeof c === "string"));
757
810
  }
758
811
  /**
812
+ * Extract CWE identifiers from a requirement.
813
+ *
814
+ * Prefers the structured `req.cwe[]` field (introduced in Evaluated_Requirement
815
+ * Wave 1) when present and non-empty. Falls back to `tags.cwe` (array or string)
816
+ * for compatibility with HDF files emitted before the structured field existed.
817
+ *
818
+ * Exported so callers and tests can verify the preference order directly.
819
+ */
820
+ function extractCwes(req) {
821
+ const cwe = req["cwe"];
822
+ if (Array.isArray(cwe)) {
823
+ const filtered = cwe.filter((c) => typeof c === "string");
824
+ if (filtered.length > 0) return new Set(filtered);
825
+ }
826
+ const tags = req["tags"];
827
+ if (!tags) return /* @__PURE__ */ new Set();
828
+ const tagCwe = tags["cwe"];
829
+ if (Array.isArray(tagCwe)) return new Set(tagCwe.filter((c) => typeof c === "string"));
830
+ if (typeof tagCwe === "string") return new Set([tagCwe]);
831
+ return /* @__PURE__ */ new Set();
832
+ }
833
+ /**
759
834
  * Extract tags.gtitle from a requirement.
760
835
  */
761
836
  function extractGtitle(req) {
@@ -839,21 +914,26 @@ function createSrgCciTiebreakStrategy() {
839
914
  if (matchedNewIndices.has(ni)) continue;
840
915
  const newCcis = extractCcis(newReqs[ni]);
841
916
  const newTitle = safeTitle$1(newReqs[ni]);
917
+ const newCwes = extractCwes(newReqs[ni]);
842
918
  let bestIdx = -1;
843
919
  let bestComposite = -1;
844
920
  let bestCci = 0;
921
+ let bestCwe = -1;
845
922
  let bestIsUnclaimed = false;
846
923
  for (const oi of oldIdxList) {
847
924
  const oldCcis = extractCcis(oldReqs[oi]);
848
925
  const oldTitle = safeTitle$1(oldReqs[oi]);
926
+ const oldCwes = extractCwes(oldReqs[oi]);
849
927
  const cci = cciJaccard(newCcis, oldCcis);
850
928
  const title = tokenJaccard(newTitle, oldTitle);
929
+ const cwe = cciJaccard(newCwes, oldCwes);
851
930
  const composite = CCI_WEIGHT * cci + TITLE_WEIGHT * title;
852
931
  const isUnclaimed = !matchedOldIndices.has(oi);
853
- if (bestIdx === -1 || isUnclaimed && !bestIsUnclaimed || isUnclaimed === bestIsUnclaimed && composite > bestComposite) {
932
+ if (bestIdx === -1 || isUnclaimed && !bestIsUnclaimed || isUnclaimed === bestIsUnclaimed && composite > bestComposite || isUnclaimed === bestIsUnclaimed && composite === bestComposite && cwe > bestCwe) {
854
933
  bestIdx = oi;
855
934
  bestComposite = composite;
856
935
  bestCci = cci;
936
+ bestCwe = cwe;
857
937
  bestIsUnclaimed = isUnclaimed;
858
938
  }
859
939
  }
@@ -1575,13 +1655,26 @@ function comparePair(oldNormalized, newNormalized, oldTimestamp, newTimestamp, t
1575
1655
  requirementDiffs
1576
1656
  };
1577
1657
  }
1658
+ /** Field names handled by dedicated CVE-ecosystem diff handlers (not generic JSON compare). */
1659
+ const CVE_ECOSYSTEM_FIELDS = new Set([
1660
+ "cvss",
1661
+ "epss",
1662
+ "kev",
1663
+ "cwe",
1664
+ "affectedPackages"
1665
+ ]);
1578
1666
  /**
1579
1667
  * Compute field-level changes for tracked fields between two requirements.
1580
1668
  * Uses JSON Patch-like op/path format.
1669
+ *
1670
+ * Always runs the CVE-ecosystem handlers (cvss, epss, kev, cwe, affectedPackages)
1671
+ * in addition to whatever scalar fields are in trackedFields, since these
1672
+ * structured types need richer diff than generic JSON comparison provides.
1581
1673
  */
1582
1674
  function computeFieldChanges(oldReq, newReq, trackedFields) {
1583
1675
  const changes = [];
1584
1676
  for (const field of trackedFields) {
1677
+ if (CVE_ECOSYSTEM_FIELDS.has(field)) continue;
1585
1678
  const oldVal = oldReq[field];
1586
1679
  const newVal = newReq[field];
1587
1680
  if (JSON.stringify(oldVal) !== JSON.stringify(newVal)) if (oldVal === void 0 && newVal !== void 0) changes.push({
@@ -1601,6 +1694,367 @@ function computeFieldChanges(oldReq, newReq, trackedFields) {
1601
1694
  newValue: newVal
1602
1695
  });
1603
1696
  }
1697
+ changes.push(...diffCvssArray(oldReq["cvss"], newReq["cvss"]));
1698
+ changes.push(...diffEpss(oldReq["epss"], newReq["epss"]));
1699
+ changes.push(...diffKev(oldReq["kev"], newReq["kev"]));
1700
+ changes.push(...diffCweSet(oldReq["cwe"], newReq["cwe"]));
1701
+ changes.push(...diffAffectedPackages(oldReq["affectedPackages"], newReq["affectedPackages"]));
1702
+ return changes;
1703
+ }
1704
+ const CVSS_VECTOR_FIELDS = [
1705
+ "baseVector",
1706
+ "threatVector",
1707
+ "environmentalVector",
1708
+ "supplementalVector"
1709
+ ];
1710
+ /**
1711
+ * Parse a CVSS vector string into a map of metric-code → value.
1712
+ *
1713
+ * Accepts both v3.x ("CVSS:3.1/AV:N/AC:L/...") and v4.0 ("CVSS:4.0/AV:N/...")
1714
+ * shapes, plus partial vectors that omit the version prefix (e.g., the
1715
+ * `threatVector` field is conventionally just the threat metrics).
1716
+ *
1717
+ * Returns `null` for inputs that don't look like CVSS vectors so callers
1718
+ * can fall back to a scalar string compare.
1719
+ *
1720
+ * Delegates to `parseCvssVector` from `@mitre/hdf-utilities` but adapts the
1721
+ * return shape: returns null for empty/unparseable inputs so call sites can
1722
+ * fall back to scalar string compare.
1723
+ */
1724
+ function parseCvssVector$1(vec) {
1725
+ if (typeof vec !== "string" || vec.length === 0) return null;
1726
+ const parsed = parseCvssVector(vec);
1727
+ if (parsed.metrics.size === 0) return null;
1728
+ return {
1729
+ version: parsed.version === "unknown" ? void 0 : parsed.version,
1730
+ metrics: parsed.metrics
1731
+ };
1732
+ }
1733
+ /**
1734
+ * Diff a single pair of cvss entries (same source). Emits per-field changes
1735
+ * including decomposed per-metric vector diffs.
1736
+ */
1737
+ function diffCvssEntry(source, oldEntry, newEntry) {
1738
+ const changes = [];
1739
+ const allKeys = new Set([...Object.keys(oldEntry), ...Object.keys(newEntry)]);
1740
+ for (const key of allKeys) {
1741
+ if (key === "source") continue;
1742
+ const oldVal = oldEntry[key];
1743
+ const newVal = newEntry[key];
1744
+ if (JSON.stringify(oldVal) === JSON.stringify(newVal)) continue;
1745
+ if (CVSS_VECTOR_FIELDS.includes(key)) {
1746
+ if (typeof oldVal === "string" && typeof newVal === "string") {
1747
+ const oldParsed = parseCvssVector$1(oldVal);
1748
+ const newParsed = parseCvssVector$1(newVal);
1749
+ if (oldParsed && newParsed) {
1750
+ const metricKeys = new Set([...oldParsed.metrics.keys(), ...newParsed.metrics.keys()]);
1751
+ for (const m of metricKeys) {
1752
+ const o = oldParsed.metrics.get(m);
1753
+ const n = newParsed.metrics.get(m);
1754
+ if (o === n) continue;
1755
+ if (o === void 0 && n !== void 0) changes.push({
1756
+ op: "add",
1757
+ path: `cvss[${source}].${key}.${m}`,
1758
+ newValue: n
1759
+ });
1760
+ else if (o !== void 0 && n === void 0) changes.push({
1761
+ op: "remove",
1762
+ path: `cvss[${source}].${key}.${m}`,
1763
+ oldValue: o
1764
+ });
1765
+ else changes.push({
1766
+ op: "replace",
1767
+ path: `cvss[${source}].${key}.${m}`,
1768
+ oldValue: o,
1769
+ newValue: n
1770
+ });
1771
+ }
1772
+ continue;
1773
+ }
1774
+ } else if (oldVal === void 0 && typeof newVal === "string") {
1775
+ const parsed = parseCvssVector$1(newVal);
1776
+ if (parsed) {
1777
+ for (const [m, v] of parsed.metrics) changes.push({
1778
+ op: "add",
1779
+ path: `cvss[${source}].${key}.${m}`,
1780
+ newValue: v
1781
+ });
1782
+ continue;
1783
+ }
1784
+ } else if (typeof oldVal === "string" && newVal === void 0) {
1785
+ const parsed = parseCvssVector$1(oldVal);
1786
+ if (parsed) {
1787
+ for (const [m, v] of parsed.metrics) changes.push({
1788
+ op: "remove",
1789
+ path: `cvss[${source}].${key}.${m}`,
1790
+ oldValue: v
1791
+ });
1792
+ continue;
1793
+ }
1794
+ }
1795
+ }
1796
+ if (oldVal === void 0) changes.push({
1797
+ op: "add",
1798
+ path: `cvss[${source}].${key}`,
1799
+ newValue: newVal
1800
+ });
1801
+ else if (newVal === void 0) changes.push({
1802
+ op: "remove",
1803
+ path: `cvss[${source}].${key}`,
1804
+ oldValue: oldVal
1805
+ });
1806
+ else changes.push({
1807
+ op: "replace",
1808
+ path: `cvss[${source}].${key}`,
1809
+ oldValue: oldVal,
1810
+ newValue: newVal
1811
+ });
1812
+ }
1813
+ return changes;
1814
+ }
1815
+ /**
1816
+ * Diff old vs new cvss[] arrays. Entries are matched by `source` (CVE ID
1817
+ * or scoring authority); added/removed entries emit whole-entry add/remove
1818
+ * records; matched pairs are decomposed via diffCvssEntry.
1819
+ */
1820
+ function diffCvssArray(oldField, newField) {
1821
+ const oldArr = Array.isArray(oldField) ? oldField : [];
1822
+ const newArr = Array.isArray(newField) ? newField : [];
1823
+ if (oldArr.length === 0 && newArr.length === 0) return [];
1824
+ const oldBySource = /* @__PURE__ */ new Map();
1825
+ for (const e of oldArr) if (typeof e?.source === "string") oldBySource.set(e.source, e);
1826
+ const newBySource = /* @__PURE__ */ new Map();
1827
+ for (const e of newArr) if (typeof e?.source === "string") newBySource.set(e.source, e);
1828
+ const changes = [];
1829
+ const allSources = new Set([...oldBySource.keys(), ...newBySource.keys()]);
1830
+ for (const source of allSources) {
1831
+ const o = oldBySource.get(source);
1832
+ const n = newBySource.get(source);
1833
+ if (o && !n) changes.push({
1834
+ op: "remove",
1835
+ path: `cvss[${source}]`,
1836
+ oldValue: o
1837
+ });
1838
+ else if (!o && n) changes.push({
1839
+ op: "add",
1840
+ path: `cvss[${source}]`,
1841
+ newValue: n
1842
+ });
1843
+ else if (o && n) changes.push(...diffCvssEntry(source, o, n));
1844
+ }
1845
+ return changes;
1846
+ }
1847
+ /** Diff the optional `epss` object (score, percentile, date). */
1848
+ function diffEpss(oldField, newField) {
1849
+ if (oldField === void 0 && newField === void 0) return [];
1850
+ if (oldField === void 0) return [{
1851
+ op: "add",
1852
+ path: "epss",
1853
+ newValue: newField
1854
+ }];
1855
+ if (newField === void 0) return [{
1856
+ op: "remove",
1857
+ path: "epss",
1858
+ oldValue: oldField
1859
+ }];
1860
+ if (typeof oldField !== "object" || typeof newField !== "object" || oldField === null || newField === null) {
1861
+ if (JSON.stringify(oldField) === JSON.stringify(newField)) return [];
1862
+ return [{
1863
+ op: "replace",
1864
+ path: "epss",
1865
+ oldValue: oldField,
1866
+ newValue: newField
1867
+ }];
1868
+ }
1869
+ const oldObj = oldField;
1870
+ const newObj = newField;
1871
+ const changes = [];
1872
+ const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
1873
+ for (const key of allKeys) {
1874
+ const o = oldObj[key];
1875
+ const n = newObj[key];
1876
+ if (JSON.stringify(o) === JSON.stringify(n)) continue;
1877
+ if (o === void 0) changes.push({
1878
+ op: "add",
1879
+ path: `epss.${key}`,
1880
+ newValue: n
1881
+ });
1882
+ else if (n === void 0) changes.push({
1883
+ op: "remove",
1884
+ path: `epss.${key}`,
1885
+ oldValue: o
1886
+ });
1887
+ else changes.push({
1888
+ op: "replace",
1889
+ path: `epss.${key}`,
1890
+ oldValue: o,
1891
+ newValue: n
1892
+ });
1893
+ }
1894
+ return changes;
1895
+ }
1896
+ /**
1897
+ * Diff the optional `kev` object. Flips of `inKev` from false→true (or
1898
+ * fresh add with `inKev: true`) get an annotated `message` field calling
1899
+ * out the CISA KEV catalog inclusion.
1900
+ */
1901
+ function diffKev(oldField, newField) {
1902
+ if (oldField === void 0 && newField === void 0) return [];
1903
+ if (oldField === void 0) {
1904
+ const change = {
1905
+ op: "add",
1906
+ path: "kev",
1907
+ newValue: newField
1908
+ };
1909
+ const nObj = newField;
1910
+ if (nObj && nObj["inKev"] === true) change.message = `Newly added to CISA KEV catalog as of ${typeof nObj["dateAdded"] === "string" ? nObj["dateAdded"] : "unknown date"}`;
1911
+ return [change];
1912
+ }
1913
+ if (newField === void 0) return [{
1914
+ op: "remove",
1915
+ path: "kev",
1916
+ oldValue: oldField
1917
+ }];
1918
+ if (typeof oldField !== "object" || typeof newField !== "object" || oldField === null || newField === null) {
1919
+ if (JSON.stringify(oldField) === JSON.stringify(newField)) return [];
1920
+ return [{
1921
+ op: "replace",
1922
+ path: "kev",
1923
+ oldValue: oldField,
1924
+ newValue: newField
1925
+ }];
1926
+ }
1927
+ const oldObj = oldField;
1928
+ const newObj = newField;
1929
+ const changes = [];
1930
+ const allKeys = new Set([...Object.keys(oldObj), ...Object.keys(newObj)]);
1931
+ for (const key of allKeys) {
1932
+ const o = oldObj[key];
1933
+ const n = newObj[key];
1934
+ if (JSON.stringify(o) === JSON.stringify(n)) continue;
1935
+ let change;
1936
+ if (o === void 0) change = {
1937
+ op: "add",
1938
+ path: `kev.${key}`,
1939
+ newValue: n
1940
+ };
1941
+ else if (n === void 0) change = {
1942
+ op: "remove",
1943
+ path: `kev.${key}`,
1944
+ oldValue: o
1945
+ };
1946
+ else change = {
1947
+ op: "replace",
1948
+ path: `kev.${key}`,
1949
+ oldValue: o,
1950
+ newValue: n
1951
+ };
1952
+ if (key === "inKev" && o === false && n === true) {
1953
+ const dateAdded = typeof newObj["dateAdded"] === "string" ? newObj["dateAdded"] : "unknown date";
1954
+ change.message = `Newly added to CISA KEV catalog as of ${dateAdded}`;
1955
+ }
1956
+ changes.push(change);
1957
+ }
1958
+ return changes;
1959
+ }
1960
+ /** Diff the `cwe[]` field as a set — order does not matter. */
1961
+ function diffCweSet(oldField, newField) {
1962
+ const toSet = (v) => {
1963
+ if (!Array.isArray(v)) return /* @__PURE__ */ new Set();
1964
+ return new Set(v.filter((x) => typeof x === "string"));
1965
+ };
1966
+ const oldSet = toSet(oldField);
1967
+ const newSet = toSet(newField);
1968
+ if (oldSet.size === 0 && newSet.size === 0) return [];
1969
+ const changes = [];
1970
+ for (const c of newSet) if (!oldSet.has(c)) changes.push({
1971
+ op: "add",
1972
+ path: "cwe",
1973
+ newValue: c
1974
+ });
1975
+ for (const c of oldSet) if (!newSet.has(c)) changes.push({
1976
+ op: "remove",
1977
+ path: "cwe",
1978
+ oldValue: c
1979
+ });
1980
+ return changes;
1981
+ }
1982
+ /** Build the natural key for an AffectedPackage: `name@version`. */
1983
+ function packageKey(pkg) {
1984
+ const name = pkg?.name;
1985
+ const version = pkg?.version;
1986
+ if (typeof name !== "string" || name.length === 0) return null;
1987
+ if (typeof version !== "string" || version.length === 0) return null;
1988
+ return `${name}@${version}`;
1989
+ }
1990
+ /**
1991
+ * Diff a single pair of affectedPackages entries (matched by name+version).
1992
+ * Emits per-field changes for cpe / purl / fixedInVersion and any other
1993
+ * non-key fields that differ.
1994
+ */
1995
+ function diffAffectedPackageEntry(key, oldPkg, newPkg) {
1996
+ const changes = [];
1997
+ const allKeys = new Set([...Object.keys(oldPkg), ...Object.keys(newPkg)]);
1998
+ for (const k of allKeys) {
1999
+ if (k === "name" || k === "version") continue;
2000
+ const o = oldPkg[k];
2001
+ const n = newPkg[k];
2002
+ if (JSON.stringify(o) === JSON.stringify(n)) continue;
2003
+ if (o === void 0) changes.push({
2004
+ op: "add",
2005
+ path: `affectedPackages[${key}].${k}`,
2006
+ newValue: n
2007
+ });
2008
+ else if (n === void 0) changes.push({
2009
+ op: "remove",
2010
+ path: `affectedPackages[${key}].${k}`,
2011
+ oldValue: o
2012
+ });
2013
+ else changes.push({
2014
+ op: "replace",
2015
+ path: `affectedPackages[${key}].${k}`,
2016
+ oldValue: o,
2017
+ newValue: n
2018
+ });
2019
+ }
2020
+ return changes;
2021
+ }
2022
+ /**
2023
+ * Diff old vs new affectedPackages[] arrays. Entries are matched by the
2024
+ * `name@version` tuple; added/removed packages emit whole-entry records;
2025
+ * matched pairs are decomposed via diffAffectedPackageEntry.
2026
+ */
2027
+ function diffAffectedPackages(oldField, newField) {
2028
+ const oldArr = Array.isArray(oldField) ? oldField : [];
2029
+ const newArr = Array.isArray(newField) ? newField : [];
2030
+ if (oldArr.length === 0 && newArr.length === 0) return [];
2031
+ const oldByKey = /* @__PURE__ */ new Map();
2032
+ for (const p of oldArr) {
2033
+ const k = packageKey(p);
2034
+ if (k) oldByKey.set(k, p);
2035
+ }
2036
+ const newByKey = /* @__PURE__ */ new Map();
2037
+ for (const p of newArr) {
2038
+ const k = packageKey(p);
2039
+ if (k) newByKey.set(k, p);
2040
+ }
2041
+ const changes = [];
2042
+ const allKeys = new Set([...oldByKey.keys(), ...newByKey.keys()]);
2043
+ for (const key of allKeys) {
2044
+ const o = oldByKey.get(key);
2045
+ const n = newByKey.get(key);
2046
+ if (o && !n) changes.push({
2047
+ op: "remove",
2048
+ path: `affectedPackages[${key}]`,
2049
+ oldValue: o
2050
+ });
2051
+ else if (!o && n) changes.push({
2052
+ op: "add",
2053
+ path: `affectedPackages[${key}]`,
2054
+ newValue: n
2055
+ });
2056
+ else if (o && n) changes.push(...diffAffectedPackageEntry(key, o, n));
2057
+ }
1604
2058
  return changes;
1605
2059
  }
1606
2060
  /**
@@ -2077,7 +2531,7 @@ function stripSnapshots(req) {
2077
2531
  return rest;
2078
2532
  }
2079
2533
  /**
2080
- * Render an HdfComparison as a JSON string.
2534
+ * Render an HDFComparison as a JSON string.
2081
2535
  *
2082
2536
  * - `detail: 'summary'` -- only `{ formatVersion, comparisonMode, summary }`
2083
2537
  * - `detail: 'control'` -- full document but `before`/`after` stripped from requirementDiffs
@@ -2191,7 +2645,7 @@ function renderStateSection(state, diffs, detail) {
2191
2645
  return lines.join("\n");
2192
2646
  }
2193
2647
  /**
2194
- * Render an HdfComparison as a Markdown string.
2648
+ * Render an HDFComparison as a Markdown string.
2195
2649
  *
2196
2650
  * - `detail: 'summary'` -- summary table only
2197
2651
  * - `detail: 'control'` -- summary + per-requirement tables by state
@@ -2327,7 +2781,7 @@ function buildHeaderLine(comparison, useColor) {
2327
2781
  return header;
2328
2782
  }
2329
2783
  /**
2330
- * Render an HdfComparison for terminal display with optional ANSI colors.
2784
+ * Render an HDFComparison for terminal display with optional ANSI colors.
2331
2785
  *
2332
2786
  * - `detail: 'summary'` -- just the summary line
2333
2787
  * - `detail: 'control'` -- requirement list + summary (excludes unchanged)
@@ -2380,7 +2834,7 @@ function formatFieldChanges(req) {
2380
2834
  }).join("; ");
2381
2835
  }
2382
2836
  /**
2383
- * Render an HdfComparison as a CSV string.
2837
+ * Render an HDFComparison as a CSV string.
2384
2838
  *
2385
2839
  * One row per requirement. Columns:
2386
2840
  * - ID, Title, State, Old Status, New Status, Impact (Old), Impact (New), Change Reasons
@@ -2426,7 +2880,7 @@ function renderCsv(comparison, options) {
2426
2880
  /**
2427
2881
  * Convenience function to render a comparison in any supported format.
2428
2882
  *
2429
- * @param comparison - The HdfComparison document to render
2883
+ * @param comparison - The HDFComparison document to render
2430
2884
  * @param format - Output format: 'json', 'markdown', 'terminal', or 'csv'
2431
2885
  * @param options - Rendering options (detail level, filters, color)
2432
2886
  * @returns The rendered string