From fd386b99f4877ad2b2d29f7749bedba022fe88b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 08:55:10 +0200 Subject: [PATCH 1/3] =?UTF-8?q?feat(cli):=20--emit-types=20=E2=80=94=20wri?= =?UTF-8?q?te=20the=20proven=20types=20back=20out=20as=20TypeScript=20(#76?= =?UTF-8?q?85)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit EXPERIMENTAL prototype. A second consumer of the `--opt-report` Entry stream: it keeps the wins and renders them as TypeScript, rather than rendering the denials. No new analysis. Coverage is exactly the proof rate --opt-report measures. --- .../src/collectors/proven_this.rs | 6 + .../perry-codegen/src/collectors/ptr_shape.rs | 55 ++- crates/perry-codegen/src/emit_types.rs | 409 ++++++++++++++++++ crates/perry-codegen/src/emit_types/tests.rs | 350 +++++++++++++++ crates/perry-codegen/src/lib.rs | 1 + crates/perry-codegen/src/opt_report/mod.rs | 73 +++- crates/perry-codegen/src/opt_report/render.rs | 6 + .../perry/src/commands/compile/build_cache.rs | 8 + .../src/commands/compile/run_pipeline.rs | 44 +- crates/perry/src/commands/compile/types.rs | 23 + crates/perry/src/commands/dev.rs | 1 + crates/perry/src/commands/run/mod.rs | 1 + docs/src/cli/flags.md | 58 +++ scripts/emit_types_accuracy.py | 314 ++++++++++++++ 14 files changed, 1341 insertions(+), 8 deletions(-) create mode 100644 crates/perry-codegen/src/emit_types.rs create mode 100644 crates/perry-codegen/src/emit_types/tests.rs create mode 100644 scripts/emit_types_accuracy.py diff --git a/crates/perry-codegen/src/collectors/proven_this.rs b/crates/perry-codegen/src/collectors/proven_this.rs index e1d44d8162..e9a18eef6f 100644 --- a/crates/perry-codegen/src/collectors/proven_this.rs +++ b/crates/perry-codegen/src/collectors/proven_this.rs @@ -209,6 +209,11 @@ pub(crate) fn method_proven_this( numeric_fields: HashSet::new(), // Phase 5a's promoted value is the receiver, not a named binding. report_name: crate::opt_report::enabled().then(|| String::from("this")), + // Never claimed for a proven `this`, for the same reason + // `numeric_fields` is not: this fact is about a receiver whose shape + // the caller proved, and `--emit-types` has no source-level binding + // here to annotate. + report_fields: None, }) } @@ -286,6 +291,7 @@ mod tests { class_name: "C".to_string(), numeric_fields: HashSet::new(), report_name: None, + report_fields: None, }; let k = |m: &str| ("C".to_string(), m.to_string()); diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index abc1ec8f1f..8c9fc20d42 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -177,6 +177,50 @@ pub struct PtrShapeLocal { /// "some local was consumed" but never "`totals` was **not**", and naming /// the value is the entire point of the distinction. pub report_name: Option, + /// The class chain's declared field set, for `--emit-types` (#7685). + /// + /// `Some` exclusively when [`crate::opt_report::enabled`], like + /// [`Self::report_name`] — an ordinary build allocates nothing for it. + /// + /// This is the input to the one output `--emit-types` has that a + /// JavaScript-only inferencer does not: a *structural interface* recovered + /// for an object literal. It exists here only because representation + /// selection had to answer "what is this object's exact field set?" in + /// order to pick `Ptr` at all. + /// + /// `None` rather than an empty vector when the report is off, so the two + /// states stay distinguishable: "no fields" and "not collected" would + /// otherwise both render as an empty interface. + pub report_fields: Option>, +} + +/// The declared field set of a proven class chain, as `--emit-types` consumes +/// it. Report-only; never consulted by codegen. +/// +/// Two field kinds are recorded name-only, with no type, rather than +/// approximated — and because `emit_types::Shape::is_emittable` requires a type +/// for *every* field, either one refuses the whole interface rather than +/// silently narrowing it: +/// +/// * a **computed key** (`key_expr: Some(..)`), whose `name` is a synthetic +/// placeholder for HIR identity and not the runtime property name at all; +/// * a **private** field, which is not part of an object's structural type. +fn declared_shape_fields(chain: &[&Class]) -> Vec { + let mut out = Vec::new(); + for class in chain { + for field in &class.fields { + let ts_type = if field.key_expr.is_some() || field.is_private { + None + } else { + crate::emit_types::ts_type_for_hir_type(&field.ty) + }; + out.push(crate::emit_types::ShapeField { + name: field.name.clone(), + ts_type, + }); + } + } + out } /// Whether an expression node is a §5.2 shape barrier for the module-wide @@ -230,7 +274,7 @@ fn note_ptr_shape_local( if opt_report::enabled() { let fallback = format!(""); let name = names.get(&id).map(String::as_str).unwrap_or(&fallback); - opt_report::select( + opt_report::select_with_shape( opt_report::Position::Local, name, Some(id), @@ -242,6 +286,14 @@ fn note_ptr_shape_local( fact.class_name, fact.numeric_fields.len() )), + // The class name and field set as DATA, for `--emit-types` + // (#7685). The `detail` string above still renders them as prose + // for the human report; a consumer that turns them into a + // TypeScript type must not have to parse that sentence. + Some(opt_report::SelectedShape { + class_name: fact.class_name.clone(), + fields: fact.report_fields.clone().unwrap_or_default(), + }), ); } if !repsel_debug_enabled() { @@ -552,6 +604,7 @@ pub(crate) fn collect_shape_proven_ptr_locals( .cloned() .unwrap_or_else(|| format!("")) }), + report_fields: opt_report::enabled().then(|| declared_shape_fields(&chain)), }; note_ptr_shape_local(*id, &fact, &names, &depths); // Aliases carry the same fact: they hold the same object, their slots diff --git a/crates/perry-codegen/src/emit_types.rs b/crates/perry-codegen/src/emit_types.rs new file mode 100644 index 0000000000..21610c7912 --- /dev/null +++ b/crates/perry-codegen/src/emit_types.rs @@ -0,0 +1,409 @@ +//! `--emit-types` (#7685, **EXPERIMENTAL**): write the representations Perry +//! *proved* back out as TypeScript. +//! +//! `--opt-report` (#6952) surfaces the negative half of representation +//! selection — which values could not be typed, and why. The positive half is +//! computed and then discarded. This module is a second **consumer** of the +//! same [`Entry`] stream: it keeps the wins and renders them as types. +//! +//! Nothing here runs an analysis. It reads what the collectors already +//! recorded, so its coverage is exactly the proof rate `--opt-report` measures +//! and nothing this module does can widen it. +//! +//! ## The one rule +//! +//! **Never emit a wrong type.** A missing annotation costs nothing; a wrong one +//! poisons a downstream `tsc`. Every mapping below is an omission unless the +//! representation *proves* the TypeScript type, and the three places where the +//! proof is conditional are omissions rather than guesses: +//! +//! - **A representation whose proof is a restatement of the source annotation** +//! is not recovered information. [`Analysis::SpecAbi`] is exactly that: +//! `codegen/typed_abi.rs::typed_param_rep_for_type` reads `param.ty`, which +//! is populated from the TypeScript annotation and by nothing else — no +//! inference writes it (the only assignments in `perry-hir` *widen* it to +//! `Any`, `lower/shared_mutable_capture.rs:363`). Echoing it back would +//! inflate any round-trip accuracy number to meaninglessness while +//! recovering nothing, so spec-ABI entries are dropped. See +//! [`recovered_type`]. +//! - **A binding two entries disagree about** is dropped entirely rather than +//! resolved by a precedence rule. A function can be lowered more than once (a +//! boxed entry plus a typed clone) and the two lowerings can select different +//! representations; picking a winner would be picking which of two proofs to +//! believe. See [`recover`]. +//! - **A synthetic class name is not a TypeScript type.** A `Ptr` local +//! whose provenance class is a compiler-synthesized `__AnonShape_*` / +//! `__EmptySite_*` shape has no source-level name to emit. It becomes a +//! structural interface when the field set is known, and is omitted when it +//! is not — never emitted under its synthetic name. +//! +//! ## What it cannot do, stated rather than worked around +//! +//! HIR carries binding *names* through lowering but not source *spans* +//! (`opt_report`'s own module doc records this; `Entry::byte_offset` is +//! populated only for `Expr::New`). So this module cannot rewrite a source file +//! to insert `: T` after `let x` — there is no offset to insert at. The +//! locals it recovers are therefore reported, not applied, and the TypeScript +//! it writes is a sidecar rather than a patch. + +use crate::opt_report::{Analysis, Entry, Outcome, Position}; +use perry_hir::types::Type; +use std::collections::BTreeMap; + +/// Prefixes the HIR lowering uses for classes it synthesizes for object +/// literals, which therefore have no source-level name a `.ts` file could +/// refer to (`perry-hir/src/lower/expr_object.rs`). +const SYNTHETIC_CLASS_PREFIXES: [&str; 2] = ["__AnonShape_", "__EmptySite_"]; + +fn is_synthetic_class(name: &str) -> bool { + SYNTHETIC_CLASS_PREFIXES + .iter() + .any(|p| name.starts_with(p)) +} + +/// A binding name the collectors invented because the source had none. Such a +/// row identifies no source-level binding, so there is nothing to annotate. +fn is_synthetic_binding(name: &str) -> bool { + name.starts_with('<') || name.starts_with('(') || name == "this" +} + +/// Map an HIR type to the TypeScript type it *proves*, or `None` when it proves +/// nothing a `tsc` run would accept as sound. +/// +/// Deliberately total and deliberately narrow. Every variant that could widen +/// at runtime, or that names something this module cannot resolve to a +/// TypeScript declaration, returns `None`: +/// +/// - [`Type::Any`] / [`Type::Unknown`] prove nothing by construction. +/// - [`Type::Named`] is emitted only when it is not one of the synthesized +/// shape classes; the caller is responsible for having a declaration in +/// scope for it (it is a source-level class, so the source has one). +/// - [`Type::Object`], [`Type::Function`], [`Type::Union`], [`Type::Generic`], +/// [`Type::TypeVar`] and [`Type::Tuple`] are omitted: rendering them +/// faithfully needs structure this module does not carry, and rendering them +/// approximately would break the one rule. +/// - [`Type::Void`] is `undefined`, not `void` — this maps *value* positions, +/// and `void` is not a value type. +pub fn ts_type_for_hir_type(ty: &Type) -> Option { + Some(match ty { + Type::Number | Type::Int32 => "number".to_string(), + Type::String | Type::StringLiteral(_) => "string".to_string(), + Type::Boolean => "boolean".to_string(), + Type::BigInt => "bigint".to_string(), + Type::Symbol => "symbol".to_string(), + Type::Null => "null".to_string(), + Type::Void => "undefined".to_string(), + Type::Array(inner) => format!("{}[]", ts_type_for_hir_type(inner)?), + Type::Named(name) if !is_synthetic_class(name) => name.clone(), + _ => return None, + }) +} + +/// One field of a recovered structural shape. +/// +/// Carried on the `Ptr` selection entry rather than re-derived here: +/// the class chain that declares these fields lives in the collector +/// (`collectors/ptr_shape.rs`), and this module never sees HIR. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct ShapeField { + pub name: String, + /// The TypeScript type, when the declared HIR type proved one. `None` + /// renders as an omitted field rather than `any` — see [`Shape::render`]. + pub ts_type: Option, +} + +/// A structural shape recovered from a `Ptr` selection whose provenance +/// class is compiler-synthesized. +#[derive(Debug, Clone, PartialEq, Eq)] +struct Shape { + fields: Vec, +} + +impl Shape { + /// A shape is emittable only when **every** field resolved to a type. + /// + /// A partial interface is a wrong type, not a smaller one: `{ a: number }` + /// for an object that also has `b` is a claim `tsc` will act on — it makes + /// `o.b` an error at every call site. Omitting the whole interface costs a + /// missing annotation; emitting a partial one costs a false diagnostic. + fn is_emittable(&self) -> bool { + !self.fields.is_empty() && self.fields.iter().all(|f| f.ts_type.is_some()) + } + + fn render(&self) -> String { + let body = self + .fields + .iter() + .filter_map(|f| { + f.ts_type + .as_ref() + .map(|t| format!(" {}: {};", f.name, t)) + }) + .collect::>() + .join("\n"); + format!("{{\n{body}\n}}") + } +} + +/// One recovered binding: a source-level name and the TypeScript type Perry +/// proved for it. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize)] +pub struct Recovered { + pub module: String, + pub function: String, + pub name: String, + pub position: &'static str, + /// The TypeScript type. For a structural shape this is the generated + /// interface name, whose declaration is in the same document. + pub ts_type: String, + /// Which representation analysis proved it, so a consumer can weigh the + /// evidence rather than trusting a bare type string. + pub analysis: &'static str, + /// The representation the analysis selected, verbatim. + pub rep: String, +} + +/// The TypeScript type an entry proves for its binding, or `None` to omit. +/// +/// `None` is the default for everything not listed. A representation this +/// function does not recognise must not become `any` — an unrecognised +/// representation is an unknown proof, and `any` is a claim. +fn recovered_type(entry: &Entry) -> Option { + // Only wins. `Denied` is the negative half `--opt-report` already renders, + // and `Unconsumed` means codegen dropped the proof — the proof itself still + // held, but this module has no way to distinguish "dropped because + // unreachable" from "dropped because refuted", so it stands down. + if !matches!(entry.outcome, Outcome::Selected | Outcome::Consumed) { + return None; + } + // v1 recovers locals. `Param`/`Return`/`Field` rows exist in the schema but + // the only analysis that populates `Param` today is spec-ABI, which is + // dropped below, and the `this` receiver rows name no source binding. + if entry.position != Position::Local { + return None; + } + if is_synthetic_binding(&entry.name) { + return None; + } + match entry.analysis { + // A restatement of the source annotation, not recovered information. + // See the module doc. + Analysis::SpecAbi => None, + Analysis::CanonicalSlot => match entry.rep.as_str() { + // `SlotRep`'s `Debug` spelling (`expr/slot_rep.rs`). `U32` is a + // number in TypeScript exactly as `I32` is — the distinction is a + // storage one. + "I32" | "U32" => Some(TypeOrShape::Ts("number".to_string())), + "Str" => Some(TypeOrShape::Ts("string".to_string())), + _ => None, + }, + Analysis::IntValuedTa => match entry.rep.as_str() { + "IntValued" => Some(TypeOrShape::Ts("number".to_string())), + _ => None, + }, + Analysis::PtrNumArray => match entry.rep.as_str() { + "Ptr" => Some(TypeOrShape::Ts("number[]".to_string())), + _ => None, + }, + Analysis::PtrShape => { + let class = entry.shape_class.as_deref()?; + if is_synthetic_class(class) { + // No source-level name. Recoverable only as structure. + let fields = entry.shape_fields.clone()?; + let shape = Shape { fields }; + shape.is_emittable().then_some(TypeOrShape::Shape(shape)) + } else { + Some(TypeOrShape::Ts(class.to_string())) + } + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TypeOrShape { + Ts(String), + Shape(Shape), +} + +/// Identity of a source-level binding across the several entries that can +/// describe it (a selection plus one row per consumption site). +type BindingKey = (String, String, String, Option); + +/// Reduce the entry stream to one type per binding, dropping every binding the +/// stream disagrees about. +/// +/// The disagreement case is not hypothetical bookkeeping: a function lowered +/// twice (a boxed entry plus a typed clone) contributes two rows per binding, +/// and `--opt-report`'s own `dedup_key` keeps them apart on purpose. If the two +/// lowerings selected different representations, there is no principled winner, +/// so the binding is omitted. +fn recover(entries: &[Entry]) -> (Vec, Vec) { + let mut by_binding: BTreeMap> = BTreeMap::new(); + for entry in entries { + let Some(ty) = recovered_type(entry) else { + continue; + }; + let key = ( + entry.module.clone(), + entry.function.clone(), + entry.name.clone(), + entry.local_id, + ); + match by_binding.get_mut(&key) { + // Already poisoned by an earlier disagreement — stays poisoned. + Some(None) => {} + Some(slot @ Some(_)) => { + let agrees = slot.as_ref().is_some_and(|(seen, _)| *seen == ty); + if !agrees { + *slot = None; + } + } + None => { + by_binding.insert(key, Some((ty, entry))); + } + } + } + + // Structural shapes are de-duplicated by their field list and named in + // first-appearance order, so the document is deterministic: the same input + // always produces the same interface numbering. + let mut shapes: Vec = Vec::new(); + let mut out = Vec::new(); + for (_, slot) in by_binding { + let Some((ty, entry)) = slot else { continue }; + let ts_type = match ty { + TypeOrShape::Ts(t) => t, + TypeOrShape::Shape(shape) => { + let index = match shapes.iter().position(|s| *s == shape) { + Some(i) => i, + None => { + shapes.push(shape); + shapes.len() - 1 + } + }; + shape_name(index) + } + }; + out.push(Recovered { + module: entry.module.clone(), + function: entry.function.clone(), + name: entry.name.clone(), + position: entry.position.as_str(), + ts_type, + analysis: entry.analysis.as_str(), + rep: entry.rep.clone(), + }); + } + (out, shapes) +} + +fn shape_name(index: usize) -> String { + format!("PerryShape{}", index + 1) +} + +/// The header every emitted document carries. +/// +/// It states the flag is experimental and states the coverage caveat in the +/// artifact itself, because the artifact outlives the terminal session that +/// produced it and a reader who finds it in a repo has no other way to know +/// that "absent" means "not proven" rather than "proven absent". +const HEADER: &str = "\ +// Generated by `perry --emit-types` — EXPERIMENTAL (#7685). Do not edit. +// +// These types are recovered from Perry's representation-selection analysis: +// they are the types the compiler PROVED in order to pick an unboxed +// representation, not the output of a type inferencer. Coverage is therefore +// exactly Perry's proof rate, which is low on code that was not written for it +// (`--opt-report` reports the other half: what could not be proven, and why). +// +// A binding absent from this file is NOT untyped — it is unproven. Nothing here +// is a guess: where the proof was conditional, the binding was omitted. +// +// Parameter and return types are NOT recovered. Perry's specialized-ABI +// analysis derives them from the source annotation, so on unannotated +// JavaScript it proves nothing and this file will contain no signatures. +"; + +/// Render the recovered types as TypeScript. +/// +/// Structural shapes become real `export interface` declarations — valid, +/// checkable TypeScript. Locals cannot: TypeScript has no syntax for declaring +/// the type of another file's function-local, and HIR carries no span to +/// rewrite the source with. They are therefore rendered as a per-function +/// comment block, which is the honest form rather than invalid syntax. +pub fn render_ts(entries: &[Entry]) -> String { + let (recovered, shapes) = recover(entries); + let mut out = String::from(HEADER); + + if recovered.is_empty() && shapes.is_empty() { + out.push_str("\n// No binding in this program had a recoverable type.\n"); + return out; + } + + if !shapes.is_empty() { + out.push_str("\n// ── Recovered structural shapes ──────────────────────────────────────────\n"); + out.push_str("// Object literals whose field set Perry proved closed and immutable.\n\n"); + for (i, shape) in shapes.iter().enumerate() { + out.push_str(&format!( + "export interface {} {}\n\n", + shape_name(i), + shape.render() + )); + } + } + + if !recovered.is_empty() { + out.push_str("// ── Recovered local bindings ─────────────────────────────────────────────\n"); + out.push_str( + "// Comments, not declarations: TypeScript cannot declare another file's\n\ + // function-local, and HIR carries no source span to rewrite in place.\n", + ); + let mut by_scope: BTreeMap<(&str, &str), Vec<&Recovered>> = BTreeMap::new(); + for r in &recovered { + by_scope + .entry((r.module.as_str(), r.function.as_str())) + .or_default() + .push(r); + } + for ((module, function), rows) in by_scope { + out.push_str(&format!("\n// {module} — {function}\n")); + for r in rows { + out.push_str(&format!( + "// {}: {}; // {} [{}]\n", + r.name, r.ts_type, r.analysis, r.rep + )); + } + } + } + out +} + +/// The machine-readable form, for measurement and tooling. +/// +/// The accuracy harness consumes this rather than parsing the TypeScript, so +/// the number it reports is a number about the mapping and not about a regex. +/// Both forms come from [`recover`], so they cannot drift. +pub fn render_json(entries: &[Entry]) -> String { + let (recovered, shapes) = recover(entries); + let shapes_json: Vec<_> = shapes + .iter() + .enumerate() + .map(|(i, s)| { + serde_json::json!({ "name": shape_name(i), "fields": s.fields }) + }) + .collect(); + let doc = serde_json::json!({ + "schema_version": 1, + "experimental": true, + "summary": { + "recovered_bindings": recovered.len(), + "recovered_shapes": shapes.len(), + }, + "shapes": shapes_json, + "bindings": recovered, + }); + serde_json::to_string_pretty(&doc).unwrap_or_else(|e| format!("{{\"error\":\"{e}\"}}")) +} + +#[cfg(test)] +mod tests; diff --git a/crates/perry-codegen/src/emit_types/tests.rs b/crates/perry-codegen/src/emit_types/tests.rs new file mode 100644 index 0000000000..ff7e8ff8b6 --- /dev/null +++ b/crates/perry-codegen/src/emit_types/tests.rs @@ -0,0 +1,350 @@ +//! Tests for `--emit-types` (#7685). +//! +//! The load-bearing ones are the **omission** tests. This module's whole claim +//! is "never emit a wrong type", and a test suite that only checks the happy +//! path cannot tell a working omission rule from a deleted one — so every +//! omission below is paired with a positive control proving the same input +//! *would* have emitted something if the rule were absent. + +use super::*; +use crate::opt_report::{Analysis, Entry, Outcome, Position, RegionKind}; + +/// A `Selected` local entry, the shape every test varies from. +fn local(name: &str, analysis: Analysis, rep: &str) -> Entry { + Entry { + module: "m.ts".to_string(), + function: "f".to_string(), + region: RegionKind::Function, + position: Position::Local, + name: name.to_string(), + local_id: Some(1), + analysis, + outcome: Outcome::Selected, + rep: rep.to_string(), + rule: None, + reason: None, + tier: None, + issue: None, + loop_depth: 0, + invoked_per_element: None, + detail: None, + byte_offset: None, + site: None, + alloc_context: None, + alloc_ordinal: None, + shape_class: None, + shape_fields: None, + } +} + +fn types_of(entries: &[Entry]) -> Vec<(String, String)> { + recover(entries) + .0 + .into_iter() + .map(|r| (r.name, r.ts_type)) + .collect() +} + +// ── The mapping ──────────────────────────────────────────────────────────── + +#[test] +fn canonical_slots_map_to_number_and_string() { + let entries = vec![ + local("i", Analysis::CanonicalSlot, "I32"), + local("u", Analysis::CanonicalSlot, "U32"), + local("s", Analysis::CanonicalSlot, "Str"), + ]; + let mut got = types_of(&entries); + got.sort(); + assert_eq!( + got, + vec![ + ("i".to_string(), "number".to_string()), + ("s".to_string(), "string".to_string()), + ("u".to_string(), "number".to_string()), + ] + ); +} + +#[test] +fn numarray_and_int_valued_map_to_number_forms() { + let entries = vec![ + local("xs", Analysis::PtrNumArray, "Ptr"), + local("acc", Analysis::IntValuedTa, "IntValued"), + ]; + let mut got = types_of(&entries); + got.sort(); + assert_eq!( + got, + vec![ + ("acc".to_string(), "number".to_string()), + ("xs".to_string(), "number[]".to_string()), + ] + ); +} + +#[test] +fn a_ptr_shape_local_recovers_its_source_class_name() { + let mut e = local("p", Analysis::PtrShape, "Ptr"); + e.shape_class = Some("Point".to_string()); + assert_eq!(types_of(&[e]), vec![("p".to_string(), "Point".to_string())]); +} + +/// An unrecognised representation must be an omission, never `any`. +/// +/// `any` is a claim — it tells `tsc` the binding may be used as anything, which +/// is exactly what an unknown proof does not license. +#[test] +fn an_unrecognised_representation_is_omitted_rather_than_any() { + let e = local("mystery", Analysis::CanonicalSlot, "F128"); + assert!(types_of(&[e]).is_empty()); + // Control: the same row with a KNOWN rep does emit, so the omission above + // is the rep check and not a dead code path swallowing everything. + let ok = local("mystery", Analysis::CanonicalSlot, "I32"); + assert_eq!(types_of(&[ok]).len(), 1); +} + +// ── The omissions ────────────────────────────────────────────────────────── + +/// Spec-ABI reps are derived from `param.ty`, which is populated from the +/// source annotation and by nothing else. Emitting them would echo the input +/// back and inflate any accuracy measurement to meaninglessness. +#[test] +fn spec_abi_is_dropped_because_it_restates_the_source_annotation() { + let mut e = local("n", Analysis::SpecAbi, "i32"); + e.position = Position::Local; + assert!(types_of(&[e]).is_empty()); + // Control: an identical row under an analysis that really does prove + // something emits, so the drop is keyed on the analysis. + let ok = local("n", Analysis::CanonicalSlot, "I32"); + assert_eq!(types_of(&[ok]).len(), 1); +} + +#[test] +fn a_denied_entry_contributes_nothing() { + let mut e = local("x", Analysis::CanonicalSlot, "Boxed"); + e.outcome = Outcome::Denied; + assert!(types_of(&[e]).is_empty()); +} + +/// A proof codegen threw away is not evidence this module can act on: nothing +/// in the stream distinguishes "dropped because unreachable" from "dropped +/// because refuted". +#[test] +fn an_unconsumed_proof_is_not_emitted() { + let mut e = local("x", Analysis::CanonicalSlot, "I32"); + e.outcome = Outcome::Unconsumed; + assert!(types_of(&[e]).is_empty()); + let mut consumed = local("x", Analysis::CanonicalSlot, "I32"); + consumed.outcome = Outcome::Consumed; + assert_eq!(types_of(&[consumed]).len(), 1, "Consumed must still emit"); +} + +#[test] +fn non_local_positions_are_not_emitted() { + for position in [Position::Param, Position::Return, Position::AllocSite] { + let mut e = local("x", Analysis::CanonicalSlot, "I32"); + e.position = position; + assert!( + types_of(&[e]).is_empty(), + "position {position:?} must not emit" + ); + } +} + +#[test] +fn synthetic_binding_names_are_not_emitted() { + for name in ["", "(parameters + return)", "this"] { + let e = local(name, Analysis::CanonicalSlot, "I32"); + assert!(types_of(&[e]).is_empty(), "{name} must not emit"); + } +} + +/// A synthesized class name is not a TypeScript type. Without a field set to +/// turn into structure, the binding is omitted — never emitted as +/// `__AnonShape_1f2e`, which would name nothing in the source. +#[test] +fn a_synthetic_shape_class_is_never_emitted_under_its_synthetic_name() { + let mut e = local("o", Analysis::PtrShape, "Ptr"); + e.shape_class = Some("__AnonShape_1f2e".to_string()); + assert!(types_of(&[e.clone()]).is_empty()); + assert!(!render_ts(&[e]).contains("__AnonShape")); + // Control: the same row with a SOURCE class name emits that name. + let mut real = local("o", Analysis::PtrShape, "Ptr"); + real.shape_class = Some("Point".to_string()); + assert_eq!(types_of(&[real]).len(), 1); +} + +// ── Structural interfaces ────────────────────────────────────────────────── + +fn shape_entry(name: &str, fields: &[(&str, Option<&str>)]) -> Entry { + let mut e = local(name, Analysis::PtrShape, "Ptr"); + e.shape_class = Some("__AnonShape_aa".to_string()); + e.shape_fields = Some( + fields + .iter() + .map(|(n, t)| ShapeField { + name: n.to_string(), + ts_type: t.map(str::to_string), + }) + .collect(), + ); + e +} + +#[test] +fn an_anon_shape_becomes_a_structural_interface() { + let e = shape_entry("rec", &[("x", Some("number")), ("tag", Some("string"))]); + let ts = render_ts(&[e.clone()]); + assert!( + ts.contains("export interface PerryShape1 {\n x: number;\n tag: string;\n}"), + "unexpected output:\n{ts}" + ); + assert_eq!( + types_of(&[e]), + vec![("rec".to_string(), "PerryShape1".to_string())] + ); +} + +/// A partial interface is a WRONG type, not a smaller one: `{ a: number }` for +/// an object that also has `b` makes `o.b` an error at every call site. +#[test] +fn a_shape_with_one_untyped_field_is_refused_whole() { + let e = shape_entry("rec", &[("x", Some("number")), ("b", None)]); + assert!(types_of(&[e.clone()]).is_empty()); + let ts = render_ts(&[e]); + assert!(!ts.contains("interface"), "unexpected interface:\n{ts}"); + // Control: type that field and the same shape emits. + let ok = shape_entry("rec", &[("x", Some("number")), ("b", Some("string"))]); + assert_eq!(types_of(&[ok]).len(), 1); +} + +#[test] +fn an_empty_field_set_is_not_an_empty_interface() { + let e = shape_entry("rec", &[]); + assert!(types_of(&[e]).is_empty()); +} + +/// Two bindings with the same field set share one interface, and the numbering +/// is first-appearance order so the document is reproducible. +#[test] +fn identical_shapes_are_deduplicated_and_numbered_deterministically() { + let a = shape_entry("a", &[("x", Some("number"))]); + let mut b = shape_entry("b", &[("x", Some("number"))]); + b.local_id = Some(2); + let mut c = shape_entry("c", &[("y", Some("string"))]); + c.local_id = Some(3); + let (recovered, shapes) = recover(&[a, b, c]); + assert_eq!(shapes.len(), 2, "identical field sets must share one shape"); + let named: Vec<_> = recovered.iter().map(|r| r.ts_type.as_str()).collect(); + assert_eq!(named, vec!["PerryShape1", "PerryShape1", "PerryShape2"]); +} + +// ── Disagreement ─────────────────────────────────────────────────────────── + +/// A function lowered twice (a boxed entry plus a typed clone) contributes two +/// rows per binding. If they disagree there is no principled winner, so the +/// binding is dropped rather than resolved by declaration order. +#[test] +fn a_binding_two_entries_disagree_about_is_dropped() { + let a = local("x", Analysis::CanonicalSlot, "I32"); + let b = local("x", Analysis::CanonicalSlot, "Str"); + assert!(types_of(&[a.clone(), b.clone()]).is_empty()); + assert!( + types_of(&[b, a]).is_empty(), + "order must not decide a conflict" + ); +} + +#[test] +fn agreeing_duplicate_entries_collapse_to_one_row() { + let a = local("x", Analysis::CanonicalSlot, "I32"); + let mut b = local("x", Analysis::CanonicalSlot, "I32"); + b.outcome = Outcome::Consumed; + assert_eq!( + types_of(&[a, b]), + vec![("x".to_string(), "number".to_string())] + ); +} + +/// A third row cannot revive a binding an earlier conflict poisoned. +#[test] +fn a_poisoned_binding_stays_poisoned() { + let a = local("x", Analysis::CanonicalSlot, "I32"); + let b = local("x", Analysis::CanonicalSlot, "Str"); + let c = local("x", Analysis::CanonicalSlot, "I32"); + assert!(types_of(&[a, b, c]).is_empty()); +} + +// ── HIR type mapping ─────────────────────────────────────────────────────── + +#[test] +fn hir_types_map_only_where_they_prove_a_typescript_type() { + use perry_hir::types::Type; + assert_eq!(ts_type_for_hir_type(&Type::Number).as_deref(), Some("number")); + assert_eq!(ts_type_for_hir_type(&Type::Int32).as_deref(), Some("number")); + assert_eq!(ts_type_for_hir_type(&Type::String).as_deref(), Some("string")); + assert_eq!( + ts_type_for_hir_type(&Type::Boolean).as_deref(), + Some("boolean") + ); + assert_eq!( + ts_type_for_hir_type(&Type::Array(Box::new(Type::Number))).as_deref(), + Some("number[]") + ); + assert_eq!( + ts_type_for_hir_type(&Type::Named("Point".into())).as_deref(), + Some("Point") + ); + // The omissions. + assert_eq!(ts_type_for_hir_type(&Type::Any), None); + assert_eq!(ts_type_for_hir_type(&Type::Unknown), None); + assert_eq!( + ts_type_for_hir_type(&Type::Union(vec![Type::Number, Type::String])), + None + ); + // A synthesized shape class is not a nameable type even inside a field. + assert_eq!( + ts_type_for_hir_type(&Type::Named("__AnonShape_1".into())), + None + ); + // An array of an unmappable element is unmappable, not `any[]`. + assert_eq!(ts_type_for_hir_type(&Type::Array(Box::new(Type::Any))), None); +} + +// ── Rendering ────────────────────────────────────────────────────────────── + +/// An empty result must say so. "No recoverable types" and "the tool did not +/// run" produce very different follow-up actions and must not look alike. +#[test] +fn an_empty_program_renders_an_explicit_statement_not_a_blank_file() { + let ts = render_ts(&[]); + assert!(ts.contains("No binding in this program had a recoverable type.")); + assert!(ts.contains("EXPERIMENTAL")); +} + +#[test] +fn the_header_states_the_coverage_caveat_and_the_parameter_limitation() { + let ts = render_ts(&[local("i", Analysis::CanonicalSlot, "I32")]); + assert!(ts.contains("EXPERIMENTAL")); + assert!( + ts.contains("Parameter and return types are NOT recovered"), + "the artifact must carry its own limitation" + ); + assert!(ts.contains("absent from this file is NOT untyped")); +} + +#[test] +fn json_and_ts_agree_on_what_was_recovered() { + let entries = vec![ + local("i", Analysis::CanonicalSlot, "I32"), + shape_entry("rec", &[("x", Some("number"))]), + ]; + let json: serde_json::Value = serde_json::from_str(&render_json(&entries)).unwrap(); + assert_eq!(json["summary"]["recovered_bindings"], 2); + assert_eq!(json["summary"]["recovered_shapes"], 1); + assert_eq!(json["experimental"], true); + let ts = render_ts(&entries); + assert!(ts.contains("PerryShape1")); + assert!(ts.contains("i: number;")); +} diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 0daf1f9f3e..14bc5db40e 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -42,6 +42,7 @@ pub mod native_emit; mod native_root_coverage; pub(crate) mod native_value; pub(crate) mod nm_install; +pub mod emit_types; pub mod opt_report; pub(crate) mod root_reload; pub mod rooting; diff --git a/crates/perry-codegen/src/opt_report/mod.rs b/crates/perry-codegen/src/opt_report/mod.rs index 552a9c5957..0464d4bea2 100644 --- a/crates/perry-codegen/src/opt_report/mod.rs +++ b/crates/perry-codegen/src/opt_report/mod.rs @@ -493,6 +493,28 @@ pub struct Entry { /// with no map iteration, so a function lowered twice (a boxed entry plus /// a typed clone) produces the same ordinals and still de-duplicates. pub alloc_ordinal: Option, + /// For a `Ptr` selection: the provenance class name, verbatim. + /// + /// `detail` already renders it into prose (`class Point (0 numeric + /// field(s) proven)`), but `--emit-types` (#7685) turns this into a + /// TypeScript type, and recovering a type by parsing an English sentence + /// is a wrong-type bug waiting for the day somebody rewords the sentence. + /// A consumer that must not guess gets a field, not a substring. + #[serde(skip_serializing_if = "Option::is_none")] + pub shape_class: Option, + /// For a `Ptr` selection whose class is a compiler-synthesized + /// object-literal shape: the declared field set of the class chain. + /// + /// This is the field set `--emit-types` renders as a structural interface — + /// the one output a JavaScript-only inferencer has no representation- + /// selection pressure to force. Populated only when [`enabled`], like + /// `PtrShapeLocal::report_name`, so an ordinary build allocates nothing. + /// + /// Both fields are `skip_serializing_if` so an entry that has neither + /// serializes byte-identically to the pre-#7685 schema; only a `Ptr` + /// selection gains keys. + #[serde(skip_serializing_if = "Option::is_none")] + pub shape_fields: Option>, } impl Entry { @@ -545,6 +567,11 @@ impl Entry { /// of the site, so the ordinal already implies it, and a second /// enforcement point that no sabotage can kill is how #7171 ended up /// with four green holes in its first pass. One discriminant, one test. + /// + /// `shape_class` / `shape_fields` are likewise **not** in this key, for + /// the same reason: both are functions of the selected representation + /// this row already identifies, so adding them could only split a row + /// from itself. #[allow(clippy::type_complexity)] fn dedup_key( &self, @@ -907,6 +934,8 @@ fn deny_in_scope(d: Denial<'_>, alloc_context: Option, alloc_ordinal: Op site: None, alloc_context, alloc_ordinal, + shape_class: None, + shape_fields: None, }); } @@ -939,6 +968,8 @@ pub(crate) fn deny_named(function: &str, region: RegionKind, d: Denial<'_>) { site: None, alloc_context: None, alloc_ordinal: None, + shape_class: None, + shape_fields: None, }); } @@ -952,7 +983,13 @@ pub(crate) fn enter_module(module: &str) -> ScopeGuard { /// Record a value that *did* get an unboxed representation, attributed to the /// active scope. Reporting wins matters: a report that only nags is less /// useful, and less trusted, than one that shows the ratio. -pub(crate) fn select( +/// +/// `shape` is `Some` only for a `Ptr` selection; it carries the class +/// name and field set as data rather than as prose inside `detail`, because +/// `--emit-types` (#7685) turns them into a TypeScript type and must not +/// recover one by parsing an English sentence. +#[allow(clippy::too_many_arguments)] +pub(crate) fn select_with_shape( position: Position, name: &str, local_id: Option, @@ -960,6 +997,7 @@ pub(crate) fn select( rep: &str, loop_depth: u32, detail: Option, + shape: Option, ) { if !enabled() { return; @@ -999,9 +1037,34 @@ pub(crate) fn select( site: None, alloc_context: None, alloc_ordinal: None, + shape_class: shape.as_ref().map(|s| s.class_name.clone()), + shape_fields: shape.map(|s| s.fields), }); } +/// The `Ptr` payload [`select_with_shape`] carries beyond an ordinary +/// win: the provenance class and, for a compiler-synthesized object-literal +/// shape, the declared field set `--emit-types` (#7685) renders as a structural +/// interface. +pub(crate) struct SelectedShape { + pub class_name: String, + pub fields: Vec, +} + +/// Record a value that *did* get an unboxed representation, attributed to the +/// active scope. See [`select_with_shape`] for the `Ptr` form. +pub(crate) fn select( + position: Position, + name: &str, + local_id: Option, + analysis: Analysis, + rep: &str, + loop_depth: u32, + detail: Option, +) { + select_with_shape(position, name, local_id, analysis, rep, loop_depth, detail, None) +} + /// Record a win from a site that already knows its own function and module /// (an `FnCtx` holder), bypassing the thread-local scope. pub(crate) fn select_explicit( @@ -1037,6 +1100,8 @@ pub(crate) fn select_explicit( site: None, alloc_context: None, alloc_ordinal: None, + shape_class: None, + shape_fields: None, }); } @@ -1108,6 +1173,8 @@ pub(crate) fn consume( site: Some(site.to_string()), alloc_context: None, alloc_ordinal: None, + shape_class: None, + shape_fields: None, }); } @@ -1158,6 +1225,8 @@ pub(crate) fn unconsumed(u: Unconsumed<'_>) { site: None, alloc_context: None, alloc_ordinal: None, + shape_class: None, + shape_fields: None, }); } @@ -1220,6 +1289,8 @@ mod tests { site: None, alloc_context: None, alloc_ordinal: None, + shape_class: None, + shape_fields: None, } } diff --git a/crates/perry-codegen/src/opt_report/render.rs b/crates/perry-codegen/src/opt_report/render.rs index 84d0d9f0f7..c58238bb29 100644 --- a/crates/perry-codegen/src/opt_report/render.rs +++ b/crates/perry-codegen/src/opt_report/render.rs @@ -532,6 +532,8 @@ mod tests { site: None, alloc_context: None, alloc_ordinal: None, + shape_class: None, + shape_fields: None, } } @@ -557,6 +559,8 @@ mod tests { site: None, alloc_context: None, alloc_ordinal: None, + shape_class: None, + shape_fields: None, } } @@ -884,6 +888,8 @@ mod r0_bucket_tests { site: None, alloc_context: Some(context.into()), alloc_ordinal: Some(ordinal), + shape_class: None, + shape_fields: None, } } diff --git a/crates/perry/src/commands/compile/build_cache.rs b/crates/perry/src/commands/compile/build_cache.rs index a6ed50122d..a601b594ef 100644 --- a/crates/perry/src/commands/compile/build_cache.rs +++ b/crates/perry/src/commands/compile/build_cache.rs @@ -422,6 +422,14 @@ fn eligibility(args: &CompileArgs, project_root: &Path) -> Result<(), String> { if args.opt_report.is_some() || std::env::var("PERRY_OPT_REPORT").is_ok() { return Err("opt-report".to_string()); } + // #7685: `--emit-types` reads the same codegen-recorded entry stream, so it + // needs codegen to run for exactly the reason above. Without this the flag + // would silently write an EMPTY types file on the second run of an + // unchanged program — the worst failure mode for this feature, because an + // empty file is indistinguishable from "nothing was provable". + if args.emit_types.is_some() { + return Err("emit-types".to_string()); + } if args.verify_native_regions || args.emit_attest || args.emit_sandbox { return Err("sidecar-or-verify".to_string()); } diff --git a/crates/perry/src/commands/compile/run_pipeline.rs b/crates/perry/src/commands/compile/run_pipeline.rs index f8be61907e..5eaaec24da 100644 --- a/crates/perry/src/commands/compile/run_pipeline.rs +++ b/crates/perry/src/commands/compile/run_pipeline.rs @@ -223,6 +223,17 @@ pub fn run_with_parse_cache( std::env::set_var("PERRY_NO_CACHE", "1"); } + // `--emit-types` (#7685, EXPERIMENTAL) consumes the same recorded entry + // stream, so it turns the same recorder on — but it does NOT imply + // `--opt-report`, which would print a second, unrelated report to stderr. + // Both may be given; the env var is idempotent and the sink is drained once + // below, before either renderer reads it. + let emit_types_path = args.emit_types.clone(); + if emit_types_path.is_some() { + std::env::set_var("PERRY_OPT_REPORT", "1"); + std::env::set_var("PERRY_NO_CACHE", "1"); + } + // Native-stack GC root-pressure report. Like `--opt-report`, this is // observational and must be enabled before rayon starts module codegen. // Cache reuse is disabled because cached objects bypass the lowering that @@ -4735,13 +4746,34 @@ pub fn run_with_parse_cache( // `--opt-report` (#6952). Drained once, after every module's codegen has // finished, and written to stderr so it never contaminates a `--format // json` stdout payload or a piped program output. - if let Some(fmt) = opt_report_format { + // + // `--emit-types` (#7685) reads the same stream, so the sink is drained ONCE + // here and both consumers read that snapshot. Draining per consumer would + // give the second one an empty vector and silently write an empty types + // file whenever both flags were passed. + if opt_report_format.is_some() || emit_types_path.is_some() { let entries = perry_codegen::opt_report::take_entries(); - let rendered = match fmt { - OptReportFormat::Json => perry_codegen::opt_report::render_json(&entries), - OptReportFormat::Text => perry_codegen::opt_report::render_text(&entries), - }; - eprintln!("{rendered}"); + if let Some(fmt) = opt_report_format { + let rendered = match fmt { + OptReportFormat::Json => perry_codegen::opt_report::render_json(&entries), + OptReportFormat::Text => perry_codegen::opt_report::render_text(&entries), + }; + eprintln!("{rendered}"); + } + if let Some(path) = emit_types_path.as_ref() { + let json = path + .extension() + .is_some_and(|e| e.eq_ignore_ascii_case("json")); + let rendered = if json { + perry_codegen::emit_types::render_json(&entries) + } else { + perry_codegen::emit_types::render_ts(&entries) + }; + std::fs::write(path, rendered).map_err(|e| { + anyhow::anyhow!("--emit-types: could not write {}: {e}", path.display()) + })?; + eprintln!("Wrote types: {}", path.display()); + } } if let Some(fmt) = statepoint_report_format { diff --git a/crates/perry/src/commands/compile/types.rs b/crates/perry/src/commands/compile/types.rs index 0facb1591c..2b766aa009 100644 --- a/crates/perry/src/commands/compile/types.rs +++ b/crates/perry/src/commands/compile/types.rs @@ -506,6 +506,29 @@ pub struct CompileArgs { /// codegen executes and produces records. #[arg(long, value_enum, num_args = 0..=1, default_missing_value = "text")] pub statepoint_report: Option, + + /// **EXPERIMENTAL** (#7685). Write the types Perry *proved* to a file. + /// + /// Representation selection has to prove a static type before it can pick + /// an unboxed representation. `--opt-report` surfaces the half of that + /// analysis which FAILED; this writes out the half that succeeded, as + /// TypeScript. + /// + /// The format follows the extension: `.json` emits machine-readable + /// records, anything else emits TypeScript. + /// + /// Coverage is exactly Perry's proof rate, which is low on code that was + /// not written for it — expect most bindings to be absent. Absent means + /// *unproven*, never *untyped*, and nothing is guessed: where the proof was + /// conditional the binding is omitted. **Parameter and return types are not + /// recovered at all** — Perry derives those from the source annotation, so + /// on unannotated JavaScript there is nothing to recover. + /// + /// Observational only: emitted code is byte-identical with the flag on and + /// off. Like `--opt-report` it disables build/object cache reuse for its + /// own run, so that codegen actually executes and has something to report. + #[arg(long, value_name = "PATH")] + pub emit_types: Option, } /// Output format for `--opt-report`. diff --git a/crates/perry/src/commands/dev.rs b/crates/perry/src/commands/dev.rs index e2da75f66e..8be783bc1b 100644 --- a/crates/perry/src/commands/dev.rs +++ b/crates/perry/src/commands/dev.rs @@ -314,6 +314,7 @@ fn build_once( disable_buffer_fast_path: false, explain_lowering: false, opt_report: None, + emit_types: None, statepoint_report: None, emit_attest: false, emit_sandbox: false, diff --git a/crates/perry/src/commands/run/mod.rs b/crates/perry/src/commands/run/mod.rs index dcfe5d7a53..2ba1786877 100644 --- a/crates/perry/src/commands/run/mod.rs +++ b/crates/perry/src/commands/run/mod.rs @@ -226,6 +226,7 @@ pub fn run(args: RunArgs, format: OutputFormat, use_color: bool, verbose: u8) -> disable_buffer_fast_path: false, explain_lowering: false, opt_report: None, + emit_types: None, statepoint_report: None, emit_attest: false, emit_sandbox: false, diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 5d9f615f0e..6eac78aea1 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -103,6 +103,7 @@ accept either the `$perryfs/` virtual path or the embed-relative key. | `--no-codegen` | Skip the `package.json` `perry.codegen` build-time steps (also `PERRY_SKIP_CODEGEN=1`). See [Project Configuration](../getting-started/project-config.md) | | `--keep-intermediates` | Keep `.o` and `.asm` intermediate files | | `--opt-report[=json]` | Report which values Perry could **not** statically type, why, and whether you can fix it. Text by default; `--opt-report=json` emits a stable schema for tooling. Also settable via `PERRY_OPT_REPORT=1` | +| `--emit-types ` | **EXPERIMENTAL** (#7685). Write the types Perry *proved* to `PATH` as TypeScript (`.json` for records). Locals only — parameter and return types are not recovered. | | `--statepoint-report[=json]` | Report native-stack GC root pressure: calls with live roots, audited non-collecting calls omitted, relocations, plain-map fallbacks, and live-root widths. Research-only; requires `PERRY_RS4GC=1`, the one native-root backend (the plain stack-map and explicit-bridge modes it also named are gone) | The `--trace`/`--focus` pair localizes "compiled to the wrong thing" bugs: @@ -173,6 +174,63 @@ yet. `--opt-report=json` carries the same data under `schema_version: 1`; diffing two builds' JSON is a cheap CI check against a representation silently regressing to zero. +### `--emit-types` — write the proven types back out (EXPERIMENTAL) + +> **Experimental (#7685).** A prototype, not a product. It is not wired into +> `perry check` and gates nothing. The output format may change or the flag may +> be withdrawn. + +`--opt-report` above surfaces the *negative* half of representation selection. +The positive half — the types Perry had to prove in order to pick an unboxed +representation — was computed and discarded. `--emit-types ` writes it +out instead: + +```bash +$ perry compile app.ts --emit-types app.perry.d.ts # TypeScript +$ perry compile app.ts --emit-types types.json # machine-readable +``` + +The format follows the extension: `.json` emits records, anything else emits +TypeScript. Like `--opt-report` it is observational (emitted code is +byte-identical with it on and off) and it disables cache reuse for its own run, +because a cache hit skips codegen and there would be nothing to report. + +**What it recovers**, and the representation each type comes from: + +| Representation | TypeScript | Notes | +|---|---|---| +| canonical `I32` / `U32` slot | `number` | | +| canonical `Str` slot | `string` | | +| `IntValued` | `number` | | +| `Ptr` | `number[]` | | +| `Ptr` of a source class | that class name | e.g. `Point` | +| `Ptr` of an object literal | a generated `interface` | the differentiating case | +| anything else | *omitted* | | + +**What it does not recover, and will not.** Parameter and return types. +Perry's specialized-ABI analysis reads `param.ty`, which is populated from the +source annotation and by nothing else — no inference writes it. On annotated +TypeScript it would hand your own annotations back; on unannotated JavaScript +it proves nothing. Those entries are therefore dropped rather than emitted, so +this flag cannot produce a `.d.ts` of function signatures. It reports *locals*. + +**It omits rather than guesses.** A missing annotation costs nothing; a wrong +one poisons a downstream `tsc`. So: `any` is never emitted (it is a claim, not +an absence); a binding two lowerings disagree about is dropped rather than +resolved by precedence; a structural interface with even one untypeable field +is refused whole, because a partial interface makes every use of the missing +field an error. A binding absent from the output is **unproven, not untyped**. + +Coverage is exactly Perry's proof rate, which is low on code not written for +it. Measure it rather than assuming it: + +```bash +# accuracy: erase the annotations from .ts inputs, emit, diff against the original +python3 scripts/emit_types_accuracy.py --perry ./perry --mode roundtrip --inputs test-files +# coverage: real dependency JS has no annotations, so only coverage is measurable +python3 scripts/emit_types_accuracy.py --perry ./perry --mode coverage --inputs node_modules +``` + ## Output Optimization | Flag | Description | diff --git a/scripts/emit_types_accuracy.py b/scripts/emit_types_accuracy.py new file mode 100644 index 0000000000..907a96c9cf --- /dev/null +++ b/scripts/emit_types_accuracy.py @@ -0,0 +1,314 @@ +#!/usr/bin/env python3 +"""Measure `--emit-types` (#7685, EXPERIMENTAL) instead of demonstrating it. + +Two modes, because the two corpora can answer different questions: + +``roundtrip`` + Take annotated ``.ts`` inputs, **erase** the local annotations, compile the + erased copy, and diff what came back against what was erased. That is an + accuracy number. + + The erasure is not a formality — it is the entire validity of the measure. + `stmt/let_stmt.rs` computes a local's `refined_ty` as *the declared type + when it is not `Any`*, falling back to inference from the initializer. Run + this against un-erased sources and every "recovered" type is the annotation + being handed straight back, and the score is 100% and means nothing. + +``coverage`` + Real dependency JavaScript has no annotations, so there is no ground truth + and no accuracy to compute. What can be measured is how many bindings get a + type at all. Reported as a rate against a syntactic count of local + declarations, which is approximate — and labelled as approximate. + +Both modes exit non-zero if they measured **zero files**, and treat a file whose +compile produced no report as a hard error rather than a zero. An accuracy +number over an empty set is worse than no number. +""" + +from __future__ import annotations + +import argparse +import json +import pathlib +import re +import shutil +import subprocess +import sys +import tempfile + +# `let x: T = ...` / `const x: T = ...`. Deliberately narrow: it matches the +# single-line, non-generic-comma annotations that make up the ground truth, and +# skips anything it cannot read rather than guessing. A missed annotation costs +# denominator, never correctness — an annotation this does not extract simply is +# not scored. +DECL_ANNOTATED = re.compile( + r"\b(?:let|const|var)\s+([A-Za-z_$][\w$]*)\s*:\s*" + r"([A-Za-z_$][\w$]*(?:\s*\[\s*\])?|[A-Za-z_$][\w$]*<[^<>,()]*>)\s*=" +) +# Any local declaration, annotated or not — the coverage denominator. +DECL_ANY = re.compile(r"\b(?:let|const|var)\s+([A-Za-z_$][\w$]*)\s*[:=]") + + +class CompileFailed(Exception): + """The input does not compile standalone — skipped, not a measurement. + + Kept distinct from every other failure so that "Perry cannot build this + file" can never be silently folded into "Perry proved nothing here". + """ + + +def normalize_type(text: str) -> str: + """Canonical spelling so `Array` and `number[]` compare equal.""" + t = " ".join(text.split()) + m = re.fullmatch(r"Array<\s*(.+?)\s*>", t) + if m: + t = f"{m.group(1)}[]" + return t.replace(" [ ]", "[]").replace("[ ]", "[]") + + +def erase_local_annotations(source: str) -> tuple[str, dict[str, str], set[str]]: + """Strip `: T` from local declarations. + + Returns the erased source, the ground truth it removed (keyed by binding + name), and the set of names whose annotation was `any`/`unknown`. + + A name declared twice with different types is dropped from the truth rather + than resolved: this harness scores by name (the report carries no span), so + an ambiguous name cannot be scored honestly either way. + """ + truth: dict[str, str] = {} + ambiguous: set[str] = set() + improved_on_any: set[str] = set() + + def repl(m: re.Match[str]) -> str: + name, ty = m.group(1), normalize_type(m.group(2)) + if name in truth and truth[name] != ty: + ambiguous.add(name) + # `any` / `unknown` are the ABSENCE of a claim, not a claim. Scoring + # against them would count "we recovered a structural interface for a + # local the author gave up on" as a WRONG answer — which is backwards, + # and is exactly what the first run of this harness did report. + # They are erased like any other annotation (so the compiler cannot + # read them) but excluded from the ground truth, and counted + # separately as an improvement. + if ty not in ("any", "unknown"): + truth[name] = ty + else: + ambiguous.discard(name) + improved_on_any.add(name) + # Rebuild the declaration without its annotation: keep everything up to + # the name, then go straight to the `=`. + head = m.group(0)[: m.start(1) - m.start(0)] + return f"{head}{name} =" + + erased = DECL_ANNOTATED.sub(repl, source) + for name in ambiguous: + truth.pop(name, None) + return erased, truth, improved_on_any + + +def compile_and_emit(perry: str, src: pathlib.Path, workdir: pathlib.Path) -> dict: + """Compile one file and return the parsed `--emit-types` JSON. + + Raises on anything that would let a broken run masquerade as a zero. + """ + out_json = workdir / "types.json" + obj = workdir / "out.o" + cmd = [ + perry, + "compile", + str(src), + "--emit-types", + str(out_json), + "--no-link", + "-o", + str(obj), + ] + env = {"PERRY_NO_AUTO_OPTIMIZE": "1", "PATH": "/usr/bin:/bin:/usr/sbin:/sbin"} + proc = subprocess.run( + cmd, capture_output=True, text=True, env=env, timeout=300, cwd=str(workdir) + ) + if proc.returncode != 0: + # A file that does not compile standalone (an unresolved import, a + # feature Perry lacks) is not a measurement — it is skipped and + # counted. Distinct from the fatal case below. + raise CompileFailed( + f"compile failed ({proc.returncode}): " + f"{proc.stderr.strip().splitlines()[-1] if proc.stderr.strip() else 'no stderr'}" + ) + if not out_json.exists(): + # Compiled fine and produced NOTHING. That is the vacuity case: it must + # never be recorded as "zero types recovered", because a silently + # inert flag and a genuinely untypeable program are the same number. + raise RuntimeError( + f"{src.name}: compile succeeded but wrote no types file — the flag " + f"did not run. This is a harness/compiler error, NOT a zero." + ) + return json.loads(out_json.read_text()) + + +def run_roundtrip(perry: str, inputs: list[pathlib.Path]) -> int: + exact = mismatch = 0 + recovered_total = 0 + truth_total = 0 + files_measured = 0 + files_zero: list[str] = [] + files_failed: list[str] = [] + mismatches: list[str] = [] + + improved = 0 + for src in inputs: + source = src.read_text() + erased, truth, any_names = erase_local_annotations(source) + if not truth and not any_names: + continue # nothing annotated to score; not a measured file + with tempfile.TemporaryDirectory() as td: + work = pathlib.Path(td) + target = work / src.name + target.write_text(erased) + try: + doc = compile_and_emit(perry, target, work) + except CompileFailed as exc: + files_failed.append(f"{src.name}: {exc}") + continue + except Exception as exc: # noqa: BLE001 - fatal by design + print(f" FATAL {src.name}: {exc}", file=sys.stderr) + return 2 + + files_measured += 1 + truth_total += len(truth) + bindings = doc.get("bindings", []) + recovered_total += len(bindings) + if not bindings: + files_zero.append(src.name) + for b in bindings: + if b["name"] in any_names: + improved += 1 + continue + want = truth.get(b["name"]) + if want is None: + continue # recovered a binding that carried no annotation + if normalize_type(b["ts_type"]) == want: + exact += 1 + else: + mismatch += 1 + mismatches.append( + f"{src.name}:{b['function']}:{b['name']} " + f"want={want} got={b['ts_type']} ({b['analysis']}/{b['rep']})" + ) + + if files_measured == 0: + print("FAIL: measured zero files — nothing was scored.", file=sys.stderr) + return 2 + + scored = exact + mismatch + print(f"files measured: {files_measured}") + print(f"files skipped (build): {len(files_failed)}") + print(f" of which 0 recovered:{len(files_zero)}") + print(f"erased annotations: {truth_total} (the ground truth)") + print(f"bindings recovered: {recovered_total}") + print(f" scored against truth:{scored}") + print(f" exact match: {exact}") + print(f" MISMATCH: {mismatch}") + print(f" improved on `any`: {improved} (source said `any`; we proved a type)") + if truth_total: + print(f"recall (exact/truth): {exact / truth_total:6.1%}") + if scored: + print(f"precision (exact/scored): {exact / scored:6.1%}") + for m in mismatches: + print(f" ! {m}") + # A mismatch is a potential wrong-type bug — the one thing this feature + # promises never to do. It fails the run so it cannot be scrolled past. + return 1 if mismatch else 0 + + +def run_coverage(perry: str, inputs: list[pathlib.Path]) -> int: + files_measured = 0 + files_failed: list[str] = [] + decls_total = 0 + recovered_total = 0 + shapes_total = 0 + per_file: list[tuple[str, int, int]] = [] + + for src in inputs: + try: + source = src.read_text(errors="replace") + except OSError: + continue + decls = len(set(DECL_ANY.findall(source))) + with tempfile.TemporaryDirectory() as td: + work = pathlib.Path(td) + target = work / src.name + shutil.copyfile(src, target) + try: + doc = compile_and_emit(perry, target, work) + except CompileFailed as exc: + files_failed.append(f"{src.name}: {str(exc)[:160]}") + continue + except Exception as exc: # noqa: BLE001 - fatal by design + # "Compiled but emitted nothing" is the vacuity case and must + # stop the run, not be counted as a build failure. + print(f" FATAL {src.name}: {exc}", file=sys.stderr) + return 2 + files_measured += 1 + decls_total += decls + n = len(doc.get("bindings", [])) + recovered_total += n + shapes_total += len(doc.get("shapes", [])) + per_file.append((src.name, decls, n)) + + if files_measured == 0: + print( + f"FAIL: measured zero files ({len(files_failed)} failed to compile).", + file=sys.stderr, + ) + for f in files_failed[:20]: + print(f" {f}", file=sys.stderr) + return 2 + + print(f"files compiled OK: {files_measured}") + print(f"files failed to build: {len(files_failed)}") + print(f"local declarations: {decls_total} (syntactic count — APPROXIMATE)") + print(f"bindings recovered: {recovered_total}") + print(f"structural shapes: {shapes_total}") + if decls_total: + print(f"coverage: {recovered_total / decls_total:6.2%}") + print("\nper file (name, decls, recovered):") + for name, d, n in sorted(per_file, key=lambda r: -r[2])[:25]: + print(f" {n:5d} / {d:5d} {name}") + for f in files_failed[:15]: + print(f" BUILD-FAIL {f}") + return 0 + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__) + ap.add_argument("--perry", required=True, help="path to the perry binary") + ap.add_argument("--mode", choices=("roundtrip", "coverage"), required=True) + ap.add_argument("--inputs", nargs="+", required=True, help="files or directories") + ap.add_argument("--limit", type=int, default=0, help="cap files (0 = no cap)") + args = ap.parse_args() + + files: list[pathlib.Path] = [] + exts = (".ts",) if args.mode == "roundtrip" else (".js", ".cjs", ".mjs") + for raw in args.inputs: + p = pathlib.Path(raw) + if p.is_dir(): + files.extend( + sorted(f for f in p.rglob("*") if f.suffix in exts and f.is_file()) + ) + elif p.is_file(): + files.append(p) + if args.limit: + files = files[: args.limit] + if not files: + print("FAIL: no input files matched.", file=sys.stderr) + return 2 + + if args.mode == "roundtrip": + return run_roundtrip(args.perry, files) + return run_coverage(args.perry, files) + + +if __name__ == "__main__": + sys.exit(main()) From c653e375c23b9b0e1b1b0ab163de684fd2a4c282 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 09:21:00 +0200 Subject: [PATCH 2/3] fix(emit-types): narrow the mapping to what a soundness audit survives (#7685) An audit of each representation against "never emit a wrong type" withdrew four of the five mapping arms. Only the Ptr object proof licenses a TypeScript type; the numeric/string slot reps are storage decisions that survive a false annotation, IntValued is sound only while unobserved, and Ptr admits holes that read back undefined. Also hardens the synthetic-class filter ($-mangled monomorphization names and __anon_class_ leaked through a prefix-only check) and corrects the spec-ABI rationale: it is a majority vote over call sites, not an echo of the source annotation. --- .../src/collectors/proven_this.rs | 6 - .../perry-codegen/src/collectors/ptr_shape.rs | 57 +--- crates/perry-codegen/src/emit_types.rs | 267 ++++++++++++++---- crates/perry-codegen/src/emit_types/tests.rs | 240 +++++++++------- crates/perry-codegen/src/lib.rs | 2 +- crates/perry-codegen/src/opt_report/mod.rs | 4 +- docs/src/cli/flags.md | 41 +-- 7 files changed, 382 insertions(+), 235 deletions(-) diff --git a/crates/perry-codegen/src/collectors/proven_this.rs b/crates/perry-codegen/src/collectors/proven_this.rs index e9a18eef6f..e1d44d8162 100644 --- a/crates/perry-codegen/src/collectors/proven_this.rs +++ b/crates/perry-codegen/src/collectors/proven_this.rs @@ -209,11 +209,6 @@ pub(crate) fn method_proven_this( numeric_fields: HashSet::new(), // Phase 5a's promoted value is the receiver, not a named binding. report_name: crate::opt_report::enabled().then(|| String::from("this")), - // Never claimed for a proven `this`, for the same reason - // `numeric_fields` is not: this fact is about a receiver whose shape - // the caller proved, and `--emit-types` has no source-level binding - // here to annotate. - report_fields: None, }) } @@ -291,7 +286,6 @@ mod tests { class_name: "C".to_string(), numeric_fields: HashSet::new(), report_name: None, - report_fields: None, }; let k = |m: &str| ("C".to_string(), m.to_string()); diff --git a/crates/perry-codegen/src/collectors/ptr_shape.rs b/crates/perry-codegen/src/collectors/ptr_shape.rs index 8c9fc20d42..56af62b35a 100644 --- a/crates/perry-codegen/src/collectors/ptr_shape.rs +++ b/crates/perry-codegen/src/collectors/ptr_shape.rs @@ -177,50 +177,6 @@ pub struct PtrShapeLocal { /// "some local was consumed" but never "`totals` was **not**", and naming /// the value is the entire point of the distinction. pub report_name: Option, - /// The class chain's declared field set, for `--emit-types` (#7685). - /// - /// `Some` exclusively when [`crate::opt_report::enabled`], like - /// [`Self::report_name`] — an ordinary build allocates nothing for it. - /// - /// This is the input to the one output `--emit-types` has that a - /// JavaScript-only inferencer does not: a *structural interface* recovered - /// for an object literal. It exists here only because representation - /// selection had to answer "what is this object's exact field set?" in - /// order to pick `Ptr` at all. - /// - /// `None` rather than an empty vector when the report is off, so the two - /// states stay distinguishable: "no fields" and "not collected" would - /// otherwise both render as an empty interface. - pub report_fields: Option>, -} - -/// The declared field set of a proven class chain, as `--emit-types` consumes -/// it. Report-only; never consulted by codegen. -/// -/// Two field kinds are recorded name-only, with no type, rather than -/// approximated — and because `emit_types::Shape::is_emittable` requires a type -/// for *every* field, either one refuses the whole interface rather than -/// silently narrowing it: -/// -/// * a **computed key** (`key_expr: Some(..)`), whose `name` is a synthetic -/// placeholder for HIR identity and not the runtime property name at all; -/// * a **private** field, which is not part of an object's structural type. -fn declared_shape_fields(chain: &[&Class]) -> Vec { - let mut out = Vec::new(); - for class in chain { - for field in &class.fields { - let ts_type = if field.key_expr.is_some() || field.is_private { - None - } else { - crate::emit_types::ts_type_for_hir_type(&field.ty) - }; - out.push(crate::emit_types::ShapeField { - name: field.name.clone(), - ts_type, - }); - } - } - out } /// Whether an expression node is a §5.2 shape barrier for the module-wide @@ -262,6 +218,7 @@ pub(crate) fn expr_is_shape_barrier(expr: &Expr) -> bool { /// the other. fn note_ptr_shape_local( id: u32, + chain: &[&Class], fact: &PtrShapeLocal, names: &HashMap, depths: &HashMap, @@ -286,14 +243,7 @@ fn note_ptr_shape_local( fact.class_name, fact.numeric_fields.len() )), - // The class name and field set as DATA, for `--emit-types` - // (#7685). The `detail` string above still renders them as prose - // for the human report; a consumer that turns them into a - // TypeScript type must not have to parse that sentence. - Some(opt_report::SelectedShape { - class_name: fact.class_name.clone(), - fields: fact.report_fields.clone().unwrap_or_default(), - }), + Some(crate::emit_types::selected_shape(&fact.class_name, chain)), ); } if !repsel_debug_enabled() { @@ -604,9 +554,8 @@ pub(crate) fn collect_shape_proven_ptr_locals( .cloned() .unwrap_or_else(|| format!("")) }), - report_fields: opt_report::enabled().then(|| declared_shape_fields(&chain)), }; - note_ptr_shape_local(*id, &fact, &names, &depths); + note_ptr_shape_local(*id, &chain, &fact, &names, &depths); // Aliases carry the same fact: they hold the same object, their slots // are equally shadow-bound, and access sites key on the local they // actually reference. diff --git a/crates/perry-codegen/src/emit_types.rs b/crates/perry-codegen/src/emit_types.rs index 21610c7912..8b47348b3d 100644 --- a/crates/perry-codegen/src/emit_types.rs +++ b/crates/perry-codegen/src/emit_types.rs @@ -10,32 +10,58 @@ //! recorded, so its coverage is exactly the proof rate `--opt-report` measures //! and nothing this module does can widen it. //! +//! ## The headline result: a representation is not a type +//! +//! Perry has five representation analyses. **One of them licenses a TypeScript +//! type.** That is the finding, and it is why this stays experimental: +//! +//! | analysis | emitted? | why | +//! |---|---|---| +//! | `PtrShape` | **yes** | a genuine value proof — exact dynamic class, provenance + containment | +//! | `CanonicalSlot` (`I32`/`U32`) | no | storage proof; the value can still be `undefined` or `bigint` | +//! | `CanonicalSlot` (`Str`) | no | annotation-derived, and *designed* to tolerate a false annotation | +//! | `IntValuedTa` | no | sound only because the value is never observed; annotating it publishes it | +//! | `PtrNumArray` | no | admits holes that read back `undefined`; also only echoes an annotation | +//! | `SpecAbi` | no | reads `param.ty`, which only an annotation populates | +//! +//! The per-arm reasoning, with the source each claim comes from, is in +//! [`recovered_type`]. The pattern is consistent: these representations were +//! chosen to be *observationally equivalent* to the boxed form, which is a +//! weaker property than "the value has this type" — and in three cases the +//! equivalence holds precisely because the value is never observed in a +//! context that could tell the difference. Publishing an annotation is exactly +//! such an observation. +//! //! ## The one rule //! //! **Never emit a wrong type.** A missing annotation costs nothing; a wrong one -//! poisons a downstream `tsc`. Every mapping below is an omission unless the +//! poisons a downstream `tsc`. Every mapping is an omission unless the //! representation *proves* the TypeScript type, and the three places where the //! proof is conditional are omissions rather than guesses: //! -//! - **A representation whose proof is a restatement of the source annotation** -//! is not recovered information. [`Analysis::SpecAbi`] is exactly that: -//! `codegen/typed_abi.rs::typed_param_rep_for_type` reads `param.ty`, which -//! is populated from the TypeScript annotation and by nothing else — no -//! inference writes it (the only assignments in `perry-hir` *widen* it to -//! `Any`, `lower/shared_mutable_capture.rs:363`). Echoing it back would -//! inflate any round-trip accuracy number to meaninglessness while -//! recovering nothing, so spec-ABI entries are dropped. See -//! [`recovered_type`]. +//! - **A majority is not a proof.** [`Analysis::SpecAbi`] is the trap here, and +//! it is worth stating precisely because the obvious reading is wrong. A +//! specialized entry is *not* derived from the parameter's annotation: it +//! comes from `codegen/spec_abi.rs::select_dominant_tuple`, which counts the +//! argument-type tuples at the **call sites** and picks the most frequent +//! one, demoting the rest to `Boxed` behind a guarded entry. So it fires on +//! completely unannotated JavaScript — measured — and it fires even when a +//! caller disagrees: a function called four times with numbers and once with +//! a string still reports `i32,i32`. Emitting `a: number` there would be +//! flatly wrong, because a real caller passes a string. Spec-ABI entries are +//! therefore dropped. See [`recovered_type`]. //! - **A binding two entries disagree about** is dropped entirely rather than //! resolved by a precedence rule. A function can be lowered more than once (a //! boxed entry plus a typed clone) and the two lowerings can select different //! representations; picking a winner would be picking which of two proofs to //! believe. See [`recover`]. -//! - **A synthetic class name is not a TypeScript type.** A `Ptr` local -//! whose provenance class is a compiler-synthesized `__AnonShape_*` / -//! `__EmptySite_*` shape has no source-level name to emit. It becomes a -//! structural interface when the field set is known, and is omitted when it -//! is not — never emitted under its synthetic name. +//! - **A compiler-minted class name is not a TypeScript type.** A `Ptr` +//! local whose provenance class is `__AnonShape_*`, `__anon_class_*`, or a +//! `$`-mangled monomorphization/collision rename has no source-level name to +//! emit. It becomes a structural interface when the field set is known, and +//! is omitted when it is not — never emitted under the minted name. See +//! [`is_synthetic_class`], whose `$` case is the one a prefix-only filter +//! misses. //! //! ## What it cannot do, stated rather than worked around //! @@ -50,15 +76,39 @@ use crate::opt_report::{Analysis, Entry, Outcome, Position}; use perry_hir::types::Type; use std::collections::BTreeMap; -/// Prefixes the HIR lowering uses for classes it synthesizes for object -/// literals, which therefore have no source-level name a `.ts` file could -/// refer to (`perry-hir/src/lower/expr_object.rs`). -const SYNTHETIC_CLASS_PREFIXES: [&str; 2] = ["__AnonShape_", "__EmptySite_"]; +/// Class-name forms the compiler mints itself, which therefore name nothing a +/// `.ts` file could refer to. Emitting one would produce TypeScript that does +/// not compile, so each is either turned into structure or omitted. +/// +/// The list is the audited set, not a guess — an earlier version carried only +/// the first entry plus a `__EmptySite_` that **matches nothing in the tree** +/// (empty literals also mint `__AnonShape_`), while three real families +/// leaked straight through: +/// +/// * `__AnonShape_<16 hex>` — object literals, incl. `{}` +/// (`perry-hir/src/lower/context.rs`, a content-addressed FNV-1a of the +/// shape key, not a counter). +/// * `__anon_class_` — `new (class {})()` +/// (`perry-hir/src/lower/expr_new/non_ident.rs`). +/// * `__inline_` / `__anon_dup_` — transform-minted specializations +/// (`perry-transform/src/inline/factory_specialize.rs`). +const SYNTHETIC_CLASS_PREFIXES: [&str; 4] = + ["__AnonShape_", "__anon_class_", "__inline_", "__anon_dup_"]; +/// Is this class name compiler-minted rather than source-level? +/// +/// The `$` test is the load-bearing one and is deliberately a *substring* +/// check, not a prefix: generic monomorphization rewrites the `New` site's +/// class to `Box$num` (`perry-hir/src/monomorph/mangle.rs`) and a scope +/// collision renames a class to `Name$2`. Both are real class names reachable +/// at a `Ptr` provenance site, and both would emit TypeScript naming a +/// type that does not exist. `$` is already this repo's reserved +/// generated-suffix namespace (`collectors/proven_this.rs`), so no source +/// identifier can contain one. fn is_synthetic_class(name: &str) -> bool { - SYNTHETIC_CLASS_PREFIXES - .iter() - .any(|p| name.starts_with(p)) + name.contains('$') + || SYNTHETIC_CLASS_PREFIXES.iter().any(|p| name.starts_with(p)) + || name.contains("__inline_") } /// A binding name the collectors invented because the source had none. Such a @@ -112,6 +162,53 @@ pub struct ShapeField { pub ts_type: Option, } +/// The declared field set of a proven class chain, as this module consumes it. +/// +/// Report-only; never consulted by codegen, and called only when +/// `opt_report::enabled()`. It lives here rather than in the collector so that +/// the whole field-to-TypeScript mapping has exactly one home. +/// +/// Two field kinds are recorded name-only, with no type, rather than +/// approximated — and because [`Shape::is_emittable`] requires a type for +/// *every* field, either one refuses the whole interface rather than silently +/// narrowing it: +/// +/// * a **computed key** (`key_expr: Some(..)`), whose `name` is a synthetic +/// placeholder for HIR identity and not the runtime property name at all; +/// * a **private** field, which is not part of an object's structural type. +fn declared_shape_fields(chain: &[&perry_hir::Class]) -> Vec { + let mut out = Vec::new(); + for class in chain { + for field in &class.fields { + let ts_type = if field.key_expr.is_some() || field.is_private { + None + } else { + ts_type_for_hir_type(&field.ty) + }; + out.push(ShapeField { + name: field.name.clone(), + ts_type, + }); + } + } + out +} + +/// Build the `Ptr` payload the `--opt-report` selection carries. +/// +/// The collector calls this instead of assembling the struct itself, so the +/// class name and the field mapping are produced in one place and the collector +/// keeps no `--emit-types` state of its own. +pub(crate) fn selected_shape( + class_name: &str, + chain: &[&perry_hir::Class], +) -> crate::opt_report::SelectedShape { + crate::opt_report::SelectedShape { + class_name: class_name.to_string(), + fields: declared_shape_fields(chain), + } +} + /// A structural shape recovered from a `Ptr` selection whose provenance /// class is compiler-synthesized. #[derive(Debug, Clone, PartialEq, Eq)] @@ -134,11 +231,7 @@ impl Shape { let body = self .fields .iter() - .filter_map(|f| { - f.ts_type - .as_ref() - .map(|t| format!(" {}: {};", f.name, t)) - }) + .filter_map(|f| f.ts_type.as_ref().map(|t| format!(" {}: {};", f.name, t))) .collect::>() .join("\n"); format!("{{\n{body}\n}}") @@ -186,25 +279,60 @@ fn recovered_type(entry: &Entry) -> Option { return None; } match entry.analysis { - // A restatement of the source annotation, not recovered information. - // See the module doc. + // ── The four analyses that do NOT license a TypeScript type ────────── + // + // Each of these looks like an obvious mapping and is not one. They are + // listed explicitly, with the reason, so that re-adding one requires + // arguing with the reason rather than noticing an absent arm. + // + // A MAJORITY VOTE over call sites, not a proof about the parameter. + // `spec_abi::select_dominant_tuple` counts the argument-type tuples at + // every call site and keeps the most frequent, demoting disagreeing + // params to `Boxed` behind a guarded entry. Measured: a function called + // four times with numbers and once with a string still reports + // `i32,i32`, so `a: number` would be wrong for a caller that exists. + // (It is also NOT annotation-derived, which is the natural guess — it + // fires on wholly unannotated JavaScript.) Analysis::SpecAbi => None, - Analysis::CanonicalSlot => match entry.rep.as_str() { - // `SlotRep`'s `Debug` spelling (`expr/slot_rep.rs`). `U32` is a - // number in TypeScript exactly as `I32` is — the distinction is a - // storage one. - "I32" | "U32" => Some(TypeOrShape::Ts("number".to_string())), - "Str" => Some(TypeOrShape::Ts("string".to_string())), - _ => None, - }, - Analysis::IntValuedTa => match entry.rep.as_str() { - "IntValued" => Some(TypeOrShape::Ts("number".to_string())), - _ => None, - }, - Analysis::PtrNumArray => match entry.rep.as_str() { - "Ptr" => Some(TypeOrShape::Ts("number[]".to_string())), - _ => None, - }, + // `I32`/`U32` are STORAGE proofs, not value proofs, and the local's JS + // value can leave `number` three ways: the scaffolding seed admits + // `var x;` with later non-dominating writes (JS reads `undefined`, + // Perry reads the entry-block `store i32 0`); `int_valued_ta` members + // are merged straight into `integer_locals` and carry §3's hazard; and + // the bitwise arm treats every `Binary` as int32-producing regardless + // of operand type, so `a & b` on BigInts is a `bigint` + // (`not_bigint_locals` is computed but is not a term in the admission + // conjunction). `Str` is worse than unproven — it is annotation-derived + // (`refined_ty` is the declared type verbatim when it is not `Any`, and + // nothing checks the initializer), and the representation is + // *designed* to tolerate the annotation being false: "a type-annotation + // lie degrades to today's behavior" (`expr/slot_rep.rs`). That is the + // same defect the `SpecAbi` arm above is rejected for. + // + // The `Entry` stream carries only `rep: "I32"`, not which admitting set + // licensed it, so the sound subsets (`loop_bounded_i32_locals`, + // `unsigned_i32_locals`) cannot be separated out here. Recovering them + // needs the collector to record provenance. + Analysis::CanonicalSlot => None, + // Self-refuting. `collectors/int_valued_ta_locals.rs`'s own module doc: + // an OOB / negative / fractional typed-array read "yields `undefined` + // …, NOT an integer", and the representation is sound *only* because + // rule (2) forbids every context in which `undefined` and an integer + // are distinguishable. Writing the annotation publishes a value whose + // safety depends on it never being observed. + Analysis::IntValuedTa => None, + // `NumArrayDensity::HolesOk` is the PRIMARY provenance (`new Array(n)`), + // and its slots read back as `undefined`. The repo pins the observable + // itself: `test-files/test_gap_repsel_p4a3_ptr_numarray.ts` asserts + // `console.log(c[0], c[1], c[3])` prints `undefined 2 undefined` for a + // promoted local, so the true type is `(number | undefined)[]`. + // Gating on `Dense` would not rescue it — the `IndexSet` arm never + // compares the index against the length, so `const a = []; a[3] = 1;` + // is `Dense` while the runtime hole-fills 0..2. And admission already + // REQUIRES the declared type be `number[]`, so this arm could only ever + // echo an annotation back. + Analysis::PtrNumArray => None, + // ── The one that survives ──────────────────────────────────────────── Analysis::PtrShape => { let class = entry.shape_class.as_deref()?; if is_synthetic_class(class) { @@ -230,13 +358,24 @@ enum TypeOrShape { type BindingKey = (String, String, String, Option); /// Reduce the entry stream to one type per binding, dropping every binding the -/// stream disagrees about. +/// stream disagrees about. A function lowered twice (a boxed entry plus a typed +/// clone) contributes two rows per binding, and if the two lowerings selected +/// different representations there is no principled winner. +/// +/// **This guard cannot currently fire on a real compile, and saying so is the +/// point.** `opt_report::take_entries` de-duplicates *before* any consumer sees +/// the stream, and `Entry::dedup_key` omits `local_id`, `rep`, `shape_class` +/// and `shape_fields` — so two `Selected` rows for one name in one function +/// with **different classes** collapse to whichever arrived first, and the +/// disagreement is destroyed upstream rather than detected here. Two distinct +/// bindings that share a name (`{const r = new A();} {const r = new B();}`) +/// collapse the same way, so a rendered row can name an ambiguous binding. /// -/// The disagreement case is not hypothetical bookkeeping: a function lowered -/// twice (a boxed entry plus a typed clone) contributes two rows per binding, -/// and `--opt-report`'s own `dedup_key` keeps them apart on purpose. If the two -/// lowerings selected different representations, there is no principled winner, -/// so the binding is omitted. +/// The guard is kept because it is the correct behaviour for the stream this +/// module is handed, and because the unit tests exercise it directly. But it is +/// a guard whose subject never arrives — CLAUDE.md's fourth way a gate cannot +/// fail — and closing it means widening `dedup_key`, which is `--opt-report`'s +/// contract and not this prototype's to change. fn recover(entries: &[Entry]) -> (Vec, Vec) { let mut by_binding: BTreeMap> = BTreeMap::new(); for entry in entries { @@ -319,9 +458,17 @@ const HEADER: &str = "\ // A binding absent from this file is NOT untyped — it is unproven. Nothing here // is a guess: where the proof was conditional, the binding was omitted. // -// Parameter and return types are NOT recovered. Perry's specialized-ABI -// analysis derives them from the source annotation, so on unannotated -// JavaScript it proves nothing and this file will contain no signatures. +// Parameter and return types are NOT recovered, so this file contains no +// function signatures. Perry's specialized-ABI analysis picks the most frequent +// argument-type tuple across a function's call sites and guards the rest — a +// majority, not a proof — so it cannot license a parameter annotation. +// +// Only ONE of Perry's five representation analyses licenses a TypeScript type: +// the Ptr object proof. The numeric and string slot representations are +// storage decisions that survive a false annotation, and the array proof admits +// holes that read back as undefined — none of them is a value proof, so none is +// emitted. See crates/perry-codegen/src/emit_types.rs for the case-by-case +// reasoning. "; /// Render the recovered types as TypeScript. @@ -341,7 +488,9 @@ pub fn render_ts(entries: &[Entry]) -> String { } if !shapes.is_empty() { - out.push_str("\n// ── Recovered structural shapes ──────────────────────────────────────────\n"); + out.push_str( + "\n// ── Recovered structural shapes ──────────────────────────────────────────\n", + ); out.push_str("// Object literals whose field set Perry proved closed and immutable.\n\n"); for (i, shape) in shapes.iter().enumerate() { out.push_str(&format!( @@ -353,7 +502,9 @@ pub fn render_ts(entries: &[Entry]) -> String { } if !recovered.is_empty() { - out.push_str("// ── Recovered local bindings ─────────────────────────────────────────────\n"); + out.push_str( + "// ── Recovered local bindings ─────────────────────────────────────────────\n", + ); out.push_str( "// Comments, not declarations: TypeScript cannot declare another file's\n\ // function-local, and HIR carries no source span to rewrite in place.\n", @@ -388,9 +539,7 @@ pub fn render_json(entries: &[Entry]) -> String { let shapes_json: Vec<_> = shapes .iter() .enumerate() - .map(|(i, s)| { - serde_json::json!({ "name": shape_name(i), "fields": s.fields }) - }) + .map(|(i, s)| serde_json::json!({ "name": shape_name(i), "fields": s.fields })) .collect(); let doc = serde_json::json!({ "schema_version": 1, diff --git a/crates/perry-codegen/src/emit_types/tests.rs b/crates/perry-codegen/src/emit_types/tests.rs index ff7e8ff8b6..36f988f6be 100644 --- a/crates/perry-codegen/src/emit_types/tests.rs +++ b/crates/perry-codegen/src/emit_types/tests.rs @@ -5,6 +5,11 @@ //! path cannot tell a working omission rule from a deleted one — so every //! omission below is paired with a positive control proving the same input //! *would* have emitted something if the rule were absent. +//! +//! [`emitting`] is that control throughout. It is a `Ptr` row because +//! after the soundness audit that is the *only* analysis which licenses a +//! TypeScript type; an earlier version of these tests used a canonical-`I32` +//! row as the control, which stopped emitting when that arm was withdrawn. use super::*; use crate::opt_report::{Analysis, Entry, Outcome, Position, RegionKind}; @@ -37,6 +42,13 @@ fn local(name: &str, analysis: Analysis, rep: &str) -> Entry { } } +/// The one row shape that DOES emit: a `Ptr` local of a source class. +fn emitting(name: &str) -> Entry { + let mut e = local(name, Analysis::PtrShape, "Ptr"); + e.shape_class = Some("Point".to_string()); + e +} + fn types_of(entries: &[Entry]) -> Vec<(String, String)> { recover(entries) .0 @@ -45,85 +57,79 @@ fn types_of(entries: &[Entry]) -> Vec<(String, String)> { .collect() } -// ── The mapping ──────────────────────────────────────────────────────────── +// ── The one analysis that licenses a type ────────────────────────────────── #[test] -fn canonical_slots_map_to_number_and_string() { - let entries = vec![ - local("i", Analysis::CanonicalSlot, "I32"), - local("u", Analysis::CanonicalSlot, "U32"), - local("s", Analysis::CanonicalSlot, "Str"), - ]; - let mut got = types_of(&entries); - got.sort(); +fn a_ptr_shape_local_recovers_its_source_class_name() { assert_eq!( - got, - vec![ - ("i".to_string(), "number".to_string()), - ("s".to_string(), "string".to_string()), - ("u".to_string(), "number".to_string()), - ] + types_of(&[emitting("p")]), + vec![("p".to_string(), "Point".to_string())] ); } +/// The four analyses that look like obvious mappings and are not. +/// +/// Each is a *storage* proof rather than a value proof, and each was emitted by +/// the first version of this module. They are pinned as omissions so that +/// re-adding one turns this test red rather than quietly widening the output. +/// The reason per analysis is in `recovered_type`; the short version: +/// +/// * `I32`/`U32` — the local's JS value can be `undefined` (non-dominating +/// writes to a `var` seed) or `bigint` (the bitwise arm ignores operand type). +/// * `Str` — derived from the annotation, and the representation explicitly +/// tolerates that annotation being false. +/// * `IntValued` — sound only because the value is never observed; an +/// annotation observes it. +/// * `Ptr` — `HolesOk` elements read back as `undefined`, which +/// `test-files/test_gap_repsel_p4a3_ptr_numarray.ts` pins as observable. #[test] -fn numarray_and_int_valued_map_to_number_forms() { - let entries = vec![ - local("xs", Analysis::PtrNumArray, "Ptr"), - local("acc", Analysis::IntValuedTa, "IntValued"), - ]; - let mut got = types_of(&entries); - got.sort(); - assert_eq!( - got, - vec![ - ("acc".to_string(), "number".to_string()), - ("xs".to_string(), "number[]".to_string()), - ] - ); +fn storage_proofs_are_not_value_proofs_and_emit_nothing() { + for (analysis, rep) in [ + (Analysis::CanonicalSlot, "I32"), + (Analysis::CanonicalSlot, "U32"), + (Analysis::CanonicalSlot, "Str"), + (Analysis::IntValuedTa, "IntValued"), + (Analysis::PtrNumArray, "Ptr"), + ] { + let e = local("x", analysis, rep); + assert!( + types_of(&[e]).is_empty(), + "{analysis:?}/{rep} must not emit a type" + ); + } + // Control: the analysis that DOES license a type still emits, so the + // omissions above are per-analysis and not a dead code path. + assert_eq!(types_of(&[emitting("p")]).len(), 1); } +/// A spec-ABI tuple is the most frequent argument-type tuple across a +/// function's call sites (`spec_abi::select_dominant_tuple`), with disagreeing +/// callers demoted to a guarded boxed entry. A function called four times with +/// numbers and once with a string still reports `i32,i32` — measured — so the +/// tuple is a majority, not a proof, and cannot license a parameter type. #[test] -fn a_ptr_shape_local_recovers_its_source_class_name() { - let mut e = local("p", Analysis::PtrShape, "Ptr"); - e.shape_class = Some("Point".to_string()); - assert_eq!(types_of(&[e]), vec![("p".to_string(), "Point".to_string())]); +fn spec_abi_is_dropped_because_a_majority_of_call_sites_is_not_a_proof() { + let e = local("n", Analysis::SpecAbi, "i32"); + assert!(types_of(&[e]).is_empty()); + assert_eq!(types_of(&[emitting("n")]).len(), 1); } /// An unrecognised representation must be an omission, never `any`. -/// -/// `any` is a claim — it tells `tsc` the binding may be used as anything, which -/// is exactly what an unknown proof does not license. #[test] fn an_unrecognised_representation_is_omitted_rather_than_any() { - let e = local("mystery", Analysis::CanonicalSlot, "F128"); + let mut e = local("mystery", Analysis::PtrShape, "Ptr"); + e.shape_class = None; assert!(types_of(&[e]).is_empty()); - // Control: the same row with a KNOWN rep does emit, so the omission above - // is the rep check and not a dead code path swallowing everything. - let ok = local("mystery", Analysis::CanonicalSlot, "I32"); - assert_eq!(types_of(&[ok]).len(), 1); + assert_eq!(types_of(&[emitting("mystery")]).len(), 1); } -// ── The omissions ────────────────────────────────────────────────────────── - -/// Spec-ABI reps are derived from `param.ty`, which is populated from the -/// source annotation and by nothing else. Emitting them would echo the input -/// back and inflate any accuracy measurement to meaninglessness. -#[test] -fn spec_abi_is_dropped_because_it_restates_the_source_annotation() { - let mut e = local("n", Analysis::SpecAbi, "i32"); - e.position = Position::Local; - assert!(types_of(&[e]).is_empty()); - // Control: an identical row under an analysis that really does prove - // something emits, so the drop is keyed on the analysis. - let ok = local("n", Analysis::CanonicalSlot, "I32"); - assert_eq!(types_of(&[ok]).len(), 1); -} +// ── Outcome and position filters ─────────────────────────────────────────── #[test] fn a_denied_entry_contributes_nothing() { - let mut e = local("x", Analysis::CanonicalSlot, "Boxed"); + let mut e = emitting("x"); e.outcome = Outcome::Denied; + e.rep = "Boxed".to_string(); assert!(types_of(&[e]).is_empty()); } @@ -132,10 +138,10 @@ fn a_denied_entry_contributes_nothing() { /// because refuted". #[test] fn an_unconsumed_proof_is_not_emitted() { - let mut e = local("x", Analysis::CanonicalSlot, "I32"); + let mut e = emitting("x"); e.outcome = Outcome::Unconsumed; assert!(types_of(&[e]).is_empty()); - let mut consumed = local("x", Analysis::CanonicalSlot, "I32"); + let mut consumed = emitting("x"); consumed.outcome = Outcome::Consumed; assert_eq!(types_of(&[consumed]).len(), 1, "Consumed must still emit"); } @@ -143,7 +149,7 @@ fn an_unconsumed_proof_is_not_emitted() { #[test] fn non_local_positions_are_not_emitted() { for position in [Position::Param, Position::Return, Position::AllocSite] { - let mut e = local("x", Analysis::CanonicalSlot, "I32"); + let mut e = emitting("x"); e.position = position; assert!( types_of(&[e]).is_empty(), @@ -155,24 +161,44 @@ fn non_local_positions_are_not_emitted() { #[test] fn synthetic_binding_names_are_not_emitted() { for name in ["", "(parameters + return)", "this"] { - let e = local(name, Analysis::CanonicalSlot, "I32"); - assert!(types_of(&[e]).is_empty(), "{name} must not emit"); + assert!( + types_of(&[emitting(name)]).is_empty(), + "{name} must not emit" + ); } } -/// A synthesized class name is not a TypeScript type. Without a field set to -/// turn into structure, the binding is omitted — never emitted as -/// `__AnonShape_1f2e`, which would name nothing in the source. +// ── Synthetic class names ────────────────────────────────────────────────── + +/// A compiler-minted class name is not a TypeScript type. Without a field set +/// to turn into structure the binding is omitted — never emitted under a name +/// that would name nothing in the source. +/// +/// The `$` cases are the ones an earlier prefix-only filter let through: +/// generic monomorphization rewrites the provenance `New` to `Box$num`, and a +/// scope collision renames a class to `Name$2`. Both are real class names +/// reachable here, and both would have produced TypeScript that does not +/// compile. #[test] -fn a_synthetic_shape_class_is_never_emitted_under_its_synthetic_name() { - let mut e = local("o", Analysis::PtrShape, "Ptr"); - e.shape_class = Some("__AnonShape_1f2e".to_string()); - assert!(types_of(&[e.clone()]).is_empty()); - assert!(!render_ts(&[e]).contains("__AnonShape")); - // Control: the same row with a SOURCE class name emits that name. - let mut real = local("o", Analysis::PtrShape, "Ptr"); - real.shape_class = Some("Point".to_string()); - assert_eq!(types_of(&[real]).len(), 1); +fn compiler_minted_class_names_are_never_emitted() { + for class in [ + "__AnonShape_1f2e", + "__anon_class_7", + "Box$num", + "Point$2", + "Target__inline_3_1", + ] { + let mut e = emitting("o"); + e.shape_class = Some(class.to_string()); + assert!( + types_of(&[e.clone()]).is_empty(), + "{class} must not be emitted as a type" + ); + let ts = render_ts(&[e]); + assert!(!ts.contains(class), "{class} leaked into:\n{ts}"); + } + // Control: a source-level class name emits. + assert_eq!(types_of(&[emitting("o")]).len(), 1); } // ── Structural interfaces ────────────────────────────────────────────────── @@ -221,8 +247,7 @@ fn a_shape_with_one_untyped_field_is_refused_whole() { #[test] fn an_empty_field_set_is_not_an_empty_interface() { - let e = shape_entry("rec", &[]); - assert!(types_of(&[e]).is_empty()); + assert!(types_of(&[shape_entry("rec", &[])]).is_empty()); } /// Two bindings with the same field set share one interface, and the numbering @@ -242,13 +267,16 @@ fn identical_shapes_are_deduplicated_and_numbered_deterministically() { // ── Disagreement ─────────────────────────────────────────────────────────── -/// A function lowered twice (a boxed entry plus a typed clone) contributes two -/// rows per binding. If they disagree there is no principled winner, so the -/// binding is dropped rather than resolved by declaration order. +/// Note: this guard cannot fire on a real compile — `take_entries` de-duplicates +/// upstream on a key that omits `rep` and `shape_class`, so the second row is +/// destroyed before this module sees it. See `recover`'s doc comment. The tests +/// drive `recover` directly, which is the only place the behaviour is +/// observable. #[test] fn a_binding_two_entries_disagree_about_is_dropped() { - let a = local("x", Analysis::CanonicalSlot, "I32"); - let b = local("x", Analysis::CanonicalSlot, "Str"); + let a = emitting("x"); + let mut b = emitting("x"); + b.shape_class = Some("Other".to_string()); assert!(types_of(&[a.clone(), b.clone()]).is_empty()); assert!( types_of(&[b, a]).is_empty(), @@ -258,21 +286,22 @@ fn a_binding_two_entries_disagree_about_is_dropped() { #[test] fn agreeing_duplicate_entries_collapse_to_one_row() { - let a = local("x", Analysis::CanonicalSlot, "I32"); - let mut b = local("x", Analysis::CanonicalSlot, "I32"); + let a = emitting("x"); + let mut b = emitting("x"); b.outcome = Outcome::Consumed; assert_eq!( types_of(&[a, b]), - vec![("x".to_string(), "number".to_string())] + vec![("x".to_string(), "Point".to_string())] ); } /// A third row cannot revive a binding an earlier conflict poisoned. #[test] fn a_poisoned_binding_stays_poisoned() { - let a = local("x", Analysis::CanonicalSlot, "I32"); - let b = local("x", Analysis::CanonicalSlot, "Str"); - let c = local("x", Analysis::CanonicalSlot, "I32"); + let a = emitting("x"); + let mut b = emitting("x"); + b.shape_class = Some("Other".to_string()); + let c = emitting("x"); assert!(types_of(&[a, b, c]).is_empty()); } @@ -281,9 +310,18 @@ fn a_poisoned_binding_stays_poisoned() { #[test] fn hir_types_map_only_where_they_prove_a_typescript_type() { use perry_hir::types::Type; - assert_eq!(ts_type_for_hir_type(&Type::Number).as_deref(), Some("number")); - assert_eq!(ts_type_for_hir_type(&Type::Int32).as_deref(), Some("number")); - assert_eq!(ts_type_for_hir_type(&Type::String).as_deref(), Some("string")); + assert_eq!( + ts_type_for_hir_type(&Type::Number).as_deref(), + Some("number") + ); + assert_eq!( + ts_type_for_hir_type(&Type::Int32).as_deref(), + Some("number") + ); + assert_eq!( + ts_type_for_hir_type(&Type::String).as_deref(), + Some("string") + ); assert_eq!( ts_type_for_hir_type(&Type::Boolean).as_deref(), Some("boolean") @@ -303,13 +341,18 @@ fn hir_types_map_only_where_they_prove_a_typescript_type() { ts_type_for_hir_type(&Type::Union(vec![Type::Number, Type::String])), None ); - // A synthesized shape class is not a nameable type even inside a field. + // A compiler-minted class is not a nameable type even inside a field — + // including the `$`-mangled monomorphization form. assert_eq!( ts_type_for_hir_type(&Type::Named("__AnonShape_1".into())), None ); + assert_eq!(ts_type_for_hir_type(&Type::Named("Box$num".into())), None); // An array of an unmappable element is unmappable, not `any[]`. - assert_eq!(ts_type_for_hir_type(&Type::Array(Box::new(Type::Any))), None); + assert_eq!( + ts_type_for_hir_type(&Type::Array(Box::new(Type::Any))), + None + ); } // ── Rendering ────────────────────────────────────────────────────────────── @@ -325,26 +368,27 @@ fn an_empty_program_renders_an_explicit_statement_not_a_blank_file() { #[test] fn the_header_states_the_coverage_caveat_and_the_parameter_limitation() { - let ts = render_ts(&[local("i", Analysis::CanonicalSlot, "I32")]); + let ts = render_ts(&[emitting("p")]); assert!(ts.contains("EXPERIMENTAL")); assert!( ts.contains("Parameter and return types are NOT recovered"), "the artifact must carry its own limitation" ); assert!(ts.contains("absent from this file is NOT untyped")); + assert!( + ts.contains("Only ONE of Perry's five representation analyses"), + "the artifact must state how narrow it is" + ); } #[test] fn json_and_ts_agree_on_what_was_recovered() { - let entries = vec![ - local("i", Analysis::CanonicalSlot, "I32"), - shape_entry("rec", &[("x", Some("number"))]), - ]; + let entries = vec![emitting("p"), shape_entry("rec", &[("x", Some("number"))])]; let json: serde_json::Value = serde_json::from_str(&render_json(&entries)).unwrap(); assert_eq!(json["summary"]["recovered_bindings"], 2); assert_eq!(json["summary"]["recovered_shapes"], 1); assert_eq!(json["experimental"], true); let ts = render_ts(&entries); assert!(ts.contains("PerryShape1")); - assert!(ts.contains("i: number;")); + assert!(ts.contains("p: Point;")); } diff --git a/crates/perry-codegen/src/lib.rs b/crates/perry-codegen/src/lib.rs index 14bc5db40e..d6811a33e3 100644 --- a/crates/perry-codegen/src/lib.rs +++ b/crates/perry-codegen/src/lib.rs @@ -11,6 +11,7 @@ pub(crate) mod collectors; #[cfg(feature = "llvm-inprocess")] pub(crate) mod dialect; pub(crate) mod eh_mode; +pub mod emit_types; pub mod expr; pub mod ext_registry; pub mod function; @@ -42,7 +43,6 @@ pub mod native_emit; mod native_root_coverage; pub(crate) mod native_value; pub(crate) mod nm_install; -pub mod emit_types; pub mod opt_report; pub(crate) mod root_reload; pub mod rooting; diff --git a/crates/perry-codegen/src/opt_report/mod.rs b/crates/perry-codegen/src/opt_report/mod.rs index 0464d4bea2..3f84e9baa9 100644 --- a/crates/perry-codegen/src/opt_report/mod.rs +++ b/crates/perry-codegen/src/opt_report/mod.rs @@ -1062,7 +1062,9 @@ pub(crate) fn select( loop_depth: u32, detail: Option, ) { - select_with_shape(position, name, local_id, analysis, rep, loop_depth, detail, None) + select_with_shape( + position, name, local_id, analysis, rep, loop_depth, detail, None, + ) } /// Record a win from a site that already knows its own function and module diff --git a/docs/src/cli/flags.md b/docs/src/cli/flags.md index 6eac78aea1..ed4f72aa69 100644 --- a/docs/src/cli/flags.md +++ b/docs/src/cli/flags.md @@ -195,24 +195,33 @@ TypeScript. Like `--opt-report` it is observational (emitted code is byte-identical with it on and off) and it disables cache reuse for its own run, because a cache hit skips codegen and there would be nothing to report. -**What it recovers**, and the representation each type comes from: +**What it recovers — and the headline result: a representation is not a type.** +Perry has five representation analyses. Exactly **one** licenses a TypeScript +type: -| Representation | TypeScript | Notes | +| Analysis | Emitted | Why | |---|---|---| -| canonical `I32` / `U32` slot | `number` | | -| canonical `Str` slot | `string` | | -| `IntValued` | `number` | | -| `Ptr` | `number[]` | | -| `Ptr` of a source class | that class name | e.g. `Point` | -| `Ptr` of an object literal | a generated `interface` | the differentiating case | -| anything else | *omitted* | | - -**What it does not recover, and will not.** Parameter and return types. -Perry's specialized-ABI analysis reads `param.ty`, which is populated from the -source annotation and by nothing else — no inference writes it. On annotated -TypeScript it would hand your own annotations back; on unannotated JavaScript -it proves nothing. Those entries are therefore dropped rather than emitted, so -this flag cannot produce a `.d.ts` of function signatures. It reports *locals*. +| `Ptr` of a source class | that class name (`Point`) | a real value proof: exact dynamic class, by provenance + containment | +| `Ptr` of an object literal | a generated `interface` | the differentiating case — a field set recovered from untyped JS | +| canonical `I32` / `U32` | *omitted* | a **storage** proof. The JS value can still be `undefined` (non-dominating writes to a `var` seed) or `bigint` (the bitwise arm ignores operand types) | +| canonical `Str` | *omitted* | derived from the annotation, and the representation is *designed* to tolerate that annotation being false ("a type-annotation lie degrades to today's behavior") | +| `IntValued` | *omitted* | sound **only because the value is never observed** — an out-of-bounds typed-array read is `undefined`. Writing the annotation observes it | +| `Ptr` | *omitted* | `new Array(n)` holes read back as `undefined`; the true type is `(number \| undefined)[]`. Also only echoes an annotation | +| spec-ABI params/returns | *omitted* | the **most frequent** argument-type tuple across the call sites, with disagreeing callers demoted to a guarded boxed entry — a majority, not a proof. A function called 4× with numbers and 1× with a string still reports `i32,i32` | + +That table is the finding, not a limitation to route around. These +representations were chosen to be *observationally equivalent* to the boxed +form, which is strictly weaker than "the value has this type" — and in three +cases the equivalence holds precisely **because** the value is never observed in +a context that could tell the difference. An annotation is such an observation. + +**What it does not recover, and will not.** Parameter and return types, so this +flag cannot produce a `.d.ts` of function signatures — it reports *locals*. +Perry does specialize function entries by argument type, and that analysis does +run on unannotated JavaScript, but it selects the **most frequent** argument +tuple across the call sites and demotes disagreeing callers to a guarded boxed +entry. A majority of call sites is not a proof about the parameter, so those +entries are dropped rather than emitted. **It omits rather than guesses.** A missing annotation costs nothing; a wrong one poisons a downstream `tsc`. So: `any` is never emitted (it is a claim, not From 4b347f5240a5c61e85c6f4217d719041fda1d9c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ralph=20K=C3=BCpper?= Date: Sun, 9 Aug 2026 09:25:51 +0200 Subject: [PATCH 3/3] docs(changelog): #7685 --emit-types prototype fragment --- changelog.d/7688-emit-types-prototype.md | 81 ++++++++++++++++++++++++ 1 file changed, 81 insertions(+) create mode 100644 changelog.d/7688-emit-types-prototype.md diff --git a/changelog.d/7688-emit-types-prototype.md b/changelog.d/7688-emit-types-prototype.md new file mode 100644 index 0000000000..960193e875 --- /dev/null +++ b/changelog.d/7688-emit-types-prototype.md @@ -0,0 +1,81 @@ +### `--emit-types` — write the proven types back out as TypeScript (#7685, EXPERIMENTAL) + +`--opt-report` (#6952) surfaces the *negative* half of representation +selection: which values Perry could not statically type, and why. The positive +half — the types it had to prove in order to pick an unboxed representation — +was computed and discarded. `--emit-types ` writes it out (`.json` → +records, anything else → TypeScript). + +A **prototype**, behind a flag, marked experimental in `docs/src/cli/flags.md`, +not wired into `perry check`, gating nothing. No new analysis: a second consumer +of the existing `opt_report::Entry` stream. + +**The result: a representation is not a type.** Perry has five representation +analyses. After auditing each against "never emit a wrong type", exactly **one** +licenses a TypeScript type: + +| analysis | emitted | why | +|---|---|---| +| `Ptr` | **yes** | a real value proof — exact dynamic class by provenance + containment | +| canonical `I32`/`U32` | no | a *storage* proof; the JS value can still be `undefined` (non-dominating writes to a `var` seed; `int_valued_ta` members merge into `integer_locals`) or `bigint` (the bitwise arm ignores operand types, and `not_bigint_locals` is not a term in the admission conjunction) | +| canonical `Str` | no | annotation-derived, and *designed* to tolerate the annotation being false — "a type-annotation lie degrades to today's behavior" (`expr/slot_rep.rs`) | +| `IntValuedTa` | no | self-refuting: its own module doc says an OOB read yields `undefined`, and the rep is sound *only* because every context that could observe that is forbidden. An annotation is such an observation | +| `Ptr` | no | `HolesOk` slots read back `undefined`; `test-files/test_gap_repsel_p4a3_ptr_numarray.ts` already pins `undefined 2 undefined` on a promoted local. True type is `(number \| undefined)[]` | +| spec-ABI | no | the most frequent argument tuple across call sites, disagreeing callers demoted behind a guarded entry — a majority, not a proof | + +These representations were chosen to be *observationally equivalent* to the +boxed form, which is strictly weaker than "the value has this type" — and in +three cases the equivalence holds precisely **because** the value is never +observed in a context that could tell the difference. + +**Measured, not demonstrated** (`scripts/emit_types_accuracy.py`). Round-trip +mode **erases** local annotations first, which is load-bearing rather than +hygiene: `stmt/let_stmt.rs` computes `refined_ty` as the declared type whenever +it is not `Any`, so measured un-erased, every "recovered" type is the annotation +handed straight back and the score is 100% and means nothing. + +| corpus | files | recovered | +|---|---|---| +| `benchmarks/{repsel_census,suite,app-patterns}` (erased `.ts`) | 25 | 6 bindings; 22 of 25 files → zero | +| `test-files/*.ts` (erased), wide pre-audit mapping | 301 | 2 bindings; 300 files → zero | +| **real dependency JS** (lodash, semver, debug, chalk, …) | **150** | **0 bindings / 400 local declarations = 0.00 %; 0 structural shapes** | + +The 17 bindings the pre-audit mapping found on dependency JS came entirely from +the four withdrawn arms. Benchmarks still recover 6, which is the positive +control that emission is alive rather than broken. The differentiating output — +a structural interface recovered from untyped JS — fires **zero** times on real +dependency JS, consistent with the repsel census recording `Ptr` at ~7 +promotions across an 18-workload corpus. + +**Two claims corrected by measuring rather than reading.** Spec-ABI is *not* an +echo of the source annotation: `spec_abi::select_dominant_tuple` counts +call-site argument tuples, and an A/B with the annotations removed still reports +`i32,i32`. The stronger reason to drop it is that a function called four times +with numbers and once with a string also reports `i32,i32`. And the +synthetic-class filter was prefix-only, leaking `__anon_class_`, `Box$num` +(generic monomorphization) and `Name$2` (scope-collision rename) — each a real +class name reachable at a `Ptr` provenance site that would have emitted +TypeScript naming a nonexistent type. It now also rejects any name containing +`$`. The original list carried an `__EmptySite_` prefix that matches nothing in +the tree. + +**#7234 does not block this**, and not merely because `--profile perry-dev` +inherits `release` and disables `debug_assert`s: the panicking assertion is in +`opt_report/render.rs::rule_buckets`, which only the opt-report JSON renderer +calls. `--emit-types` renders through `emit_types.rs` and never reaches it. +150/150 dependency-JS files compiled clean. + +**Known limitation, stated in code rather than papered over.** `recover`'s +disagreement guard cannot fire on a real compile: `take_entries` de-duplicates +before any consumer sees the stream, and `Entry::dedup_key` omits `local_id`, +`rep` and `shape_class`, so two `Selected` rows for one name with different +classes collapse upstream. Closing that means widening `dedup_key`, which is +`--opt-report`'s contract. + +**Producer-side change**, report-only and allocation-free when the report is +off: `Entry` gains `shape_class` and `shape_fields`, both +`skip_serializing_if = "Option::is_none"` so an entry with neither serializes +byte-identically to the pre-#7685 schema. They carry the `Ptr` class name +and declared field set as *data* — `detail` already rendered them as prose, and +recovering a type by parsing an English sentence is a wrong-type bug waiting for +someone to reword the sentence.