Skip to content

Commit d9678d0

Browse files
committed
Add spans and before-after to review JSON
1 parent 0769330 commit d9678d0

3 files changed

Lines changed: 184 additions & 10 deletions

File tree

README.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ It currently implements:
1515
- `rss check --json <file.rss>` with serde-backed machine-readable diagnostics
1616
- `rss check --explain <code>` for diagnostic code explanations
1717
- `rss fmt <file.rss>` as a parse/check gate that prints the source unchanged when valid
18-
- `rss review <old.rss> <new.rss>` and `rss review --json <old.rss> <new.rss>` for first-pass API/type/effect/freshness diffs with structured risk categories and path-aware local/manage boundary summaries
18+
- `rss review <old.rss> <new.rss>` and `rss review --json <old.rss> <new.rss>` for first-pass API/type/effect/freshness diffs with structured risk categories, spans, before/after values, and path-aware local/manage boundary summaries
1919
- Lexer, lightweight parser, syntax AST, HIR signature table, and semantic checks for the review-critical v0.4.1 rules
2020
- Builtin signatures for the current fixture stdlib surface, including `Image`, `File`, `Map`, `ResourcePool`, `Json`, `Csv`, database/resource helpers, and cache/config helpers
2121
- HIR type and field tables for class/struct/resource declarations and handle fields
@@ -61,7 +61,7 @@ Implemented diagnostic classes include:
6161
- local capture by managed closures
6262
- local capture by closures passed to retaining APIs
6363
- `take` of handle fields
64-
- API, type layout, path-aware local/manage boundary, effect, mode, and freshness changes in `rss review`, tagged with structured risk categories in human and JSON output
64+
- API, type layout, path-aware local/manage boundary, effect, mode, and freshness changes in `rss review`, tagged with structured risk categories, spans, and before/after values in JSON output
6565
- likely operator overload attempts
6666

6767
Non-goals for this stage:
@@ -79,7 +79,7 @@ Non-goals for this stage:
7979
Near-term roadmap:
8080

8181
1. Expand local/resource dataflow precision around typed `ResourcePool<T>` lease propagation and expression-order coverage for short-circuiting/control expressions.
82-
2. Expand `rss review` JSON with spans and structured before/after fields.
82+
2. Expand `rss review` JSON with richer machine-applicable remediation hints.
8383

8484
Run:
8585

src/review.rs

Lines changed: 167 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ use std::collections::{BTreeMap, BTreeSet};
22

33
use serde::Serialize;
44

5-
use crate::diagnostic::code;
5+
use crate::diagnostic::{Span, code};
66
use crate::syntax::ast::{
77
Block, CallArg, DataEffect, EffectDecl, Expr, FieldDecl, FileMode, FunctionDecl, Item, LetKind,
88
Param, Stmt, TypeDecl, TypeKind, TypeRef,
@@ -14,6 +14,20 @@ pub struct ReviewFinding {
1414
pub code: String,
1515
pub risk: ReviewRisk,
1616
pub summary: String,
17+
pub spans: Vec<ReviewSpan>,
18+
#[serde(skip_serializing_if = "Option::is_none")]
19+
pub before: Option<String>,
20+
#[serde(skip_serializing_if = "Option::is_none")]
21+
pub after: Option<String>,
22+
}
23+
24+
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
25+
pub struct ReviewSpan {
26+
pub file: String,
27+
pub line: usize,
28+
pub column: usize,
29+
pub length: usize,
30+
pub label: String,
1731
}
1832

1933
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
@@ -57,6 +71,9 @@ pub fn review_sources(
5771
file_mode_label(old_program.mode),
5872
file_mode_label(new_program.mode)
5973
),
74+
Vec::new(),
75+
Some(file_mode_label(old_program.mode).to_string()),
76+
Some(file_mode_label(new_program.mode).to_string()),
6077
));
6178
}
6279

