@tishlang/tish-format 3.3.3 → 3.6.0

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 CHANGED
Binary file
@@ -108,3 +108,93 @@ fn scope_tish_crate_routes_native() {
108
108
  "@scope package with tish.crate must NOT be merged as Tish source"
109
109
  );
110
110
  }
111
+
112
+ // ── #587: two modules exporting the SAME name, flattened into one scope ──────────────────────────
113
+ //
114
+ // `isolate_private_top_level_bindings` renamed colliding PRIVATE top-level names but skipped
115
+ // exported ones, on the reasoning that "exported names stay stable so imports keep resolving".
116
+ // `module_exports` is an indirection from export name to source symbol, so that was stricter than
117
+ // necessary — and leaving the collision produced a silently WRONG program rather than a slower one.
118
+ //
119
+ // The barrel pattern is where it bites: `import { greet as _greet }` + a local `export fn greet`
120
+ // that calls `_greet`. Flattened, that emits two `function greet` declarations, and because JS
121
+ // function declarations HOIST, the second overwrote the first BEFORE `const _greet = greet` ran.
122
+ // `_greet` then pointed at the wrapper and the program recursed until the stack blew.
123
+ //
124
+ // The entry module is excluded from renaming on purpose: its exports are the bundle's public
125
+ // surface, so moving those would change the API the caller sees. Only the dep is renamed here.
126
+
127
+ /// dep exports `greet`; barrel imports it aliased and re-exports its own `greet` wrapper.
128
+ fn setup_barrel() -> tempfile::TempDir {
129
+ let dir = tempfile::tempdir().unwrap();
130
+ let root = dir.path();
131
+ fs::create_dir_all(root.join("dep")).unwrap();
132
+ fs::create_dir_all(root.join("app")).unwrap();
133
+ fs::write(
134
+ root.join("dep/lib.tish"),
135
+ "export fn greet(name) { return \"hi \" + name }\n",
136
+ )
137
+ .unwrap();
138
+ fs::write(
139
+ root.join("app/barrel.tish"),
140
+ "import { greet as _greet } from \"../dep/lib.tish\"\n\
141
+ export fn greet(name) { return _greet(name) }\n",
142
+ )
143
+ .unwrap();
144
+ fs::write(
145
+ root.join("main.tish"),
146
+ "import { greet } from \"./app/barrel.tish\"\nconsole.log(greet(\"world\"))\n",
147
+ )
148
+ .unwrap();
149
+ dir
150
+ }
151
+
152
+ #[test]
153
+ fn colliding_exported_names_are_isolated_across_modules() {
154
+ let dir = setup_barrel();
155
+ let root = dir.path();
156
+ let modules = resolve_project(&root.join("main.tish"), Some(root)).expect("resolve");
157
+ let merged = merge_modules(modules).expect("merge");
158
+
159
+ // The two `greet` declarations must not both be named `greet` in the flat program — that is the
160
+ // collision, and on the JS target hoisting silently resolves it the wrong way.
161
+ let greet_decls: Vec<&str> = merged
162
+ .program
163
+ .statements
164
+ .iter()
165
+ .filter_map(|s| match s {
166
+ Statement::FunDecl { name, .. } => Some(name.as_ref()),
167
+ _ => None,
168
+ })
169
+ .filter(|n| n.starts_with("greet"))
170
+ .collect();
171
+ assert!(
172
+ greet_decls.len() >= 2,
173
+ "expected both modules' greet fns in the merged program, got {:?}",
174
+ greet_decls
175
+ );
176
+ let bare = greet_decls.iter().filter(|n| **n == "greet").count();
177
+ assert!(
178
+ bare <= 1,
179
+ "two top-level `greet` declarations survived the merge — on the JS target the later one \
180
+ hoists over the former and the barrel's alias captures the wrapper (infinite recursion). \
181
+ Got {:?}",
182
+ greet_decls
183
+ );
184
+
185
+ // The alias must bind to the DEP's symbol, not to the local wrapper.
186
+ let alias_target = merged.program.statements.iter().find_map(|s| match s {
187
+ Statement::VarDecl {
188
+ name,
189
+ init: Some(Expr::Ident { name: src, .. }),
190
+ ..
191
+ } if name.as_ref() == "_greet" => Some(src.to_string()),
192
+ _ => None,
193
+ });
194
+ if let Some(target) = alias_target {
195
+ assert_ne!(
196
+ target, "greet",
197
+ "`_greet` must not bind to the ambiguous bare `greet`; it should name the dep's symbol"
198
+ );
199
+ }
200
+ }
@@ -773,3 +773,181 @@ mod tests {
773
773
  assert_eq!(diags("fn f(a: number) {}\nf(someUnknown())"), Vec::<String>::new());
774
774
  }
