Skip to content

Commit f6b0b63

Browse files
committed
Add rss lint signature complexity warnings
1 parent 9009ae7 commit f6b0b63

6 files changed

Lines changed: 243 additions & 2 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -400,6 +400,7 @@ Current CLI surface:
400400
```sh
401401
rss check [--json] [--core|--no-core] [--interface <file.rssi> ...] <file.rss>
402402
rss check --explain <code>
403+
rss lint [--json] [--core|--no-core] [--interface <file.rssi> ...] <file.rss>
403404
rss fmt <file.rss>
404405
rss review [--json] --diff <old.rss> <new.rss>
405406
rss review [--json] --map <file-or-directory>
@@ -414,6 +415,8 @@ rss verify-rust [--json] <file.rss> --out-dir <directory>
414415

415416
`rss check` loads bundled core `.rssi` signatures by default. Use `--no-core` only when testing a file against user-supplied interfaces in isolation. Bundled signatures are ordinary `.rssi` files under `core/`, not a second Rust-side builtin table.
416417

418+
`rss lint` runs the same frontend checks and emits warning diagnostics for reviewability issues. The first lint is `RSL001`, which flags public signatures that exceed the current review budget for parameter count, generic parameter count, effect count, or nested type depth.
419+
417420
If a lowered package contains `fn main() -> Unit` or `fn main() -> Result<Unit, E>`, `rss lower --rust --out-dir` also emits a Rust `src/main.rs` harness. `rss run <file.rss>` uses the same lowering path, writes a temporary Rust package, and delegates execution to `cargo run`. Use `rss run <file.rss> --out-dir <directory>` to keep the generated Rust package for inspection.
418421

419422
`rss verify-rust <file.rss> --out-dir <directory>` keeps the generated package used for backend checking, including `rsscript-source-map.json`, so unmappable rustc diagnostics can be inspected against the generated Rust.

src/diagnostic.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ pub mod code {
4848
pub const SURFACE_REFERENCE_ATTEMPT: &str = "RS1004";
4949
pub const RUSTC_DIAGNOSTIC_MAPPED: &str = "RS1101";
5050
pub const RUSTC_DIAGNOSTIC_UNMAPPABLE: &str = "RS1102";
51+
pub const LINT_SIGNATURE_COMPLEXITY: &str = "RSL001";
5152

5253
pub const REVIEW_FEATURES_CHANGED: &str = "RSR001";
5354
pub const REVIEW_FUNCTION_REMOVED: &str = "RSR002";
@@ -452,6 +453,11 @@ static DIAGNOSTIC_EXPLANATIONS: &[DiagnosticExplanation] = &[
452453
title: "unmappable rustc diagnostic",
453454
explanation: "rustc reported a backend diagnostic whose generated Rust location could not be mapped back to RSScript source. The compiler should surface the generated Rust reference as secondary internal detail instead of exposing raw rustc output as the primary diagnostic.",
454455
},
456+
DiagnosticExplanation {
457+
code: code::LINT_SIGNATURE_COMPLEXITY,
458+
title: "signature complexity lint",
459+
explanation: "Public signatures should remain reviewable in one screen. The linter warns when public parameters, generic parameters, effect clauses, or nested type shapes exceed the current review budget.",
460+
},
455461
];
456462

457463
pub fn format_diagnostics_json(diagnostics: &[Diagnostic]) -> String {

src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ mod diagnostic;
44
mod hir;
55
mod interfaces;
66
mod lexer;
7+
mod lint;
78
mod review;
89
mod rust_lower;
910
pub mod syntax;
@@ -15,6 +16,7 @@ pub use diagnostic::{
1516
Diagnostic, DiagnosticExplanation, Severity, explain_diagnostic_code,
1617
format_diagnostic_explanation, format_diagnostics_human, format_diagnostics_json,
1718
};
19+
pub use lint::lint_source;
1820
pub use review::{
1921
ReviewFinding, ReviewFix, ReviewMap, ReviewMapCategorySummary, ReviewMapClassification,
2022
ReviewMapFile, ReviewMapFileRisk, ReviewMapRegion, ReviewMapSummary, ReviewRisk,

src/lint.rs

Lines changed: 125 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,125 @@
1+
use crate::diagnostic::{Diagnostic, Span, code};
2+
use crate::syntax::ast::{Item, TypeRef};
3+
use crate::syntax::parse_source;
4+
5+
const MAX_PUBLIC_PARAMS: usize = 6;
6+
const MAX_PUBLIC_GENERICS: usize = 3;
7+
const MAX_PUBLIC_EFFECTS: usize = 4;
8+
const MAX_TYPE_DEPTH: usize = 4;
9+
10+
pub fn lint_source(file: &str, source: &str) -> Vec<Diagnostic> {
11+
let program = parse_source(file, source);
12+
let mut diagnostics = Vec::new();
13+
14+
for item in program.items {
15+
let Item::Function(function) = item else {
16+
continue;
17+
};
18+
if !function.is_public {
19+
continue;
20+
}
21+
22+
if function.params.len() > MAX_PUBLIC_PARAMS {
23+
signature_complexity_warning(
24+
&mut diagnostics,
25+
function.span.clone(),
26+
format!(
27+
"Public function `{}` has {} parameters.",
28+
function.name,
29+
function.params.len()
30+
),
31+
format!(
32+
"Keep public signatures at or below {MAX_PUBLIC_PARAMS} parameters, or group related values into a reviewable struct."
33+
),
34+
);
35+
}
36+
37+
if function.type_params.len() > MAX_PUBLIC_GENERICS {
38+
signature_complexity_warning(
39+
&mut diagnostics,
40+
function.span.clone(),
41+
format!(
42+
"Public function `{}` has {} generic parameters.",
43+
function.name,
44+
function.type_params.len()
45+
),
46+
format!(
47+
"Keep public signatures at or below {MAX_PUBLIC_GENERICS} generic parameters."
48+
),
49+
);
50+
}
51+
52+
if function.effects.len() > MAX_PUBLIC_EFFECTS {
53+
signature_complexity_warning(
54+
&mut diagnostics,
55+
function.span.clone(),
56+
format!(
57+
"Public function `{}` declares {} effects.",
58+
function.name,
59+
function.effects.len()
60+
),
61+
format!("Keep public effect clauses at or below {MAX_PUBLIC_EFFECTS} entries."),
62+
);
63+
}
64+
65+
for param in &function.params {
66+
let depth = type_depth(&param.ty);
67+
if depth > MAX_TYPE_DEPTH {
68+
signature_complexity_warning(
69+
&mut diagnostics,
70+
param.span.clone(),
71+
format!(
72+
"Parameter `{}` in public function `{}` has nested type depth {}.",
73+
param.name, function.name, depth
74+
),
75+
format!(
76+
"Keep public parameter types at or below nesting depth {MAX_TYPE_DEPTH}."
77+
),
78+
);
79+
}
80+
}
81+
82+
if let Some(return_ty) = &function.return_ty {
83+
let depth = type_depth(return_ty);
84+
if depth > MAX_TYPE_DEPTH {
85+
signature_complexity_warning(
86+
&mut diagnostics,
87+
return_ty.span.clone(),
88+
format!(
89+
"Return type of public function `{}` has nested type depth {}.",
90+
function.name, depth
91+
),
92+
format!("Keep public return types at or below nesting depth {MAX_TYPE_DEPTH}."),
93+
);
94+
}
95+
}
96+
}
97+
98+
diagnostics
99+
}
100+
101+
fn signature_complexity_warning(
102+
diagnostics: &mut Vec<Diagnostic>,
103+
span: Span,
104+
summary: impl Into<String>,
105+
cause: impl Into<String>,
106+
) {
107+
diagnostics.push(
108+
Diagnostic::warning(
109+
code::LINT_SIGNATURE_COMPLEXITY,
110+
summary,
111+
span,
112+
"signature complexity",
113+
)
114+
.with_cause(cause)
115+
.with_fix(
116+
"simplify_public_signature",
117+
"Simplify this public signature so it remains reviewable in one screen.",
118+
"manual",
119+
),
120+
);
121+
}
122+
123+
fn type_depth(ty: &TypeRef) -> usize {
124+
1 + ty.args.iter().map(type_depth).max().unwrap_or(0)
125+
}

src/main.rs

Lines changed: 60 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use rsscript::{
88
Diagnostic, analyze_source, analyze_source_with_interfaces, check_generated_rust_package,
99
core_interfaces, explain_diagnostic_code, format_diagnostic_explanation,
1010
format_diagnostics_human, format_diagnostics_json, format_review_human, format_review_json,
11-
format_review_map_human, format_review_map_json, lower_source_to_rust,
11+
format_review_map_human, format_review_map_json, lint_source, lower_source_to_rust,
1212
lower_source_to_rust_package, parse_source_map_json, remap_rustc_diagnostic_json_lines,
1313
review_map_sources, review_sources, write_generated_rust_package,
1414
};
@@ -22,6 +22,7 @@ fn main() -> ExitCode {
2222

2323
match command {
2424
"check" => run_check(&args[2..]),
25+
"lint" => run_lint(&args[2..]),
2526
"fmt" => run_fmt(&args[2..]),
2627
"review" => run_review(&args[2..]),
2728
"lower" => run_lower(&args[2..]),
@@ -35,6 +36,61 @@ fn main() -> ExitCode {
3536
}
3637
}
3738

39+
fn run_lint(args: &[String]) -> ExitCode {
40+
let options = parse_check_args(args);
41+
let Some(path) = options.path else {
42+
print_usage();
43+
return ExitCode::from(2);
44+
};
45+
46+
let source = match fs::read_to_string(path) {
47+
Ok(source) => source,
48+
Err(error) => {
49+
eprintln!("failed to read {path}: {error}");
50+
return ExitCode::from(2);
51+
}
52+
};
53+
54+
let interfaces = match read_interface_sources(&options.interfaces) {
55+
Ok(interfaces) => interfaces,
56+
Err(error) => {
57+
eprintln!("{error}");
58+
return ExitCode::from(2);
59+
}
60+
};
61+
let interface_refs = interfaces
62+
.iter()
63+
.map(|interface| (interface.path.as_str(), interface.contents.as_str()))
64+
.collect::<Vec<_>>();
65+
let mut diagnostics = if options.use_core {
66+
let mut combined = core_interfaces().to_vec();
67+
combined.extend(interface_refs);
68+
analyze_source_with_interfaces(path, &source, &combined)
69+
} else if interface_refs.is_empty() {
70+
analyze_source(path, &source)
71+
} else {
72+
analyze_source_with_interfaces(path, &source, &interface_refs)
73+
};
74+
diagnostics.extend(lint_source(path, &source));
75+
76+
if options.json {
77+
println!("{}", format_diagnostics_json(&diagnostics));
78+
} else if diagnostics.is_empty() {
79+
println!("{path}: lint ok");
80+
} else {
81+
print!("{}", format_diagnostics_human(&diagnostics));
82+
}
83+
84+
if diagnostics
85+
.iter()
86+
.any(|diagnostic| diagnostic.severity.is_error())
87+
{
88+
ExitCode::from(1)
89+
} else {
90+
ExitCode::SUCCESS
91+
}
92+
}
93+
3894
fn run_check(args: &[String]) -> ExitCode {
3995
if let Some(code) = parse_explain_args(args) {
4096
let Some(explanation) = explain_diagnostic_code(code) else {
@@ -835,6 +891,9 @@ fn print_usage() {
835891
eprintln!(
836892
" rsscript check [--json] [--core|--no-core] [--interface <file.rssi> ...] <file.rss>"
837893
);
894+
eprintln!(
895+
" rsscript lint [--json] [--core|--no-core] [--interface <file.rssi> ...] <file.rss>"
896+
);
838897
eprintln!(" rsscript check --explain <code>");
839898
eprintln!(" rsscript fmt <file.rss>");
840899
eprintln!(" rsscript lower --rust <file.rss>");

tests/checker.rs

Lines changed: 47 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ use rsscript::{
88
analyze_source_with_core, analyze_source_with_interfaces, core_interfaces,
99
explain_diagnostic_code, format_diagnostic_explanation, format_diagnostics_json,
1010
format_review_human, format_review_json, format_review_map_human, format_review_map_json,
11-
lower_source_to_rust, lower_source_to_rust_package, lower_source_to_rust_with_map,
11+
lint_source, lower_source_to_rust, lower_source_to_rust_package, lower_source_to_rust_with_map,
1212
remap_rustc_diagnostic_json, remap_rustc_diagnostic_json_lines, review_map_sources,
1313
review_sources,
1414
};
@@ -196,6 +196,52 @@ fn diagnostic_explanations_are_available_by_code() {
196196
assert!(explain_diagnostic_code("RS9999").is_none());
197197
}
198198

199+
#[test]
200+
fn lint_warns_on_public_signature_complexity() {
201+
let source = r#"
202+
pub fn overloaded<A, B, C, D>(
203+
first: read Result<Option<List<Map<String, Image>>>, Error>,
204+
second: read String,
205+
third: read String,
206+
fourth: read String,
207+
fifth: read String,
208+
sixth: read String,
209+
seventh: read String,
210+
) -> Result<Option<List<Map<String, Image>>>, Error>
211+
effects(no_panic, noalloc, no_block, pure, native)
212+
{
213+
return Ok(None)
214+
}
215+
"#;
216+
let diagnostics = lint_source("lint.rss", source);
217+
let codes = diagnostics
218+
.iter()
219+
.map(|diagnostic| diagnostic.code.as_str())
220+
.collect::<Vec<_>>();
221+
222+
assert!(codes.contains(&"RSL001"));
223+
assert!(
224+
diagnostics
225+
.iter()
226+
.all(|diagnostic| !diagnostic.severity.is_error())
227+
);
228+
assert!(
229+
diagnostics
230+
.iter()
231+
.any(|diagnostic| diagnostic.summary.contains("7 parameters"))
232+
);
233+
assert!(
234+
diagnostics
235+
.iter()
236+
.any(|diagnostic| diagnostic.summary.contains("4 generic parameters"))
237+
);
238+
assert!(
239+
diagnostics
240+
.iter()
241+
.any(|diagnostic| diagnostic.summary.contains("5 effects"))
242+
);
243+
}
244+
199245
#[test]
200246
fn parser_accepts_qualified_interface_function_signatures() {
201247
let source = r#"

0 commit comments

Comments
 (0)