@@ -66,15 +83,21 @@ pub fn review_sources(
6683

6784
for name in type_names {
6885
match (old_types.get(&name), new_types.get(&name)) {
69-
(Some(_), None) => findings.push(review_finding(
86+
(Some(old), None) => findings.push(review_finding(
7087
code::REVIEW_TYPE_REMOVED,
7188
ReviewRisk::TypeLayout,
7289
format!("type `{name}` was removed."),
90+
vec![review_span(&old.span, "removed type")],
91+
Some(type_contract(old)),
92+
None,
7393
)),
74-
(None, Some(_)) => findings.push(review_finding(
94+
(None, Some(new)) => findings.push(review_finding(
7595
code::REVIEW_TYPE_ADDED,
7696
ReviewRisk::TypeLayout,
7797
format!("type `{name}` was added."),
98+
vec![review_span(&new.span, "added type")],
99+
None,
100+
Some(type_contract(new)),
78101
)),
79102
(Some(old), Some(new)) => compare_type(old, new, &mut findings),
80103
(None, None) => {}
@@ -91,15 +114,21 @@ pub fn review_sources(
91114

92115
for name in function_names {
93116
match (old_functions.get(&name), new_functions.get(&name)) {
94-
(Some(_), None) => findings.push(review_finding(
117+
(Some(old), None) => findings.push(review_finding(
95118
code::REVIEW_FUNCTION_REMOVED,
96119
ReviewRisk::Api,
97120
format!("function `{name}` was removed."),
121+
vec![review_span(&old.span, "removed function")],
122+
Some(function_contract(old)),
123+
None,
98124
)),
99-
(None, Some(_)) => findings.push(review_finding(
125+
(None, Some(new)) => findings.push(review_finding(
100126
code::REVIEW_FUNCTION_ADDED,
101127
ReviewRisk::Api,
102128
format!("function `{name}` was added."),
129+
vec![review_span(&new.span, "added function")],
130+
None,
131+
Some(function_contract(new)),
103132
)),
104133
(Some(old), Some(new)) => compare_function(old, new, &mut findings),
105134
(None, None) => {}
@@ -130,11 +159,21 @@ pub fn format_review_json(findings: &[ReviewFinding]) -> String {
130159
serde_json::to_string(findings).expect("review JSON serialization should not fail")
131160
}
132161

133-
fn review_finding(code: &str, risk: ReviewRisk, summary: impl Into<String>) -> ReviewFinding {
162+
fn review_finding(
163+
code: &str,
164+
risk: ReviewRisk,
165+
summary: impl Into<String>,
166+
spans: Vec<ReviewSpan>,
167+
before: Option<String>,
168+
after: Option<String>,
169+
) -> ReviewFinding {
134170
ReviewFinding {
135171
code: code.to_string(),
136172
risk,
137173
summary: summary.into(),
174+
spans,
175+
before,
176+
after,
138177
}
139178
}
140179

@@ -146,6 +185,7 @@ struct FunctionSig {
146185
returns_fresh: bool,
147186
effects: BTreeSet<String>,
148187
boundary: BoundarySig,
188+
span: Span,
149189
}
150190

151191
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -179,6 +219,7 @@ struct TypeSig {
179219
name: String,
180220
kind: TypeKind,
181221
fields: BTreeMap<String, FieldSig>,
222+
span: Span,
182223
}
183224

184225
#[derive(Debug, Clone, PartialEq, Eq)]
@@ -206,6 +247,7 @@ fn type_sig(type_decl: &TypeDecl) -> TypeSig {
206247
.iter()
207248
.map(|field| (field.name.clone(), field_sig(field)))
208249
.collect(),
250+
span: type_decl.span.clone(),
209251
}
210252
}
211253

@@ -234,6 +276,7 @@ fn function_sig(function: &FunctionDecl) -> FunctionSig {
234276
returns_fresh: function.returns_fresh,
235277
effects: function.effects.iter().map(effect_name).collect(),
236278
boundary: boundary_sig(&function.body),
279+
span: function.span.clone(),
237280
}
238281
}
239282

@@ -251,20 +294,29 @@ fn compare_function(old: &FunctionSig, new: &FunctionSig, findings: &mut Vec<Rev
251294
code::REVIEW_PARAMS_CHANGED,
252295
ReviewRisk::Api,
253296
format!("function `{}` parameters changed.", old.name),
297+
paired_spans(&old.span, &new.span, "old function", "new function"),
298+
Some(params_contract(&old.params)),
299+
Some(params_contract(&new.params)),
254300
));
255301
}
256302
if old.return_type != new.return_type || old.returns_fresh != new.returns_fresh {
257303
findings.push(review_finding(
258304
code::REVIEW_RETURN_CHANGED,
259305
ReviewRisk::Api,
260306
format!("function `{}` return contract changed.", old.name),
307+
paired_spans(&old.span, &new.span, "old function", "new function"),
308+
Some(return_contract(old)),
309+
Some(return_contract(new)),
261310
));
262311
}
263312
if old.effects != new.effects {
264313
findings.push(review_finding(
265314
code::REVIEW_EFFECTS_CHANGED,
266315
ReviewRisk::Effect,
267316
format!("function `{}` effects changed.", old.name),
317+
paired_spans(&old.span, &new.span, "old function", "new function"),
318+
Some(effects_contract(&old.effects)),
319+
Some(effects_contract(&new.effects)),
268320
));
269321
}
270322
if old.boundary != new.boundary {
@@ -276,6 +328,9 @@ fn compare_function(old: &FunctionSig, new: &FunctionSig, findings: &mut Vec<Rev
276328
old.name,
277329
boundary_change_summary(&old.boundary, &new.boundary)
278330
),
331+
paired_spans(&old.span, &new.span, "old function", "new function"),
332+
Some(boundary_contract(&old.boundary)),
333+
Some(boundary_contract(&new.boundary)),
279334
));
280335
}
281336
}
@@ -291,13 +346,19 @@ fn compare_type(old: &TypeSig, new: &TypeSig, findings: &mut Vec<ReviewFinding>)
291346
type_kind_label(old.kind),
292347
type_kind_label(new.kind)
293348
),
349+
paired_spans(&old.span, &new.span, "old type", "new type"),
350+
Some(type_kind_label(old.kind).to_string()),
351+
Some(type_kind_label(new.kind).to_string()),
294352
));
295353
}
296354
if old.fields != new.fields {
297355
findings.push(review_finding(
298356
code::REVIEW_TYPE_FIELDS_CHANGED,
299357
ReviewRisk::TypeLayout,
300358
format!("type `{}` field layout changed.", old.name),
359+
paired_spans(&old.span, &new.span, "old type", "new type"),
360+
Some(fields_contract(&old.fields)),
361+
Some(fields_contract(&new.fields)),
301362
));
302363
}
303364
}
@@ -310,6 +371,20 @@ fn file_mode_label(mode: Option<FileMode>) -> &'static str {
310371
}
311372
}
312373

374+
fn review_span(span: &Span, label: &str) -> ReviewSpan {
375+
ReviewSpan {
376+
file: span.file.clone(),
377+
line: span.line,
378+
column: span.column,
379+
length: span.length,
380+
label: label.to_string(),
381+
}
382+
}
383+
384+
fn paired_spans(old: &Span, new: &Span, old_label: &str, new_label: &str) -> Vec<ReviewSpan> {
385+
vec![review_span(old, old_label), review_span(new, new_label)]
386+
}
387+
313388
fn type_kind_label(kind: TypeKind) -> &'static str {
314389
match kind {
315390
TypeKind::Class => "class",
@@ -326,6 +401,92 @@ fn effect_label(effect: DataEffect) -> &'static str {
326401
}
327402
}
328403

