feat(cli): --emit-types — write the proven types back out as TypeScript (#7685, EXPERIMENTAL) - #7688
feat(cli): --emit-types — write the proven types back out as TypeScript (#7685, EXPERIMENTAL)#7688proggeramlug wants to merge 3 commits into
Conversation
…pt (#7685) 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.
#7685) An audit of each representation against "never emit a wrong type" withdrew four of the five mapping arms. Only the Ptr<Shape> 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<NumArray> 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.
📝 WalkthroughWalkthroughAdds experimental ChangesProven type emission
Estimated code review effort: 4 (Complex) | ~60 minutes Possibly related issues
Possibly related PRs
Sequence Diagram(s)sequenceDiagram
participant CompileArgs
participant CompilePipeline
participant OptReport
participant EmitTypes
participant OutputFile
CompileArgs->>CompilePipeline: provide emit_types path
CompilePipeline->>OptReport: enable recording and drain entries
OptReport-->>CompilePipeline: return optimization entries
CompilePipeline->>EmitTypes: render TypeScript or JSON
EmitTypes->>OutputFile: write emitted types
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
There was a problem hiding this comment.
Actionable comments posted: 4
🧹 Nitpick comments (1)
crates/perry-codegen/src/emit_types/tests.rs (1)
206-219: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick winAdd a direct test for
declared_shape_fields.
shape_entryconstructsShapeFieldvalues by hand, so every structural test starts after the mapping step.declared_shape_fieldsis the function that turns aperry_hir::Classchain into that field list, and it carries the two rules the module relies on: a computed key or a private field yieldsts_type: None, and the whole chain contributes. Neither rule is exercised. A regression there produces a wrong interface while this file stays green.Add one test that builds a small class chain and calls
declared_shape_fieldsdirectly. Cover a private field, a computed key, and a base-class field.🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the rest with a brief reason, keep changes minimal, and validate. In `@crates/perry-codegen/src/emit_types/tests.rs` around lines 206 - 219, Add a focused test for declared_shape_fields that constructs a small perry_hir::Class inheritance chain and invokes the function directly. Assert that private and computed-key fields produce ShapeField entries with ts_type: None, while a field from the base class is included in the returned list. Keep existing shape_entry-based structural tests unchanged.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
Inline comments:
In `@crates/perry-codegen/src/emit_types.rs`:
- Around line 179-195: The declared_shape_fields function must de-duplicate
field names while traversing the class chain, preserving the first occurrence so
the most-derived declaration wins. Track already-emitted names and skip later
inherited or shadowed fields before pushing ShapeField entries, while retaining
the existing type-generation behavior.
In `@crates/perry-codegen/src/opt_report/mod.rs`:
- Around line 496-517: Update the documentation for the shape_fields field in
the report struct to state that it may be populated for any Ptr<Shape>
selection, including source-level classes such as Point, not only
compiler-synthesized object-literal shapes. Keep the note that emission
currently uses the field only for synthetic classes, and leave
note_ptr_shape_local and the producer behavior unchanged.
In `@crates/perry/src/commands/compile/run_pipeline.rs`:
- Around line 4749-4776: Ensure --emit-types is handled before early returns for
the web, wasm, and widget target handlers, or reject it during preflight for
targets that cannot emit types. Update the target dispatch/preflight logic
around the relevant CompileResult-returning handlers while preserving the
existing native codegen writer behavior.
In `@scripts/emit_types_accuracy.py`:
- Around line 69-107: Update erase_local_annotations and the roundtrip scoring
flow so declarations are not treated as unique solely by binding name. Exclude
every repeated annotated name from scoring, preventing reused names or
any/unknown annotations from affecting unrelated bindings until scope-aware
identities are available. In the coverage calculation, count every DECL_ANY
match rather than de-duplicating names, and remove declaration-count
de-duplication so the reported syntactic count is accurate.
---
Nitpick comments:
In `@crates/perry-codegen/src/emit_types/tests.rs`:
- Around line 206-219: Add a focused test for declared_shape_fields that
constructs a small perry_hir::Class inheritance chain and invokes the function
directly. Assert that private and computed-key fields produce ShapeField entries
with ts_type: None, while a field from the base class is included in the
returned list. Keep existing shape_entry-based structural tests unchanged.
🪄 Autofix
Fix all unresolved CodeRabbit comments on this PR:
- Push a commit to this branch (recommended)
- Create a new PR with the fixes
ℹ️ Review info
⚙️ Run configuration
Configuration used: defaults
Review profile: CHILL
Plan: Pro Plus
Run ID: 5f7598d5-e915-4bc7-b6cf-4aafda4e72a6
📒 Files selected for processing (14)
changelog.d/7688-emit-types-prototype.mdcrates/perry-codegen/src/collectors/ptr_shape.rscrates/perry-codegen/src/emit_types.rscrates/perry-codegen/src/emit_types/tests.rscrates/perry-codegen/src/lib.rscrates/perry-codegen/src/opt_report/mod.rscrates/perry-codegen/src/opt_report/render.rscrates/perry/src/commands/compile/build_cache.rscrates/perry/src/commands/compile/run_pipeline.rscrates/perry/src/commands/compile/types.rscrates/perry/src/commands/dev.rscrates/perry/src/commands/run/mod.rsdocs/src/cli/flags.mdscripts/emit_types_accuracy.py
| fn declared_shape_fields(chain: &[&perry_hir::Class]) -> Vec<ShapeField> { | ||
| 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 | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Establish the ordering `chain_classes` produces (base-first or derived-first).
ast-grep run --pattern 'fn chain_classes($$$) { $$$ }' --lang rust crates/perry-codegen/src/collectors/
rg -n -C10 'fn chain_classes' crates/perry-codegen/src/Repository: PerryTS/perry
Length of output: 1919
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== emit_types relevant sections =="
sed -n '150,245p' crates/perry-codegen/src/emit_types.rs
echo
echo "== chain_classes implementation =="
sed -n '668,720p' crates/perry-codegen/src/collectors/ptr_shape.rs
echo
echo "== declared_shape_fields call sites =="
rg -n "declared_shape_fields|selected_shape|shape_fields|chain_classes" crates/perry-codegen/src -C 3
echo
echo "== shape field definitions and methods =="
rg -n "struct Shape|impl Shape|struct ShapeField|fn render|fn is_emittable" crates/perry-codegen/src/emit_types.rs -C 5
echo
echo "== code generation path for anonymous classes =="
rg -n "__anon_class|__AnonShape|anon_class|new \\(class extends|extends Base" crates/perry-codegen/src crates/perry/tests packages dist -C 3 || true
echo
echo "== tests around shape/interface generation =="
fd -e rs -e ts -e d.ts . crates/perry-codegen/src crates/perry/tests packages dist | sed -n '1,120p'Repository: PerryTS/perry
Length of output: 50369
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== anon class lowering =="
fd -i 'non_ident.rs' crates/perry-hir/src/lower | xargs sed -n '1,220p'
echo
echo "== synthetic class constructors and extends handling =="
rg -n "__anon_class_|__AnonShape_|anon_shape_class_for_element_type|anon_class|extends_name|fields:" crates/perry-hir/src/lower crates/perry-codegen/src -g '*.rs' -C 4 | sed -n '1,220p'
echo
echo "== relevant structs/tests =="
fd -i 'anon.*shape|shape.*loop|new.*anon|class.*anon' crates/perry/hir crates/perry-codegen -g '*.rs' | sed -n '1,80p'Repository: PerryTS/perry
Length of output: 30255
De-duplicate shadowed field names across the class chain.
chain_classes builds the chain with self first, so declared_shape_fields adds derived fields before inherited fields. If a derived class redeclares an inherited field, Shape::render emits both entries in one interface and produces a duplicate TypeScript member. Keep only one declaration per field name, with the first/most-derived entry winning.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/perry-codegen/src/emit_types.rs` around lines 179 - 195, The
declared_shape_fields function must de-duplicate field names while traversing
the class chain, preserving the first occurrence so the most-derived declaration
wins. Track already-emitted names and skip later inherited or shadowed fields
before pushing ShapeField entries, while retaining the existing type-generation
behavior.
| /// For a `Ptr<Shape>` 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<String>, | ||
| /// For a `Ptr<Shape>` 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<Shape>` | ||
| /// selection gains keys. | ||
| #[serde(skip_serializing_if = "Option::is_none")] | ||
| pub shape_fields: Option<Vec<crate::emit_types::ShapeField>>, |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
# Show the derive attributes on `Entry` and on `ShapeField`.
rg -n -B6 'pub struct Entry' crates/perry-codegen/src/opt_report/mod.rs
rg -n -B6 'pub struct ShapeField' crates/perry-codegen/src/emit_types.rs
# Any deserialization of the report entries anywhere?
rg -n -C3 'Deserialize' crates/perry-codegen/src/opt_report/Repository: PerryTS/perry
Length of output: 735
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "## opt_report outline"
ast-grep outline crates/perry-codegen/src/opt_report/mod.rs --view compact || true
echo "## relevant opt_report struct sections"
sed -n '400,535p' crates/perry-codegen/src/opt_report/mod.rs | cat -n
echo "## selected_shape definition and nearby usage"
sed -n '1,230p' crates/perry-codegen/src/emit_types.rs | cat -n
sed -n '230,290p' crates/perry-codegen/src/emit_types.rs | cat -n
echo "## ptr_shape collector relevant lines"
sed -n '220,260p' crates/perry-codegen/src/collectors/ptr_shape.rs | cat -n
echo "## deserialize/import checks"
rg -n 'Deserialize|serde_json::from|Entry|opt_report' crates/perry-codegen/src -S
echo "## rust parser serde derives structural check"
python3 - <<'PY'
from pathlib import Path
import re
files = [
Path("crates/perry-codegen/src/opt_report/mod.rs"),
Path("crates/perry-codegen/src/emit_types.rs"),
]
for path in files:
text = path.read_text()
print(f"\n## {path}")
for struct in ("struct Entry", "struct ShapeField"):
m = re.search(r"#\[derive\((.*?)\)\]\s*pub\s+" + struct + r"\s*(?:|<[^>]*>)\s*\{", text, re.S)
print(f"{struct}: derives={m.group(1)}.serialize={'serde::Serialize' in m.group(1)}.deserialize={'serde::Deserialize' in m.group(1)}" if m else "not found")
PYRepository: PerryTS/perry
Length of output: 50369
Fix shape_fields documentation to match the producer.
note_ptr_shape_local calls emit_types::selected_shape for every Ptr<Shape> selection, not only compiler-synthesized classes. Keep the producer’s behavior and update the doc so consumers know a source-level class such as Point can also carry a field list. Emission uses it only for synthetic classes, but the JSON schema field still documents too narrow a contract.
📝 Proposed doc correction
- /// For a `Ptr<Shape>` selection whose class is a compiler-synthesized
- /// object-literal shape: the declared field set of the class chain.
+ /// For a `Ptr<Shape>` selection: the declared field set of the class
+ /// chain. `--emit-types` uses it only when the class is a
+ /// compiler-synthesized object-literal shape.📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| /// For a `Ptr<Shape>` 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<String>, | |
| /// For a `Ptr<Shape>` 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<Shape>` | |
| /// selection gains keys. | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub shape_fields: Option<Vec<crate::emit_types::ShapeField>>, | |
| /// For a `Ptr<Shape>` 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<String>, | |
| /// For a `Ptr<Shape>` selection: the declared field set of the class | |
| /// chain. `--emit-types` uses it only when the class is a | |
| /// compiler-synthesized object-literal shape. | |
| /// | |
| /// 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<Shape>` | |
| /// selection gains keys. | |
| #[serde(skip_serializing_if = "Option::is_none")] | |
| pub shape_fields: Option<Vec<crate::emit_types::ShapeField>>, |
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/perry-codegen/src/opt_report/mod.rs` around lines 496 - 517, Update
the documentation for the shape_fields field in the report struct to state that
it may be populated for any Ptr<Shape> selection, including source-level classes
such as Point, not only compiler-synthesized object-literal shapes. Keep the
note that emission currently uses the field only for synthetic classes, and
leave note_ptr_shape_local and the producer behavior unchanged.
| // | ||
| // `--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()); | ||
| } |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟠 Major | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
ast-grep outline crates/perry/src/commands/compile --items all --type function
rg -nP --type rust -C 5 \
'fn\s+compile_for_(wasm|ios_widget|watchos_widget|android_widget|wearos_tile)|emit_types|emit-types' \
crates/perry/src/commands
cargo check -p perry --profile perry-devRepository: PerryTS/perry
Length of output: 50369
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== run_pipeline around return targets and sidecar write =="
sed -n '420,550p' crates/perry/src/commands/compile/run_pipeline.rs | cat -n | sed 's/^/line /'
echo "== targets.rs function blocks for relevant compile handlers =="
rg -n --type rust 'pub\s+fn\s+compile_for_(web|wasm|ios_widget|watchos_widget|android_widget|wearos_tile)|emit_types_path|opt_report_format|OptReportFormat|--emit-types' crates/perry/src/commands/compile/targets.rs crates/perry/src/commands/compile/run_pipeline.rs -C 8
echo "== precise call sites in compile handlers =="
python3 - <<'PY'
import re
from pathlib import Path
p=Path('crates/perry/src/commands/compile/targets.rs')
text=p.read_text()
for fn in ['compile_for_web', 'compile_for_wasm', 'compile_for_ios_widget', 'compile_for_watchos_widget', 'compile_for_android_widget', 'compile_for_wearos_tile']:
m=re.search(r'pub\s+fn\s+'+re.escape(fn)+r'\([^{]*(?:\{(?!\w+::\{|pub\s+fn\s+)\n(?s).*?(?=\n \})', text)
if not m:
m=re.search(r'pub\s+fn\s+'+re.escape(fn)+r'\([^{]*(?:\{(?!\w+::\{|pub\s+fn\s+)[\s\S]*?(?=\n\})', text)
print(f'\n-- {fn} --')
if not m:
print('not found')
continue
# simple brace balance
s=m.start(); e=text.find('}', s); depth=0; ok=False
# not relying on one char; scan from first "{" after signature
start=text.find('{',s); depth=1; e=0
for i,ch in enumerate(text[start:]):
if ch=='{': depth+=1
elif ch=='}':
depth-=1
if depth==0:
e=start+i
break
body=text[s:e+1]
lines=body.splitlines()
for idx,line in enumerate(lines,1):
if any(kw in line for kw in ['emit_types_path', 'opt_report_format', 'OptReportFormat', '--emit-types']):
print(f'{idx}: {line}')
print(body)
PY
echo "== perry_codegen opt/emit symbols =="
rg -n --type rust 'pub\s+struct|struct\s+.*Entries|pub\s+fn\s+take_entries|render_json|render_ts|emit_types' crates/perry/src -C 4Repository: PerryTS/perry
Length of output: 15907
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== Locate relevant symbols =="
rg -n --type rust 'pub\s+fn\s+(compile_for_ios_widget|compile_for_watchos_widget|compile_for_android_widget|compile_for_wearos_tile|compile_for_web|compile_for_wasm)\(' crates/perry/src/commands/compile/targets.rs
rg -n --type rust 'PubArgs|CompileArgs|struct\s+.*Args|emit_types|opt_report' crates/perry/src/commands/compile -g '*.rs' | head -200
echo "== Read compile_for wasm/web/widget bodies by file locations =="
wc -l crates/perry/src/commands/compile/targets.rs
rg -n --type rust 'compile_for_(web|wasm|ios_widget|watchos_widget|android_widget|wearos_tile)\(' crates/perry/src/commands/compile/targets.rs
python3 - <<'PY'
from pathlib import Path
p=Path('crates/perry/src/commands/compile/targets.rs')
lines=p.read_text().splitlines()
print("file lines:", len(lines))
def find_block(name):
for i,l in enumerate(lines,1):
if f"compile_for_{name}(" in l:
return i
for name in ["wasm","web","ios_widget","watchos_widget","android_widget","wearos_tile"]:
i=find_block(name)
print("\n==", name, "starts line", i, "==")
if not i: continue
# Print 200 lines if available and enough
for j in range(i, min(i+220, len(lines)+1)):
print(f"{j}: {lines[j-1]}")
# Read CLI arg definitions around emit
for defn in ["OptReportFormat", "emit_types", "opt_report"]:
print("\n== snippets around", defn, "==")
for j,l in enumerate(lines,1):
if defn in l:
lo=max(1,j-20); hi=min(len(lines),j+80)
for k in range(lo,hi+1):
print(f"{k}: {lines[k-1]}")
# Read run_pipeline call/signature context
r=Path('crates/perry/src/commands/compile/run_pipeline.rs')
rl=r.read_text().splitlines()
for j,l in enumerate(rl,1):
if "PubArgs" in l or "fn run_pipeline" in l or "compile_for_wasm" in l or "compile_for_ios_widget" in l:
print(f"{j}: {l}")
PYRepository: PerryTS/perry
Length of output: 181
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== file sizes and candidate files =="
wc -l crates/perry/src/commands/compile/targets.rs crates/perry/src/commands/compile/run_pipeline.rs crates/perry/src/commands/compile/cli*.rs crates/perry/src/commands/compile/*.rs 2>/dev/null | sort -n | tail -50
echo "== exact symbol definitions/usages =="
rg -n 'compile_for_(web|wasm|ios_widget|watchos_widget|android_widget|wearos_tile)|compile_for_wasm|compile_for_web|emit_types|opt_report|OptReportFormat|OptReportFormat|PubArgs|CompileArgs|struct\s+.*Args' crates/perry/src/commands/compile crates/perry/src -S --glob '*.rs' | head -300
echo "== targets.rs excerpts without regex compilation =="
awk '/compile_for_wasm|compile_for_web|compile_for_ios_widget|compile_for_watchos_widget|compile_for_android_widget|compile_for_wearos_tile|emit_types|opt_report|OptReportFormat/{flag=1} flag{print NR": "$0} flag && /^pub fn |^fn /{if(NR>start+220) flag=0} END{}' crates/perry/src/commands/compile/targets.rs | sed -n '1,260p'
echo "== cli args emit/opt report =="
rg -n 'emit_types|opt_report|OptReportFormat' crates/perry/src -S --glob '*.rs' -C 5 | sed -n '1,220p'Repository: PerryTS/perry
Length of output: 2617
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== target handler signatures and early exit context =="
rg -n --type rust 'pub fn compile_for_(wasm|ios_widget|watchos_widget|android_widget|wearos_tile|web)\(|pub fn compile_for_wasm|pub fn compile_for_web' crates/perry/src/commands/compile/targets.rs -C 3
echo "== target handler call sites =="
rg -n --type rust 'compile_for_(wasm|ios_widget|watchos_widget|android_widget|wearos_tile|web)\(' crates/perry/src -S
echo "== relevant targets.rs ranges =="
for pat in 'compile_for_wasm\(' 'compile_for_ios_widget\(' 'compile_for_watchos_widget\(' 'compile_for_android_widget\(' 'compile_for_wearos_tile\(' 'compile_for_web\(' 'OptReportFormat' 'emit_types'; do
echo "--- $pat ---"
rg -n --type rust "$pat" crates/perry/src/commands/compile/targets.rs -C 8 | sed -n '1,220p'
done
echo "== command arg definitions for emit/opt flags =="
rg -n --type rust 'emit_types|opt_report|OptReportFormat|pub struct .*Args|struct .*Args' crates/perry/src --glob '*.rs' -C 6 | sed -n '1,260p'Repository: PerryTS/perry
Length of output: 206
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== list compile command files and perry command module =="
git ls-files crates/perry/src/commands/compile | sed -n '1,120p'
rg -n --type rust 'pub\s+mod\s+compile|mod\s+compile|commands::compile|run_pipeline::run_pipeline|PubArgs' crates/perry/src crates -S --glob '*.rs' | sed -n '1,200p'
echo "== search all perry source for compile_for identifiers =="
rg -n 'compile_for_(web|wasm|ios_widget|watchos_widget|android_widget|wearos_tile|android|ios|watchos|android_widget|wearos_tile|ios_widget|watchos_widget|web|wasm)' crates/perry/src --glob '*.rs' -S | sed -n '1,260p'
echo "== search for type definitions containing emit_types/opt_report in perry src =="
rg -n 'emit_types|opt_report|OptReportFormat' crates/perry/src --glob '*.rs' -S | sed -n '1,260p'Repository: PerryTS/perry
Length of output: 17821
🏁 Script executed:
#!/bin/bash
set -euo pipefail
for start in 228 545 999 1265 1365 1520; do
echo "== targets.rs lines $((start-10)) to $((start+140)) =="
sed -n "$((start-10)),$((start+140))p" crates/perry/src/commands/compile/targets.rs | cat -n | sed -n '1,160p'
done
echo "== compile.rs run around target handlers =="
sed -n '100,220p' crates/Autobench/perry/src/commands/compile.rs 2>/dev/null || sed -n '100,220p' crates/perry/src/commands/compile.rs | cat -n | sed -n '1,130p'Repository: PerryTS/perry
Length of output: 42175
🏁 Script executed:
#!/bin/bash
set -euo pipefail
echo "== run_pipeline dispatch and all compile_for paths =="
sed -n '440,515p' crates/perry/src/commands/compile/run_pipeline.rs | cat -n | sed 's/^/line /'
echo "== targets.rs later return blocks =="
sed -n '1600,1730p' crates/perry/src/commands/compile/targets.rs | cat -n | sed 's/^/line /'
echo "== search for --emit-types support text in docs/comments =="
rg -n 'emit-types|emit_types|types.*sidecar|sidecar.*types|emit.*types' crates/perry/src README.md docs . --glob '*.md' --glob '*.rs' -S | sed -n '1,220p'
echo "== deterministic control-flow verifier for early-return target bodies =="
python3 - <<'PY'
import re
from pathlib import Path
p=Path('crates/perry/src/commands/compile/targets.rs')
run=Path('crates/perry/src/commands/compile/run_pipeline.rs')
lines=p.read_text().splitlines()
handlers=['compile_for_web','compile_for_wasm','compile_for_ios_widget','compile_for_watchos_widget','compile_for_android_widget','compile_for_wearos_tile']
for fn in handlers:
m=re.search(fr'{re.escape(fn)}\s*\([\s\S]*?\)\s*->\s*Result<CompileResult>', ''.join(lines))
if not m:
continue
start=lines.index(next(l for l in lines if fn+'(' in l))
# balance braces after signature line opening brace
body='\n'.join(lines[start:])
first=body.find('{')
depth=0; end=-1
for idx,ch in enumerate(body[first:], first+1):
if ch == '{':
depth += 1
elif ch == '}':
depth -= 1
if depth == 0:
end = idx
break
blob=body[first:end+1]
print(f"{fn}: emit_types_path={'emit_types_path' in blob}; compile_result_return={bool(re.search(r'\n\s*Ok\s*\(\s*CompileResult\s*\{', blob))}; has_return={len(re.findall(r'r\s*\(', blob))} direct return statements (including Err/println/other) = {len(re.findall(r'\breturn\b', blob))}")
runlines=run.read_text().splitlines()
for idx,l in enumerate(runlines,1):
if re.search(r'\breturn\s+compile_for_.*widget|compile_for_wasm|compile_for_web', l):
print(f"run_pipeline line {idx}: {l.strip()}")
PYRepository: PerryTS/perry
Length of output: 50370
Write or reject --emit-types before returning from early targets.
The common writer after the dispatch only handles the native codegen path. The web, wasm, and widget handlers return CompileResult before reaching it, so perry compile --target web --emit-types PATH can succeed without writing PATH. Add --emit-types support in those handlers, or reject the option during preflight when they cannot emit it.
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@crates/perry/src/commands/compile/run_pipeline.rs` around lines 4749 - 4776,
Ensure --emit-types is handled before early returns for the web, wasm, and
widget target handlers, or reject it during preflight for targets that cannot
emit types. Update the target dispatch/preflight logic around the relevant
CompileResult-returning handlers while preserving the existing native codegen
writer behavior.
Source: Coding guidelines
| 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 |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Count declarations by identity, not only by name.
truth, any_names, and recovered bindings use only name. Reused names in separate functions or nested scopes can increase exact multiple times while increasing truth_total once. A reused any name can also suppress scoring for an unrelated typed binding. Line 238 also de-duplicates declarations despite reporting a syntactic declaration count. These errors can inflate the reported accuracy and coverage.
Exclude every repeated annotated name from roundtrip scoring until the harness can key it by scope. Count all DECL_ANY matches for coverage.
Proposed fix
+from collections import Counter
+
def erase_local_annotations(source: str) -> tuple[str, dict[str, str], set[str]]:
+ annotation_counts = Counter(
+ m.group(1) for m in DECL_ANNOTATED.finditer(source)
+ )
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 annotation_counts[name] > 1:
+ ambiguous.add(name)
if name in truth and truth[name] != ty:
ambiguous.add(name)
@@
for name in ambiguous:
truth.pop(name, None)
+ improved_on_any.difference_update(ambiguous)
return erased, truth, improved_on_any
@@
- decls = len(set(DECL_ANY.findall(source)))
+ decls = len(DECL_ANY.findall(source))Also applies to: 233-258
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.
In `@scripts/emit_types_accuracy.py` around lines 69 - 107, Update
erase_local_annotations and the roundtrip scoring flow so declarations are not
treated as unique solely by binding name. Exclude every repeated annotated name
from scoring, preventing reused names or any/unknown annotations from affecting
unrelated bindings until scope-aware identities are available. In the coverage
calculation, count every DECL_ANY match rather than de-duplicating names, and
remove declaration-count de-duplication so the reported syntactic count is
accurate.
Prototype for #7685. Experimental, behind a flag, marked experimental in the docs, not wired into
perry check, gates nothing.What it is
--opt-report(#6952) surfaces the negative half of representation selection. The positive half — the types Perry had to prove to pick an unboxed representation — was computed and discarded.--emit-types <PATH>writes it out (.json→ records, anything else → TypeScript). It is a second consumer of the existingopt_report::Entrystream; no new analysis.The result: a representation is not a type
Perry has five representation analyses. After auditing each one against "never emit a wrong type", exactly one licenses a TypeScript type.
Ptr<Shape>I32/U32undefined(non-dominating writes to avarseed;int_valued_tamembers merge straight intointeger_locals) orbigint(the bitwise arm ignores operand types;not_bigint_localsis computed but is not a term in the admission conjunction)Strrefined_tyis the declared type verbatim when notAny; nothing checks the initializer) and designed to tolerate the annotation being false — "a type-annotation lie degrades to today's behavior" (expr/slot_rep.rs)IntValuedTaundefined…, NOT an integer", and the rep is sound only because rule (2) forbids every context where that is observable. An annotation is such an observationPtr<NumArray>HolesOkis the primary provenance and its slots read backundefined.test-files/test_gap_repsel_p4a3_ptr_numarray.tsalready pinsconsole.log(c[0], c[1], c[3])→undefined 2 undefinedon a promoted local. True type is(number | undefined)[]The pattern is consistent: 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 because the value is never observed in a distinguishing context.
Two things I got wrong and corrected by measuring
"Spec-ABI just echoes the annotation" — false.
codegen/spec_abi.rs::select_dominant_tuplecounts argument-type tuples at the call sites and keeps the most frequent, demoting the rest behind a guarded entry. It fires on wholly unannotated JavaScript (A/B'd: identical body, annotations removed, stillrep=i32,i32). The real reason to drop it is stronger: a function called 4× with numbers and 1× with a string still reportsi32,i32, soa: numberwould be wrong for a caller that exists.The synthetic-class filter was a prefix check and leaked.
__anon_class_<id>(new (class{})()),Box$num(generic monomorphization) andName$2(scope-collision rename) are all real class names reachable at aPtr<Shape>provenance site; each would have emitted TypeScript naming a type that does not exist. The filter now also rejects any name containing$(already this repo's reserved generated-suffix namespace). The original list also contained__EmptySite_, which matches nothing in the tree.Measured coverage
scripts/emit_types_accuracy.py, two modes. Round-trip erases local annotations first — that is load-bearing, not hygiene:refined_tyis the declared type when it is notAny, so measured un-erased, every "recovered" type is the annotation handed back and the score is 100% and means nothing.benchmarks/{repsel_census,suite,app-patterns}(erased.ts)test-files/*.ts(erased) — wide mapping, before the auditThe 17 bindings the wide mapping found on dependency JS were all from the four withdrawn arms. Benchmarks still recover 6, which is the positive control that emission is alive rather than broken.
The differentiating feature — a structural interface recovered from untyped JS — fires zero times on real dependency JS. That is consistent with the repsel census, which records
Ptr<Shape>promoting ~7 values across an 18-workload corpus and notes "the honest floor forPtr<Shape>on real code is zero today".Is it worth pursuing?
On this evidence, no — not as a type emitter, and the PR says so in its own docs rather than burying it. The blocker is not engineering effort; it is that Perry's representations are storage decisions justified by unobservability, and an annotation is an observation. The one sound arm depends on
Ptr<Shape>, whose promotion rate on real code is ~zero for reasons tracked separately (#7152/#7170).Two things would change the answer, both larger than this prototype: recording provenance on canonical-i32 entries (the
loop_bounded_i32andunsigned_i32admitting sets look genuinely type-sound, but theEntrystream carries onlyrep: "I32", so they cannot be separated out downstream), and whatever movesPtr<Shape>off zero.#7234 does not block this
Not merely because
--profile perry-devinheritsreleaseand turnsdebug_asserts off. The panicking assertion is inopt_report/render.rs::rule_buckets, which folds tiers overDeniedentries for the opt-report JSON renderer;--emit-typesrenders throughemit_types.rsand never calls it. Empirically 150/150 dependency-JS files compiled clean.Also worth flagging
recover's disagreement guard cannot fire on a real compile, and the code now says so.opt_report::take_entriesde-duplicates before any consumer sees the stream andEntry::dedup_keyomitslocal_id,rep,shape_class— so twoSelectedrows for one name with different classes collapse upstream. Closing that means wideningdedup_key, which is--opt-report's contract and not this prototype's to change.Producer-side change
Entrygainsshape_classandshape_fields, report-only and allocation-free when the report is off, bothskip_serializing_if = "Option::is_none"so an entry that has neither serializes byte-identically to the pre-#7685 schema. They carry the class name and field set as data:detailalready 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.Verification
cargo test -p perry-codegen --lib --no-fail-fast— 797 pass (20 inemit_types)cargo check --all-targets— cleanlint-job commands +cargo fmt --all -- --check— pass (ptr_shape.rsis at 1998/2000 lines; the shape-field helper lives inemit_types.rspartly for that reason)undefinedlocal, mutated shape) emits nothing, with a positive control in the same file so the probe can failTests are omission-first: every omission is paired with a positive control proving the same input would have emitted if the rule were absent.
Summary by CodeRabbit
--emit-types <PATH>support for generating TypeScript definitions or JSON output.