Skip to content

Commit 88526a6

Browse files
Haofeiclaude
andcommitted
Phase 3: capability diff in pkg diff (what powers a change introduces)
`pkg diff` now reports the distinct capabilities added/removed between two package versions, high-risk first, and flags new high-risk powers as review reasons. This is the core "AI-generated code review" signal: surface capability escalation (and de-classification) instead of making a reviewer read the code. capability changes: + [high] unknown via SqlxFfi.query_strings # dropped binding -> unclassified - [medium] database.read via SqlxFfi.query_strings Additive PackageDiff.capability_changes field (JSON + human); unit-tested. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent c5afbea commit 88526a6

3 files changed

Lines changed: 159 additions & 1 deletion

File tree

src/package/diff.rs

Lines changed: 123 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,8 @@ use super::contract::{
1414
};
1515
use super::source_set::{ManifestNativeRust, PackageSource, load_package};
1616
use super::{
17-
Manifest, PackageDiff, PackageInterfaceChange, PackageInterfaceChangeKind,
17+
Manifest, PackageCapabilityChange, PackageCapabilityChangeKind, PackageDiff,
18+
PackageInterfaceChange, PackageInterfaceChangeKind,
1819
PackageManifestChange, PackageReviewAwaitBoundary, PackageReviewAwaitSite,
1920
PackageReviewFileKind, PackageRisk, feature_values_label,
2021
package_feature_may_change_boundary_risk, package_identity, package_risk_label,
@@ -71,10 +72,23 @@ pub fn diff_package_dirs(old_dir: &Path, new_dir: &Path) -> Result<PackageDiff,
7172
old_review.await_sites.as_slice(),
7273
new_review.await_sites.as_slice(),
7374
);
75+
let capability_changes =
76+
diff_package_capabilities(&old_review.capabilities, &new_review.capabilities);
77+
7478
let mut reasons = package_diff_reasons(&manifest_changes, &interface_changes);
7579
if await_sites_changed {
7680
reasons.push("await site review metadata changed".to_string());
7781
}
82+
for change in &capability_changes {
83+
if change.change == PackageCapabilityChangeKind::Added
84+
&& change.risk == crate::CapabilityRisk::High
85+
{
86+
reasons.push(format!(
87+
"new high-risk capability `{}` via {}",
88+
change.category, change.binding_symbol
89+
));
90+
}
91+
}
7892
if old_review.risk != new_review.risk {
7993
reasons.push(format!(
8094
"package risk changed from {} to {}",
@@ -99,11 +113,66 @@ pub fn diff_package_dirs(old_dir: &Path, new_dir: &Path) -> Result<PackageDiff,
99113
reasons,
100114
manifest_changes,
101115
interface_changes,
116+
capability_changes,
102117
old_review: old_review.summary,
103118
new_review: new_review.summary,
104119
})
105120
}
106121

