@tishlang/tish-format 2.37.0 → 2.37.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/tish-format +0 -0
- package/crates/tish_compile/src/infer.rs +521 -17
- package/crates/tish_compile/src/types.rs +7 -2
- package/crates/tish_compile/tests/regr_484_obj_field_param.rs +18 -0
- package/crates/tish_compile/tests/regr_e0382_typed_struct.rs +26 -0
- package/crates/tish_compile/tests/regr_numeric_arg_whitelist.rs +29 -0
- package/crates/tish_fmt/Cargo.toml +1 -1
- package/package.json +1 -1
- package/platform/darwin-arm64/tish-fmt +0 -0
- package/platform/darwin-x64/tish-fmt +0 -0
- package/platform/linux-arm64/tish-fmt +0 -0
- package/platform/linux-x64/tish-fmt +0 -0
- package/platform/win32-x64/tish-fmt.exe +0 -0
package/bin/tish-format
CHANGED
|
Binary file
|
|
@@ -283,7 +283,7 @@ fn param_infer_program(program: Program) -> Program {
|
|
|
283
283
|
// numeric" (`return SHARED`) yet the caller passes e.g. `"arg"`, which the f64 coercion would
|
|
284
284
|
// panic on / turn to NaN where JS keeps the value. Skip those fns (they stay fully boxed). Same
|
|
285
285
|
// set `collect_native_fns` uses to refuse the M5 native form.
|
|
286
|
-
let nonnumeric =
|
|
286
|
+
let nonnumeric = fns_with_unsafe_numeric_arg(&program.statements);
|
|
287
287
|
// Soundness (#477): a fn referenced as a first-class VALUE — passed as a call ARGUMENT
|
|
288
288
|
// (`serve(port, handler)`), assigned, returned, or stored, i.e. any use that is NOT a direct
|
|
289
289
|
// `f(...)` call — escapes into the boxed `Value::Function` path and is later invoked via
|
|
@@ -322,6 +322,522 @@ fn escaping_fn_names(statements: &[Statement]) -> HashSet<String> {
|
|
|
322
322
|
out
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/// Shared candidate predicate: would param `pname` (unannotated, default-less) of a fn with `body`
|
|
326
|
+
/// be M4-promoted to `: number` on the basis of its BODY uses (the #172 optimistic per-param
|
|
327
|
+
/// fixpoint)? Used by BOTH pi_stmt (the promotion) and `numeric_candidate_positions` (the call-arg
|
|
328
|
+
/// whitelist) so the two agree on exactly which positions are numeric candidates.
|
|
329
|
+
fn param_is_body_numeric_candidate(body: &Statement, base_nums: &HashSet<String>, pname: &str) -> bool {
|
|
330
|
+
let mut nums = base_nums.clone();
|
|
331
|
+
nums.insert(pname.to_string());
|
|
332
|
+
collect_numeric_locals_fixpoint(body, &mut nums);
|
|
333
|
+
let copy_descendants: Vec<&String> = nums
|
|
334
|
+
.iter()
|
|
335
|
+
.filter(|x| x.as_str() != pname && !base_nums.contains(*x))
|
|
336
|
+
.collect();
|
|
337
|
+
nus_stmt(body, pname, &nums)
|
|
338
|
+
&& copy_descendants.iter().all(|x| lns_stmt(body, x.as_str(), &nums))
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/// analyze_aggregate's S-0 numeric-param set for one fn: the optimistic `pus_stmt` fixpoint (a param
|
|
342
|
+
/// used only as an object-field VALUE / numeric operand is numeric). Shared with the call-arg
|
|
343
|
+
/// whitelist so its candidate set covers what S-0 (via `stamp_numeric_params`) actually promotes.
|
|
344
|
+
fn s0_numeric_candidates(params: &[FunParam], body: &Statement) -> HashSet<String> {
|
|
345
|
+
let mut nums = HashSet::new();
|
|
346
|
+
collect_numeric_locals_fixpoint(body, &mut nums);
|
|
347
|
+
let mut numeric_params: HashSet<String> = params
|
|
348
|
+
.iter()
|
|
349
|
+
.filter_map(|p| match p {
|
|
350
|
+
FunParam::Simple(tp) if tp.type_ann.is_none() && tp.default.is_none() => {
|
|
351
|
+
Some(tp.name.to_string())
|
|
352
|
+
}
|
|
353
|
+
_ => None,
|
|
354
|
+
})
|
|
355
|
+
.collect();
|
|
356
|
+
loop {
|
|
357
|
+
let mut local_nums = nums.clone();
|
|
358
|
+
for n in &numeric_params {
|
|
359
|
+
local_nums.insert(n.clone());
|
|
360
|
+
}
|
|
361
|
+
let drop: Vec<String> = numeric_params
|
|
362
|
+
.iter()
|
|
363
|
+
.filter(|n| !pus_stmt(body, n.as_str(), &local_nums))
|
|
364
|
+
.cloned()
|
|
365
|
+
.collect();
|
|
366
|
+
if drop.is_empty() {
|
|
367
|
+
break;
|
|
368
|
+
}
|
|
369
|
+
for n in drop {
|
|
370
|
+
numeric_params.remove(&n);
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
numeric_params
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
/// Per fn (top-level and nested), the parameter POSITIONS that are numeric candidates — i.e. either
|
|
377
|
+
/// promotion path would M4-promote them to f64. The call-arg whitelist (#485/#477/#484) checks ONLY
|
|
378
|
+
/// these positions, so array/object params (e.g. native-vec devirt) never disqualify a fn.
|
|
379
|
+
pub(crate) fn numeric_candidate_positions(
|
|
380
|
+
statements: &[Statement],
|
|
381
|
+
) -> std::collections::HashMap<String, Vec<(usize, String)>> {
|
|
382
|
+
let mut out = std::collections::HashMap::new();
|
|
383
|
+
ncp_stmt_list(statements, &mut out);
|
|
384
|
+
out
|
|
385
|
+
}
|
|
386
|
+
fn ncp_stmt_list(statements: &[Statement], out: &mut std::collections::HashMap<String, Vec<(usize, String)>>) {
|
|
387
|
+
for s in statements {
|
|
388
|
+
ncp_stmt(s, out);
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
fn ncp_stmt(s: &Statement, out: &mut std::collections::HashMap<String, Vec<(usize, String)>>) {
|
|
392
|
+
use Statement::*;
|
|
393
|
+
match s {
|
|
394
|
+
FunDecl { name, params, body, .. } => {
|
|
395
|
+
let mut base_nums = HashSet::new();
|
|
396
|
+
collect_numeric_locals_fixpoint(body, &mut base_nums);
|
|
397
|
+
// A position is a candidate if EITHER promotion path would take it: pi_stmt (nus_stmt) OR
|
|
398
|
+
// analyze_aggregate's S-0 (pus_stmt — which also accepts a param used as an object-field
|
|
399
|
+
// VALUE, e.g. `{ v: s }`). The whitelist must cover BOTH or a param S-0 promotes but
|
|
400
|
+
// pi_stmt doesn't (tishlang/tish#484) gets an f64 unbox with no call-arg check.
|
|
401
|
+
let s0 = s0_numeric_candidates(params, body);
|
|
402
|
+
let mut positions: Vec<(usize, String)> = Vec::new();
|
|
403
|
+
for (i, p) in params.iter().enumerate() {
|
|
404
|
+
if let FunParam::Simple(tp) = p {
|
|
405
|
+
if tp.type_ann.is_none()
|
|
406
|
+
&& tp.default.is_none()
|
|
407
|
+
&& (param_is_body_numeric_candidate(body, &base_nums, tp.name.as_ref())
|
|
408
|
+
|| s0.contains(tp.name.as_ref()))
|
|
409
|
+
{
|
|
410
|
+
positions.push((i, tp.name.to_string()));
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
if !positions.is_empty() {
|
|
415
|
+
out.insert(name.to_string(), positions);
|
|
416
|
+
}
|
|
417
|
+
ncp_stmt(body, out); // nested fns
|
|
418
|
+
}
|
|
419
|
+
Block { statements, .. } | Multi { statements, .. } => ncp_stmt_list(statements, out),
|
|
420
|
+
If { then_branch, else_branch, .. } => {
|
|
421
|
+
ncp_stmt(then_branch, out);
|
|
422
|
+
if let Some(e) = else_branch {
|
|
423
|
+
ncp_stmt(e, out);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
While { body, .. }
|
|
427
|
+
| DoWhile { body, .. }
|
|
428
|
+
| For { body, .. }
|
|
429
|
+
| ForOf { body, .. }
|
|
430
|
+
| ForIn { body, .. } => ncp_stmt(body, out),
|
|
431
|
+
Try { body, catch_body, finally_body, .. } => {
|
|
432
|
+
ncp_stmt(body, out);
|
|
433
|
+
if let Some(c) = catch_body {
|
|
434
|
+
ncp_stmt(c, out);
|
|
435
|
+
}
|
|
436
|
+
if let Some(f) = finally_body {
|
|
437
|
+
ncp_stmt(f, out);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
Switch { cases, default_body, .. } => {
|
|
441
|
+
for (_, b) in cases {
|
|
442
|
+
ncp_stmt_list(b, out);
|
|
443
|
+
}
|
|
444
|
+
if let Some(d) = default_body {
|
|
445
|
+
ncp_stmt_list(d, out);
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
Export { declaration, .. } => {
|
|
449
|
+
if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
|
|
450
|
+
ncp_stmt(inner, out);
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
_ => {}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
// ---- interprocedural numeric-arg soundness (tishlang/tish#485/#477/#484) ----
|
|
458
|
+
//
|
|
459
|
+
// M4 promotes an unannotated param to native f64 and unboxes it at the (boxed) closure entry, which
|
|
460
|
+
// panics `"expected number"` if a caller ever passes a non-number. Instead of BLACKLISTING the known
|
|
461
|
+
// ways that happens (a non-numeric literal / an escaping fn / an await), compute — soundly, by
|
|
462
|
+
// construction — which parameter POSITIONS provably receive only numbers, then disqualify any fn with
|
|
463
|
+
// a body-numeric CANDIDATE position that is NOT provably numeric. An optimistic fixpoint (assume all
|
|
464
|
+
// positions numeric, then drop any that a call site violates) makes pass-through params transitive:
|
|
465
|
+
// `f(n)` where `n` is a numeric param of the caller stays numeric down the chain, back to a literal.
|
|
466
|
+
|
|
467
|
+
#[derive(Clone)]
|
|
468
|
+
enum ArgClass {
|
|
469
|
+
Num, // provably a number regardless of context (literal, arithmetic, Math)
|
|
470
|
+
NotNum, // provably NOT reliably a number (string/object/unknown-return call)
|
|
471
|
+
NeedsNumeric(Vec<String>), // a number iff every listed ident is numeric in the caller's context
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
struct CallSite {
|
|
475
|
+
caller: Option<String>, // enclosing fn (None = top level)
|
|
476
|
+
callee: String,
|
|
477
|
+
args: Vec<ArgClass>,
|
|
478
|
+
has_spread: bool,
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
fn arg_number_producing_call(callee: &Expr) -> bool {
|
|
482
|
+
match callee {
|
|
483
|
+
Expr::Ident { name, .. } => name.as_ref() == "Number",
|
|
484
|
+
Expr::Member { object, prop: tishlang_ast::MemberProp::Name { name: m, .. }, .. } => {
|
|
485
|
+
matches!(object.as_ref(), Expr::Ident { name, .. } if name.as_ref() == "Math")
|
|
486
|
+
&& matches!(
|
|
487
|
+
m.as_ref(),
|
|
488
|
+
"sqrt" | "sin" | "cos" | "tan" | "abs" | "floor" | "ceil" | "exp" | "trunc"
|
|
489
|
+
| "log" | "log2" | "log10" | "round" | "pow" | "min" | "max" | "sign"
|
|
490
|
+
| "cbrt" | "atan" | "atan2" | "asin" | "acos" | "hypot" | "random"
|
|
491
|
+
)
|
|
492
|
+
}
|
|
493
|
+
_ => false,
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
fn combine_add(l: ArgClass, r: ArgClass) -> ArgClass {
|
|
498
|
+
match (l, r) {
|
|
499
|
+
(ArgClass::NotNum, _) | (_, ArgClass::NotNum) => ArgClass::NotNum,
|
|
500
|
+
(ArgClass::Num, ArgClass::Num) => ArgClass::Num,
|
|
501
|
+
(ArgClass::Num, x) | (x, ArgClass::Num) => x,
|
|
502
|
+
(ArgClass::NeedsNumeric(mut a), ArgClass::NeedsNumeric(b)) => {
|
|
503
|
+
a.extend(b);
|
|
504
|
+
ArgClass::NeedsNumeric(a)
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
fn classify_arg(e: &Expr) -> ArgClass {
|
|
510
|
+
use tishlang_ast::BinOp::*;
|
|
511
|
+
match e {
|
|
512
|
+
Expr::Literal { value: Literal::Number(_), .. } => ArgClass::Num,
|
|
513
|
+
Expr::Ident { name, .. } => ArgClass::NeedsNumeric(vec![name.to_string()]),
|
|
514
|
+
// `-`,`*`,`/`,`%`,`**`, bitwise/shift JS-ToNumber unconditionally → always a number.
|
|
515
|
+
Expr::Binary {
|
|
516
|
+
op: Sub | Mul | Div | Mod | Pow | BitAnd | BitOr | BitXor | Shl | Shr | UShr,
|
|
517
|
+
..
|
|
518
|
+
} => ArgClass::Num,
|
|
519
|
+
// `+` is a number only if BOTH sides are (else string concat).
|
|
520
|
+
Expr::Binary { op: Add, left, right, .. } => {
|
|
521
|
+
combine_add(classify_arg(left), classify_arg(right))
|
|
522
|
+
}
|
|
523
|
+
Expr::Unary {
|
|
524
|
+
op:
|
|
525
|
+
tishlang_ast::UnaryOp::Neg
|
|
526
|
+
| tishlang_ast::UnaryOp::Pos
|
|
527
|
+
| tishlang_ast::UnaryOp::BitNot,
|
|
528
|
+
..
|
|
529
|
+
} => ArgClass::Num,
|
|
530
|
+
Expr::Call { callee, .. } | Expr::New { callee, .. } => {
|
|
531
|
+
if arg_number_producing_call(callee) {
|
|
532
|
+
ArgClass::Num
|
|
533
|
+
} else {
|
|
534
|
+
ArgClass::NotNum
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
_ => ArgClass::NotNum,
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
fn arg_ok(ac: &ArgClass, ctx: &HashSet<String>) -> bool {
|
|
542
|
+
match ac {
|
|
543
|
+
ArgClass::Num => true,
|
|
544
|
+
ArgClass::NotNum => false,
|
|
545
|
+
ArgClass::NeedsNumeric(idents) => idents.iter().all(|n| ctx.contains(n)),
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
// Collect (param-name-list, numeric-locals) for every fn (top-level and nested).
|
|
550
|
+
fn cfm_stmt(
|
|
551
|
+
s: &Statement,
|
|
552
|
+
params: &mut std::collections::HashMap<String, Vec<String>>,
|
|
553
|
+
locals: &mut std::collections::HashMap<String, HashSet<String>>,
|
|
554
|
+
) {
|
|
555
|
+
use Statement::*;
|
|
556
|
+
match s {
|
|
557
|
+
FunDecl { name, params: ps, body, .. } => {
|
|
558
|
+
let pnames: Vec<String> = ps
|
|
559
|
+
.iter()
|
|
560
|
+
.map(|p| match p {
|
|
561
|
+
FunParam::Simple(tp) => tp.name.to_string(),
|
|
562
|
+
_ => String::new(),
|
|
563
|
+
})
|
|
564
|
+
.collect();
|
|
565
|
+
params.insert(name.to_string(), pnames);
|
|
566
|
+
let mut nums = HashSet::new();
|
|
567
|
+
collect_numeric_locals_fixpoint(body, &mut nums);
|
|
568
|
+
locals.insert(name.to_string(), nums);
|
|
569
|
+
cfm_stmt(body, params, locals);
|
|
570
|
+
}
|
|
571
|
+
Block { statements, .. } | Multi { statements, .. } => {
|
|
572
|
+
for x in statements {
|
|
573
|
+
cfm_stmt(x, params, locals);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
If { then_branch, else_branch, .. } => {
|
|
577
|
+
cfm_stmt(then_branch, params, locals);
|
|
578
|
+
if let Some(e) = else_branch {
|
|
579
|
+
cfm_stmt(e, params, locals);
|
|
580
|
+
}
|
|
581
|
+
}
|
|
582
|
+
While { body, .. } | DoWhile { body, .. } | For { body, .. } | ForOf { body, .. }
|
|
583
|
+
| ForIn { body, .. } => cfm_stmt(body, params, locals),
|
|
584
|
+
Try { body, catch_body, finally_body, .. } => {
|
|
585
|
+
cfm_stmt(body, params, locals);
|
|
586
|
+
if let Some(c) = catch_body {
|
|
587
|
+
cfm_stmt(c, params, locals);
|
|
588
|
+
}
|
|
589
|
+
if let Some(f) = finally_body {
|
|
590
|
+
cfm_stmt(f, params, locals);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
Switch { cases, default_body, .. } => {
|
|
594
|
+
for (_, b) in cases {
|
|
595
|
+
for x in b {
|
|
596
|
+
cfm_stmt(x, params, locals);
|
|
597
|
+
}
|
|
598
|
+
}
|
|
599
|
+
if let Some(d) = default_body {
|
|
600
|
+
for x in d {
|
|
601
|
+
cfm_stmt(x, params, locals);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
Export { declaration, .. } => {
|
|
606
|
+
if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
|
|
607
|
+
cfm_stmt(inner, params, locals);
|
|
608
|
+
}
|
|
609
|
+
}
|
|
610
|
+
_ => {}
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
// Record every direct `f(...)` call in `e`, tagged with the enclosing fn; recurse into sub-exprs.
|
|
615
|
+
fn collect_calls_expr(e: &Expr, encl: &Option<String>, out: &mut Vec<CallSite>) {
|
|
616
|
+
if let Expr::Call { callee, args, .. } | Expr::New { callee, args, .. } = e {
|
|
617
|
+
if let Expr::Ident { name, .. } = callee.as_ref() {
|
|
618
|
+
let mut classes = Vec::new();
|
|
619
|
+
let mut has_spread = false;
|
|
620
|
+
for a in args {
|
|
621
|
+
match a {
|
|
622
|
+
CallArg::Expr(x) => classes.push(classify_arg(x)),
|
|
623
|
+
CallArg::Spread(_) => has_spread = true,
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
out.push(CallSite {
|
|
627
|
+
caller: encl.clone(),
|
|
628
|
+
callee: name.to_string(),
|
|
629
|
+
args: classes,
|
|
630
|
+
has_spread,
|
|
631
|
+
});
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
walk_exprs_expr(e, &mut |sub| {
|
|
635
|
+
if let Expr::Call { callee, args, .. } | Expr::New { callee, args, .. } = sub {
|
|
636
|
+
if !std::ptr::eq(sub, e) {
|
|
637
|
+
if let Expr::Ident { name, .. } = callee.as_ref() {
|
|
638
|
+
let mut classes = Vec::new();
|
|
639
|
+
let mut has_spread = false;
|
|
640
|
+
for a in args {
|
|
641
|
+
match a {
|
|
642
|
+
CallArg::Expr(x) => classes.push(classify_arg(x)),
|
|
643
|
+
CallArg::Spread(_) => has_spread = true,
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
out.push(CallSite {
|
|
647
|
+
caller: encl.clone(),
|
|
648
|
+
callee: name.to_string(),
|
|
649
|
+
args: classes,
|
|
650
|
+
has_spread,
|
|
651
|
+
});
|
|
652
|
+
}
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
fn collect_calls_stmt(s: &Statement, encl: &Option<String>, out: &mut Vec<CallSite>) {
|
|
659
|
+
use Statement::*;
|
|
660
|
+
match s {
|
|
661
|
+
FunDecl { name, body, .. } => {
|
|
662
|
+
let inner = Some(name.to_string());
|
|
663
|
+
collect_calls_stmt(body, &inner, out);
|
|
664
|
+
}
|
|
665
|
+
Block { statements, .. } | Multi { statements, .. } => {
|
|
666
|
+
for x in statements {
|
|
667
|
+
collect_calls_stmt(x, encl, out);
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
VarDecl { init: Some(e), .. } => collect_calls_expr(e, encl, out),
|
|
671
|
+
VarDeclDestructure { init, .. } => collect_calls_expr(init, encl, out),
|
|
672
|
+
ExprStmt { expr, .. } => collect_calls_expr(expr, encl, out),
|
|
673
|
+
Return { value: Some(e), .. } | Throw { value: e, .. } => collect_calls_expr(e, encl, out),
|
|
674
|
+
If { cond, then_branch, else_branch, .. } => {
|
|
675
|
+
collect_calls_expr(cond, encl, out);
|
|
676
|
+
collect_calls_stmt(then_branch, encl, out);
|
|
677
|
+
if let Some(e) = else_branch {
|
|
678
|
+
collect_calls_stmt(e, encl, out);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
While { cond, body, .. } | DoWhile { cond, body, .. } => {
|
|
682
|
+
collect_calls_expr(cond, encl, out);
|
|
683
|
+
collect_calls_stmt(body, encl, out);
|
|
684
|
+
}
|
|
685
|
+
For { init, cond, update, body, .. } => {
|
|
686
|
+
if let Some(i) = init {
|
|
687
|
+
collect_calls_stmt(i, encl, out);
|
|
688
|
+
}
|
|
689
|
+
if let Some(c) = cond {
|
|
690
|
+
collect_calls_expr(c, encl, out);
|
|
691
|
+
}
|
|
692
|
+
if let Some(u) = update {
|
|
693
|
+
collect_calls_expr(u, encl, out);
|
|
694
|
+
}
|
|
695
|
+
collect_calls_stmt(body, encl, out);
|
|
696
|
+
}
|
|
697
|
+
ForOf { iterable, body, .. } | ForIn { object: iterable, body, .. } => {
|
|
698
|
+
collect_calls_expr(iterable, encl, out);
|
|
699
|
+
collect_calls_stmt(body, encl, out);
|
|
700
|
+
}
|
|
701
|
+
Switch { expr, cases, default_body, .. } => {
|
|
702
|
+
collect_calls_expr(expr, encl, out);
|
|
703
|
+
for (g, b) in cases {
|
|
704
|
+
if let Some(ge) = g {
|
|
705
|
+
collect_calls_expr(ge, encl, out);
|
|
706
|
+
}
|
|
707
|
+
for x in b {
|
|
708
|
+
collect_calls_stmt(x, encl, out);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
if let Some(d) = default_body {
|
|
712
|
+
for x in d {
|
|
713
|
+
collect_calls_stmt(x, encl, out);
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
Try { body, catch_body, finally_body, .. } => {
|
|
718
|
+
collect_calls_stmt(body, encl, out);
|
|
719
|
+
if let Some(c) = catch_body {
|
|
720
|
+
collect_calls_stmt(c, encl, out);
|
|
721
|
+
}
|
|
722
|
+
if let Some(f) = finally_body {
|
|
723
|
+
collect_calls_stmt(f, encl, out);
|
|
724
|
+
}
|
|
725
|
+
}
|
|
726
|
+
Export { declaration, .. } => {
|
|
727
|
+
if let tishlang_ast::ExportDeclaration::Named(inner) = declaration.as_ref() {
|
|
728
|
+
collect_calls_stmt(inner, encl, out);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
_ => {}
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/// SOUND replacement for the "nonnumeric arg" blacklist (tishlang/tish#485/#477/#484): a per-position
|
|
736
|
+
/// interprocedural fixpoint. Returns the set of fns that have a body-numeric CANDIDATE param position
|
|
737
|
+
/// which is NOT provably fed numbers at every call site — those must stay boxed (not `: number`).
|
|
738
|
+
pub(crate) fn fns_with_unsafe_numeric_arg(statements: &[Statement]) -> HashSet<String> {
|
|
739
|
+
let cand = numeric_candidate_positions(statements);
|
|
740
|
+
if cand.is_empty() {
|
|
741
|
+
return HashSet::new();
|
|
742
|
+
}
|
|
743
|
+
let mut all_params: std::collections::HashMap<String, Vec<String>> =
|
|
744
|
+
std::collections::HashMap::new();
|
|
745
|
+
let mut fn_locals: std::collections::HashMap<String, HashSet<String>> =
|
|
746
|
+
std::collections::HashMap::new();
|
|
747
|
+
for s in statements {
|
|
748
|
+
cfm_stmt(s, &mut all_params, &mut fn_locals);
|
|
749
|
+
}
|
|
750
|
+
// Top-level numeric locals — run a fixpoint ACROSS statements so a chain propagates
|
|
751
|
+
// (`let PI = …; let SOLAR_MASS = 4 * PI * PI` ⇒ both numeric, so `body(…, SOLAR_MASS)` is safe).
|
|
752
|
+
let mut top_nums = HashSet::new();
|
|
753
|
+
loop {
|
|
754
|
+
let before = top_nums.len();
|
|
755
|
+
for s in statements {
|
|
756
|
+
collect_numeric_locals_fixpoint(s, &mut top_nums);
|
|
757
|
+
}
|
|
758
|
+
if top_nums.len() == before {
|
|
759
|
+
break;
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
let mut calls = Vec::new();
|
|
763
|
+
for s in statements {
|
|
764
|
+
collect_calls_stmt(s, &None, &mut calls);
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
// numeric_pos[f] = positions currently believed to receive only numbers. Start optimistic (all).
|
|
768
|
+
let mut numeric_pos: std::collections::HashMap<String, HashSet<usize>> = all_params
|
|
769
|
+
.iter()
|
|
770
|
+
.map(|(f, ps)| (f.clone(), (0..ps.len()).collect()))
|
|
771
|
+
.collect();
|
|
772
|
+
|
|
773
|
+
loop {
|
|
774
|
+
let mut changed = false;
|
|
775
|
+
for cs in &calls {
|
|
776
|
+
if !numeric_pos.contains_key(&cs.callee) {
|
|
777
|
+
continue;
|
|
778
|
+
}
|
|
779
|
+
// Caller context: its numeric locals + params whose position is still numeric.
|
|
780
|
+
let ctx: HashSet<String> = match &cs.caller {
|
|
781
|
+
None => top_nums.clone(),
|
|
782
|
+
Some(c) => {
|
|
783
|
+
// Top-level numeric globals (`SOLAR_MASS`, `PI`, …) are numeric inside every fn,
|
|
784
|
+
// plus this fn's numeric locals + its still-numeric params.
|
|
785
|
+
let mut ctx = top_nums.clone();
|
|
786
|
+
ctx.extend(fn_locals.get(c).cloned().unwrap_or_default());
|
|
787
|
+
if let (Some(ps), Some(np)) = (all_params.get(c), numeric_pos.get(c)) {
|
|
788
|
+
for &pos in np {
|
|
789
|
+
if let Some(n) = ps.get(pos) {
|
|
790
|
+
ctx.insert(n.clone());
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
}
|
|
794
|
+
ctx
|
|
795
|
+
}
|
|
796
|
+
};
|
|
797
|
+
let arity = all_params.get(&cs.callee).map(|p| p.len()).unwrap_or(0);
|
|
798
|
+
let set = numeric_pos.get_mut(&cs.callee).unwrap();
|
|
799
|
+
if cs.has_spread {
|
|
800
|
+
if !set.is_empty() {
|
|
801
|
+
set.clear();
|
|
802
|
+
changed = true;
|
|
803
|
+
}
|
|
804
|
+
continue;
|
|
805
|
+
}
|
|
806
|
+
for pos in 0..arity {
|
|
807
|
+
if !set.contains(&pos) {
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
let ok = match cs.args.get(pos) {
|
|
811
|
+
Some(ac) => arg_ok(ac, &ctx),
|
|
812
|
+
None => false, // missing arg → undefined/Null → not a number
|
|
813
|
+
};
|
|
814
|
+
if !ok {
|
|
815
|
+
set.remove(&pos);
|
|
816
|
+
changed = true;
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
if !changed {
|
|
821
|
+
break;
|
|
822
|
+
}
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
let mut disq = HashSet::new();
|
|
826
|
+
for (f, positions) in &cand {
|
|
827
|
+
match numeric_pos.get(f) {
|
|
828
|
+
Some(np) => {
|
|
829
|
+
if positions.iter().any(|(pos, _)| !np.contains(pos)) {
|
|
830
|
+
disq.insert(f.clone());
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
None => {
|
|
834
|
+
disq.insert(f.clone());
|
|
835
|
+
}
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
disq
|
|
839
|
+
}
|
|
840
|
+
|
|
325
841
|
fn pi_stmt(s: Statement, nonnumeric: &HashSet<String>, escaping: &HashSet<String>) -> Statement {
|
|
326
842
|
if let Statement::FunDecl {
|
|
327
843
|
async_,
|
|
@@ -374,21 +890,9 @@ fn pi_stmt(s: Statement, nonnumeric: &HashSet<String>, escaping: &HashSet<String
|
|
|
374
890
|
// Monotone, locals/param-candidate-only.
|
|
375
891
|
if tp.type_ann.is_none()
|
|
376
892
|
&& tp.default.is_none()
|
|
893
|
+
&& param_is_body_numeric_candidate(&body, &base_nums, tp.name.as_ref())
|
|
377
894
|
{
|
|
378
|
-
|
|
379
|
-
nums.insert(tp.name.to_string());
|
|
380
|
-
collect_numeric_locals_fixpoint(&body, &mut nums);
|
|
381
|
-
let copy_descendants: Vec<&String> = nums
|
|
382
|
-
.iter()
|
|
383
|
-
.filter(|x| x.as_str() != tp.name.as_ref() && !base_nums.contains(*x))
|
|
384
|
-
.collect();
|
|
385
|
-
if nus_stmt(&body, tp.name.as_ref(), &nums)
|
|
386
|
-
&& copy_descendants
|
|
387
|
-
.iter()
|
|
388
|
-
.all(|x| lns_stmt(&body, x.as_str(), &nums))
|
|
389
|
-
{
|
|
390
|
-
tp.type_ann = Some(TypeAnnotation::Simple(std::sync::Arc::from("number"), tishlang_ast::Span::default()));
|
|
391
|
-
}
|
|
895
|
+
tp.type_ann = Some(TypeAnnotation::Simple(std::sync::Arc::from("number"), tishlang_ast::Span::default()));
|
|
392
896
|
}
|
|
393
897
|
FunParam::Simple(tp)
|
|
394
898
|
}
|
|
@@ -484,7 +988,7 @@ fn collect_numeric_locals(s: &Statement, out: &mut HashSet<String>) {
|
|
|
484
988
|
/// names (a copy of an already-numeric source), so it converges in at most one pass per copy-chain
|
|
485
989
|
/// link; the `out.len()` watermark detects quiescence. Soundness: every added name is a copy of a
|
|
486
990
|
/// value already proven/assumed numeric, the same basis as the literal seeding.
|
|
487
|
-
fn collect_numeric_locals_fixpoint(body: &Statement, out: &mut HashSet<String>) {
|
|
991
|
+
pub(crate) fn collect_numeric_locals_fixpoint(body: &Statement, out: &mut HashSet<String>) {
|
|
488
992
|
loop {
|
|
489
993
|
let before = out.len();
|
|
490
994
|
collect_numeric_locals(body, out);
|
|
@@ -3710,7 +4214,7 @@ pub fn analyze_aggregate(program: &Program) -> AggregateAnalysis {
|
|
|
3710
4214
|
// params either — `fn makeRecord(id, name){ return {id, name} }` called `makeRecord(1, "Alice")`
|
|
3711
4215
|
// would otherwise emit a `match … panic!("expected number")` coercion on the String. Same set the
|
|
3712
4216
|
// M4 `param_infer` path uses (infer.rs:273).
|
|
3713
|
-
let nonnumeric =
|
|
4217
|
+
let nonnumeric = fns_with_unsafe_numeric_arg(&program.statements);
|
|
3714
4218
|
// Soundness (#477): a fn referenced as a first-class VALUE (a `serve` handler / stored callback)
|
|
3715
4219
|
// is invoked via the boxed `Value::Function` path with arbitrary runtime Values, so — exactly as
|
|
3716
4220
|
// in `param_infer_program` — its params must NOT get an S-0 `: number` shadow either. Without this
|
|
@@ -390,13 +390,18 @@ impl RustType {
|
|
|
390
390
|
let field_assigns = fields
|
|
391
391
|
.iter()
|
|
392
392
|
.map(|(k, ty)| {
|
|
393
|
-
let fetch = format!("tishlang_runtime::get_prop(
|
|
393
|
+
let fetch = format!("tishlang_runtime::get_prop(_src, {:?})", k.as_ref());
|
|
394
394
|
format!("{}: {}", field_ident(k), ty.from_value_expr(&fetch))
|
|
395
395
|
})
|
|
396
396
|
.collect::<Vec<_>>()
|
|
397
397
|
.join(", ");
|
|
398
|
+
// Bind `_src` as a BORROW (not a move) of the source Value: field fetches only need
|
|
399
|
+
// `&Value` (get_prop borrows), and moving would use-after-move a caller-owned temp —
|
|
400
|
+
// e.g. an assignment-as-expression `{ let _v = obj; lhs = <coerce _v>; _v }` reuses
|
|
401
|
+
// `_v` for its value (tishlang/tish#486). `&(expr)` evaluates the source once and
|
|
402
|
+
// (for a temporary) lifetime-extends it to this block, so it's still single-eval.
|
|
398
403
|
format!(
|
|
399
|
-
"{{ let _src = {}; {} {{ {} }} }}",
|
|
404
|
+
"{{ let _src = &({}); {} {{ {} }} }}",
|
|
400
405
|
value_expr,
|
|
401
406
|
named_struct_ident(name),
|
|
402
407
|
field_assigns
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
//! tishlang/tish#484 — a string param used only as an object-field VALUE (`{ v: s }`) must not be
|
|
2
|
+
//! native-promoted to f64. analyze_aggregate's S-0 (`pus_stmt`) treats a field-value param as
|
|
3
|
+
//! numeric; the call-arg whitelist must cover S-0's candidate set, so `f("hello")` disqualifies f.
|
|
4
|
+
use std::path::PathBuf;
|
|
5
|
+
use tishlang_compile::compile_project_full;
|
|
6
|
+
fn compile(rel: &str) -> String {
|
|
7
|
+
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
8
|
+
let path = manifest.join("../..").join(rel).canonicalize().unwrap();
|
|
9
|
+
compile_project_full(&path, path.parent(), &[], true).unwrap().0
|
|
10
|
+
}
|
|
11
|
+
#[test]
|
|
12
|
+
fn obj_field_string_param_stays_boxed() {
|
|
13
|
+
for f in ["tests/regression/obj_field_string_param.tish", "tests/regression/async_param_across_await.tish"] {
|
|
14
|
+
let rust = compile(f);
|
|
15
|
+
let bad: Vec<&str> = rust.lines().filter(|l| l.contains("expected number")).collect();
|
|
16
|
+
assert!(bad.is_empty(), "{f}: string field-value param must not f64-unbox:\n{}", bad.join("\n"));
|
|
17
|
+
}
|
|
18
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
//! tishlang/tish#486 — a typed nullable-struct local reassigned to a struct literal in a conditional
|
|
2
|
+
//! must not emit a Value→struct coercion that MOVES the source temp, because the assignment-as-
|
|
3
|
+
//! expression wrapper (`{ let _v = obj; lhs = <coerce _v>; _v }`) reuses that temp for its value —
|
|
4
|
+
//! moving it produced `error[E0382]: use of moved value`. The coercion must borrow the source.
|
|
5
|
+
use std::path::PathBuf;
|
|
6
|
+
use tishlang_compile::compile_project_full;
|
|
7
|
+
|
|
8
|
+
fn compile(rel: &str) -> String {
|
|
9
|
+
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
10
|
+
let path = manifest.join("../..").join(rel).canonicalize().unwrap();
|
|
11
|
+
compile_project_full(&path, path.parent(), &[], true).unwrap().0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[test]
|
|
15
|
+
fn typed_struct_coercion_borrows_source() {
|
|
16
|
+
let rust = compile("tests/regression/typed_struct_cond_assign.tish");
|
|
17
|
+
// The Value→struct coercion must bind `_src` by BORROW, never by moving a bare temp.
|
|
18
|
+
assert!(
|
|
19
|
+
rust.contains("let _src = &("),
|
|
20
|
+
"expected the struct coercion to borrow its source (`let _src = &(...)`)"
|
|
21
|
+
);
|
|
22
|
+
assert!(
|
|
23
|
+
!rust.contains("let _src = _v;"),
|
|
24
|
+
"tishlang/tish#486: struct coercion must not MOVE the wrapper temp `_v` (use-after-move / E0382)"
|
|
25
|
+
);
|
|
26
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
//! tishlang/tish#485/#477/#484 — SYSTEMIC: a param is native-promoted to f64 only if the
|
|
2
|
+
//! interprocedural per-position fixpoint proves every call site feeds it numbers. #485's
|
|
3
|
+
//! return-either-param helper (called with string|null results) must NOT get an f64 unbox; the
|
|
4
|
+
//! recursive + pass-through numeric fns MUST still promote (no perf regression).
|
|
5
|
+
use std::path::PathBuf;
|
|
6
|
+
use tishlang_compile::compile_project_full;
|
|
7
|
+
|
|
8
|
+
fn compile(rel: &str) -> String {
|
|
9
|
+
let manifest = PathBuf::from(env!("CARGO_MANIFEST_DIR"));
|
|
10
|
+
let path = manifest.join("../..").join(rel).canonicalize().unwrap();
|
|
11
|
+
compile_project_full(&path, path.parent(), &[], true).unwrap().0
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
#[test]
|
|
15
|
+
fn unsafe_numeric_arg_stays_boxed_no_f64_unbox() {
|
|
16
|
+
let rust = compile("tests/regression/f64_return_either_param.tish");
|
|
17
|
+
let bad: Vec<&str> = rust.lines().filter(|l| l.contains("expected number")).collect();
|
|
18
|
+
assert!(bad.is_empty(), "no fn here does arithmetic; nothing should f64-unbox:\n{}", bad.join("\n"));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
#[test]
|
|
22
|
+
fn recursive_and_passthrough_numeric_fns_still_promote() {
|
|
23
|
+
let rust = compile("tests/regression/numeric_arg_whitelist.tish");
|
|
24
|
+
// fib(n-1)/fib(n-2): n is arithmetic → promoted; work(n) via driver's param n → pass-through.
|
|
25
|
+
assert!(
|
|
26
|
+
rust.contains("fib") && (rust.contains(": f64") || rust.contains("Value::Number")),
|
|
27
|
+
"recursive/pass-through numeric params must still get the native f64 fast path"
|
|
28
|
+
);
|
|
29
|
+
}
|
package/package.json
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|