Skip to content

Commit fe95ab4

Browse files
userFRMclaude
andauthored
fix: scope auto-lift rules by language to prevent cross-language collisions (#42)
Language-specific auto-lift rules (e.g., go.init, c.init) now carry their source paradigm's language metadata. The engine skips rules whose languages don't match the entity's file extension, preventing incorrect feature assignment in mixed-language repos. When the file language is unknown, language-specific rules are skipped entirely (only core rules apply). Adds TaggedRule wrapper, file-extension filtering in try_lift, and regression tests for Go/C collision, core rule universality, and unknown-extension safety. Closes #41 Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
1 parent cf20f88 commit fe95ab4

1 file changed

Lines changed: 108 additions & 7 deletions

File tree

crates/rpg-encoder/src/lift.rs

Lines changed: 108 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -140,14 +140,22 @@ fn normalize_field(s: &str) -> String {
140140
result
141141
}
142142

143+
/// An auto-lift rule tagged with its source paradigm's languages.
144+
/// Rules with empty `languages` (core rules) match any file.
145+
struct TaggedRule {
146+
rule: AutoLiftRule,
147+
languages: Vec<String>,
148+
}
149+
143150
/// Engine that matches entities against TOML-defined auto-lift rules.
144151
///
145152
/// Rules are collected from paradigm definitions in priority order.
146153
/// `core.toml` (priority 100) is always included; framework-specific rules
147154
/// from detected paradigms are prepended (lower priority number = higher priority
148-
/// = checked first).
155+
/// = checked first). Language-specific rules only match entities whose file
156+
/// extension belongs to the rule's source language.
149157
pub struct AutoLiftEngine {
150-
rules: Vec<AutoLiftRule>,
158+
rules: Vec<TaggedRule>,
151159
}
152160

153161
impl AutoLiftEngine {
@@ -163,17 +171,38 @@ impl AutoLiftEngine {
163171
let is_core = def.languages.is_empty();
164172
let is_active = active_paradigm_names.iter().any(|n| n == &def.name);
165173
if is_core || is_active {
166-
rules.extend(def.auto_lift.iter().cloned());
174+
for auto_rule in &def.auto_lift {
175+
rules.push(TaggedRule {
176+
rule: auto_rule.clone(),
177+
languages: def.languages.clone(),
178+
});
179+
}
167180
}
168181
}
169182
Self { rules }
170183
}
171184

172185
/// Try to match an entity against auto-lift rules. First match wins.
186+
/// Language-specific rules are skipped when the entity's file extension
187+
/// doesn't belong to the rule's source language.
173188
pub fn try_lift(&self, raw: &RawEntity) -> Option<Vec<String>> {
174-
for rule in &self.rules {
175-
if matches_entity(&rule.match_rule, raw, &raw.file) {
176-
return Some(Self::expand_templates(rule, raw));
189+
let file_lang = raw
190+
.file
191+
.extension()
192+
.and_then(|e| e.to_str())
193+
.and_then(Language::from_extension);
194+
195+
for tagged in &self.rules {
196+
// Skip language-specific rules that don't match the entity's file language.
197+
// When the file language is unknown, language-specific rules are skipped
198+
// (only core rules with empty languages apply).
199+
if !tagged.languages.is_empty()
200+
&& !file_lang.is_some_and(|lang| tagged.languages.iter().any(|l| l == lang.name()))
201+
{
202+
continue;
203+
}
204+
if matches_entity(&tagged.rule.match_rule, raw, &raw.file) {
205+
return Some(Self::expand_templates(&tagged.rule, raw));
177206
}
178207
}
179208
None
@@ -374,10 +403,14 @@ mod tests {
374403
use rpg_core::graph::EntityKind;
375404

376405
fn make_raw(name: &str, parent: Option<&str>, source: &str) -> RawEntity {
406+
make_raw_file(name, parent, source, "src/lib.rs")
407+
}
408+
409+
fn make_raw_file(name: &str, parent: Option<&str>, source: &str, file: &str) -> RawEntity {
377410
RawEntity {
378411
name: name.to_string(),
379412
kind: EntityKind::Method,
380-
file: std::path::PathBuf::from("src/lib.rs"),
413+
file: std::path::PathBuf::from(file),
381414
line_start: 1,
382415
line_end: source.lines().count(),
383416
parent_class: parent.map(|s| s.to_string()),
@@ -775,4 +808,72 @@ mod tests {
775808
vec!["return string representation of user"]
776809
);
777810
}
811+
812+
// --- Language scoping tests ---
813+
814+
fn make_mixed_engine() -> AutoLiftEngine {
815+
let defs = rpg_parser::paradigms::defs::load_builtin_defs().unwrap_or_default();
816+
// Activate both C and Go paradigms (simulates a mixed-language repo)
817+
AutoLiftEngine::new(&defs, &["c".to_string(), "go".to_string()])
818+
}
819+
820+
#[test]
821+
fn test_engine_language_scoping_go_init() {
822+
// Regression: Go `init()` in a .go file must match go.init ("initialize package"),
823+
// NOT c.init ("initialize init") — even though C paradigm is also active.
824+
let engine = make_mixed_engine();
825+
let raw = make_raw_file("init", None, "func init() { setup() }", "cmd/main.go");
826+
let features = engine.try_lift(&raw).unwrap();
827+
assert_eq!(
828+
features,
829+
vec!["initialize package"],
830+
"Go init() should match go.init, not c.init"
831+
);
832+
}
833+
834+
#[test]
835+
fn test_engine_language_scoping_c_init() {
836+
// C `init` in a .c file should match c.init
837+
let engine = make_mixed_engine();
838+
let raw = make_raw_file("init", None, "void init() { setup(); }", "src/module.c");
839+
let features = engine.try_lift(&raw).unwrap();
840+
assert_eq!(
841+
features,
842+
vec!["initialize init"],
843+
"C init() should match c.init, not go.init"
844+
);
845+
}
846+
847+
#[test]
848+
fn test_engine_core_rules_match_any_language() {
849+
// Core rules (languages = []) should match regardless of file extension
850+
let engine = make_mixed_engine();
851+
let raw = make_raw_file(
852+
"getName",
853+
None,
854+
"String getName() { return this.name; }",
855+
"src/User.java",
856+
);
857+
let features = engine.try_lift(&raw);
858+
assert!(
859+
features.is_some(),
860+
"core camelCase getter should match .java files"
861+
);
862+
assert_eq!(features.unwrap(), vec!["return name"]);
863+
}
864+
865+
#[test]
866+
fn test_engine_unknown_extension_skips_language_rules() {
867+
// Language-specific rules should NOT match files with unknown extensions.
868+
// Only core rules (languages = []) should apply.
869+
let engine = make_mixed_engine();
870+
let raw = make_raw_file("init", None, "func init() { }", "src/module.xyz");
871+
// go.init and c.init should both be skipped (unknown extension).
872+
// No core rule matches bare "init", so no auto-lift.
873+
let features = engine.try_lift(&raw);
874+
assert!(
875+
features.is_none(),
876+
"language-specific rules should not match unknown file extensions"
877+
);
878+
}
778879
}

0 commit comments

Comments
 (0)