775
775
  }
776
+
777
+ // ── #641: call arity ────────────────────────────────────────────────────────────────────────────
778
+ //
779
+ // tish did not check arity at all. A call with too few arguments compiled, the missing parameter
780
+ // became `Value::Null`, and what happened next depended on which lowering the CALLEE happened to
781
+ // get — a property of the callee, not of the call:
782
+ //
783
+ // boxed `Value::native` + typed params -> builds clean, PANICS at runtime the first time that
784
+ // path runs ("expected number", pointing at generated
785
+ // code, saying nothing about the call site)
786
+ // direct native `fn` + typed params -> rustc E0061 against generated code
787
+ // interpreter + typed params -> silently returns NaN / null
788
+ // untyped params -> `null` arrives; a deliberate, widely-used idiom
789
+ //
790
+ // So the same mistake is a build error in one function and a shipped crash in the next.
791
+ //
792
+ // THE RULE, and why it is exactly this narrow: an omitted argument bound to a TYPED parameter can
793
+ // never be correct, because `Value::Null` cannot satisfy a `Value::Number` prologue. That is a fact
794
+ // about the lowering, not a style preference, so it can be a hard error without a policy debate.
795
+ //
796
+ // An omitted argument bound to an UNTYPED parameter stays legal, because it is an established
797
+ // idiom in shipping code — `packages/shop.tish` has six such calls, `packages/ui.tish` one:
798
+ //
799
+ // function renderTab(defer, stream) { if (present(defer) === 1) { … } }
800
+ // renderTab() // deliberate: "render now, no defer, no stream"
801
+ //
802
+ // A parameter with a DEFAULT imposes no minimum either: the default is what fills it. Rest params
803
+ // impose none by definition.
804
+
805
+ /// The minimum argument count a call to `params` must supply: one past the LAST parameter that is
806
+ /// typed and has no default. A typed parameter at index 2 forces arity 3 even when 0 and 1 are
807
+ /// untyped, because the call cannot skip a position to reach it.
808
+ fn min_required_arity(params: &[FunParam]) -> usize {
809
+ let mut min = 0;
810
+ for (i, p) in params.iter().enumerate() {
811
+ if let FunParam::Simple(tp) = p {
812
+ if tp.type_ann.is_some() && tp.default.is_none() {
813
+ min = i + 1;
814
+ }
815
+ }
816
+ }
817
+ min
818
+ }
819
+
820
+ /// #641: report every call that supplies fewer arguments than a typed parameter requires. Runs
821
+ /// unconditionally in the compile path — unlike [`check_program`], which is opt-in behind
822
+ /// `TISH_CHECK` and would therefore never have caught the shipped case this exists for.
823
+ pub fn check_call_arity(program: &Program) -> Vec<TypeDiagnostic> {
824
+ use std::collections::HashMap;
825
+
826
+ // name -> (minimum arity, declared parameter count). Nested and top-level fns alike; a later
827
+ // declaration of the same name wins, matching the runtime's own last-wins binding.
828
+ let mut sigs: HashMap<String, (usize, usize)> = HashMap::new();
829
+ fn collect(stmts: &[Statement], sigs: &mut HashMap<String, (usize, usize)>) {
830
+ for s in stmts {
831
+ if let Statement::FunDecl { name, params, rest_param, body, .. } = s {
832
+ // A rest param swallows any surplus, but the leading typed params still bind
833
+ // positionally, so the minimum is unchanged.
834
+ let _ = rest_param;
835
+ sigs.insert(name.to_string(), (min_required_arity(params), params.len()));
836
+ collect(std::slice::from_ref(body.as_ref()), sigs);
837
+ } else {
838
+ for_each_child_block(s, &mut |b| collect(b, sigs));
839
+ }
840
+ }
841
+ }
842
+ collect(&program.statements, &mut sigs);
843
+
844
+ let mut diags = Vec::new();
845
+ for s in &program.statements {
846
+ crate::codegen::Codegen::for_each_stmt_expr(s, &mut |root| {
847
+ walk_expr(root, &mut |e| {
848
+ let Expr::Call { callee, args, span } = e else { return };
849
+ let Expr::Ident { name, .. } = callee.as_ref() else { return };
850
+ let Some((min, declared)) = sigs.get(name.as_ref()).copied() else { return };
851
+ // A spread makes the count unknowable at compile time — say nothing rather than guess.
852
+ if args.iter().any(|a| matches!(a, CallArg::Spread(_))) {
853
+ return;
854
+ }
855
+ if args.len() < min {
856
+ diags.push(TypeDiagnostic {
857
+ message: format!(
858
+ "'{}' needs {} argument(s) but {} were supplied; parameter {} is typed, and \
859
+ an omitted argument arrives as null, which a typed parameter cannot accept. \
860
+ (An untyped parameter may be omitted — that idiom is unaffected.)",
861
+ name,
862
+ min,
863
+ args.len(),
864
+ min,
865
+ ),
866
+ span: *span,
867
+ });
868
+ }
869
+ let _ = declared;
870
+ });
871
+ });
872
+ }
873
+ diags
874
+ }
875
+
876
+ /// Visit `e` and every sub-expression. `for_each_stmt_expr` hands out only a statement's ROOT
877
+ /// expression, so without this the checker never sees `paintTyped(1)` inside `log("x" +
878
+ /// paintTyped(1))` — which is the shape the reported bug actually had.
879
+ ///
880
+ /// The catch-all arm is deliberate: a variant not enumerated here yields a MISSED diagnostic, never
881
+ /// a wrong one, so a future AST addition degrades this check quietly instead of breaking builds.
882
+ fn walk_expr(e: &Expr, f: &mut dyn FnMut(&Expr)) {
883
+ f(e);
884
+ let mut go = |x: &Expr| walk_expr(x, f);
885
+ match e {
886
+ Expr::Binary { left, right, .. }
887
+ | Expr::NullishCoalesce { left, right, .. }
888
+ | Expr::Index { object: left, index: right, .. } => {
889
+ go(left);
890
+ go(right);
891
+ }
892
+ Expr::Unary { operand, .. } | Expr::TypeOf { operand, .. } | Expr::Await { operand, .. } => {
893
+ go(operand)
894
+ }
895
+ // The inc/dec forms name a variable rather than holding a sub-expression, so there is
896
+ // nothing to descend into.
897
+ Expr::Delete { target, .. } => go(target),
898
+ Expr::Member { object, .. } => go(object),
899
+ Expr::Call { callee, args, .. } | Expr::New { callee, args, .. } => {
900
+ go(callee);
901
+ for a in args {
902
+ match a {
903
+ CallArg::Expr(x) | CallArg::Spread(x) => go(x),
904
+ }
905
+ }
906
+ }
907
+ Expr::Conditional { cond, then_branch, else_branch, .. } => {
908
+ go(cond);
909
+ go(then_branch);
910
+ go(else_branch);
911
+ }
912
+ Expr::Array { elements, .. } => {
913
+ for el in elements {
914
+ match el {
915
+ tishlang_ast::ArrayElement::Expr(x)
916
+ | tishlang_ast::ArrayElement::Spread(x) => go(x),
917
+ _ => {}
918
+ }
919
+ }
920
+ }
921
+ Expr::Object { props, .. } => {
922
+ for pr in props {
923
+ match pr {
924
+ tishlang_ast::ObjectProp::KeyValue(_, x, _) => go(x),
925
+ tishlang_ast::ObjectProp::Spread(x) => go(x),
926
+ }
927
+ }
928
+ }
929
+ Expr::Assign { value, .. }
930
+ | Expr::CompoundAssign { value, .. }
931
+ | Expr::LogicalAssign { value, .. } => go(value),
932
+ Expr::MemberAssign { object, value, .. } => {
933
+ go(object);
934
+ go(value);
935
+ }
936
+ Expr::IndexAssign { object, index, value, .. } => {
937
+ go(object);
938
+ go(index);
939
+ go(value);
940
+ }
941
+ Expr::TemplateLiteral { exprs, .. } => {
942
+ for x in exprs {
943
+ go(x);
944
+ }
945
+ }
946
+ Expr::ArrowFunction { body, .. } => {
947
+ if let tishlang_ast::ArrowBody::Expr(x) = body {
948
+ go(x.as_ref());
949
+ }
950
+ }
951
+ _ => {}
952
+ }
953
+ }