122+
/// Distinct capabilities (by category + binding symbol) added or removed between
123+
/// two package versions, high-risk first.
124+
fn diff_package_capabilities(
125+
old: &[crate::package::types::PackageReviewCapability],
126+
new: &[crate::package::types::PackageReviewCapability],
127+
) -> Vec<PackageCapabilityChange> {
128+
use std::collections::BTreeMap;
129+
fn distinct(
130+
capabilities: &[crate::package::types::PackageReviewCapability],
131+
) -> BTreeMap<(String, String), crate::CapabilityRisk> {
132+
let mut map = BTreeMap::new();
133+
for capability in capabilities {
134+
map.entry((capability.category.clone(), capability.binding_symbol.clone()))
135+
.or_insert(capability.risk);
136+
}
137+
map
138+
}
139+
let old_set = distinct(old);
140+
let new_set = distinct(new);
141+
let mut changes = Vec::new();
142+
for ((category, binding_symbol), risk) in &new_set {
143+
if !old_set.contains_key(&(category.clone(), binding_symbol.clone())) {
144+
changes.push(PackageCapabilityChange {
145+
change: PackageCapabilityChangeKind::Added,
146+
category: category.clone(),
147+
binding_symbol: binding_symbol.clone(),
148+
risk: *risk,
149+
});
150+
}
151+
}
152+
for ((category, binding_symbol), risk) in &old_set {
153+
if !new_set.contains_key(&(category.clone(), binding_symbol.clone())) {
154+
changes.push(PackageCapabilityChange {
155+
change: PackageCapabilityChangeKind::Removed,
156+
category: category.clone(),
157+
binding_symbol: binding_symbol.clone(),
158+
risk: *risk,
159+
});
160+
}
161+
}
162+
let rank = |risk: crate::CapabilityRisk| match risk {
163+
crate::CapabilityRisk::High => 0u8,
164+
crate::CapabilityRisk::Medium => 1,
165+
crate::CapabilityRisk::Low => 2,
166+
};
167+
changes.sort_by(|a, b| {
168+
rank(a.risk)
169+
.cmp(&rank(b.risk))
170+
.then_with(|| a.category.cmp(&b.category))
171+
.then_with(|| a.binding_symbol.cmp(&b.binding_symbol))
172+
});
173+
changes
174+
}
175+
107176
fn compare_package_identity(
108177
old: &Manifest,
109178
new: &Manifest,
@@ -647,3 +716,56 @@ fn manifest_change(
647716
risk,
648717
}
649718
}
719+
720+
#[cfg(test)]
721+
mod capability_diff_tests {
722+
use super::*;
723+
use crate::CapabilityRisk;
724+
use crate::package::types::PackageReviewCapability;
725+
726+
fn cap(binding: &str, category: &str, risk: CapabilityRisk) -> PackageReviewCapability {
727+
PackageReviewCapability {
728+
function: binding.to_string(),
729+
binding_symbol: binding.to_string(),
730+
category: category.to_string(),
731+
risk,
732+
provider: None,
733+
service: None,
734+
action: None,
735+
resource: None,
736+
call_chain: vec![binding.to_string()],
737+
span: None,
738+
unknown_reason: None,
739+
}
740+
}
741+
742+
#[test]
743+
fn reports_added_and_removed_capabilities_high_risk_first() {
744+
let old = vec![cap("Db.read", "database.read", CapabilityRisk::Medium)];
745+
let new = vec![
746+
cap("Db.read", "database.read", CapabilityRisk::Medium),
747+
cap("Db.write", "database.write", CapabilityRisk::High),
748+
cap("Net.get", "network.client", CapabilityRisk::High),
749+
];
750+
let changes = diff_package_capabilities(&old, &new);
751+
// Two high-risk additions, sorted high-first by category.
752+
assert_eq!(changes.len(), 2);
753+
assert!(changes.iter().all(|c| c.change == PackageCapabilityChangeKind::Added));
754+
assert_eq!(changes[0].category, "database.write");
755+
assert_eq!(changes[0].risk, CapabilityRisk::High);
756+
assert_eq!(changes[1].category, "network.client");
757+
}
758+
759+
#[test]
760+
fn dedups_per_binding_and_detects_removal() {
761+
let old = vec![
762+
cap("Db.write", "database.write", CapabilityRisk::High),
763+
cap("Db.write", "database.write", CapabilityRisk::High),
764+
];
765+
let new: Vec<PackageReviewCapability> = vec![];
766+
let changes = diff_package_capabilities(&old, &new);
767+
assert_eq!(changes.len(), 1);
768+
assert_eq!(changes[0].change, PackageCapabilityChangeKind::Removed);
769+
assert_eq!(changes[0].binding_symbol, "Db.write");
770+
}
771+
}

src/package/format.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -461,6 +461,24 @@ pub fn format_package_diff_human(diff: &PackageDiff) -> String {
461461
diff.old_review.await_sites, diff.new_review.await_sites
462462
));
463463
}
464+
if !diff.capability_changes.is_empty() {
465+
output.push_str("capability changes:\n");
466+
for change in &diff.capability_changes {
467+
let sign = match change.change {
468+
crate::package::types::PackageCapabilityChangeKind::Added => "+",
469+
crate::package::types::PackageCapabilityChangeKind::Removed => "-",
470+
};
471+
let risk = match change.risk {
472+
crate::CapabilityRisk::High => "high",
473+
crate::CapabilityRisk::Medium => "medium",
474+
crate::CapabilityRisk::Low => "low",
475+
};
476+
output.push_str(&format!(
477+
" {sign} [{risk}] {} via {}\n",
478+
change.category, change.binding_symbol
479+
));
480+
}
481+
}
464482
for change in &diff.manifest_changes {
465483
output.push_str(&format!(
466484
"{} {}: {} -> {} ({})\n",

src/package/types.rs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,10 +95,28 @@ pub struct PackageDiff {
9595
pub reasons: Vec<String>,
9696
pub manifest_changes: Vec<PackageManifestChange>,
9797
pub interface_changes: Vec<PackageInterfaceChange>,
98+
/// Distinct capabilities added/removed between the two versions — the
99+
/// "what powers did this change introduce" view.
100+
pub capability_changes: Vec<PackageCapabilityChange>,
98101
pub old_review: PackageReviewSummary,
99102
pub new_review: PackageReviewSummary,
100103
}
101104

105+
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
106+
#[serde(rename_all = "lowercase")]
107+
pub enum PackageCapabilityChangeKind {
108+
Added,
109+
Removed,
110+
}
111+
112+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
113+
pub struct PackageCapabilityChange {
114+
pub change: PackageCapabilityChangeKind,
115+
pub category: String,
116+
pub binding_symbol: String,
117+
pub risk: crate::CapabilityRisk,
118+
}
119+
102120
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
103121
pub struct PackageCheck {
104122
pub package: PackageIdentity,

0 commit comments

Comments
 (0)