404+
fn function_contract(function: &FunctionSig) -> String {
405+
let mut contract = format!(
406+
"fn {}({})",
407+
function.name,
408+
params_contract(&function.params)
409+
);
410+
if let Some(return_type) = &function.return_type {
411+
if function.returns_fresh {
412+
contract.push_str(&format!(" -> fresh {return_type}"));
413+
} else {
414+
contract.push_str(&format!(" -> {return_type}"));
415+
}
416+
}
417+
if !function.effects.is_empty() {
418+
contract.push_str(&format!(
419+
" effects({})",
420+
effects_contract(&function.effects)
421+
));
422+
}
423+
contract
424+
}
425+
426+
fn params_contract(params: &[ParamSig]) -> String {
427+
params
428+
.iter()
429+
.map(|param| match param.effect {
430+
Some(effect) => format!("{}: {} {}", param.name, effect, param.type_name),
431+
None => format!("{}: {}", param.name, param.type_name),
432+
})
433+
.collect::<Vec<_>>()
434+
.join(", ")
435+
}
436+
437+
fn return_contract(function: &FunctionSig) -> String {
438+
match (&function.return_type, function.returns_fresh) {
439+
(Some(return_type), true) => format!("fresh {return_type}"),
440+
(Some(return_type), false) => return_type.clone(),
441+
(None, _) => "<missing>".to_string(),
442+
}
443+
}
444+
445+
fn effects_contract(effects: &BTreeSet<String>) -> String {
446+
if effects.is_empty() {
447+
return "<none>".to_string();
448+
}
449+
effects.iter().cloned().collect::<Vec<_>>().join(", ")
450+
}
451+
452+
fn type_contract(ty: &TypeSig) -> String {
453+
format!(
454+
"{} {} {{ {} }}",
455+
type_kind_label(ty.kind),
456+
ty.name,
457+
fields_contract(&ty.fields)
458+
)
459+
}
460+
461+
fn fields_contract(fields: &BTreeMap<String, FieldSig>) -> String {
462+
if fields.is_empty() {
463+
return "<none>".to_string();
464+
}
465+
fields
466+
.iter()
467+
.map(|(name, field)| {
468+
if field.is_handle {
469+
format!("{name}: handle {}", field.type_name)
470+
} else {
471+
format!("{name}: {}", field.type_name)
472+
}
473+
})
474+
.collect::<Vec<_>>()
475+
.join(", ")
476+
}
477+
478+
fn boundary_contract(boundary: &BoundarySig) -> String {
479+
if boundary.events.is_empty() {
480+
return "<none>".to_string();
481+
}
482+
boundary
483+
.events
484+
.iter()
485+
.map(boundary_event_label)
486+
.collect::<Vec<_>>()
487+
.join("; ")
488+
}
489+
329490
fn type_name(ty: &TypeRef) -> String {
330491
if ty.args.is_empty() {
331492
return ty.name.clone();

tests/checker.rs

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -82,7 +82,9 @@ fn review_json_uses_protocol_shape() {
8282
let old_source = r#"
8383
mode: managed
8484
85-
fn render(path: read Path) -> Image {
85+
fn render(path: read Path) -> Image
86+
effects(no_panic)
87+
{
8688
Image.load(path: read path)
8789
}
8890
"#;
@@ -110,6 +112,17 @@ fn render(path: take Path) -> fresh Image
110112
.iter()
111113
.any(|item| item["code"] == "RSR006" && item["risk"] == "effect")
112114
);
115+
let effect = items
116+
.iter()
117+
.find(|item| item["code"] == "RSR006")
118+
.expect("expected effect review finding");
119+
assert_eq!(effect["before"], "no_panic");
120+
assert_eq!(effect["after"], "retains(path)");
121+
assert!(
122+
effect["spans"]
123+
.as_array()
124+
.is_some_and(|spans| spans.len() == 2)
125+
);
113126
assert!(items.iter().all(|item| {
114127
item["summary"]
115128
.as_str()

0 commit comments

Comments
 (0)