Skip to content

Commit be3e0e0

Browse files
olwangclaude
andcommitted
Merge defect fixes: tuple typed facts, tuple arity, RS0401 wording, .rssi signature checks
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01DSzo4WPJ7WyKhhCsaAXUXB
2 parents b1918ae + b71cd86 commit be3e0e0

27 files changed

Lines changed: 803 additions & 106 deletions

crates/rsscript-diagnostics/src/implementation.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -673,8 +673,8 @@ static DIAGNOSTIC_EXPLANATIONS: &[DiagnosticExplanation] = &[
673673
},
674674
DiagnosticExplanation {
675675
code: code::USE_AFTER_MANAGE,
676-
title: "use after manage",
677-
explanation: "`manage value` moves a local value into the managed runtime. The original local binding cannot be used afterwards on any reachable path.",
676+
title: "use after move",
677+
explanation: "A local value can be moved out of its binding two ways: `manage value` hands it to the managed runtime, and `take value` hands it to a callee (or to a `match take value` scrutinee). Either way the original binding cannot be used afterwards on any reachable path. The diagnostic names the move that happened and points at it.",
678678
},
679679
DiagnosticExplanation {
680680
code: code::LOCAL_VALUE_RETAINED,

crates/rsscript-lowering/src/mir.rs

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1098,6 +1098,32 @@ struct LoopTargets {
10981098
break_target: BlockId,
10991099
cleanup_depth: usize,
11001100
}
1101+
/// Replace a signature's own type parameters with the concrete type arguments
1102+
/// the checker inferred for one call site.
1103+
///
1104+
/// Substitution is keyed by the **declared parameter name**, never by spelling
1105+
/// or position within a rendered type, so a generic record whose parameters are
1106+
/// `A`, `B`, … (the synthetic `__TupleN` structs) and one whose parameters are
1107+
/// `T`, `Key`, … behave identically. With no inferred arguments — a
1108+
/// non-generic record, or a call site the checker could not fully solve — the
1109+
/// declared type is returned unchanged.
1110+
fn substitute_signature_type_params(
1111+
ty: &ResolvedType,
1112+
signature: &checked::FunctionSig,
1113+
type_arguments: &[ResolvedType],
1114+
) -> ResolvedType {
1115+
if type_arguments.is_empty() || signature.type_params.is_empty() {
1116+
return ty.clone();
1117+
}
1118+
let substitutions = signature
1119+
.type_params
1120+
.iter()
1121+
.cloned()
1122+
.zip(type_arguments.iter().cloned())
1123+
.collect::<BTreeMap<String, ResolvedType>>();
1124+
ty.substitute(&substitutions)
1125+
}
1126+
11011127
/// Convert semantic type facts into the provider-neutral wire representation
11021128
/// without round-tripping through a rendered type string. This keeps source
11031129
/// spelling and formatting changes out of MIR identity. Function values are

crates/rsscript-lowering/src/mir/lowerer.rs

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -619,7 +619,7 @@ impl<'source, 'types, 'closures> CheckedHirLowerer<'source, 'types, 'closures> {
619619
type_kind: checked::HirTypeKind::Struct | checked::HirTypeKind::Class,
620620
}
621621
) {
622-
return self.lower_record_constructor(signature, args);
622+
return self.lower_record_constructor(signature, args, type_arguments);
623623
}
624624
if matches!(kind, checked::ResolvedCalleeKind::Constructor { .. }) {
625625
return self.unsupported("non-record checked HIR constructor");
@@ -779,13 +779,35 @@ impl<'source, 'types, 'closures> CheckedHirLowerer<'source, 'types, 'closures> {
779779
/// Materialize a resolved struct/class constructor directly from checked
780780
/// signature facts. Arguments still evaluate in source order, while the
781781
/// resulting layout fields use declaration/parameter order.
782+
///
783+
/// A generic record's declared result names its own type parameters
784+
/// (`__Tuple2<A, B>`, `Box<T>`). The constructed value's type is that
785+
/// result with the call site's inferred type arguments substituted in, so
786+
/// the typed executable facts record `__Tuple2<Int, String>` rather than
787+
/// the declaration's parameter names — which no downstream consumer can
788+
/// resolve, and which the bytecode verifier rejects against the concrete
789+
/// return type of the enclosing function.
782790
pub(super) fn lower_record_constructor(
783791
&mut self,
784792
signature: &checked::FunctionSig,
785793
args: &[checked::HirCallArg],
794+
type_arguments: &[ResolvedType],
786795
) -> Result<ValueId, MirLoweringError> {
787-
let wire_type = signature
796+
if !signature.type_params.is_empty() && type_arguments.is_empty() {
797+
// The checker proves a generic call site's type arguments all at
798+
// once or not at all. With no proof, the declared result still
799+
// names the record's own parameters, and emitting that would put
800+
// `__Tuple2<A, B>` into the typed executable facts: a type no
801+
// consumer can resolve, which the bytecode verifier then rejects
802+
// against the concrete result of the enclosing function. Refusing
803+
// here reports the gap at build time instead.
804+
return self.unsupported("generic record constructor without a proved type instance");
805+
}
806+
let return_ty = signature
788807
.return_ty
808+
.as_ref()
809+
.map(|ty| substitute_signature_type_params(ty, signature, type_arguments));
810+
let wire_type = return_ty
789811
.as_ref()
790812
.map(|ty| checked_type_to_wire(ty, &self.function_name))
791813
.transpose()?

crates/rsscript-sdk/src/tests.rs

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1243,3 +1243,116 @@ fn provider_host_context_and_trace_reach_the_execution_report() {
12431243
assert_eq!(summary.response_bytes, 0);
12441244
assert_eq!(summary.total_duration_ns, summary.max_duration_ns);
12451245
}
1246+
1247+
/// A tuple is a synthetic `__TupleN<A, B, ..>` generic struct, so every tuple
1248+
/// value is a generic instance whose type arguments have to be substituted
1249+
/// before they reach the typed executable facts. Until they were, a program
1250+
/// that returned a tuple compiled and then failed bytecode verification with
1251+
/// `__Tuple2<A, B>` — the declaration's parameter names — where the concrete
1252+
/// element types belonged. The same fixture is checked by
1253+
/// `tests/fixtures/pass/tuple-values-round-trip.rss`; this runs it.
1254+
#[test]
1255+
fn tuple_values_survive_verification_and_execute() {
1256+
const SOURCE: &str = r#"
1257+
struct Point {
1258+
location: (Int, Int)
1259+
}
1260+
1261+
fn labelled() -> (Int, String) {
1262+
return (1, "one")
1263+
}
1264+
1265+
fn widened(value: Int) -> (Int, Int, Bool) {
1266+
return (value, value * 2, true)
1267+
}
1268+
1269+
fn sum(pair: read (Int, Int)) -> Int {
1270+
return pair.item0 + pair.item1
1271+
}
1272+
1273+
fn picked(values: read List<Int>) -> (Int, Int) {
1274+
return (values[0], values[1] + 1)
1275+
}
1276+
1277+
fn main() -> String {
1278+
local pair = labelled()
1279+
let (base, doubled, flag) = widened(value: pair.item0)
1280+
local point = Point(location: (base, doubled))
1281+
let total = sum(pair: point.location)
1282+
local values = [4, 5]
1283+
let (head, next) = picked(values: values)
1284+
return String.concat(
1285+
left: pair.item1,
1286+
right: Int.to_string(value: total + head + next),
1287+
)
1288+
}
1289+
"#;
1290+
1291+
let built = Compiler
1292+
.compile("tuples.rss", SOURCE)
1293+
.expect("a tuple-returning program compiles");
1294+
let admitted = ArtifactVerifier
1295+
.verify(built)
1296+
.expect("substituted tuple type arguments pass bytecode verification")
1297+
.admit_trusted_input();
1298+
let report = Runtime::default()
1299+
.link(&admitted)
1300+
.expect("link tuple program")
1301+
.execute(ExecutionRequest::default());
1302+
1303+
assert_eq!(report.termination_reason(), TerminationReason::Completed);
1304+
// `labelled()` is `(1, "one")`; `widened(value: 1)` is `(1, 2, true)`, so
1305+
// the stored `Point.location` is `(1, 2)` and `sum` is 3; `picked` reads
1306+
// `(4, 6)` off the list. 3 + 4 + 6 = 13.
1307+
assert_eq!(report.value(), Some("one13"));
1308+
}
1309+
1310+
/// Tuple arity is not capped by the generated type-parameter names: the
1311+
/// synthetic parameters are unique for any arity, and substitution is keyed by
1312+
/// declared name rather than by spelling, so a wide tuple resolves its
1313+
/// elements and runs like a narrow one.
1314+
#[test]
1315+
fn wide_tuples_keep_distinct_type_parameters_and_execute() {
1316+
const ARITY: usize = 30;
1317+
let element_types = std::iter::repeat_n("Int", ARITY - 1)
1318+
.chain(std::iter::once("String"))
1319+
.collect::<Vec<_>>()
1320+
.join(", ");
1321+
let values = (0..ARITY - 1)
1322+
.map(|index| index.to_string())
1323+
.collect::<Vec<_>>()
1324+
.join(", ");
1325+
let source = format!(
1326+
r#"
1327+
fn wide() -> ({element_types}) {{
1328+
return ({values}, "last")
1329+
}}
1330+
1331+
fn main() -> String {{
1332+
local tuple = wide()
1333+
return String.concat(
1334+
left: Int.to_string(value: tuple.item0 + tuple.item{last}),
1335+
right: tuple.item{tail},
1336+
)
1337+
}}
1338+
"#,
1339+
last = ARITY - 2,
1340+
tail = ARITY - 1,
1341+
);
1342+
1343+
let built = Compiler
1344+
.compile("wide-tuple.rss", &source)
1345+
.expect("an arity-30 tuple compiles");
1346+
let admitted = ArtifactVerifier
1347+
.verify(built)
1348+
.expect("an arity-30 tuple passes bytecode verification")
1349+
.admit_trusted_input();
1350+
let report = Runtime::default()
1351+
.link(&admitted)
1352+
.expect("link wide tuple program")
1353+
.execute(ExecutionRequest::default());
1354+
1355+
assert_eq!(report.termination_reason(), TerminationReason::Completed);
1356+
// `item0` is 0 and `item28` is 28.
1357+
assert_eq!(report.value(), Some("28last"));
1358+
}

crates/rsscript-sdk/tests/fixture_corpus.rs

Lines changed: 153 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,3 +221,156 @@ fn fail_fixtures_emit_exactly_their_expected_codes() {
221221
failures.join("\n")
222222
);
223223
}
224+
225+
/// The workspace root, from this crate's manifest directory.
226+
fn workspace_root() -> PathBuf {
227+
Path::new(env!("CARGO_MANIFEST_DIR"))
228+
.join("../..")
229+
.canonicalize()
230+
.expect("workspace root should exist")
231+
}
232+
233+
/// Every `.rssi` under `directory`, recursively, sorted so failures report
234+
/// stably.
235+
fn interface_files(directory: &Path, found: &mut Vec<PathBuf>) {
236+
let mut entries = fs::read_dir(directory)
237+
.unwrap_or_else(|error| panic!("read {}: {error}", directory.display()))
238+
.map(|entry| entry.expect("directory entry").path())
239+
.collect::<Vec<_>>();
240+
entries.sort();
241+
for path in entries {
242+
if path.is_dir() {
243+
interface_files(&path, found);
244+
} else if path
245+
.extension()
246+
.is_some_and(|extension| extension == "rssi")
247+
{
248+
found.push(path);
249+
}
250+
}
251+
}
252+
253+
/// A signature-level rule holds wherever the signature is written. An `.rssi`
254+
/// declaration has no body, but it has a contract, and an interface that
255+
/// escaped the rule could export one the language does not have — `pub fn
256+
/// make_default<T>() -> fresh T` was accepted in an interface while the same
257+
/// signature in a `.rss` file was `RS0603`.
258+
///
259+
/// The diagnostic must also land on the interface, not on the source that
260+
/// supplied it: the reader has to be sent to the file they can fix.
261+
#[test]
262+
fn invalid_interface_signatures_are_diagnosed_against_the_interface_file() {
263+
const SOURCE: &str = "fn main() -> Unit {\n return Unit\n}\n";
264+
const INTERFACE: &str = "pub fn make_default<T>() -> fresh T\n";
265+
266+
let mut interfaces = standard_package_interfaces().to_vec();
267+
interfaces.push(("host/defaults.rssi", INTERFACE));
268+
let diagnostics = analyze_sources_with_interfaces(&[("main.rss", SOURCE)], &interfaces);
269+
270+
let invalid_fresh = diagnostics
271+
.iter()
272+
.find(|diagnostic| diagnostic.code == "RS0603")
273+
.unwrap_or_else(|| {
274+
panic!(
275+
"an invalid `.rssi` signature must be diagnosed; got {:?}",
276+
diagnostics
277+
.iter()
278+
.map(|diagnostic| diagnostic.code.as_str())
279+
.collect::<Vec<_>>()
280+
)
281+
});
282+
assert_eq!(
283+
invalid_fresh.span.file, "host/defaults.rssi",
284+
"the diagnostic must point at the interface that declares the signature"
285+
);
286+
assert!(
287+
invalid_fresh.summary.contains("make_default"),
288+
"the diagnostic must name the interface declaration: {}",
289+
invalid_fresh.summary
290+
);
291+
292+
// The bounded form of the same signature is clean, so the rule is not
293+
// rejecting every generic `fresh` return in an interface.
294+
let mut bounded = standard_package_interfaces().to_vec();
295+
bounded.push((
296+
"host/defaults.rssi",
297+
"pub fn make_default<T: Struct>() -> fresh T\n",
298+
));
299+
assert!(
300+
analyze_sources_with_interfaces(&[("main.rss", SOURCE)], &bounded).is_empty(),
301+
"a correctly bounded interface signature must stay clean"
302+
);
303+
}
304+
305+
/// The prelude is the one interface set every program sees, so a regression in
306+
/// it would be invisible in ordinary fixtures until it reached users. Check
307+
/// every `.rssi` that ships, read from disk rather than from the embedded
308+
/// catalog, so an interface added to `stdlib/` or `packages/` is covered the
309+
/// day it lands.
310+
#[test]
311+
fn every_shipped_interface_passes_the_signature_checks() {
312+
const SOURCE: &str = "fn main() -> Unit {\n return Unit\n}\n";
313+
314+
let root = workspace_root();
315+
let mut paths = Vec::new();
316+
interface_files(&root.join("stdlib"), &mut paths);
317+
for package in {
318+
let mut packages = fs::read_dir(root.join("packages"))
319+
.expect("packages directory should exist")
320+
.map(|entry| entry.expect("packages entry").path())
321+
.collect::<Vec<_>>();
322+
packages.sort();
323+
packages
324+
} {
325+
let interface = package.join("interface");
326+
if interface.is_dir() {
327+
interface_files(&interface, &mut paths);
328+
}
329+
}
330+
assert!(
331+
paths.len() >= 30,
332+
"the shipped interface set should not have shrunk to {} files",
333+
paths.len()
334+
);
335+
336+
// Supply the whole set at once: the interfaces reference each other's
337+
// protocols (`Ord`, `Eq`, `Hashable`), so checking one in isolation would
338+
// report a missing protocol that the prelude does in fact declare. Each
339+
// diagnostic still carries its own interface's path, so a failure names
340+
// the file to fix.
341+
let sources = paths
342+
.iter()
343+
.map(|path| {
344+
let relative = path
345+
.strip_prefix(&root)
346+
.unwrap_or(path)
347+
.to_string_lossy()
348+
.into_owned();
349+
let text = fs::read_to_string(path)
350+
.unwrap_or_else(|error| panic!("read {}: {error}", path.display()));
351+
(relative, text)
352+
})
353+
.collect::<Vec<_>>();
354+
let interfaces = sources
355+
.iter()
356+
.map(|(file, text)| (file.as_str(), text.as_str()))
357+
.collect::<Vec<_>>();
358+
359+
let failures = analyze_sources_with_interfaces(&[("main.rss", SOURCE)], &interfaces)
360+
.into_iter()
361+
.map(|diagnostic| {
362+
format!(
363+
"{}:{}:{}: {}",
364+
diagnostic.span.file, diagnostic.span.line, diagnostic.span.column, diagnostic.code
365+
)
366+
})
367+
.collect::<Vec<_>>();
368+
369+
assert!(
370+
failures.is_empty(),
371+
"{} diagnostics across the {} shipped interfaces:\n{}",
372+
failures.len(),
373+
paths.len(),
374+
failures.join("\n")
375+
);
376+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// expect: RS0603
2+
3+
// interface: interface-invalid-fresh-generic.rssi
4+
5+
// The source file is clean. The diagnostic comes from the supplied interface:
6+
// signature-level checks run over `.rssi` declarations too, so an interface
7+
// cannot export a contract the language does not have.
8+
9+
fn main() -> Unit {
10+
return Unit
11+
}
Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
// A signature-level rule is as true of a bodyless `.rssi` declaration as of a
2+
// `.rss` one: `fresh T` needs `T: Struct` for freshness to hold at every
3+
// instantiation. `pass/interface-fresh-generic-bounded.rss` shows the same
4+
// signature written correctly.
5+
pub fn make_default<T>() -> fresh T

0 commit comments

Comments
 (0)