@tishlang/tish-lint 2.36.0 → 2.36.2

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-lint CHANGED
Binary file
@@ -284,16 +284,45 @@ fn param_infer_program(program: Program) -> Program {
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
286
  let nonnumeric = crate::codegen::Codegen::fns_called_with_nonnumeric_arg(&program.statements);
287
+ // Soundness (#477): a fn referenced as a first-class VALUE — passed as a call ARGUMENT
288
+ // (`serve(port, handler)`), assigned, returned, or stored, i.e. any use that is NOT a direct
289
+ // `f(...)` call — escapes into the boxed `Value::Function` path and is later invoked via
290
+ // `.call(&[Value])` with arbitrary runtime Values (a `serve` handler is called with the request
291
+ // OBJECT). Such a fn's params CANNOT be soundly `: number`-shadowed: the escaping boxed closure
292
+ // unboxes arg0 as `match … Value::Number(n) => *n, _ => panic!("expected number")` and would panic
293
+ // on the first non-numeric call. `fns_called_with_nonnumeric_arg` only catches SYNTACTIC
294
+ // non-numeric call args — it can't see this value-escape — so exclude escaping fns explicitly.
295
+ // Over-approximation is safe: an escaping fn just stays fully boxed (correct, at worst slower).
296
+ let escaping = escaping_fn_names(&program.statements);
287
297
  Program {
288
298
  statements: program
289
299
  .statements
290
300
  .into_iter()
291
- .map(|s| pi_stmt(s, &nonnumeric))
301
+ .map(|s| pi_stmt(s, &nonnumeric, &escaping))
292
302
  .collect(),
293
303
  }
294
304
  }
295
305
 
