upstream-radar 0.34.0 → 0.36.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.
@@ -2,8 +2,10 @@ import { createHash, randomUUID } from 'node:crypto';
2
2
  import { mkdir, readFile, rename, rm, writeFile } from 'node:fs/promises';
3
3
  import { spawn } from 'node:child_process';
4
4
  import { dirname, resolve } from 'node:path';
5
+ import { findReverseDependencyImpacts } from './dependency-index.js';
5
6
  import { parseNpmLockGraph, parsePnpmLockGraph } from './graph.js';
6
7
  import { parsePackageManifestSnapshot } from './inventory.js';
8
+ import { buildUpstreamDownstreamIR, parseUpstreamDownstreamIR } from './upstream-alignment.js';
7
9
  export const OBSERVER_TARGETS_SCHEMA = 'upstream-radar.observer-targets/v1alpha1';
8
10
  export const OBSERVATION_STATE_SCHEMA = 'upstream-radar.observation-state/v1alpha1';
9
11
  export const UPSTREAM_CHANGE_TASK_SCHEMA = 'upstream-radar.upstream-change-task/v1alpha1';
@@ -421,6 +423,7 @@ function parseSnapshot(value, label) {
421
423
  : Array.isArray(source.warnings) && source.warnings.length <= 100
422
424
  ? source.warnings.map((item, index) => boundedString(item, `${label}.warnings[${index}]`, 2_048))
423
425
  : (() => { throw new Error(`${label}.warnings must be a bounded string array`); })();
426
+ const alignment = source.alignment === undefined ? undefined : parseUpstreamDownstreamIR(source.alignment, `${label}.alignment`);
424
427
  return {
425
428
  targetId,
426
429
  ecosystem,
@@ -431,6 +434,7 @@ function parseSnapshot(value, label) {
431
434
  ...(source.graph === undefined ? {} : { graph: source.graph }),
432
435
  ...(graphError === undefined ? {} : { graphError }),
433
436
  ...(warnings === undefined ? {} : { warnings }),
437
+ ...(alignment === undefined ? {} : { alignment }),
434
438
  };
435
439
  }
436
440
  function parseTask(value, label) {
@@ -921,6 +925,24 @@ export class UpstreamObserverClient {
921
925
  graphError = safeError(error);
922
926
  }
923
927
  }
928
+ const alignment = buildUpstreamDownstreamIR({
929
+ targetId: targetValue.id,
930
+ ecosystem: targetValue.ecosystem,
931
+ source: {
932
+ repository: targetRepository.fullName,
933
+ commit,
934
+ packagePath,
935
+ },
936
+ manifest,
937
+ ...(packageObservation === undefined ? {} : {
938
+ package: {
939
+ name: packageObservation.name,
940
+ version: packageObservation.version,
941
+ },
942
+ }),
943
+ ...(graph === undefined ? {} : { graph }),
944
+ ...(graphError === undefined ? {} : { graphError }),
945
+ });
924
946
  return {
925
947
  targetId: targetValue.id,
926
948
  ecosystem: targetValue.ecosystem,
@@ -940,6 +962,7 @@ export class UpstreamObserverClient {
940
962
  ...(graph === undefined ? {} : { graph }),
941
963
  ...(graphError === undefined ? {} : { graphError }),
942
964
  ...(warnings.length === 0 ? {} : { warnings }),
965
+ alignment,
943
966
  };
944
967
  }
945
968
  async compare(repositoryValue, beforeCommit, afterCommit) {
@@ -1028,8 +1051,22 @@ function summary(snapshot) {
1028
1051
  }),
1029
1052
  ...(snapshot.graphError === undefined ? {} : { graphError: snapshot.graphError }),
1030
1053
  ...(snapshot.warnings === undefined ? {} : { warnings: [...snapshot.warnings] }),
1054
+ ...(snapshot.alignment === undefined ? {} : { alignment: structuredClone(snapshot.alignment) }),
1031
1055
  };
1032
1056
  }
