Skip to content

Commit 9009ae7

Browse files
committed
Reject duplicate file features
1 parent 4914eb9 commit 9009ae7

8 files changed

Lines changed: 101 additions & 3 deletions

File tree

README.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,8 @@ features: local
124124

125125
The MVP implements only `local`. Future feature names such as `native`, `unsafe`, `async`, `device`, `ffi`, and `reflection` are reserved for capabilities that change review risk or require checker/runtime support. Ordinary libraries like JSON, File, Image, HTTP, Map, and Regex are not features.
126126

127+
Each feature may appear only once. Unknown or repeated feature names are diagnostics so capability boundaries are not silently normalized.
128+
127129
### Reviewable at the boundary
128130

129131
Function calls use named arguments and visible effects.

RSScript_v0.5_Spec.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -624,6 +624,8 @@ A file without a `features:` declaration is managed-only:
624624

625625
This lowers entry friction for ordinary scripts.
626626

627+
Each feature may appear at most once in a `features:` declaration. Duplicate feature names are diagnostics rather than silently ignored, because the header is a review capability boundary.
628+
627629
---
628630

629631
## 8.2 Default Managed File

src/analyzer.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,7 @@ impl Analyzer<'_> {
6767
fn run(&mut self) {
6868
self.check_single_feature_declaration();
6969
self.check_unknown_file_features();
70+
self.check_duplicate_file_features();
7071
self.check_removed_profile_declarations();
7172
self.check_unsupported_syntax();
7273
self.check_duplicate_declarations();
@@ -123,6 +124,27 @@ impl Analyzer<'_> {
123124
}
124125
}
125126