296
- fn pi_stmt(s: Statement, nonnumeric: &HashSet<String>) -> Statement {
306
+ /// Names of fns referenced as a first-class VALUE anywhere in the program — any use of the name that
307
+ /// is NOT a direct `f(...)` call (a bare-ident call argument like `serve(port, handler)`, an
308
+ /// assignment, a return, storage in an object/array). Such a fn is reachable via the boxed
309
+ /// `Value::Function` `.call(&[Value])` path with arbitrary runtime Values, so its params must stay
310
+ /// boxed and are never `: number`-shadowed. Backed by the same `fn_name_uses` escape counter the
311
+ /// inliner uses. See `param_infer_program`. (#477)
312
+ fn escaping_fn_names(statements: &[Statement]) -> HashSet<String> {
313
+ let mut out = HashSet::new();
314
+ for s in statements {
315
+ if let Statement::FunDecl { name, .. } = s {
316
+ let (_, others) = fn_name_uses(statements, name.as_ref());
317
+ if others > 0 {
318
+ out.insert(name.to_string());
319
+ }
320
+ }
321
+ }
322
+ out
323
+ }
324
+
325
+ fn pi_stmt(s: Statement, nonnumeric: &HashSet<String>, escaping: &HashSet<String>) -> Statement {
297
326
  if let Statement::FunDecl {
298
327
  async_,
299
328
  name,
@@ -305,8 +334,10 @@ fn pi_stmt(s: Statement, nonnumeric: &HashSet<String>) -> Statement {
305
334
  span,
306
335
  } = s
307
336
  {
308
- // A fn reached with a non-numeric arg somewhere: leave its params dynamic (boxed).
309
- if nonnumeric.contains(name.as_ref()) {
337
+ // A fn reached with a non-numeric arg somewhere, OR escaping as a first-class value
338
+ // (#477 — a `serve` handler / stored callback invoked via the boxed path with arbitrary
339
+ // Values): leave its params dynamic (boxed).
340
+ if nonnumeric.contains(name.as_ref()) || escaping.contains(name.as_ref()) {
310
341
  return Statement::FunDecl {
311
342
  async_,
312
343
  name,
@@ -3680,6 +3711,12 @@ pub fn analyze_aggregate(program: &Program) -> AggregateAnalysis {
3680
3711
  // would otherwise emit a `match … panic!("expected number")` coercion on the String. Same set the
3681
3712
  // M4 `param_infer` path uses (infer.rs:273).
3682
3713
  let nonnumeric = crate::codegen::Codegen::fns_called_with_nonnumeric_arg(&program.statements);
3714
+ // Soundness (#477): a fn referenced as a first-class VALUE (a `serve` handler / stored callback)
3715
+ // is invoked via the boxed `Value::Function` path with arbitrary runtime Values, so — exactly as
3716
+ // in `param_infer_program` — its params must NOT get an S-0 `: number` shadow either. Without this
3717
+ // an UNUSED param (vacuously "numeric" under `pus_stmt`) would be stamped `: number` and the
3718
+ // escaping boxed closure would `panic!("expected number")` on the request object.
3719
+ let escaping = escaping_fn_names(&program.statements);
3683
3720
 
3684
3721
  // ---- S-0 + S-A: per top-level function, find numeric params and a return shape.
3685
3722
  for s in &program.statements {
@@ -3731,7 +3768,10 @@ pub fn analyze_aggregate(program: &Program) -> AggregateAnalysis {
3731
3768
  }
3732
3769
  }
3733
3770
  }
3734
- if !numeric_params.is_empty() && !nonnumeric.contains(name.as_ref()) {
3771
+ if !numeric_params.is_empty()
3772
+ && !nonnumeric.contains(name.as_ref())
3773
+ && !escaping.contains(name.as_ref())
3774
+ {
3735
3775
  analysis.fn_numeric_params.insert(name.to_string(), numeric_params);
3736
3776
  }
3737
3777
  // S-A: a single all-f64 object-literal return shape ⇒ a struct alias.
@@ -4677,6 +4717,54 @@ mod param_infer_tests {
4677
4717
  let src = "fn g(x) { let r = x; return r + \"!\" }";
4678
4718
  assert_eq!(inferred_param(src, "g", "x"), None);
4679
4719
  }
4720
+
4721
+ /// Final annotation name (if any) for `fn <fn_name>`'s `<param>` after the FULL pipeline
4722
+ /// (`infer_program`: M4 `param_infer` + local/struct + S-0 aggregate).
4723
+ fn end_to_end_param_ann(src: &str, fn_name: &str, param: &str) -> Option<String> {
4724
+ let prog = infer_program(&parse(src).unwrap());
4725
+ for s in &prog.statements {
4726
+ if let Statement::FunDecl { name, params, .. } = s {
4727
+ if name.as_ref() == fn_name {
4728
+ for p in params {
4729
+ if let FunParam::Simple(tp) = p {
4730
+ if tp.name.as_ref() == param {
4731
+ return tp.type_ann.as_ref().map(|a| match a {
4732
+ TypeAnnotation::Simple(s, _) => s.to_string(),
4733
+ _ => "<complex>".to_string(),
4734
+ });
4735
+ }
4736
+ }
4737
+ }
4738
+ }
4739
+ }
4740
+ }
4741
+ None
4742
+ }
4743
+
4744
+ #[test]
4745
+ fn escaping_value_fn_param_stays_boxed_477() {
4746
+ // #477: `handler` is passed as a call ARGUMENT (escapes as a first-class value, exactly like a
4747
+ // `serve` handler). Its `req` param is UNUSED and the body is all-numeric — so BOTH the M4
4748
+ // `param_infer` and the S-0 aggregate paths would otherwise stamp it `: number`. Because the
4749
+ // fn is invoked via the boxed `Value::Function` path with arbitrary runtime Values, `req` MUST
4750
+ // stay dynamic across the WHOLE pipeline (else the boxed closure panics "expected number" on
4751
+ // the request object). Locks both promotion sites.
4752
+ let src = "fn handler(req) { let s = 200; return s } \
4753
+ fn callWith(f, a) { return f(a) } \
4754
+ console.log(callWith(handler, { method: \"GET\" }))";
4755
+ assert_eq!(end_to_end_param_ann(src, "handler", "req"), None);
4756
+ }
4757
+
4758
+ #[test]
4759
+ fn non_escaping_numeric_fn_keeps_native_fast_path_477() {
4760
+ // CONTROL (no over-broad regression): a fn only ever called DIRECTLY with numbers still gets
4761
+ // its `: number` native fast path — the #477 fix narrows promotion to escaping fns only.
4762
+ let src = "fn square(n) { return n * n } console.log(square(7))";
4763
+ assert_eq!(
4764
+ end_to_end_param_ann(src, "square", "n").as_deref(),
4765
+ Some("number")
4766
+ );
4767
+ }
4680
4768
  }
4681
4769
 
4682
4770
  #[cfg(test)]
@@ -1,6 +1,6 @@
1
1
  [package]
2
2
  name = "tishlang_lint"
3
- version = "2.36.0"
3
+ version = "2.36.2"
4
4
  edition = "2021"
5
5
  description = "AST-based linter for Tish"
6
6
  license-file = { workspace = true }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@tishlang/tish-lint",
3
- "version": "2.36.0",
3
+ "version": "2.36.2",
4
4
  "description": "Tish code linter",
5
5
  "license": "PIF",
6
6
  "repository": {
Binary file
Binary file
Binary file
Binary file
Binary file