1057
+ function alignmentSignature(value) {
1058
+ if (value === undefined)
1059
+ return '';
1060
+ return JSON.stringify({
1061
+ status: value.status,
1062
+ checks: value.checks.map(check => ({
1063
+ code: check.code,
1064
+ status: check.status,
1065
+ upstream: check.upstream,
1066
+ downstream: check.downstream,
1067
+ })),
1068
+ });
1069
+ }
1033
1070
  function manifestChanges(before, after) {
1034
1071
  const fields = ['type', 'main', 'exports', 'engines', 'dependencies', 'optionalDependencies', 'peerDependencies', 'peerDependenciesMeta', 'dsh']
1035
1072
  .filter(field => JSON.stringify(before[field]) !== JSON.stringify(after[field]));
@@ -1076,6 +1113,38 @@ function graphChanges(before, after) {
1076
1113
  removedUnresolved: [...beforeUnresolved].filter(edge => !afterUnresolved.has(edge)).sort(),
1077
1114
  };
1078
1115
  }
1116
+ function addObservedPackageVersion(versions, side, name, version) {
1117
+ if (name === undefined || version === undefined || name === '' || version === '')
1118
+ return;
1119
+ const entry = versions.get(name) ?? { before: new Set(), after: new Set() };
1120
+ entry[side].add(version);
1121
+ versions.set(name, entry);
1122
+ }
1123
+ function addSnapshotPackages(versions, side, snapshot) {
1124
+ addObservedPackageVersion(versions, side, snapshot.manifest.name, snapshot.manifest.version);
1125
+ addObservedPackageVersion(versions, side, snapshot.package?.name, snapshot.package?.version);
1126
+ for (const node of snapshot.graph?.nodes ?? [])
1127
+ addObservedPackageVersion(versions, side, node.name, node.version);
1128
+ }
1129
+ /** Convert two complete snapshots into package-name changes for downstream lookup. */
1130
+ function reverseDependencyChanges(before, after) {
1131
+ const versions = new Map();
1132
+ addSnapshotPackages(versions, 'before', before);
1133
+ addSnapshotPackages(versions, 'after', after);
1134
+ return [...versions.entries()]
1135
+ .filter(([, value]) => {
1136
+ const beforeVersions = [...value.before].sort();
1137
+ const afterVersions = [...value.after].sort();
1138
+ return JSON.stringify(beforeVersions) !== JSON.stringify(afterVersions);
1139
+ })
1140
+ .map(([name, value]) => ({
1141
+ ecosystem: 'npm',
1142
+ name,
1143
+ beforeVersions: [...value.before].sort(),
1144
+ afterVersions: [...value.after].sort(),
1145
+ }))
1146
+ .sort((left, right) => left.name.localeCompare(right.name));
1147
+ }
1079
1148
  function packageChanged(before, after) {
1080
1149
  if (before.package?.name !== after.package?.name || before.package?.version !== after.package?.version)
1081
1150
  return true;
@@ -1109,6 +1178,13 @@ function meaningfulChange(sourceChange, before, after) {
1109
1178
  if (after.package !== undefined && after.manifest.version !== after.package.version) {
1110
1179
  reasons.push(`source/published version drift: source ${after.manifest.name}@${after.manifest.version}, npm ${after.package.name}@${after.package.version}`);
1111
1180
  }
1181
+ // Legacy observation files predate the IR. Populate the new field on the
1182
+ // next run without manufacturing a fake upstream change; only compare two
1183
+ // already-established alignment records.
1184
+ if (before.alignment !== undefined && after.alignment !== undefined
1185
+ && alignmentSignature(before.alignment) !== alignmentSignature(after.alignment)) {
1186
+ reasons.push(`upstream/downstream alignment changed: ${after.alignment?.status ?? 'not observed'}`);
1187
+ }
1112
1188
  if (sourceChange.beforeCommit !== sourceChange.afterCommit) {
1113
1189
  if (sourceChange.comparison === 'unavailable')
1114
1190
  reasons.push('source commit changed and file comparison was unavailable');
@@ -1122,10 +1198,13 @@ function meaningfulChange(sourceChange, before, after) {
1122
1198
  function createTaskId(targetId, beforeCommit, afterCommit, packageVersion, graphDigest) {
1123
1199
  return `upstream-task-${hash([targetId, beforeCommit, afterCommit, packageVersion ?? '', graphDigest ?? ''].join('\0'))}`;
1124
1200
  }
1125
- function createChange(target, before, after, sourceChange) {
1201
+ function createChange(target, before, after, sourceChange, reverseDependencyIndex) {
1126
1202
  const manifest = manifestChanges(before.manifest, after.manifest);
1127
1203
  const graph = graphChanges(before.graph, after.graph);
1128
1204
  const decision = meaningfulChange(sourceChange, before, after);
1205
+ const reverseDependencyImpacts = reverseDependencyIndex === undefined
1206
+ ? []
1207
+ : findReverseDependencyImpacts(reverseDependencyIndex, reverseDependencyChanges(before, after));
1129
1208
  const taskId = decision.meaningful
1130
1209
  ? createTaskId(target.id, before.source.commit, after.source.commit, after.package?.version, after.graph?.digest)
1131
1210
  : undefined;
@@ -1140,6 +1219,7 @@ function createChange(target, before, after, sourceChange) {
1140
1219
  ...(graph === undefined ? {} : { graph }),
1141
1220
  reasons: decision.reasons,
1142
1221
  meaningful: decision.meaningful,
1222
+ ...(reverseDependencyImpacts.length === 0 ? {} : { reverseDependencyImpacts }),
1143
1223
  ...(taskId === undefined ? {} : { taskId }),
1144
1224
  };
1145
1225
  }
@@ -1184,7 +1264,8 @@ export function renderUpstreamChangeAgentPrompt(task) {
1184
1264
  3. 如果需要项目代码证据,只读取当前项目已经存在的文件;不要为了验证结论安装包或执行上游代码。
1185
1265
  4. 区分源码变化、发布包变化、依赖图变化和模型判断。只有明确指出文件、配置、符号或“不知道缺少什么证据”,结论才算完整。
1186
1266
  5. breaking_change 只能在有明确入口、导出、DSH bundle、peer/API 或发布说明证据时判断;不因为版本号变化就直接断言。
1187
- 6. 返回严格 JSON 对象,字段只能是 impact、confidence、evidence、breaking_change、dependency_risk、recommended_action、urgency、reasoning_summary。
1267
+ 6. 如果 change_json.reverseDependencyImpacts 存在,它表示静态反向索引找到的下游插件和依赖路径;把它当作影响范围证据,不要把“可能受影响”写成“已经损坏”。
1268
+ 7. 返回严格 JSON 对象,字段只能是 impact、confidence、evidence、breaking_change、dependency_risk、recommended_action、urgency、reasoning_summary。
1188
1269
 
1189
1270
  expected_output:
1190
1271
  ${JSON.stringify(task.expectedOutput, null, 2)}
@@ -1485,6 +1566,7 @@ export async function runObserver(config, previousState, options = {}) {
1485
1566
  let pendingTasks = [...previousState.pendingTasks];
1486
1567
  const changes = [];
1487
1568
  const baselineTargets = [];
1569
+ const alignmentFindings = [];
1488
1570
  const errors = [];
1489
1571
  const newTasks = [];
1490
1572
  for (const configuredTarget of config.targets) {
@@ -1494,8 +1576,16 @@ export async function runObserver(config, previousState, options = {}) {
1494
1576
  nextTargets[configuredTarget.id] = current;
1495
1577
  if (before === undefined) {
1496
1578
  baselineTargets.push(configuredTarget.id);
1579
+ if (current.alignment !== undefined)
1580
+ alignmentFindings.push({ targetId: configuredTarget.id, alignment: structuredClone(current.alignment) });
1497
1581
  continue;
1498
1582
  }
1583
+ if (before.alignment === undefined && current.alignment !== undefined) {
1584
+ // Upgrade legacy state into the IR and show its first finding once.
1585
+ // This is evidence for the author, not a fake upstream change and not
1586
+ // a reason to wake the Agent on every subsequent scheduled run.
1587
+ alignmentFindings.push({ targetId: configuredTarget.id, alignment: structuredClone(current.alignment) });
1588
+ }
1499
1589
  const sourceChange = before.source.commit === current.source.commit
1500
1590
  ? {
1501
1591
  beforeCommit: before.source.commit,
@@ -1506,7 +1596,7 @@ export async function runObserver(config, previousState, options = {}) {
1506
1596
  nonRuntimeFiles: [],
1507
1597
  }
1508
1598
  : await source.compare(current.source.repository, before.source.commit, current.source.commit);
1509
- const change = createChange(configuredTarget, before, current, sourceChange);
1599
+ const change = createChange(configuredTarget, before, current, sourceChange, options.reverseDependencyIndex);
1510
1600
  if (change.reasons.length === 0)
1511
1601
  continue;
1512
1602
  let reviewedChange = change;
@@ -1575,6 +1665,17 @@ export async function runObserver(config, previousState, options = {}) {
1575
1665
  checkedAt,
1576
1666
  targetsChecked: config.targets.length,
1577
1667
  baselineTargets,
1668
+ alignmentFindings,
1669
+ ...(options.reverseDependencyIndex === undefined ? {} : {
1670
+ reverseDependencyIndex: {
1671
+ observations: options.reverseDependencyIndex.observations,
1672
+ plugins: options.reverseDependencyIndex.plugins.length,
1673
+ dependencies: options.reverseDependencyIndex.dependencies.length,
1674
+ completeObservations: options.reverseDependencyIndex.coverage.completeObservations,
1675
+ incompleteObservations: options.reverseDependencyIndex.coverage.incompleteObservations,
1676
+ unresolvedEdges: options.reverseDependencyIndex.coverage.unresolvedEdges,
1677
+ },
1678
+ }),
1578
1679
  changes,
1579
1680
  pendingTasks: pendingTasks.map(task => task.id),
1580
1681
  pendingTaskDetails: pendingTasks.map(summarizePendingTask),
@@ -1640,13 +1741,46 @@ function summarizePendingTask(task) {
1640
1741
  removedEdges: (change.graph?.removedEdges ?? []).slice(0, 24),
1641
1742
  addedUnresolved: (change.graph?.addedUnresolved ?? []).slice(0, 24),
1642
1743
  removedUnresolved: (change.graph?.removedUnresolved ?? []).slice(0, 24),
1744
+ ...(change.reverseDependencyImpacts === undefined ? {} : { reverseDependencyImpacts: structuredClone(change.reverseDependencyImpacts) }),
1643
1745
  ...(change.artifactReview === undefined ? {} : { artifactReview: structuredClone(change.artifactReview) }),
1644
1746
  };
1645
1747
  }
1748
+ function renderAlignment(lines, alignment, label) {
1749
+ lines.push(`${label}: ${alignment.status.toUpperCase()}`);
1750
+ for (const check of alignment.checks.filter(item => item.status !== 'aligned')) {
1751
+ lines.push(`${label} ${check.status}: ${check.code} — ${check.summary}`);
1752
+ if (check.upstream !== undefined || check.downstream !== undefined) {
1753
+ lines.push(` Upstream: ${check.upstream ?? 'unknown'}; downstream: ${check.downstream ?? 'unknown'}`);
1754
+ }
1755
+ if (check.remediation !== undefined)
1756
+ lines.push(` Alignment next step: ${check.remediation}`);
1757
+ }
1758
+ }
1759
+ function renderReverseDependencyImpacts(lines, impacts, label = 'Downstream impact') {
1760
+ for (const impact of impacts.slice(0, 32)) {
1761
+ const from = impact.changedFrom.length === 0 ? 'not observed' : impact.changedFrom.join(', ');
1762
+ const to = impact.changedTo.length === 0 ? 'not observed' : impact.changedTo.join(', ');
1763
+ lines.push(`${label}: ${impact.dependency.name} (${from} → ${to}); ${impact.dependents.length}${impact.truncated ? '+' : ''} downstream plugin(s); coverage ${impact.coverage}`);
1764
+ for (const dependent of impact.dependents.slice(0, 8)) {
1765
+ const project = dependent.project === undefined ? '' : ` [${dependent.project.name}]`;
1766
+ lines.push(` ${dependent.plugin.name}@${dependent.plugin.version}${project} (${dependent.coverage})`);
1767
+ for (const path of dependent.paths.slice(0, 2))
1768
+ lines.push(` ${path.nodes.join(' → ')}`);
1769
+ if (dependent.paths.length > 2)
1770
+ lines.push(` ... ${dependent.paths.length - 2} more path(s)`);
1771
+ }
1772
+ if (impact.truncated)
1773
+ lines.push(' ... additional downstream plugins omitted from this bounded report');
1774
+ }
1775
+ }
1646
1776
  export function renderObserverReport(report) {
1647
1777
  const lines = [];
1648
- if (report.changes.length === 0 && report.errors.length === 0 && report.pendingTasks.length === 0) {
1778
+ const alignmentFindings = report.alignmentFindings.filter(item => item.alignment.status !== 'aligned');
1779
+ if (report.changes.length === 0 && report.errors.length === 0 && report.pendingTasks.length === 0 && alignmentFindings.length === 0) {
1649
1780
  lines.push(`No meaningful upstream changes. Checked ${report.targetsChecked} target(s); DSH Agent was not called.`);
1781
+ if (report.reverseDependencyIndex !== undefined) {
1782
+ lines.push(`Loaded downstream reverse index: ${report.reverseDependencyIndex.plugins} plugin(s), ${report.reverseDependencyIndex.dependencies} dependency coordinate(s); coverage ${report.reverseDependencyIndex.completeObservations} complete / ${report.reverseDependencyIndex.incompleteObservations} incomplete.`);
1783
+ }
1650
1784
  if (report.baselineTargets.length > 0)
1651
1785
  lines.push(`Created baseline for: ${report.baselineTargets.join(', ')}.`);
1652
1786
  return `${lines.join('\n')}\n`;
@@ -1655,11 +1789,23 @@ export function renderObserverReport(report) {
1655
1789
  lines.push('');
1656
1790
  lines.push(`Checked at: ${report.checkedAt}`);
1657
1791
  lines.push(`Targets: ${report.targetsChecked}; baselines: ${report.baselineTargets.length}; changes: ${report.changes.length}`);
1792
+ if (report.reverseDependencyIndex !== undefined) {
1793
+ lines.push(`Downstream reverse index: ${report.reverseDependencyIndex.plugins} plugin(s), ${report.reverseDependencyIndex.dependencies} dependency coordinate(s); coverage ${report.reverseDependencyIndex.completeObservations} complete / ${report.reverseDependencyIndex.incompleteObservations} incomplete.`);
1794
+ }
1658
1795
  lines.push('');
1659
1796
  if (report.baselineTargets.length > 0) {
1660
1797
  lines.push(`Baseline created: ${report.baselineTargets.join(', ')}`);
1661
1798
  lines.push('');
1662
1799
  }
1800
+ if (alignmentFindings.length > 0) {
1801
+ lines.push('## Upstream/downstream alignment findings');
1802
+ lines.push('');
1803
+ for (const item of alignmentFindings) {
1804
+ lines.push(`### ${item.targetId}`);
1805
+ renderAlignment(lines, item.alignment, 'Alignment');
1806
+ lines.push('');
1807
+ }
1808
+ }
1663
1809
  for (const change of report.changes) {
1664
1810
  lines.push(`## ${change.targetId} (${change.ecosystem})`);
1665
1811
  lines.push('');
@@ -1668,6 +1814,8 @@ export function renderObserverReport(report) {
1668
1814
  lines.push(`Package: ${displayPackage(change.previous.package)} → ${displayPackage(change.current.package)}`);
1669
1815
  lines.push(`Graph: ${displayGraph(change.previous.graph)} → ${displayGraph(change.current.graph)}`);
1670
1816
  lines.push(`Meaningful: ${change.meaningful ? 'yes' : 'no'}`);
1817
+ if (change.current.alignment !== undefined)
1818
+ renderAlignment(lines, change.current.alignment, 'Upstream/downstream alignment');
1671
1819
  for (const warning of change.current.warnings ?? [])
1672
1820
  lines.push(`Warning: ${warning}`);
1673
1821
  if (change.source.changedFiles.length > 0)
@@ -1691,6 +1839,8 @@ export function renderObserverReport(report) {
1691
1839
  lines.push(`Coverage change: resolved dependency edges: ${removedUnresolved.slice(0, 8).join(', ')}`);
1692
1840
  }
1693
1841
  }
1842
+ if (change.reverseDependencyImpacts !== undefined)
1843
+ renderReverseDependencyImpacts(lines, change.reverseDependencyImpacts);
1694
1844
  if (change.reasons.length > 0)
1695
1845
  lines.push(`Reasons: ${change.reasons.join('; ')}`);
1696
1846
  if (change.current.package !== undefined) {
@@ -1757,6 +1907,8 @@ export function renderObserverReport(report) {
1757
1907
  lines.push(`New unresolved dependency edges: ${task.addedUnresolved.join(', ')}`);
1758
1908
  if (task.removedUnresolved.length > 0)
1759
1909
  lines.push(`Resolved dependency edges: ${task.removedUnresolved.join(', ')}`);
1910
+ if (task.reverseDependencyImpacts !== undefined)
1911
+ renderReverseDependencyImpacts(lines, task.reverseDependencyImpacts, 'Downstream impact');
1760
1912
  if (task.reasons.length > 0)
1761
1913
  lines.push(`Reasons: ${task.reasons.join('; ')}`);
1762
1914
  if (task.artifactReview !== undefined) {