-
-
Notifications
You must be signed in to change notification settings - Fork 132
fix(hir): functional source-compile correctness batch 3 (semver + minimatch green) #5408
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
334a6b4
fix(hir): don't lower .test()/.exec() on untyped locals to RegExpTest
fe3b74d
fix(hir): named class expr must not hijack same-named top-level class…
34b92bc
fix(hir): .match()/.matchAll() on untyped arg isn't String.match; cla…
93dff34
Merge branch 'main' into fix/functional-correctness-batch3
proggeramlug File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
117 changes: 117 additions & 0 deletions
117
crates/perry-hir/tests/instance_test_method_not_regex.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,117 @@ | ||
| //! Batch-3 functional-correctness fix (semver / minimatch source-compile): | ||
| //! `receiver.test(arg)` on an `Any`/`Unknown`/untyped local must NOT be | ||
| //! lowered to the RegExp `Expr::RegExpTest` codegen fast path. `.test()` is | ||
| //! also a common INSTANCE method name (semver's `Comparator.test` / | ||
| //! `Range.test`), and an imported class instance is typed `Any` at the call | ||
| //! site. The old heuristic (`Type::Any | Type::Unknown | unwrap_or(true)`) | ||
| //! mis-lowered `comparator.test(v)` to `js_regexp_test(comparator-as-string)`, | ||
| //! silently returning a bogus boolean and never running the real method body — | ||
| //! so `semver.satisfies(...)` always returned `false`. | ||
| //! | ||
| //! The runtime already routes a genuine RegExp receiver's `.test()`/`.exec()` | ||
| //! through dynamic method dispatch (`dispatch_regex_receiver_method`, #1731), | ||
| //! so falling through to a normal method call is correct for BOTH a regex | ||
| //! value and a class instance. A regex *literal* receiver still takes the fast | ||
| //! path. | ||
|
|
||
| use perry_diagnostics::SourceCache; | ||
| use perry_hir::{lower_module, Module}; | ||
| use perry_parser::parse_typescript_with_cache; | ||
|
|
||
| fn lower(src: &str) -> Module { | ||
| let mut cache = SourceCache::new(); | ||
| let parsed = parse_typescript_with_cache(src, "/tmp/instance_test_method.ts", &mut cache) | ||
| .expect("parse failed"); | ||
| lower_module(&parsed.module, "test", "/tmp/instance_test_method.ts").expect("lower failed") | ||
| } | ||
|
|
||
| /// True if any function/init body in the module debug-prints a `RegExpTest` | ||
| /// node. Using the Debug rendering keeps the test independent of the internal | ||
| /// walker API surface. | ||
| fn module_has_regexp_test(module: &Module) -> bool { | ||
| format!("{:#?}", module).contains("RegExpTest") | ||
| } | ||
|
|
||
| #[test] | ||
| fn untyped_receiver_test_call_is_not_regexp_test() { | ||
| // `c` is an untyped local (the `as any` mirror of an imported class | ||
| // instance). `c.test(10)` must lower to a normal method call, not a | ||
| // RegExpTest fast path. | ||
| let src = r#" | ||
| const c: any = makeComparator(); | ||
| const r = c.test(10); | ||
| "#; | ||
| let module = lower(src); | ||
| assert!( | ||
| !module_has_regexp_test(&module), | ||
| "`c.test(10)` on an untyped local must NOT lower to RegExpTest" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn member_receiver_test_call_is_not_regexp_test() { | ||
| // A member-access receiver (`this.set[i].test(f)` shape) must also fall | ||
| // through to dynamic dispatch. | ||
| let src = r#" | ||
| function f(obj: any, file: any) { | ||
| return obj.matcher.test(file); | ||
| } | ||
| "#; | ||
| let module = lower(src); | ||
| assert!( | ||
| !module_has_regexp_test(&module), | ||
| "`obj.matcher.test(file)` must NOT lower to RegExpTest" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn regex_literal_receiver_test_call_still_uses_fast_path() { | ||
| // A regex *literal* receiver has positive evidence and keeps the fast path. | ||
| let src = r#" | ||
| const hit = /foo/.test("foobar"); | ||
| "#; | ||
| let module = lower(src); | ||
| assert!( | ||
| module_has_regexp_test(&module), | ||
| "`/foo/.test(...)` (regex literal) should still lower to RegExpTest" | ||
| ); | ||
| } | ||
|
|
||
| /// True if any body debug-prints a `StringMatch`/`StringMatchAll` node. | ||
| fn module_has_string_match(module: &Module) -> bool { | ||
| let dbg = format!("{:#?}", module); | ||
| dbg.contains("StringMatch") | ||
| } | ||
|
|
||
| #[test] | ||
| fn chained_new_dot_match_is_not_string_match() { | ||
| // minimatch's `minimatch(p, pat)` arrow returns | ||
| // `new Minimatch(pat).match(p)` — a chained `new X(arg).match(arg)` where | ||
| // `arg` is an untyped param. `.match()` here is an INSTANCE method, not | ||
| // `String.prototype.match(regex)`. The old heuristic (untyped arg ⇒ | ||
| // regex) lowered it to `StringMatch(new Minimatch(pat), p)`, so the call | ||
| // returned `null` instead of the boolean match result. | ||
| let src = r#" | ||
| function f(p: any, pat: any) { | ||
| return new Matcher(pat).match(p); | ||
| } | ||
| "#; | ||
| let module = lower(src); | ||
| assert!( | ||
| !module_has_string_match(&module), | ||
| "`new Matcher(pat).match(p)` must NOT lower to StringMatch" | ||
| ); | ||
| } | ||
|
|
||
| #[test] | ||
| fn string_match_regex_literal_still_uses_fast_path() { | ||
| // A regex *literal* arg keeps the StringMatch fast path. | ||
| let src = r#" | ||
| const m = "abc123".match(/\d+/); | ||
| "#; | ||
| let module = lower(src); | ||
| assert!( | ||
| module_has_string_match(&module), | ||
| "`\"...\".match(/\\d+/)` (regex literal arg) should still lower to StringMatch" | ||
| ); | ||
| } |
92 changes: 92 additions & 0 deletions
92
crates/perry-hir/tests/nested_class_expr_name_collision.rs
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,92 @@ | ||
| //! Batch-3 functional-correctness fix (minimatch source-compile): a NAMED | ||
| //! class EXPRESSION nested in a function body whose name collides with a | ||
| //! TOP-LEVEL class DECLARATION in the same module must NOT reuse the | ||
| //! declaration's ClassId / module-scope registration. | ||
| //! | ||
| //! minimatch's `defaults()` returns | ||
| //! `Object.assign(m, { Minimatch: class Minimatch extends orig.Minimatch | ||
| //! { constructor(){ super(...) } static defaults(){…} } })` | ||
| //! — a near-empty nested class expression that happens to share the name of | ||
| //! the real top-level `export class Minimatch { …18 fields, ~10 methods… }`. | ||
| //! Per JS spec a class-expression's name binds only inside its own body, so | ||
| //! the two are distinct classes. Perry reused the top-level class's ClassId | ||
| //! for the nested expression, silently overwriting the real exported class | ||
| //! with the body-less nested one — `new Minimatch(pattern)` then produced an | ||
| //! instance whose every field/method was `undefined`. | ||
| //! | ||
| //! The fix renames the colliding class expression to a fresh unique name so | ||
| //! it gets its own ClassId; the value position (object property / `new` site) | ||
| //! holds the resulting ClassRef directly. | ||
|
|
||
| use perry_diagnostics::SourceCache; | ||
| use perry_hir::{lower_module, Module}; | ||
| use perry_parser::parse_typescript_with_cache; | ||
|
|
||
| fn lower(src: &str) -> Module { | ||
| let src = src.to_string(); | ||
| std::thread::Builder::new() | ||
| .stack_size(32 * 1024 * 1024) | ||
| .spawn(move || { | ||
| let mut cache = SourceCache::new(); | ||
| let parsed = | ||
| parse_typescript_with_cache(&src, "/tmp/nested_class_collision.ts", &mut cache) | ||
| .expect("parse failed"); | ||
| lower_module(&parsed.module, "test", "/tmp/nested_class_collision.ts") | ||
| .expect("lower failed") | ||
| }) | ||
| .expect("spawn") | ||
| .join() | ||
| .expect("lower thread panicked") | ||
| } | ||
|
|
||
| /// Find the lowered `Class` whose name is exactly `name` (not a synthetic | ||
| /// `<name>__class_expr_N` rename). | ||
| fn find_class<'a>(module: &'a Module, name: &str) -> Option<&'a perry_hir::Class> { | ||
| module.classes.iter().find(|c| c.name == name) | ||
| } | ||
|
|
||
| #[test] | ||
| fn nested_class_expr_does_not_overwrite_toplevel_declaration() { | ||
| // The nested `class Thing` appears in file order BEFORE the real | ||
| // top-level `class Thing` — exactly the minimatch shape (the nested | ||
| // expression in `defaults()` precedes `export class Minimatch`). | ||
| let src = r#" | ||
| const reg: any = (p: any) => p; | ||
| export const defaults = (def: any) => { | ||
| const orig: any = reg; | ||
| const m: any = (p: any) => orig(p); | ||
| return Object.assign(m, { | ||
| Thing: class Thing extends orig.Thing { | ||
| constructor(x: any) { super(x); } | ||
| static defaults() { return null; } | ||
| }, | ||
| }); | ||
| }; | ||
| export class Thing { | ||
| a; | ||
| set; | ||
| constructor(x: any) { | ||
| this.a = x; | ||
| this.set = [1, 2]; | ||
| } | ||
| greet() { return "hi"; } | ||
| make() { return this.set.length; } | ||
| } | ||
| "#; | ||
| let module = lower(src); | ||
|
|
||
| // The real top-level `Thing` must survive with its full body — at least | ||
| // one real method (`greet`/`make`) and its declared fields. The nested | ||
| // expression (constructor + a single static) must NOT have clobbered it. | ||
| let thing = find_class(&module, "Thing").expect("top-level `Thing` class must exist"); | ||
| let method_names: Vec<&str> = thing.methods.iter().map(|m| m.name.as_str()).collect(); | ||
| assert!( | ||
| method_names.contains(&"greet") && method_names.contains(&"make"), | ||
| "top-level `Thing` must keep its real methods (got methods: {:?})", | ||
| method_names | ||
| ); | ||
| assert!( | ||
| thing.fields.iter().any(|f| f.name == "a") && thing.fields.iter().any(|f| f.name == "set"), | ||
| "top-level `Thing` must keep its declared fields" | ||
| ); | ||
| } |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 16118
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 1455
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 4550
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 1272
🏁 Script executed:
rg -n "class_expr|CollisionDetect" crates/perry-hir/src/lower/tests.rs -A 3 -B 3Repository: PerryTS/perry
Length of output: 39
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 39
🏁 Script executed:
Repository: PerryTS/perry
Length of output: 3540
Remove overly broad collision check from class expression renaming logic.
The comment at line 2368 explicitly states that imported class bindings are caught via
lookup_class(since "named class imports are registered too"). Checkingctx.lookup_imported_func(&n)is unnecessary and over-broad—it triggers renaming on collisions with imported functions, not just classes. This changes behavior for non-class import name collisions when it shouldn't. Restrict the predicate to class bindings only.Suggested diff
🤖 Prompt for AI Agents