127+
fn check_duplicate_file_features(&mut self) {
128+
for feature in &self.syntax_program.duplicate_features {
129+
self.diagnostics.push(
130+
Diagnostic::error(
131+
code::DUPLICATE_FILE_FEATURE,
132+
format!("Duplicate file feature `{}`.", feature.name),
133+
feature.span.clone(),
134+
"duplicate feature",
135+
)
136+
.with_cause(
137+
"File features are capability declarations; repeating one makes the review boundary noisier without changing semantics.",
138+
)
139+
.with_fix(
140+
"remove_duplicate_feature",
141+
format!("Remove the repeated `{}` feature.", feature.name),
142+
"machine-applicable",
143+
),
144+
);
145+
}
146+
}
147+
126148
fn check_removed_profile_declarations(&mut self) {
127149
for span in &self.syntax_program.profile_spans {
128150
self.diagnostics.push(

src/diagnostic.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ pub mod code {
1717
pub const INVALID_NOALLOC_ALLOCATION: &str = "RS0014";
1818
pub const UNSUPPORTED_SYNTAX: &str = "RS0015";
1919
pub const UNKNOWN_FILE_FEATURE: &str = "RS0016";
20+
pub const DUPLICATE_FILE_FEATURE: &str = "RS0017";
2021
pub const FEATURE_VIOLATION: &str = "RS0101";
2122
pub const UNNAMED_ARGUMENT: &str = "RS0201";
2223
pub const MISSING_DATA_EFFECT: &str = "RS0202";
@@ -296,6 +297,11 @@ static DIAGNOSTIC_EXPLANATIONS: &[DiagnosticExplanation] = &[
296297
title: "unknown file feature",
297298
explanation: "A `features:` header may only list review-relevant capabilities known to this compiler version. Unknown feature names are rejected so typos do not silently change review risk.",
298299
},
300+
DiagnosticExplanation {
301+
code: code::DUPLICATE_FILE_FEATURE,
302+
title: "duplicate file feature",
303+
explanation: "Each review-relevant capability may appear at most once in a `features:` header. Duplicate entries are rejected instead of silently folded away.",
304+
},
299305
DiagnosticExplanation {
300306
code: code::FEATURE_VIOLATION,
301307
title: "feature violation",

src/syntax/ast.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,10 +17,17 @@ pub struct UnknownFileFeature {
1717
pub span: Span,
1818
}
1919

20+
#[derive(Debug, Clone, PartialEq, Eq)]
21+
pub struct DuplicateFileFeature {
22+
pub name: String,
23+
pub span: Span,
24+
}
25+
2026
#[derive(Debug, Clone, PartialEq, Eq)]
2127
pub struct Program {
2228
pub features: Vec<FileFeature>,
2329
pub unknown_features: Vec<UnknownFileFeature>,
30+
pub duplicate_features: Vec<DuplicateFileFeature>,
2431
pub feature_spans: Vec<Span>,
2532
pub profile_spans: Vec<Span>,
2633
pub items: Vec<Item>,

src/syntax/parser.rs

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1+
use std::collections::HashSet;
2+
13
use crate::lexer::{Token, TokenKind, lex};
24
use crate::syntax::ast::{
3-
BinaryOp, Block, CallArg, Callee, DataEffect, EffectDecl, Expr, FieldDecl, FileFeature,
4-
FunctionDecl, GenericBound, GenericParam, IfStmt, Item, LetKind, LetStmt, LoopStmt, Param,
5-
Program, ReturnStmt, Stmt, TypeDecl, TypeKind, TypeRef, UnknownFileFeature, WithStmt,
5+
BinaryOp, Block, CallArg, Callee, DataEffect, DuplicateFileFeature, EffectDecl, Expr,
6+
FieldDecl, FileFeature, FunctionDecl, GenericBound, GenericParam, IfStmt, Item, LetKind,
7+
LetStmt, LoopStmt, Param, Program, ReturnStmt, Stmt, TypeDecl, TypeKind, TypeRef,
8+
UnknownFileFeature, WithStmt,
69
};
710

811
pub fn parse_source(file: &str, source: &str) -> Program {
@@ -22,12 +25,14 @@ struct Parser<'a> {
2225
struct ParsedFeatures {
2326
features: Vec<FileFeature>,
2427
unknown_features: Vec<UnknownFileFeature>,
28+
duplicate_features: Vec<DuplicateFileFeature>,
2529
}
2630

2731
impl Parser<'_> {
2832
fn parse_program(&mut self) -> Program {
2933
let mut features = Vec::new();
3034
let mut unknown_features = Vec::new();
35+
let mut duplicate_features = Vec::new();
3136
let mut feature_spans = Vec::new();
3237
let mut profile_spans = Vec::new();
3338
let mut items = Vec::new();
@@ -38,6 +43,7 @@ impl Parser<'_> {
3843
let parsed = self.parse_features();
3944
features.extend(parsed.features);
4045
unknown_features.extend(parsed.unknown_features);
46+
duplicate_features.extend(parsed.duplicate_features);
4147
} else if self.at_ident("profile") && self.peek_symbol(1, ":") {
4248
profile_spans.push(self.tokens[self.index].span.clone());
4349
self.index += 1;
@@ -58,6 +64,7 @@ impl Parser<'_> {
5864
Program {
5965
features,
6066
unknown_features,
67+
duplicate_features,
6168
feature_spans,
6269
profile_spans,
6370
items,
@@ -69,13 +76,24 @@ impl Parser<'_> {
6976
let end = declaration_line_end(self.tokens, self.index);
7077
let mut features = Vec::new();
7178
let mut unknown_features = Vec::new();
79+
let mut duplicate_features = Vec::new();
80+
let mut seen_features = HashSet::new();
7281
while self.index < end {
7382
if self.at_symbol(",") {
7483
self.index += 1;
7584
continue;
7685
}
7786
let token = self.tokens.get(self.index);
7887
if let Some(feature) = parse_file_feature(token) {
88+
let name = file_feature_name(feature).to_string();
89+
if !seen_features.insert(feature)
90+
&& let Some(token) = token
91+
{
92+
duplicate_features.push(DuplicateFileFeature {
93+
name,
94+
span: token.span.clone(),
95+
});
96+
}
7997
features.push(feature);
8098
} else if let Some(token) = token
8199
&& !matches!(token.kind, TokenKind::Eof)
@@ -90,6 +108,7 @@ impl Parser<'_> {
90108
ParsedFeatures {
91109
features,
92110
unknown_features,
111+
duplicate_features,
93112
}
94113
}
95114

@@ -1110,6 +1129,18 @@ fn parse_file_feature(token: Option<&Token>) -> Option<FileFeature> {
11101129
}
11111130
}
11121131

1132+
fn file_feature_name(feature: FileFeature) -> &'static str {
1133+
match feature {
1134+
FileFeature::Local => "local",
1135+
FileFeature::Native => "native",
1136+
FileFeature::Unsafe => "unsafe",
1137+
FileFeature::Async => "async",
1138+
FileFeature::Device => "device",
1139+
FileFeature::Ffi => "ffi",
1140+
FileFeature::Reflection => "reflection",
1141+
}
1142+
}
1143+
11131144
fn statement_end(tokens: &[Token], start: usize, limit: usize) -> usize {
11141145
let line = tokens[start].span.line;
11151146
let mut depth = 0usize;

tests/checker.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1664,6 +1664,28 @@ fn main() -> Unit {
16641664
);
16651665
}
16661666

1667+
#[test]
1668+
fn checker_rejects_duplicate_file_features() {
1669+
let source = r#"
1670+
features: local, local
1671+
1672+
fn main() -> Unit {
1673+
return Unit
1674+
}
1675+
"#;
1676+
let program = parse_source("features.rss", source);
1677+
1678+
assert_eq!(program.features.len(), 2);
1679+
assert_eq!(program.duplicate_features.len(), 1);
1680+
assert_eq!(program.duplicate_features[0].name, "local");
1681+
let diagnostics = analyze_source("features.rss", source);
1682+
assert!(
1683+
diagnostics
1684+
.iter()
1685+
.any(|diagnostic| diagnostic.code == "RS0017")
1686+
);
1687+
}
1688+
16671689
#[test]
16681690
fn review_map_marks_public_rssi_signatures_review_required() {
16691691
let source = r#"
Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
// expect: RS0017
2+
features: local, local
3+
4+
fn main() -> Unit {
5+
return Unit
6+
}

0 commit comments

Comments
 (0)