From e03b9bb45fc1e26091bb269d52d4d85df7cc38c2 Mon Sep 17 00:00:00 2001 From: codecaaron Date: Thu, 6 Aug 2026 14:26:15 -0400 Subject: [PATCH 01/16] Dechunk --- .gitignore | 4 + packages/_parity/__tests__/scoreboard.test.ts | 215 ++++ packages/_parity/src/scoreboard.ts | 141 ++- packages/extract/crates/extract-v2/Cargo.toml | 48 + .../extract/crates/extract-v2/src/assemble.rs | 628 +----------- .../crates/extract-v2/src/assemble/config.rs | 248 +++++ .../extract-v2/src/assemble/source_edit.rs | 381 ++++++++ .../crates/extract-v2/src/chain_walk.rs | 294 +----- .../crates/extract-v2/src/chain_walk/expr.rs | 48 + .../extract-v2/src/chain_walk/terminal.rs | 91 ++ .../crates/extract-v2/src/chain_walk/walk.rs | 176 ++++ .../extract/crates/extract-v2/src/engine.rs | 2 +- .../extract/crates/extract-v2/src/eval.rs | 2 +- .../extract/crates/extract-v2/src/facts.rs | 2 +- .../extract/crates/extract-v2/src/jsx_scan.rs | 922 +----------------- .../crates/extract-v2/src/jsx_scan/compose.rs | 246 +++++ .../extract-v2/src/jsx_scan/system_props.rs | 134 +++ .../crates/extract-v2/src/jsx_scan/usage.rs | 361 +++++++ .../extract-v2/src/jsx_scan/value_eval.rs | 186 ++++ .../crates/extract-v2/src/transforms.rs | 294 +----- .../src/transforms/self_contained.rs | 303 ++++++ .../extract/crates/system-loader/Cargo.toml | 38 + .../extract/crates/system-loader/src/lib.rs | 170 +++- scripts/verify/clippy.sh | 9 + scripts/verify/rust-policy.test.ts | 135 +++ scripts/verify/rust-policy.ts | 126 ++- 26 files changed, 3050 insertions(+), 2154 deletions(-) create mode 100644 packages/_parity/__tests__/scoreboard.test.ts create mode 100644 packages/extract/crates/extract-v2/src/assemble/config.rs create mode 100644 packages/extract/crates/extract-v2/src/assemble/source_edit.rs create mode 100644 packages/extract/crates/extract-v2/src/chain_walk/expr.rs create mode 100644 packages/extract/crates/extract-v2/src/chain_walk/terminal.rs create mode 100644 packages/extract/crates/extract-v2/src/chain_walk/walk.rs create mode 100644 packages/extract/crates/extract-v2/src/jsx_scan/compose.rs create mode 100644 packages/extract/crates/extract-v2/src/jsx_scan/system_props.rs create mode 100644 packages/extract/crates/extract-v2/src/jsx_scan/usage.rs create mode 100644 packages/extract/crates/extract-v2/src/jsx_scan/value_eval.rs create mode 100644 packages/extract/crates/extract-v2/src/transforms/self_contained.rs diff --git a/.gitignore b/.gitignore index f788a87e..f39ab557 100644 --- a/.gitignore +++ b/.gitignore @@ -52,6 +52,10 @@ tsconfig.tsbuildinfo .claude .roadmap .playwright-mcp +# Per-machine MCP server wiring: the entries carry absolute paths to this +# checkout and assume locally-installed binaries, so committing one breaks +# every other clone and CI. +.mcp.json tmp diff --git a/packages/_parity/__tests__/scoreboard.test.ts b/packages/_parity/__tests__/scoreboard.test.ts new file mode 100644 index 00000000..6a2f570c --- /dev/null +++ b/packages/_parity/__tests__/scoreboard.test.ts @@ -0,0 +1,215 @@ +/** + * Characterization tests for renderScoreboard. + * + * The scoreboard is a committed, diffable text artifact (scoreboard.snap, + * self-check.snap) that `verify:parity` compares byte-for-byte. Until now the + * renderer had no unit coverage at all — only `familyViolations` was tested — + * so every structural change to it was licensed solely by a medium-cost parity + * run that needs a freshly built NAPI. + * + * These tests pin the exact rendered bytes across every branch of the renderer + * (empty corpus, registered vs unregistered divergences, CSS classification, + * both family verdicts, and appended family-verdict errors) so that a + * structural refactor is provably output-preserving in milliseconds. + */ +import { describe, expect, test } from 'vitest'; + +import { renderScoreboard } from '../src/scoreboard'; + +import type { Divergence, FamilyDecl } from '../src/types'; + +function divergence(overrides: Partial = {}): Divergence { + return { + unit: 'unit-a', + artifact: 'css', + detail: 'css differs', + baselineSha256: 'aaa111', + candidateSha256: 'bbb222', + ...overrides, + }; +} + +const BASE = { + mode: 'baseline', + engines: ['baseline:v2', 'v2'] as [string, string], + devMode: false, + unitIds: [] as string[], + divergences: [] as Divergence[], + families: [] as FamilyDecl[], + familyVerdictErrors: [] as string[], +}; + +describe('renderScoreboard', () => { + test('empty corpus reports 100% rather than dividing by zero', () => { + expect(renderScoreboard(BASE)).toBe( + [ + 'parity baseline — engines: baseline:v2 vs v2 — devMode: false', + '', + 'Units passed: 0/0 (100.00%)', + 'Divergences: 0 (0 unregistered)', + '', + 'Usage-case families:', + '', + ].join('\n') + ); + }); + + test('all-passing run matches the committed snapshot header shape', () => { + const out = renderScoreboard({ + ...BASE, + unitIds: ['unit-a', 'unit-b'], + families: [ + { family: 'fam-1', units: ['unit-a'], expectedVerdict: 'identical' }, + ], + }); + + expect(out).toBe( + [ + 'parity baseline — engines: baseline:v2 vs v2 — devMode: false', + '', + 'Units passed: 2/2 (100.00%)', + 'Divergences: 0 (0 unregistered)', + '', + 'Usage-case families:', + ' ok fam-1 — expected identical, observed identical', + '', + ].join('\n') + ); + }); + + test('unregistered divergence renders hashes, marker, and violated family', () => { + const out = renderScoreboard({ + ...BASE, + unitIds: ['unit-a', 'unit-b'], + divergences: [divergence()], + families: [ + { + family: 'fam-1', + units: ['unit-a'], + expectedVerdict: 'identical', + }, + ], + }); + + expect(out).toBe( + [ + 'parity baseline — engines: baseline:v2 vs v2 — devMode: false', + '', + 'Units passed: 1/2 (50.00%)', + 'Divergences: 1 (1 unregistered)', + '', + 'Failing units (sorted):', + ' unit-a · css (UNREGISTERED) [aaa111 -> bbb222] — css differs', + '', + 'Usage-case families:', + ' VIOLATED fam-1 — expected identical, observed divergence', + '', + ].join('\n') + ); + }); + + test('registered divergence renders its category and satisfies the family', () => { + const out = renderScoreboard({ + ...BASE, + unitIds: ['unit-a'], + divergences: [ + divergence({ + classification: 'rule-order', + registered: { + unit: 'unit-a', + artifact: 'css', + category: 'ordering', + note: 'known', + status: 'active', + baselineSha256: 'aaa111', + candidateSha256: 'bbb222', + }, + }), + ], + families: [ + { + family: 'fam-1', + units: ['unit-a'], + expectedVerdict: 'registered-divergence', + }, + ], + }); + + expect(out).toBe( + [ + 'parity baseline — engines: baseline:v2 vs v2 — devMode: false', + '', + 'Units passed: 0/1 (0.00%)', + 'Divergences: 1 (0 unregistered)', + '', + 'Failing units (sorted):', + ' unit-a · css [rule-order] (registered: ordering) [aaa111 -> bbb222] — css differs', + '', + 'Usage-case families:', + ' ok fam-1 — expected registered-divergence, observed divergence', + '', + ].join('\n') + ); + }); + + test('failing units are sorted and grouped by unit', () => { + const out = renderScoreboard({ + ...BASE, + unitIds: ['unit-a', 'unit-b', 'unit-c'], + divergences: [ + divergence({ + unit: 'unit-c', + artifact: 'code', + detail: 'code differs', + }), + divergence({ unit: 'unit-a', artifact: 'css', detail: 'first' }), + divergence({ + unit: 'unit-a', + artifact: 'diagnostics', + detail: 'second', + }), + ], + }); + + const failing = out + .split('\n') + .filter((l) => l.startsWith(' unit-')) + .map((l) => l.split(' · ')[1]); + + expect(failing).toEqual([ + 'css (UNREGISTERED) [aaa111 -> bbb222] — first', + 'diagnostics (UNREGISTERED) [aaa111 -> bbb222] — second', + 'code (UNREGISTERED) [aaa111 -> bbb222] — code differs', + ]); + }); + + test('family verdict errors are appended after the family list', () => { + const out = renderScoreboard({ + ...BASE, + unitIds: ['unit-a'], + families: [ + { family: 'fam-1', units: ['unit-a'], expectedVerdict: 'identical' }, + ], + familyVerdictErrors: ['fam-2: expected registered divergence, saw none'], + }); + + expect(out.split('\n').slice(-3)).toEqual([ + ' ok fam-1 — expected identical, observed identical', + ' VIOLATED fam-2: expected registered divergence, saw none', + '', + ]); + }); + + test('self-check mode and devMode are reflected in the header', () => { + const out = renderScoreboard({ + ...BASE, + mode: 'self-check', + engines: ['v2', 'v2'], + devMode: true, + }); + + expect(out.split('\n')[0]).toBe( + 'parity self-check — engines: v2 vs v2 — devMode: true' + ); + }); +}); diff --git a/packages/_parity/src/scoreboard.ts b/packages/_parity/src/scoreboard.ts index f2843ea5..ec17f7b8 100644 --- a/packages/_parity/src/scoreboard.ts +++ b/packages/_parity/src/scoreboard.ts @@ -4,7 +4,7 @@ */ import type { Divergence, FamilyDecl } from './types'; -export function renderScoreboard(opts: { +interface ScoreboardInput { mode: string; engines: [string, string]; devMode: boolean; @@ -12,63 +12,98 @@ export function renderScoreboard(opts: { divergences: Divergence[]; families: FamilyDecl[]; familyVerdictErrors: string[]; -}): string { - const { - mode, - engines, - devMode, - unitIds, - divergences, - families, - familyVerdictErrors, - } = opts; - const divergentUnits = [...new Set(divergences.map((d) => d.unit))].sort(); +} + +/** Header plus the pass/divergence totals, always emitted. */ +function summarySection( + input: ScoreboardInput, + divergentUnits: string[] +): string[] { + const { mode, engines, devMode, unitIds, divergences } = input; const passed = unitIds.length - divergentUnits.length; const pct = unitIds.length === 0 ? 100 : (passed / unitIds.length) * 100; const unregistered = divergences.filter((d) => !d.registered); - const lines: string[] = []; - lines.push( - `parity ${mode} — engines: ${engines[0]} vs ${engines[1]} — devMode: ${devMode}` - ); - lines.push(''); - lines.push(`Units passed: ${passed}/${unitIds.length} (${pct.toFixed(2)}%)`); - lines.push( - `Divergences: ${divergences.length} (${unregistered.length} unregistered)` + return [ + `parity ${mode} — engines: ${engines[0]} vs ${engines[1]} — devMode: ${devMode}`, + '', + `Units passed: ${passed}/${unitIds.length} (${pct.toFixed(2)}%)`, + `Divergences: ${divergences.length} (${unregistered.length} unregistered)`, + '', + ]; +} + +/** One failing-unit row: ` · [cls](reg)[hashes] — detail`. */ +function divergenceRow(unit: string, d: Divergence): string { + const cls = d.classification ? ` [${d.classification}]` : ''; + const reg = d.registered + ? ` (registered: ${d.registered.category})` + : ' (UNREGISTERED)'; + const hashes = ` [${d.baselineSha256} -> ${d.candidateSha256}]`; + return ` ${unit} · ${d.artifact}${cls}${reg}${hashes} — ${d.detail}`; +} + +/** Sorted failing-unit block, or nothing at all when the run is clean. */ +function failingSection( + divergences: Divergence[], + divergentUnits: string[] +): string[] { + if (divergentUnits.length === 0) return []; + return [ + 'Failing units (sorted):', + ...divergentUnits.flatMap((u) => + divergences.filter((x) => x.unit === u).map((d) => divergenceRow(u, d)) + ), + '', + ]; +} + +/** A family holds when its observed verdict matches the one it declared. */ +function familyHolds( + f: FamilyDecl, + divergences: Divergence[], + familyDiverged: boolean +): boolean { + if (f.expectedVerdict === 'identical') return !familyDiverged; + return ( + familyDiverged && + divergences + .filter((d) => f.units.includes(d.unit)) + .every((d) => d.registered) ); - lines.push(''); - if (divergentUnits.length) { - lines.push('Failing units (sorted):'); - for (const u of divergentUnits) { - for (const d of divergences.filter((x) => x.unit === u)) { - const cls = d.classification ? ` [${d.classification}]` : ''; - const reg = d.registered - ? ` (registered: ${d.registered.category})` - : ' (UNREGISTERED)'; - const hashes = ` [${d.baselineSha256} -> ${d.candidateSha256}]`; - lines.push(` ${u} · ${d.artifact}${cls}${reg}${hashes} — ${d.detail}`); - } - } - lines.push(''); - } - lines.push('Usage-case families:'); - for (const f of families) { - const familyDiverged = f.units.some((u) => divergentUnits.includes(u)); - const actual = familyDiverged ? 'divergence' : 'identical'; - const ok = - (f.expectedVerdict === 'identical' && !familyDiverged) || - (f.expectedVerdict === 'registered-divergence' && - familyDiverged && - divergences - .filter((d) => f.units.includes(d.unit)) - .every((d) => d.registered)); - lines.push( - ` ${ok ? 'ok' : 'VIOLATED'} ${f.family} — expected ${f.expectedVerdict}, observed ${actual}` - ); - } - for (const e of familyVerdictErrors) lines.push(` VIOLATED ${e}`); - lines.push(''); - return lines.join('\n'); +} + +/** Usage-case family verdicts, then any externally supplied violations. */ +function familySection( + input: ScoreboardInput, + divergentUnits: string[] +): string[] { + const { families, divergences, familyVerdictErrors } = input; + return [ + 'Usage-case families:', + ...families.map((f) => { + const familyDiverged = f.units.some((u) => divergentUnits.includes(u)); + const actual = familyDiverged ? 'divergence' : 'identical'; + const verdict = familyHolds(f, divergences, familyDiverged) + ? 'ok' + : 'VIOLATED'; + return ` ${verdict} ${f.family} — expected ${f.expectedVerdict}, observed ${actual}`; + }), + ...familyVerdictErrors.map((e) => ` VIOLATED ${e}`), + '', + ]; +} + +export function renderScoreboard(input: ScoreboardInput): string { + const divergentUnits = [ + ...new Set(input.divergences.map((d) => d.unit)), + ].sort(); + + return [ + ...summarySection(input, divergentUnits), + ...failingSection(input.divergences, divergentUnits), + ...familySection(input, divergentUnits), + ].join('\n'); } /** Family verdict violations (spec: each family produces its declared verdict). */ diff --git a/packages/extract/crates/extract-v2/Cargo.toml b/packages/extract/crates/extract-v2/Cargo.toml index d0982e6e..379b2286 100644 --- a/packages/extract/crates/extract-v2/Cargo.toml +++ b/packages/extract/crates/extract-v2/Cargo.toml @@ -36,3 +36,51 @@ napi-build = "2" [profile.release] lto = true strip = "symbols" + +# Lint posture lives HERE, not only in scripts/verify/clippy.sh, so that a bare +# `cargo clippy`, rust-analyzer in an editor, and CI all reach the same verdict. +# With the CLI passing `-D warnings`, every "warn" below is an error in CI. +# +# Curated from the intersection of oxc's and biome's workspace lints — the two +# closest analogues (Rust tooling over JS/TS/CSS ASTs) — plus this crate's own +# hazards. The pedantic/nursery/cargo groups are deliberately NOT enabled: both +# upstreams carry long per-lint `allow` lists to make them tractable, and that +# bookkeeping is not worth it at this crate's size. +[lints.rust] +unit_bindings = "warn" +unsafe_op_in_unsafe_fn = "warn" +unused_unsafe = "warn" +unused_lifetimes = "warn" +# The chain_walk split moved `get_arg_span!` next to its callers; this catches +# a macro that outlives its last use. +unused_macro_rules = "warn" +unused_import_braces = "warn" +redundant_lifetimes = "warn" +explicit_outlives_requirements = "warn" +trivial_numeric_casts = "warn" + +[lints.clippy] +# This crate is a NAPI cdylib: stdout is the host's channel, so a stray print +# corrupts the caller rather than merely dirtying a log. stderr stays available +# as the diagnostic channel and is intentionally not linted. +print_stdout = "warn" +dbg_macro = "warn" +# Fail loud, never silently wrong (G5) — a stub must not reach a release build. +todo = "warn" +unimplemented = "warn" +exit = "warn" +# Bans mod.rs: module directories must be `foo.rs` + `foo/bar.rs`, which is the +# layout the assemble/ transforms/ chain_walk splits already use. +mod_module_files = "warn" +undocumented_unsafe_blocks = "warn" +unnecessary_safety_comment = "warn" +empty_drop = "warn" +empty_enum_variants_with_brackets = "warn" +get_unwrap = "warn" +rc_buffer = "warn" +rc_mutex = "warn" +rest_pat_in_fully_bound_structs = "warn" +infinite_loop = "warn" +clone_on_ref_ptr = "warn" +format_push_string = "warn" +unnecessary_self_imports = "warn" diff --git a/packages/extract/crates/extract-v2/src/assemble.rs b/packages/extract/crates/extract-v2/src/assemble.rs index 56d1a893..9407ad8c 100644 --- a/packages/extract/crates/extract-v2/src/assemble.rs +++ b/packages/extract/crates/extract-v2/src/assemble.rs @@ -11,19 +11,35 @@ //! shapes), css_generator::{content_hash, make_class_name} (FNV-1a class //! identity over "{filename}::{binding}" — stable across style edits, //! the HMR-critical property), lib.rs process_chain (stable_id). +//! +//! Module layout — the public surface is re-exported here unchanged, so +//! `crate::assemble::X` resolves exactly as it did before the split: +//! +//! `config` — runtime-config JSON construction (v1 build_runtime_config) +//! `source_edit` — consumed-import stripping + directive-prologue placement use std::collections::{BTreeMap, HashMap}; use rustc_hash::FxHashMap; -use serde_json::{json, Map, Value}; +use serde_json::Value; use crate::chain_walk::TerminalKind; use crate::dynamic_meta::DynamicPropMeta; -use crate::facts::{ChainFacts, DirectivePrologueFact, FileFacts}; +use crate::facts::{ChainFacts, FileFacts}; pub use crate::ids::{class_name_for, content_hash, make_class_name}; +mod config; +mod source_edit; + +use config::build_config; + +pub use source_edit::{ + consumed_import_removals, directive_and_imports, directive_prefix_and_body, + strip_consumed_imports, strip_consumed_imports_with_removals, +}; + #[derive(Debug)] pub enum AssembleError { /// Component requires config-dependent payloads (row 07 inputs). @@ -64,236 +80,6 @@ pub struct MergedChainConfig { pub state_names: Vec, } -/// Build the runtime-config JSON string for the facts-derivable subset — -/// key order matches v1's inc-01-patched serialization exactly (sorted -/// compound conditions; insertion order variants→compounds→states). -fn build_config( - filename: &str, - binding: &str, - chain: &ChainFacts, - prefix: &str, - payload: Option<&ReplacementPayload>, - group_registry: &FxHashMap>, -) -> Result { - let mut config = Map::new(); - - // Variants (v1: {prop: {options[, default]}} keyed per variant stage) - let mut variants = Map::new(); - let mut compounds: Vec = Vec::new(); - let mut states: Vec = Vec::new(); - let mut compound_index = 0usize; - let class_name = class_name_for(filename, binding, prefix); - let use_merged = payload.and_then(|p| p.merged_config.as_ref()); - - for stage in &chain.stages { - if use_merged.is_some() && matches!(stage.method.as_str(), "variant" | "compound" | "states") - { - // Extension child: the merged trio below is authoritative. - continue; - } - match stage.method.as_str() { - "variant" => { - if let Some(v) = &stage.value { - let prop = v["prop"].as_str().unwrap_or("variant").to_string(); - let mut entry = Map::new(); - let options: Vec = v["variants"] - .as_object() - .map(|m| m.keys().cloned().collect()) - .unwrap_or_default(); - entry.insert("options".into(), json!(options)); - if let Some(d) = v["defaultVariant"].as_str() { - entry.insert("default".into(), json!(d)); - } - variants.insert(prop, Value::Object(entry)); - } - } - "compound" => { - // v1 lib.rs 536-554: a CompoundConfig exists ONLY when the - // second (styles) argument does — one-arg .compound(cond) - // contributes neither config nor CSS, and the positional - // class index counts styled compounds only. - if stage.second_value.is_some() { - if let Some(cond) = &stage.value { - // Sorted conditions (v1 inc-01 determinism patch). - let sorted: BTreeMap = cond - .as_object() - .map(|m| { - m.iter() - .filter(|(_, v)| v.is_string() || v.is_array()) - .map(|(k, v)| (k.clone(), v.clone())) - .collect() - }) - .unwrap_or_default(); - compounds.push(json!({ - "conditions": sorted, - "className": format!("{class_name}--compound-{compound_index}"), - })); - compound_index += 1; - } - } - } - "states" => { - if let Some(v) = &stage.value { - if let Some(m) = v.as_object() { - states.extend(m.keys().cloned()); - } - } - } - "system" | "props" - // Payload-fed when analyze ran with config inputs; a bare - // call without payloads still fails loud (never a wrong - // template). - if payload.is_none() => { - return Err(AssembleError::NeedsConfig(format!( - "{binding}: '{}' stage payloads require prop config (row 07)", - stage.method - ))); - } - _ => {} - } - } - - if let Some(merged) = use_merged { - // v1 build_runtime_config 195-227 over the POST-MERGE config. - for (prop, options, default) in &merged.variant_config { - let mut entry = Map::new(); - entry.insert("options".into(), json!(options)); - if let Some(d) = default { - entry.insert("default".into(), json!(d)); - } - variants.insert(prop.clone(), Value::Object(entry)); - } - for (conditions, cname) in &merged.compound_configs { - compounds.push(json!({ - "conditions": conditions, - "className": cname, - })); - } - states = merged.state_names.clone(); - } - if !variants.is_empty() { - config.insert("variants".into(), Value::Object(variants)); - } - if !compounds.is_empty() { - config.insert("compounds".into(), json!(compounds)); - } - if !states.is_empty() { - config.insert("states".into(), json!(states)); - } - - let base_json = - serde_json::to_string(&Value::Object(config)).unwrap_or_else(|_| "{}".into()); - let Some(p) = payload else { - return Ok(base_json); - }; - - // v1 build_runtime_config tail (transform_emitter 232-338), verbatim - // string-splice semantics. - let mut result = if !p.system_group_names.is_empty() { - let mut concat_parts: Vec = p - .system_group_names - .iter() - .map(|g| format!("systemPropGroups.{}", g)) - .collect(); - { - let mut extra_names: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); - if !p.system_prop_names.is_empty() { - let group_covered: rustc_hash::FxHashSet = p - .system_group_names - .iter() - .filter_map(|g| group_registry.get(g)) - .flat_map(|props| props.iter().cloned()) - .collect(); - for prop in &p.system_prop_names { - if !group_covered.contains(prop) { - extra_names.insert(prop.clone()); - } - } - } - if let Some(ref cpm) = p.custom_prop_class_map { - extra_names.extend(cpm.keys().cloned()); - } - if let Some(ref cdc) = p.custom_dynamic_config { - extra_names.extend(cdc.keys().cloned()); - } - if !extra_names.is_empty() { - let mut sorted: Vec = extra_names.into_iter().collect(); - sorted.sort(); - concat_parts - .push(serde_json::to_string(&sorted).unwrap_or_else(|_| "[]".to_string())); - } - } - let concat_expr = concat_parts.join(","); - let spn_field = format!("\"systemPropNames\":[].concat({})", concat_expr); - if base_json == "{}" { - format!("{{{}}}", spn_field) - } else { - format!("{},{}}}", &base_json[..base_json.len() - 1], spn_field) - } - } else if !p.system_prop_names.is_empty() { - let mut config_map: Map = - serde_json::from_str(&base_json).unwrap_or_default(); - config_map.insert("systemPropNames".to_string(), json!(p.system_prop_names)); - serde_json::to_string(&Value::Object(config_map)).unwrap_or(base_json) - } else { - base_json - }; - - if let Some(ref cpm) = p.custom_prop_class_map { - let sorted_cpm: BTreeMap<&String, BTreeMap<&String, &String>> = - cpm.iter().map(|(k, v)| (k, v.iter().collect())).collect(); - let cpm_json = serde_json::to_string(&sorted_cpm).unwrap_or_else(|_| "{}".to_string()); - if result == "{}" { - result = format!("{{\"customPropMap\":{}}}", cpm_json); - } else { - result = format!("{},\"customPropMap\":{}}}", &result[..result.len() - 1], cpm_json); - } - } - - if let Some(ref cdc) = p.custom_dynamic_config { - let mut entries: Vec = Vec::new(); - let mut sorted_keys: Vec<&String> = cdc.keys().collect(); - sorted_keys.sort(); - for prop_name in sorted_keys { - let meta = &cdc[prop_name]; - let mut fields: Vec = Vec::new(); - fields.push(format!("\"varName\":\"{}\"", meta.var_name)); - fields.push(format!("\"slotClass\":\"{}\"", meta.slot_class)); - fields.push(format!("\"property\":\"{}\"", meta.property)); - if !meta.properties.is_empty() { - let props_json = - serde_json::to_string(&meta.properties).unwrap_or_else(|_| "[]".to_string()); - fields.push(format!("\"properties\":{}", props_json)); - } - if let Some(ref fn_src) = meta.transform_fn_source { - fields.push(format!("\"transform\":{}", fn_src)); - } else if let Some(ref tn) = meta.transform_name { - fields.push(format!("\"transformName\":\"{}\"", tn)); - fields.push(format!("\"transform\":transforms.{}", tn)); - } - if !meta.scale_values.is_empty() { - let sorted_sv: BTreeMap<&String, &String> = meta.scale_values.iter().collect(); - let sv_json = - serde_json::to_string(&sorted_sv).unwrap_or_else(|_| "{}".to_string()); - fields.push(format!("\"scaleValues\":{}", sv_json)); - } - entries.push(format!("\"{}\":{{{}}}", prop_name, fields.join(","))); - } - let cdc_str = format!("{{{}}}", entries.join(",")); - if result == "{}" { - result = format!("{{\"customDynamicConfig\":{}}}", cdc_str); - } else { - result = format!( - "{},\"customDynamicConfig\":{}}}", - &result[..result.len() - 1], - cdc_str - ); - } - } - - Ok(result) -} - /// v1 generate_replacement template shapes (no-system-props forms; the /// system/dynamic forms require config and are row-07-gated upstream). pub fn generate_replacement( @@ -369,255 +155,26 @@ pub fn assemble_replacements( Ok(out) } - -/// v1 strip_consumed_imports VERBATIM (transform_emitter 497-535): the -/// split/rebuild loop IS the trailing-newline quirk's origin — porting the -/// loop, not a replay of its observed behavior (inc-07 review F7: the -/// replay diverged at EOF-consumed-import corners). -pub fn strip_consumed_imports( - source: &str, - consumed_sources: &[&str], - extracted_bindings: &[&str], -) -> String { - strip_consumed_imports_with_removals(source, consumed_sources, extracted_bindings).0 -} - -/// The verbatim v1 strip plus byte ranges removed from its input. The ranges -/// are observation-only metadata: output construction remains the exact loop -/// used by `strip_consumed_imports`. -pub fn strip_consumed_imports_with_removals( - source: &str, - consumed_sources: &[&str], - extracted_bindings: &[&str], -) -> (String, Vec<(usize, usize)>) { - let mut result = String::with_capacity(source.len()); - let mut removals = Vec::new(); - let mut line_start = 0usize; - - for line in source.split('\n') { - let line_end = line_start + line.len(); - let next_line_start = if line_end < source.len() { - line_end + 1 - } else { - line_end - }; - let trimmed = line.trim(); - let mut remove = false; - - if trimmed.starts_with("import") && trimmed.contains('{') && trimmed.contains("from") { - if let Some((bindings, source_str)) = parse_named_import(trimmed) { - if consumed_sources.contains(&source_str.as_str()) { - let all_extracted = - bindings.iter().all(|b| extracted_bindings.contains(&b.as_str())); - if all_extracted { - remove = true; - } - } - } - } - - if remove { - removals.push((line_start, next_line_start)); - } else { - result.push_str(line); - result.push('\n'); - } - line_start = next_line_start; - } - - if !source.ends_with('\n') && result.ends_with('\n') { - result.pop(); - } - - (result, removals) -} - -fn leading_line_terminator_len(source: &str) -> usize { - if source.starts_with("\r\n") { - 2 - } else if source.starts_with('\r') || source.starts_with('\n') { - 1 - } else if source.starts_with('\u{2028}') || source.starts_with('\u{2029}') { - 3 - } else { - 0 - } -} - -/// v1 apply_replacements directive tail (transform_emitter 471-490), -/// operating on the POST-STRIP string (inc-07 review F6), with the -/// offset-0 quirk shed (inc 03): OXC's parsed directive list supplies the -/// authoritative prologue boundary (including ECMAScript trivia + ASI), -/// and the whole prologue stays ABOVE the injected imports. -/// v1's single-blank-line strip after the prologue is retained. -pub fn directive_prefix_and_body( - result: String, - needs_use_client: bool, - prologue: Option<&DirectivePrologueFact>, -) -> (String, String) { - match prologue { - Some(prologue) => { - let end = prologue.end as usize; - let mut rest_start = end; - // Consume the line terminator ending the directive line. - rest_start += leading_line_terminator_len(&result[rest_start..]); - // v1 quirk parity: strip ONE blank line following the directive. - if result[rest_start..].starts_with('\n') { - rest_start += 1; - } - let mut prefix = result[..end].to_string(); - prefix.push('\n'); - if needs_use_client && !prologue.has_use_client { - prefix.push_str("'use client';\n"); - } - let rest = result[rest_start..].to_string(); - (prefix, rest) - } - None if needs_use_client => ("'use client';\n".to_string(), result), - None => (String::new(), result), - } -} - -/// v1 parse_named_import, ported: single-line `import { a, b as c } from 's'` -/// ONLY (the line-based quirk is the contract — multi-line imports are NOT -/// stripped; anticipated register entry). Returns IMPORTED names (left of -/// `as`) and the source specifier. -fn parse_named_import(line: &str) -> Option<(Vec, String)> { - let rest = line.strip_prefix("import")?.trim_start(); - let brace_start = rest.find('{')?; - let brace_end = rest.find('}')?; - if brace_end <= brace_start { - return None; - } - let names_str = &rest[brace_start + 1..brace_end]; - let bindings: Vec = names_str - .split(',') - .map(|s| s.split_whitespace().next().unwrap_or("").to_string()) - .filter(|s| !s.is_empty()) - .collect(); - let after = &rest[brace_end + 1..]; - let from_idx = after.find("from")?; - let spec = after[from_idx + 4..].trim(); - let quote = spec.chars().next()?; - if quote != '\'' && quote != '"' { - return None; - } - let end = spec[1..].find(quote)? + 1; - Some((bindings, spec[1..end].to_string())) -} - -/// Consumed-import removal SPANS over the ORIGINAL source — v1's -/// line-based strip semantics (transform_emitter::strip_consumed_imports) -/// mapped to the span model: a line is removed iff it single-line-parses -/// as a named import, its source is consumed, and ALL its imported names -/// were extracted. -pub fn consumed_import_removals( - source: &str, - consumed_sources: &[&str], - extracted_bindings: &[&str], -) -> Vec<(u32, u32)> { - let mut out = Vec::new(); - let mut offset = 0usize; - for line in source.split('\n') { - let line_len = line.len(); - let trimmed = line.trim(); - if trimmed.starts_with("import") && trimmed.contains('{') && trimmed.contains("from") { - if let Some((bindings, src)) = parse_named_import(trimmed) { - if consumed_sources.contains(&src.as_str()) - && bindings - .iter() - .all(|b| extracted_bindings.contains(&b.as_str())) - { - // Remove the line INCLUDING its newline when present. - let end = if offset + line_len < source.len() { - offset + line_len + 1 - } else { - offset + line_len - }; - out.push((offset as u32, end as u32)); - } - } - } - offset += line_len + 1; - } - out -} - -/// Directive + import prepend (v1 apply_replacements tail, span form), -/// offset-0 quirk shed (inc 03): an EXISTING directive prologue — -/// including leading comments/blank lines — is kept ABOVE the injected -/// imports; `needs_use_client` injects one when absent. v1's -/// single-blank-line strip after the prologue is retained. -/// Returns (prepend_text, extra_removals). -pub fn directive_and_imports( - source: &str, - import_lines: &str, - needs_use_client: bool, - prologue: Option<&DirectivePrologueFact>, -) -> (String, Vec<(u32, u32)>) { - match prologue { - Some(prologue) => { - let end = prologue.end as usize; - let mut consumed_end = end; - // Consume the line terminator ending the directive line. - consumed_end += leading_line_terminator_len(&source[consumed_end..]); - // v1 quirk parity: strip ONE blank line following the directive - // (transform_emitter: `if result.starts_with('\n')` after removal). - if source[consumed_end..].starts_with('\n') { - consumed_end += 1; - } - let mut prefix = source[..end].to_string(); - prefix.push('\n'); - if needs_use_client && !prologue.has_use_client { - prefix.push_str("'use client';\n"); - } - ( - format!("{prefix}{import_lines}"), - vec![(0, consumed_end as u32)], - ) - } - None if needs_use_client => (format!("'use client';\n{import_lines}"), Vec::new()), - None => (import_lines.to_string(), Vec::new()), - } -} - +/// Fact construction shared by this module's tests and those of its +/// submodules — `source_edit`'s directive cases need real parsed prologue +/// facts, so the helper lives at the module root rather than being +/// duplicated per file. #[cfg(test)] -mod tests { - use super::*; - use crate::facts::extract_file_facts; +pub(crate) mod test_support { + use crate::facts::{extract_file_facts, FileFacts}; use crate::owned_ast::{OwnedAst, ParseCounter}; - fn facts_for(path: &str, source: &str) -> FileFacts { + pub(crate) fn facts_for(path: &str, source: &str) -> FileFacts { let counter = ParseCounter::new(0); let ast = OwnedAst::parse(path.to_string(), source.to_string(), &counter); extract_file_facts(&ast) } +} - fn directive_and_imports_for( - source: &str, - import_lines: &str, - needs_use_client: bool, - ) -> (String, Vec<(u32, u32)>) { - let facts = facts_for("directive.tsx", source); - directive_and_imports( - source, - import_lines, - needs_use_client, - facts.directive_prologue.as_ref(), - ) - } - - fn directive_prefix_and_body_for( - source: String, - needs_use_client: bool, - ) -> (String, String) { - let facts = facts_for("directive.tsx", &source); - directive_prefix_and_body( - source, - needs_use_client, - facts.directive_prologue.as_ref(), - ) - } +#[cfg(test)] +mod tests { + use super::test_support::facts_for; + use super::*; #[test] fn class_name_shape() { @@ -672,131 +229,6 @@ mod tests { } } - - #[test] - fn strip_semantics_match_v1_line_quirks() { - let src = "import { A, B } from './x';\nimport {\n C,\n} from './y';\nimport { D, E } from './x';\nconst k = 1;\n"; - let removals = - consumed_import_removals(src, &["./x", "./y"], &["A", "B", "D"]); - // Line 1: all extracted → removed. Multi-line ./y import: NOT - // stripped (quirk). Line with D,E: E not extracted → kept. - assert_eq!(removals.len(), 1); - assert_eq!(removals[0].0, 0); - let out = crate::emit::apply_plan( - src, - &crate::emit::EmissionPlan { removals, ..Default::default() }, - ) - .unwrap(); - assert!(!out.code.contains("{ A, B }")); - assert!(out.code.contains("C,")); - assert!(out.code.contains("{ D, E }")); - } - - #[test] - fn directive_at_offset_zero_moves_above_imports() { - let src = "'use client';\nconst x = 1;\n"; - let (prepend, removals) = - directive_and_imports_for(src, "import Z from 'z';\n", false); - let out = crate::emit::apply_plan( - src, - &crate::emit::EmissionPlan { prepend, removals, ..Default::default() }, - ) - .unwrap(); - assert!(out.code.starts_with("'use client';\nimport Z from 'z';\nconst x = 1;")); - } - - #[test] - fn comment_preceded_directive_keeps_prologue_above_imports() { - let src = "// note\n'use client';\nconst x = 1;\n"; - let (prepend, removals) = - directive_and_imports_for(src, "import Z from 'z';\n", false); - let out = crate::emit::apply_plan( - src, - &crate::emit::EmissionPlan { prepend, removals, ..Default::default() }, - ) - .unwrap(); - // Shed (inc 03): the whole prologue — comment included — stays - // above the injected imports (v1's offset-0 quirk put them above - // the directive; licensed register entry - // parity/use-client-comment.tsx). - assert!( - out.code - .starts_with("// note\n'use client';\nimport Z from 'z';\nconst x = 1;"), - "got {}", - out.code - ); - } - - #[test] - fn prologue_prefix_comment_then_directive() { - let (prefix, rest) = directive_prefix_and_body_for( - "// note\n'use client';\nconst x = 1;\n".to_string(), - false, - ); - assert_eq!(prefix, "// note\n'use client';\n"); - assert_eq!(rest, "const x = 1;\n"); - } - - #[test] - fn prologue_prefix_blank_line_then_directive() { - // Leading blank lines are trivia; the directive is still in - // prologue position and stays above the imports. - let (prefix, rest) = directive_prefix_and_body_for( - "\n\n'use client';\nconst x = 1;\n".to_string(), - false, - ); - assert_eq!(prefix, "\n\n'use client';\n"); - assert_eq!(rest, "const x = 1;\n"); - } - - #[test] - fn prologue_prefix_directive_then_blank_line_strips_one_blank() { - // v1 parity: exactly one blank line after the prologue is eaten - // (keeps use-client-blank-line.tsx byte-identical across engines). - let (prefix, rest) = directive_prefix_and_body_for( - "'use client';\n\nimport { ds } from './x';\n".to_string(), - false, - ); - assert_eq!(prefix, "'use client';\n"); - assert_eq!(rest, "import { ds } from './x';\n"); - } - - #[test] - fn prologue_recognizes_multiple_directives_and_block_comments() { - let (prefix, rest) = directive_prefix_and_body_for( - "/* header */\n'use strict';\n// mid\n\"use client\"\nconst x = 1;\n".to_string(), - false, - ); - assert_eq!(prefix, "/* header */\n'use strict';\n// mid\n\"use client\"\n"); - assert_eq!(rest, "const x = 1;\n"); - } - - #[test] - fn non_directive_string_is_not_a_prologue() { - // A string literal in expression (non-statement) position is not - // a directive; neither is one consumed by a member expression. - let (prefix, rest) = - directive_prefix_and_body_for("const s = 'use client';\n".to_string(), false); - assert_eq!(prefix, ""); - assert_eq!(rest, "const s = 'use client';\n"); - let (prefix, _) = - directive_prefix_and_body_for( - "'use client'.length;\nconst x = 1;\n".to_string(), - false, - ); - assert_eq!(prefix, ""); - } - - #[test] - fn needs_use_client_appends_below_existing_prologue() { - let (prefix, rest) = directive_prefix_and_body_for( - "'use strict';\nconst x = 1;\n".to_string(), - true, - ); - assert_eq!(prefix, "'use strict';\n'use client';\n"); - assert_eq!(rest, "const x = 1;\n"); - } - #[test] fn class_resolver_shape() { let facts = facts_for("d.tsx", "export const card = ds.styles({ p: 8 }).asClass();"); diff --git a/packages/extract/crates/extract-v2/src/assemble/config.rs b/packages/extract/crates/extract-v2/src/assemble/config.rs new file mode 100644 index 00000000..da15a393 --- /dev/null +++ b/packages/extract/crates/extract-v2/src/assemble/config.rs @@ -0,0 +1,248 @@ +//! Runtime-config JSON construction for a replacement. +//! +//! Split out of `assemble.rs` unchanged: this is v1's +//! `build_runtime_config` (transform_emitter 232-338) including its verbatim +//! string-splice tail. The splices are load-bearing — key order and the +//! `[].concat(...)` shape are compared byte-for-byte by the parity oracle — +//! so this module is deliberately literal rather than idiomatic. + +use std::collections::BTreeMap; + +use rustc_hash::FxHashMap; + +use serde_json::{json, Map, Value}; + +use crate::facts::ChainFacts; +use crate::ids::class_name_for; + +use super::{AssembleError, ReplacementPayload}; + +/// Build the runtime-config JSON string for the facts-derivable subset — +/// key order matches v1's inc-01-patched serialization exactly (sorted +/// compound conditions; insertion order variants→compounds→states). +pub(super) fn build_config( + filename: &str, + binding: &str, + chain: &ChainFacts, + prefix: &str, + payload: Option<&ReplacementPayload>, + group_registry: &FxHashMap>, +) -> Result { + let mut config = Map::new(); + + // Variants (v1: {prop: {options[, default]}} keyed per variant stage) + let mut variants = Map::new(); + let mut compounds: Vec = Vec::new(); + let mut states: Vec = Vec::new(); + let mut compound_index = 0usize; + let class_name = class_name_for(filename, binding, prefix); + let use_merged = payload.and_then(|p| p.merged_config.as_ref()); + + for stage in &chain.stages { + if use_merged.is_some() && matches!(stage.method.as_str(), "variant" | "compound" | "states") + { + // Extension child: the merged trio below is authoritative. + continue; + } + match stage.method.as_str() { + "variant" => { + if let Some(v) = &stage.value { + let prop = v["prop"].as_str().unwrap_or("variant").to_string(); + let mut entry = Map::new(); + let options: Vec = v["variants"] + .as_object() + .map(|m| m.keys().cloned().collect()) + .unwrap_or_default(); + entry.insert("options".into(), json!(options)); + if let Some(d) = v["defaultVariant"].as_str() { + entry.insert("default".into(), json!(d)); + } + variants.insert(prop, Value::Object(entry)); + } + } + "compound" => { + // v1 lib.rs 536-554: a CompoundConfig exists ONLY when the + // second (styles) argument does — one-arg .compound(cond) + // contributes neither config nor CSS, and the positional + // class index counts styled compounds only. + if stage.second_value.is_some() { + if let Some(cond) = &stage.value { + // Sorted conditions (v1 inc-01 determinism patch). + let sorted: BTreeMap = cond + .as_object() + .map(|m| { + m.iter() + .filter(|(_, v)| v.is_string() || v.is_array()) + .map(|(k, v)| (k.clone(), v.clone())) + .collect() + }) + .unwrap_or_default(); + compounds.push(json!({ + "conditions": sorted, + "className": format!("{class_name}--compound-{compound_index}"), + })); + compound_index += 1; + } + } + } + "states" => { + if let Some(v) = &stage.value { + if let Some(m) = v.as_object() { + states.extend(m.keys().cloned()); + } + } + } + "system" | "props" + // Payload-fed when analyze ran with config inputs; a bare + // call without payloads still fails loud (never a wrong + // template). + if payload.is_none() => { + return Err(AssembleError::NeedsConfig(format!( + "{binding}: '{}' stage payloads require prop config (row 07)", + stage.method + ))); + } + _ => {} + } + } + + if let Some(merged) = use_merged { + // v1 build_runtime_config 195-227 over the POST-MERGE config. + for (prop, options, default) in &merged.variant_config { + let mut entry = Map::new(); + entry.insert("options".into(), json!(options)); + if let Some(d) = default { + entry.insert("default".into(), json!(d)); + } + variants.insert(prop.clone(), Value::Object(entry)); + } + for (conditions, cname) in &merged.compound_configs { + compounds.push(json!({ + "conditions": conditions, + "className": cname, + })); + } + states = merged.state_names.clone(); + } + if !variants.is_empty() { + config.insert("variants".into(), Value::Object(variants)); + } + if !compounds.is_empty() { + config.insert("compounds".into(), json!(compounds)); + } + if !states.is_empty() { + config.insert("states".into(), json!(states)); + } + + let base_json = + serde_json::to_string(&Value::Object(config)).unwrap_or_else(|_| "{}".into()); + let Some(p) = payload else { + return Ok(base_json); + }; + + // v1 build_runtime_config tail (transform_emitter 232-338), verbatim + // string-splice semantics. + let mut result = if !p.system_group_names.is_empty() { + let mut concat_parts: Vec = p + .system_group_names + .iter() + .map(|g| format!("systemPropGroups.{}", g)) + .collect(); + { + let mut extra_names: rustc_hash::FxHashSet = rustc_hash::FxHashSet::default(); + if !p.system_prop_names.is_empty() { + let group_covered: rustc_hash::FxHashSet = p + .system_group_names + .iter() + .filter_map(|g| group_registry.get(g)) + .flat_map(|props| props.iter().cloned()) + .collect(); + for prop in &p.system_prop_names { + if !group_covered.contains(prop) { + extra_names.insert(prop.clone()); + } + } + } + if let Some(ref cpm) = p.custom_prop_class_map { + extra_names.extend(cpm.keys().cloned()); + } + if let Some(ref cdc) = p.custom_dynamic_config { + extra_names.extend(cdc.keys().cloned()); + } + if !extra_names.is_empty() { + let mut sorted: Vec = extra_names.into_iter().collect(); + sorted.sort(); + concat_parts + .push(serde_json::to_string(&sorted).unwrap_or_else(|_| "[]".to_string())); + } + } + let concat_expr = concat_parts.join(","); + let spn_field = format!("\"systemPropNames\":[].concat({})", concat_expr); + if base_json == "{}" { + format!("{{{}}}", spn_field) + } else { + format!("{},{}}}", &base_json[..base_json.len() - 1], spn_field) + } + } else if !p.system_prop_names.is_empty() { + let mut config_map: Map = + serde_json::from_str(&base_json).unwrap_or_default(); + config_map.insert("systemPropNames".to_string(), json!(p.system_prop_names)); + serde_json::to_string(&Value::Object(config_map)).unwrap_or(base_json) + } else { + base_json + }; + + if let Some(ref cpm) = p.custom_prop_class_map { + let sorted_cpm: BTreeMap<&String, BTreeMap<&String, &String>> = + cpm.iter().map(|(k, v)| (k, v.iter().collect())).collect(); + let cpm_json = serde_json::to_string(&sorted_cpm).unwrap_or_else(|_| "{}".to_string()); + if result == "{}" { + result = format!("{{\"customPropMap\":{}}}", cpm_json); + } else { + result = format!("{},\"customPropMap\":{}}}", &result[..result.len() - 1], cpm_json); + } + } + + if let Some(ref cdc) = p.custom_dynamic_config { + let mut entries: Vec = Vec::new(); + let mut sorted_keys: Vec<&String> = cdc.keys().collect(); + sorted_keys.sort(); + for prop_name in sorted_keys { + let meta = &cdc[prop_name]; + let mut fields: Vec = Vec::new(); + fields.push(format!("\"varName\":\"{}\"", meta.var_name)); + fields.push(format!("\"slotClass\":\"{}\"", meta.slot_class)); + fields.push(format!("\"property\":\"{}\"", meta.property)); + if !meta.properties.is_empty() { + let props_json = + serde_json::to_string(&meta.properties).unwrap_or_else(|_| "[]".to_string()); + fields.push(format!("\"properties\":{}", props_json)); + } + if let Some(ref fn_src) = meta.transform_fn_source { + fields.push(format!("\"transform\":{}", fn_src)); + } else if let Some(ref tn) = meta.transform_name { + fields.push(format!("\"transformName\":\"{}\"", tn)); + fields.push(format!("\"transform\":transforms.{}", tn)); + } + if !meta.scale_values.is_empty() { + let sorted_sv: BTreeMap<&String, &String> = meta.scale_values.iter().collect(); + let sv_json = + serde_json::to_string(&sorted_sv).unwrap_or_else(|_| "{}".to_string()); + fields.push(format!("\"scaleValues\":{}", sv_json)); + } + entries.push(format!("\"{}\":{{{}}}", prop_name, fields.join(","))); + } + let cdc_str = format!("{{{}}}", entries.join(",")); + if result == "{}" { + result = format!("{{\"customDynamicConfig\":{}}}", cdc_str); + } else { + result = format!( + "{},\"customDynamicConfig\":{}}}", + &result[..result.len() - 1], + cdc_str + ); + } + } + + Ok(result) +} diff --git a/packages/extract/crates/extract-v2/src/assemble/source_edit.rs b/packages/extract/crates/extract-v2/src/assemble/source_edit.rs new file mode 100644 index 00000000..75cdc5d7 --- /dev/null +++ b/packages/extract/crates/extract-v2/src/assemble/source_edit.rs @@ -0,0 +1,381 @@ +//! Source-text surgery: consumed-import stripping and directive-prologue +//! placement. +//! +//! Split out of `assemble.rs` unchanged. This half is pure text +//! manipulation — its only type dependency is `DirectivePrologueFact` — and +//! it shares no private helper with the config/replacement half, which is +//! what made the seam mechanical. +//! +//! Every function here is a verbatim port of a v1 routine whose *quirks* are +//! the contract (the line-based strip, the trailing-newline behaviour, the +//! single-blank-line eat after a prologue). Registered parity entries depend +//! on those quirks, so resist tidying them. + +use crate::facts::DirectivePrologueFact; + +/// v1 strip_consumed_imports VERBATIM (transform_emitter 497-535): the +/// split/rebuild loop IS the trailing-newline quirk's origin — porting the +/// loop, not a replay of its observed behavior (inc-07 review F7: the +/// replay diverged at EOF-consumed-import corners). +pub fn strip_consumed_imports( + source: &str, + consumed_sources: &[&str], + extracted_bindings: &[&str], +) -> String { + strip_consumed_imports_with_removals(source, consumed_sources, extracted_bindings).0 +} + +/// The verbatim v1 strip plus byte ranges removed from its input. The ranges +/// are observation-only metadata: output construction remains the exact loop +/// used by `strip_consumed_imports`. +pub fn strip_consumed_imports_with_removals( + source: &str, + consumed_sources: &[&str], + extracted_bindings: &[&str], +) -> (String, Vec<(usize, usize)>) { + let mut result = String::with_capacity(source.len()); + let mut removals = Vec::new(); + let mut line_start = 0usize; + + for line in source.split('\n') { + let line_end = line_start + line.len(); + let next_line_start = if line_end < source.len() { + line_end + 1 + } else { + line_end + }; + let trimmed = line.trim(); + let mut remove = false; + + if trimmed.starts_with("import") && trimmed.contains('{') && trimmed.contains("from") { + if let Some((bindings, source_str)) = parse_named_import(trimmed) { + if consumed_sources.contains(&source_str.as_str()) { + let all_extracted = + bindings.iter().all(|b| extracted_bindings.contains(&b.as_str())); + if all_extracted { + remove = true; + } + } + } + } + + if remove { + removals.push((line_start, next_line_start)); + } else { + result.push_str(line); + result.push('\n'); + } + line_start = next_line_start; + } + + if !source.ends_with('\n') && result.ends_with('\n') { + result.pop(); + } + + (result, removals) +} + +fn leading_line_terminator_len(source: &str) -> usize { + if source.starts_with("\r\n") { + 2 + } else if source.starts_with('\r') || source.starts_with('\n') { + 1 + } else if source.starts_with('\u{2028}') || source.starts_with('\u{2029}') { + 3 + } else { + 0 + } +} + +/// v1 apply_replacements directive tail (transform_emitter 471-490), +/// operating on the POST-STRIP string (inc-07 review F6), with the +/// offset-0 quirk shed (inc 03): OXC's parsed directive list supplies the +/// authoritative prologue boundary (including ECMAScript trivia + ASI), +/// and the whole prologue stays ABOVE the injected imports. +/// v1's single-blank-line strip after the prologue is retained. +pub fn directive_prefix_and_body( + result: String, + needs_use_client: bool, + prologue: Option<&DirectivePrologueFact>, +) -> (String, String) { + match prologue { + Some(prologue) => { + let end = prologue.end as usize; + let mut rest_start = end; + // Consume the line terminator ending the directive line. + rest_start += leading_line_terminator_len(&result[rest_start..]); + // v1 quirk parity: strip ONE blank line following the directive. + if result[rest_start..].starts_with('\n') { + rest_start += 1; + } + let mut prefix = result[..end].to_string(); + prefix.push('\n'); + if needs_use_client && !prologue.has_use_client { + prefix.push_str("'use client';\n"); + } + let rest = result[rest_start..].to_string(); + (prefix, rest) + } + None if needs_use_client => ("'use client';\n".to_string(), result), + None => (String::new(), result), + } +} + +/// v1 parse_named_import, ported: single-line `import { a, b as c } from 's'` +/// ONLY (the line-based quirk is the contract — multi-line imports are NOT +/// stripped; anticipated register entry). Returns IMPORTED names (left of +/// `as`) and the source specifier. +fn parse_named_import(line: &str) -> Option<(Vec, String)> { + let rest = line.strip_prefix("import")?.trim_start(); + let brace_start = rest.find('{')?; + let brace_end = rest.find('}')?; + if brace_end <= brace_start { + return None; + } + let names_str = &rest[brace_start + 1..brace_end]; + let bindings: Vec = names_str + .split(',') + .map(|s| s.split_whitespace().next().unwrap_or("").to_string()) + .filter(|s| !s.is_empty()) + .collect(); + let after = &rest[brace_end + 1..]; + let from_idx = after.find("from")?; + let spec = after[from_idx + 4..].trim(); + let quote = spec.chars().next()?; + if quote != '\'' && quote != '"' { + return None; + } + let end = spec[1..].find(quote)? + 1; + Some((bindings, spec[1..end].to_string())) +} + +/// Consumed-import removal SPANS over the ORIGINAL source — v1's +/// line-based strip semantics (transform_emitter::strip_consumed_imports) +/// mapped to the span model: a line is removed iff it single-line-parses +/// as a named import, its source is consumed, and ALL its imported names +/// were extracted. +pub fn consumed_import_removals( + source: &str, + consumed_sources: &[&str], + extracted_bindings: &[&str], +) -> Vec<(u32, u32)> { + let mut out = Vec::new(); + let mut offset = 0usize; + for line in source.split('\n') { + let line_len = line.len(); + let trimmed = line.trim(); + if trimmed.starts_with("import") && trimmed.contains('{') && trimmed.contains("from") { + if let Some((bindings, src)) = parse_named_import(trimmed) { + if consumed_sources.contains(&src.as_str()) + && bindings + .iter() + .all(|b| extracted_bindings.contains(&b.as_str())) + { + // Remove the line INCLUDING its newline when present. + let end = if offset + line_len < source.len() { + offset + line_len + 1 + } else { + offset + line_len + }; + out.push((offset as u32, end as u32)); + } + } + } + offset += line_len + 1; + } + out +} + +/// Directive + import prepend (v1 apply_replacements tail, span form), +/// offset-0 quirk shed (inc 03): an EXISTING directive prologue — +/// including leading comments/blank lines — is kept ABOVE the injected +/// imports; `needs_use_client` injects one when absent. v1's +/// single-blank-line strip after the prologue is retained. +/// Returns (prepend_text, extra_removals). +pub fn directive_and_imports( + source: &str, + import_lines: &str, + needs_use_client: bool, + prologue: Option<&DirectivePrologueFact>, +) -> (String, Vec<(u32, u32)>) { + match prologue { + Some(prologue) => { + let end = prologue.end as usize; + let mut consumed_end = end; + // Consume the line terminator ending the directive line. + consumed_end += leading_line_terminator_len(&source[consumed_end..]); + // v1 quirk parity: strip ONE blank line following the directive + // (transform_emitter: `if result.starts_with('\n')` after removal). + if source[consumed_end..].starts_with('\n') { + consumed_end += 1; + } + let mut prefix = source[..end].to_string(); + prefix.push('\n'); + if needs_use_client && !prologue.has_use_client { + prefix.push_str("'use client';\n"); + } + ( + format!("{prefix}{import_lines}"), + vec![(0, consumed_end as u32)], + ) + } + None if needs_use_client => (format!("'use client';\n{import_lines}"), Vec::new()), + None => (import_lines.to_string(), Vec::new()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::assemble::test_support::facts_for; + + fn directive_and_imports_for( + source: &str, + import_lines: &str, + needs_use_client: bool, + ) -> (String, Vec<(u32, u32)>) { + let facts = facts_for("directive.tsx", source); + directive_and_imports( + source, + import_lines, + needs_use_client, + facts.directive_prologue.as_ref(), + ) + } + + fn directive_prefix_and_body_for( + source: String, + needs_use_client: bool, + ) -> (String, String) { + let facts = facts_for("directive.tsx", &source); + directive_prefix_and_body( + source, + needs_use_client, + facts.directive_prologue.as_ref(), + ) + } + + #[test] + fn strip_semantics_match_v1_line_quirks() { + let src = "import { A, B } from './x';\nimport {\n C,\n} from './y';\nimport { D, E } from './x';\nconst k = 1;\n"; + let removals = + consumed_import_removals(src, &["./x", "./y"], &["A", "B", "D"]); + // Line 1: all extracted → removed. Multi-line ./y import: NOT + // stripped (quirk). Line with D,E: E not extracted → kept. + assert_eq!(removals.len(), 1); + assert_eq!(removals[0].0, 0); + let out = crate::emit::apply_plan( + src, + &crate::emit::EmissionPlan { removals, ..Default::default() }, + ) + .unwrap(); + assert!(!out.code.contains("{ A, B }")); + assert!(out.code.contains("C,")); + assert!(out.code.contains("{ D, E }")); + } + + #[test] + fn directive_at_offset_zero_moves_above_imports() { + let src = "'use client';\nconst x = 1;\n"; + let (prepend, removals) = + directive_and_imports_for(src, "import Z from 'z';\n", false); + let out = crate::emit::apply_plan( + src, + &crate::emit::EmissionPlan { prepend, removals, ..Default::default() }, + ) + .unwrap(); + assert!(out.code.starts_with("'use client';\nimport Z from 'z';\nconst x = 1;")); + } + + #[test] + fn comment_preceded_directive_keeps_prologue_above_imports() { + let src = "// note\n'use client';\nconst x = 1;\n"; + let (prepend, removals) = + directive_and_imports_for(src, "import Z from 'z';\n", false); + let out = crate::emit::apply_plan( + src, + &crate::emit::EmissionPlan { prepend, removals, ..Default::default() }, + ) + .unwrap(); + // Shed (inc 03): the whole prologue — comment included — stays + // above the injected imports (v1's offset-0 quirk put them above + // the directive; licensed register entry + // parity/use-client-comment.tsx). + assert!( + out.code + .starts_with("// note\n'use client';\nimport Z from 'z';\nconst x = 1;"), + "got {}", + out.code + ); + } + + #[test] + fn prologue_prefix_comment_then_directive() { + let (prefix, rest) = directive_prefix_and_body_for( + "// note\n'use client';\nconst x = 1;\n".to_string(), + false, + ); + assert_eq!(prefix, "// note\n'use client';\n"); + assert_eq!(rest, "const x = 1;\n"); + } + + #[test] + fn prologue_prefix_blank_line_then_directive() { + // Leading blank lines are trivia; the directive is still in + // prologue position and stays above the imports. + let (prefix, rest) = directive_prefix_and_body_for( + "\n\n'use client';\nconst x = 1;\n".to_string(), + false, + ); + assert_eq!(prefix, "\n\n'use client';\n"); + assert_eq!(rest, "const x = 1;\n"); + } + + #[test] + fn prologue_prefix_directive_then_blank_line_strips_one_blank() { + // v1 parity: exactly one blank line after the prologue is eaten + // (keeps use-client-blank-line.tsx byte-identical across engines). + let (prefix, rest) = directive_prefix_and_body_for( + "'use client';\n\nimport { ds } from './x';\n".to_string(), + false, + ); + assert_eq!(prefix, "'use client';\n"); + assert_eq!(rest, "import { ds } from './x';\n"); + } + + #[test] + fn prologue_recognizes_multiple_directives_and_block_comments() { + let (prefix, rest) = directive_prefix_and_body_for( + "/* header */\n'use strict';\n// mid\n\"use client\"\nconst x = 1;\n".to_string(), + false, + ); + assert_eq!(prefix, "/* header */\n'use strict';\n// mid\n\"use client\"\n"); + assert_eq!(rest, "const x = 1;\n"); + } + + #[test] + fn non_directive_string_is_not_a_prologue() { + // A string literal in expression (non-statement) position is not + // a directive; neither is one consumed by a member expression. + let (prefix, rest) = + directive_prefix_and_body_for("const s = 'use client';\n".to_string(), false); + assert_eq!(prefix, ""); + assert_eq!(rest, "const s = 'use client';\n"); + let (prefix, _) = + directive_prefix_and_body_for( + "'use client'.length;\nconst x = 1;\n".to_string(), + false, + ); + assert_eq!(prefix, ""); + } + + #[test] + fn needs_use_client_appends_below_existing_prologue() { + let (prefix, rest) = directive_prefix_and_body_for( + "'use strict';\nconst x = 1;\n".to_string(), + true, + ); + assert_eq!(prefix, "'use strict';\n'use client';\n"); + assert_eq!(rest, "const x = 1;\n"); + } +} diff --git a/packages/extract/crates/extract-v2/src/chain_walk.rs b/packages/extract/crates/extract-v2/src/chain_walk.rs index 9b534be4..38eec126 100644 --- a/packages/extract/crates/extract-v2/src/chain_walk.rs +++ b/packages/extract/crates/extract-v2/src/chain_walk.rs @@ -8,14 +8,22 @@ //! span for argument kinds outside v1's macro list). v1's test module is //! ported verbatim below as the executable contract. Deviations are //! register material, not improvements. +//! +//! Module layout — the public surface is unchanged; `chain_walk::walk_program` +//! and the descriptor types resolve exactly as before: +//! +//! `walk` — the backward chain walk (entry point) +//! `terminal` — terminal-argument resolution + v1-parity argument spans +//! `expr` — expression-shape helpers (leaf; no chain knowledge) -use oxc::ast::ast::{ - Argument, BindingPattern, CallExpression, Declaration, Expression, Program, Statement, - VariableDeclarator, -}; -use oxc::span::Span; use serde::Serialize; +mod expr; +mod terminal; +mod walk; + +pub use walk::walk_program; + #[derive(Debug, Clone, PartialEq, Serialize)] #[serde(rename_all = "camelCase")] pub enum TerminalKind { @@ -45,282 +53,6 @@ pub struct ChainDescriptor { pub extends_from: Option, } -const BAIL_METHODS: &[&str] = &[]; -const CHAIN_METHODS: &[&str] = &["styles", "variant", "compound", "states", "system", "props"]; - -pub fn walk_program(program: &Program<'_>) -> Vec { - let mut chains = Vec::new(); - for stmt in &program.body { - match stmt { - Statement::VariableDeclaration(decl) => { - for declarator in &decl.declarations { - if let Some(chain) = try_extract_chain(declarator) { - chains.push(chain); - } - } - } - // export default chains are uncommon in Animus — v1 skips them. - Statement::ExportDefaultDeclaration(_) => {} - Statement::ExportNamedDeclaration(export) => { - if let Some(Declaration::VariableDeclaration(decl)) = &export.declaration { - for declarator in &decl.declarations { - if let Some(chain) = try_extract_chain(declarator) { - chains.push(chain); - } - } - } - } - _ => {} - } - } - chains -} - -fn try_extract_chain(declarator: &VariableDeclarator<'_>) -> Option { - let init = declarator.init.as_ref()?; - let binding = match &declarator.id { - BindingPattern::BindingIdentifier(id) => id.name.to_string(), - _ => return None, // destructuring not supported (v1 parity) - }; - let call = match init { - Expression::CallExpression(call) => call.as_ref(), - _ => return None, - }; - try_walk_chain(call, binding) -} - -fn try_walk_chain(call: &CallExpression<'_>, binding: String) -> Option { - let (object, method_name) = match_static_member(&call.callee)?; - - let terminal = match method_name { - "asElement" => TerminalKind::AsElement, - "asComponent" => TerminalKind::AsComponent, - "asClass" => TerminalKind::AsClass, - _ => return None, - }; - - let mut stages = Vec::new(); - let mut extractable = true; - let mut bail_reason: Option = None; - - let tag = match extract_terminal_arg(call, &terminal) { - TerminalArg::Resolved(tag) => tag, - TerminalArg::Unresolvable(reason) => { - extractable = false; - bail_reason = Some(format!("{}: {}", method_name, reason)); - String::new() - } - }; - let mut has_extend_marker = false; - let chain_end = call.span; - - let (chain_start, root_identifier) = walk_chain_backwards( - object, - &mut stages, - &mut extractable, - &mut bail_reason, - &mut has_extend_marker, - )?; - - stages.reverse(); - - let extends_from = if has_extend_marker { - Some(root_identifier) - } else if !stages.is_empty() { - // PRIMARY CHAIN: method pattern suffices; root NAME is irrelevant - // (v1 parity — supports `animus.styles(...)` and custom instances). - None - } else { - return None; - }; - - Some(ChainDescriptor { - binding, - terminal, - tag, - stages, - extractable, - bail_reason, - span: (chain_start, chain_end.end), - extends_from, - }) -} - -fn walk_chain_backwards( - expr: &Expression<'_>, - stages: &mut Vec, - extractable: &mut bool, - bail_reason: &mut Option, - has_extend_marker: &mut bool, -) -> Option<(u32, String)> { - match expr { - Expression::Identifier(id) => Some((id.span.start, id.name.to_string())), - Expression::CallExpression(call) => { - let (object, method_name) = match_static_member(&call.callee)?; - - if method_name == "extend" { - if call.arguments.is_empty() { - *has_extend_marker = true; - } else { - *extractable = false; - if bail_reason.is_none() { - *bail_reason = Some("extend with arguments is not supported".to_string()); - } - } - } else { - if BAIL_METHODS.contains(&method_name) { - *extractable = false; - if bail_reason.is_none() { - *bail_reason = Some(format!("{} stage not supported", method_name)); - } - } - if CHAIN_METHODS.contains(&method_name) || BAIL_METHODS.contains(&method_name) { - // v1 parity: zero-arg known methods record NOTHING and - // do not bail. - if let Some(arg_span) = first_arg_span(call) { - let second_arg_span = if method_name == "compound" { - second_arg_span_fn(call) - } else { - None - }; - stages.push(ChainStage { - method: method_name.to_string(), - arg_span: (arg_span.start, arg_span.end), - second_arg_span: second_arg_span.map(|s| (s.start, s.end)), - }); - } - } else { - *extractable = false; - if bail_reason.is_none() { - *bail_reason = Some(format!("unknown chain method: {}", method_name)); - } - } - } - - walk_chain_backwards(object, stages, extractable, bail_reason, has_extend_marker) - } - _ => None, - } -} - -fn match_static_member<'a, 'b>(expr: &'a Expression<'b>) -> Option<(&'a Expression<'b>, &'a str)> { - match expr { - Expression::StaticMemberExpression(member) => { - Some((&member.object, member.property.name.as_str())) - } - _ => None, - } -} - -/// What the terminal argument resolved to: a static name the emitter may -/// compile into the replacement, or a bail. Emitting a placeholder for an -/// unresolvable target is never an option — `createComponent(unknown, …)` -/// is a ReferenceError in the browser (ANI-015). -enum TerminalArg { - Resolved(String), - Unresolvable(String), -} - -/// Peel TS type-assertion wrappers and parentheses from a terminal argument: -/// `asComponent(Link as ComponentType)` names the same runtime value as -/// `asComponent(Link)`, and `asElement('div' as const)` the same tag as -/// `asElement('div')`. -fn unwrap_type_assertions<'a, 'b>(expr: &'a Expression<'b>) -> &'a Expression<'b> { - match expr { - Expression::TSAsExpression(x) => unwrap_type_assertions(&x.expression), - Expression::TSSatisfiesExpression(x) => unwrap_type_assertions(&x.expression), - Expression::TSNonNullExpression(x) => unwrap_type_assertions(&x.expression), - Expression::ParenthesizedExpression(x) => unwrap_type_assertions(&x.expression), - _ => expr, - } -} - -/// Render a dotted static-member path (`Compound.Item`, `Ns.Compound.Item`) -/// when every link is a plain identifier or static member — type-assertion -/// wrappers are peeled at every hop, since assertions are erased type-level -/// syntax and must never change extraction. Computed members, calls, and any -/// other base return None (the caller bails loudly). The emitter renders an -/// AsComponent tag VERBATIM into `createComponent(, …)`, so a dotted -/// path is exactly as valid at the definition site as the identifier form. -fn static_member_path(expr: &Expression<'_>) -> Option { - match unwrap_type_assertions(expr) { - Expression::Identifier(id) => Some(id.name.to_string()), - Expression::StaticMemberExpression(member) => { - let base = static_member_path(&member.object)?; - Some(format!("{}.{}", base, member.property.name.as_str())) - } - _ => None, - } -} - -fn extract_terminal_arg(call: &CallExpression<'_>, terminal: &TerminalKind) -> TerminalArg { - match terminal { - TerminalKind::AsClass => TerminalArg::Resolved(String::new()), - TerminalKind::AsElement => { - // v1 parity: a missing or non-literal tag keeps the empty tag. - match call - .arguments - .first() - .and_then(|arg| arg.as_expression()) - .map(unwrap_type_assertions) - { - Some(Expression::StringLiteral(lit)) => { - TerminalArg::Resolved(lit.value.to_string()) - } - _ => TerminalArg::Resolved(String::new()), - } - } - TerminalKind::AsComponent => { - match call - .arguments - .first() - .and_then(|arg| arg.as_expression()) - .map(unwrap_type_assertions) - .and_then(static_member_path) - { - Some(path) => TerminalArg::Resolved(path), - None => TerminalArg::Unresolvable( - "target has no static identifier or member path".to_string(), - ), - } - } - } -} - -/// v1-parity argument span: the EXACT variant list from v1's macro; kinds -/// outside it (e.g. arrow functions) fall back to the whole call span. -macro_rules! get_arg_span { - ($arg:expr, $fallback:expr) => { - match $arg { - Argument::SpreadElement(x) => x.span, - Argument::BooleanLiteral(x) => x.span, - Argument::NullLiteral(x) => x.span, - Argument::NumericLiteral(x) => x.span, - Argument::BigIntLiteral(x) => x.span, - Argument::RegExpLiteral(x) => x.span, - Argument::StringLiteral(x) => x.span, - Argument::TemplateLiteral(x) => x.span, - Argument::Identifier(x) => x.span, - Argument::ObjectExpression(x) => x.span, - Argument::ArrayExpression(x) => x.span, - Argument::CallExpression(x) => x.span, - _ => $fallback, - } - }; -} - -fn second_arg_span_fn(call: &CallExpression<'_>) -> Option { - call.arguments - .get(1) - .map(|arg| get_arg_span!(arg, call.span)) -} - -fn first_arg_span(call: &CallExpression<'_>) -> Option { - call.arguments - .first() - .map(|arg| get_arg_span!(arg, call.span)) -} - // ─── v1 chain_walker test module, ported VERBATIM as the bug-compatibility // contract (design.md D3). Do not "fix" expectations here — a behavioral // difference is a register entry, not a test edit. Source of truth: diff --git a/packages/extract/crates/extract-v2/src/chain_walk/expr.rs b/packages/extract/crates/extract-v2/src/chain_walk/expr.rs new file mode 100644 index 00000000..a0c2859d --- /dev/null +++ b/packages/extract/crates/extract-v2/src/chain_walk/expr.rs @@ -0,0 +1,48 @@ +//! Expression-shape helpers for the chain walk. +//! +//! Split out of `chain_walk.rs` unchanged. Pure, allocation-light readers +//! over OXC expressions with no knowledge of chains or terminals — the leaf +//! of this module's dependency layering. + +use oxc::ast::ast::Expression; + +pub(super) fn match_static_member<'a, 'b>(expr: &'a Expression<'b>) -> Option<(&'a Expression<'b>, &'a str)> { + match expr { + Expression::StaticMemberExpression(member) => { + Some((&member.object, member.property.name.as_str())) + } + _ => None, + } +} + +/// Peel TS type-assertion wrappers and parentheses from a terminal argument: +/// `asComponent(Link as ComponentType)` names the same runtime value as +/// `asComponent(Link)`, and `asElement('div' as const)` the same tag as +/// `asElement('div')`. +pub(super) fn unwrap_type_assertions<'a, 'b>(expr: &'a Expression<'b>) -> &'a Expression<'b> { + match expr { + Expression::TSAsExpression(x) => unwrap_type_assertions(&x.expression), + Expression::TSSatisfiesExpression(x) => unwrap_type_assertions(&x.expression), + Expression::TSNonNullExpression(x) => unwrap_type_assertions(&x.expression), + Expression::ParenthesizedExpression(x) => unwrap_type_assertions(&x.expression), + _ => expr, + } +} + +/// Render a dotted static-member path (`Compound.Item`, `Ns.Compound.Item`) +/// when every link is a plain identifier or static member — type-assertion +/// wrappers are peeled at every hop, since assertions are erased type-level +/// syntax and must never change extraction. Computed members, calls, and any +/// other base return None (the caller bails loudly). The emitter renders an +/// AsComponent tag VERBATIM into `createComponent(, …)`, so a dotted +/// path is exactly as valid at the definition site as the identifier form. +pub(super) fn static_member_path(expr: &Expression<'_>) -> Option { + match unwrap_type_assertions(expr) { + Expression::Identifier(id) => Some(id.name.to_string()), + Expression::StaticMemberExpression(member) => { + let base = static_member_path(&member.object)?; + Some(format!("{}.{}", base, member.property.name.as_str())) + } + _ => None, + } +} diff --git a/packages/extract/crates/extract-v2/src/chain_walk/terminal.rs b/packages/extract/crates/extract-v2/src/chain_walk/terminal.rs new file mode 100644 index 00000000..3bb94509 --- /dev/null +++ b/packages/extract/crates/extract-v2/src/chain_walk/terminal.rs @@ -0,0 +1,91 @@ +//! Terminal-argument resolution and v1-parity argument spans. +//! +//! Split out of `chain_walk.rs` unchanged. Owns what a terminal call's +//! arguments resolve to, plus the `get_arg_span!` macro encoding v1's exact +//! variant list. The macro stays here with both of its callers: `macro_rules!` +//! is textually scoped from its definition point, so separating it from +//! `second_arg_span_fn`/`first_arg_span` would need a `#[macro_use]` dance for +//! no benefit. + +use oxc::ast::ast::{Argument, CallExpression, Expression}; +use oxc::span::Span; + +use super::expr::{static_member_path, unwrap_type_assertions}; +use super::TerminalKind; + +/// What the terminal argument resolved to: a static name the emitter may +/// compile into the replacement, or a bail. Emitting a placeholder for an +/// unresolvable target is never an option — `createComponent(unknown, …)` +/// is a ReferenceError in the browser (ANI-015). +pub(super) enum TerminalArg { + Resolved(String), + Unresolvable(String), +} + +pub(super) fn extract_terminal_arg(call: &CallExpression<'_>, terminal: &TerminalKind) -> TerminalArg { + match terminal { + TerminalKind::AsClass => TerminalArg::Resolved(String::new()), + TerminalKind::AsElement => { + // v1 parity: a missing or non-literal tag keeps the empty tag. + match call + .arguments + .first() + .and_then(|arg| arg.as_expression()) + .map(unwrap_type_assertions) + { + Some(Expression::StringLiteral(lit)) => { + TerminalArg::Resolved(lit.value.to_string()) + } + _ => TerminalArg::Resolved(String::new()), + } + } + TerminalKind::AsComponent => { + match call + .arguments + .first() + .and_then(|arg| arg.as_expression()) + .map(unwrap_type_assertions) + .and_then(static_member_path) + { + Some(path) => TerminalArg::Resolved(path), + None => TerminalArg::Unresolvable( + "target has no static identifier or member path".to_string(), + ), + } + } + } +} + +/// v1-parity argument span: the EXACT variant list from v1's macro; kinds +/// outside it (e.g. arrow functions) fall back to the whole call span. +macro_rules! get_arg_span { + ($arg:expr, $fallback:expr) => { + match $arg { + Argument::SpreadElement(x) => x.span, + Argument::BooleanLiteral(x) => x.span, + Argument::NullLiteral(x) => x.span, + Argument::NumericLiteral(x) => x.span, + Argument::BigIntLiteral(x) => x.span, + Argument::RegExpLiteral(x) => x.span, + Argument::StringLiteral(x) => x.span, + Argument::TemplateLiteral(x) => x.span, + Argument::Identifier(x) => x.span, + Argument::ObjectExpression(x) => x.span, + Argument::ArrayExpression(x) => x.span, + Argument::CallExpression(x) => x.span, + _ => $fallback, + } + }; +} + +pub(super) fn second_arg_span_fn(call: &CallExpression<'_>) -> Option { + call.arguments + .get(1) + .map(|arg| get_arg_span!(arg, call.span)) +} + +pub(super) fn first_arg_span(call: &CallExpression<'_>) -> Option { + call.arguments + .first() + .map(|arg| get_arg_span!(arg, call.span)) +} diff --git a/packages/extract/crates/extract-v2/src/chain_walk/walk.rs b/packages/extract/crates/extract-v2/src/chain_walk/walk.rs new file mode 100644 index 00000000..2fc2cc51 --- /dev/null +++ b/packages/extract/crates/extract-v2/src/chain_walk/walk.rs @@ -0,0 +1,176 @@ +//! The backward chain walk itself. +//! +//! Split out of `chain_walk.rs` unchanged. Discovers `.asElement()` / +//! `.asComponent()` / `.asClass()` terminals and walks the member chain +//! backwards to its root, recording one `ChainStage` per known method. +//! +//! BUG-COMPATIBILITY: the bail rules, the zero-arg `.extend()` marker, and +//! the silent non-recording of zero-arg known methods are v1 outcomes carried +//! verbatim. A behavioural difference here is register material, not a fix. + +use oxc::ast::ast::{ + BindingPattern, CallExpression, Declaration, Expression, Program, Statement, + VariableDeclarator, +}; + +use super::expr::match_static_member; +use super::terminal::{extract_terminal_arg, first_arg_span, second_arg_span_fn, TerminalArg}; +use super::{ChainDescriptor, ChainStage, TerminalKind}; + +const BAIL_METHODS: &[&str] = &[]; +const CHAIN_METHODS: &[&str] = &["styles", "variant", "compound", "states", "system", "props"]; + +pub fn walk_program(program: &Program<'_>) -> Vec { + let mut chains = Vec::new(); + for stmt in &program.body { + match stmt { + Statement::VariableDeclaration(decl) => { + for declarator in &decl.declarations { + if let Some(chain) = try_extract_chain(declarator) { + chains.push(chain); + } + } + } + // export default chains are uncommon in Animus — v1 skips them. + Statement::ExportDefaultDeclaration(_) => {} + Statement::ExportNamedDeclaration(export) => { + if let Some(Declaration::VariableDeclaration(decl)) = &export.declaration { + for declarator in &decl.declarations { + if let Some(chain) = try_extract_chain(declarator) { + chains.push(chain); + } + } + } + } + _ => {} + } + } + chains +} + +fn try_extract_chain(declarator: &VariableDeclarator<'_>) -> Option { + let init = declarator.init.as_ref()?; + let binding = match &declarator.id { + BindingPattern::BindingIdentifier(id) => id.name.to_string(), + _ => return None, // destructuring not supported (v1 parity) + }; + let call = match init { + Expression::CallExpression(call) => call.as_ref(), + _ => return None, + }; + try_walk_chain(call, binding) +} + +fn try_walk_chain(call: &CallExpression<'_>, binding: String) -> Option { + let (object, method_name) = match_static_member(&call.callee)?; + + let terminal = match method_name { + "asElement" => TerminalKind::AsElement, + "asComponent" => TerminalKind::AsComponent, + "asClass" => TerminalKind::AsClass, + _ => return None, + }; + + let mut stages = Vec::new(); + let mut extractable = true; + let mut bail_reason: Option = None; + + let tag = match extract_terminal_arg(call, &terminal) { + TerminalArg::Resolved(tag) => tag, + TerminalArg::Unresolvable(reason) => { + extractable = false; + bail_reason = Some(format!("{}: {}", method_name, reason)); + String::new() + } + }; + let mut has_extend_marker = false; + let chain_end = call.span; + + let (chain_start, root_identifier) = walk_chain_backwards( + object, + &mut stages, + &mut extractable, + &mut bail_reason, + &mut has_extend_marker, + )?; + + stages.reverse(); + + let extends_from = if has_extend_marker { + Some(root_identifier) + } else if !stages.is_empty() { + // PRIMARY CHAIN: method pattern suffices; root NAME is irrelevant + // (v1 parity — supports `animus.styles(...)` and custom instances). + None + } else { + return None; + }; + + Some(ChainDescriptor { + binding, + terminal, + tag, + stages, + extractable, + bail_reason, + span: (chain_start, chain_end.end), + extends_from, + }) +} + +fn walk_chain_backwards( + expr: &Expression<'_>, + stages: &mut Vec, + extractable: &mut bool, + bail_reason: &mut Option, + has_extend_marker: &mut bool, +) -> Option<(u32, String)> { + match expr { + Expression::Identifier(id) => Some((id.span.start, id.name.to_string())), + Expression::CallExpression(call) => { + let (object, method_name) = match_static_member(&call.callee)?; + + if method_name == "extend" { + if call.arguments.is_empty() { + *has_extend_marker = true; + } else { + *extractable = false; + if bail_reason.is_none() { + *bail_reason = Some("extend with arguments is not supported".to_string()); + } + } + } else { + if BAIL_METHODS.contains(&method_name) { + *extractable = false; + if bail_reason.is_none() { + *bail_reason = Some(format!("{} stage not supported", method_name)); + } + } + if CHAIN_METHODS.contains(&method_name) || BAIL_METHODS.contains(&method_name) { + // v1 parity: zero-arg known methods record NOTHING and + // do not bail. + if let Some(arg_span) = first_arg_span(call) { + let second_arg_span = if method_name == "compound" { + second_arg_span_fn(call) + } else { + None + }; + stages.push(ChainStage { + method: method_name.to_string(), + arg_span: (arg_span.start, arg_span.end), + second_arg_span: second_arg_span.map(|s| (s.start, s.end)), + }); + } + } else { + *extractable = false; + if bail_reason.is_none() { + *bail_reason = Some(format!("unknown chain method: {}", method_name)); + } + } + } + + walk_chain_backwards(object, stages, extractable, bail_reason, has_extend_marker) + } + _ => None, + } +} diff --git a/packages/extract/crates/extract-v2/src/engine.rs b/packages/extract/crates/extract-v2/src/engine.rs index 141111fe..4d86e180 100644 --- a/packages/extract/crates/extract-v2/src/engine.rs +++ b/packages/extract/crates/extract-v2/src/engine.rs @@ -792,7 +792,7 @@ mod tests { ) .unwrap(); replacement_import_needs( - engine.facts.get("structured-imports.tsx").unwrap(), + &engine.facts["structured-imports.tsx"], &payloads, ) } diff --git a/packages/extract/crates/extract-v2/src/eval.rs b/packages/extract/crates/extract-v2/src/eval.rs index bc32df4e..7db6cfaa 100644 --- a/packages/extract/crates/extract-v2/src/eval.rs +++ b/packages/extract/crates/extract-v2/src/eval.rs @@ -882,7 +882,7 @@ const Component = { gap: GAP };"#; let ast = parse_ts(source.to_string()); let result_program = ast.program(); let values = collect_static_values(result_program); - let config = values.get("config").unwrap(); + let config = &values["config"]; assert_eq!(config["gap"], 16); assert_eq!(config["display"], "flex"); } diff --git a/packages/extract/crates/extract-v2/src/facts.rs b/packages/extract/crates/extract-v2/src/facts.rs index 4d625150..e47aff6d 100644 --- a/packages/extract/crates/extract-v2/src/facts.rs +++ b/packages/extract/crates/extract-v2/src/facts.rs @@ -711,7 +711,7 @@ mod tests { assert_eq!(stage.value.as_ref().unwrap()["gap"], 16); assert_eq!(stage.skipped.len(), 1); assert_eq!(stage.skipped[0].0, "color"); - assert_eq!(facts.statics.get("GAP").unwrap(), &Value::from(16)); + assert_eq!(&facts.statics["GAP"], &Value::from(16)); } #[test] diff --git a/packages/extract/crates/extract-v2/src/jsx_scan.rs b/packages/extract/crates/extract-v2/src/jsx_scan.rs index 13b11e5d..faa93ab1 100644 --- a/packages/extract/crates/extract-v2/src/jsx_scan.rs +++ b/packages/extract/crates/extract-v2/src/jsx_scan.rs @@ -5,19 +5,33 @@ //! classification replicate v1 OUTCOMES; v1's test module is carried //! verbatim below as the executable contract. Runs as a second READ of the //! stored AST inside the per-file pass (G1: zero parses added). - -use std::marker::PhantomData; - -use rustc_hash::{FxHashMap, FxHashSet}; - -use oxc::ast::ast::{ - Argument, BindingPattern, CallExpression, Declaration, Expression, JSXAttributeItem, - JSXAttributeName, JSXAttributeValue, JSXElementName, JSXExpression, JSXMemberExpression, - JSXOpeningElement, ObjectPropertyKind, Program, PropertyKey, PropertyKind, Statement, +//! +//! Module layout — the public surface is unchanged; every `jsx_scan::X` path +//! resolves exactly as before: +//! +//! `system_props` — Visit scanner for system prop usages (`scan_jsx`) +//! `usage` — variant/state usage tracking (`scan_jsx_usage`) +//! `compose` — compose() family detection (`scan_compose_calls`) +//! `value_eval` — static attribute-value evaluation (leaf) +//! +//! The result types stay here at the root: they are shared by more than one +//! scanner, so pushing them down would only create a cross-import. + +use serde_json::Value; + +mod compose; +mod system_props; +mod usage; +mod value_eval; + +pub use compose::{scan_compose_calls, ComposeFamilyInfo}; +pub use system_props::scan_jsx; +pub use usage::{ + scan_jsx_usage, ComponentUsageConfig, StateUsage, UsageScanResult, VariantUsage, }; -use oxc::ast_visit::Visit; -use oxc::span::GetSpan; -use serde_json::{Map, Value}; + +pub(crate) use usage::{classify_jsx_attribute_as_variant_value, is_component_like_identifier}; +pub(crate) use value_eval::eval_jsx_attribute_value; /// A system prop usage found in JSX. #[derive(Debug, Clone, serde::Serialize)] @@ -85,889 +99,6 @@ pub struct CustomPropScanResult { pub dynamic_usages: Vec, } -/// Scan JSX elements in a parsed program for system prop usages. -/// -/// `component_props` maps component binding names to their set of active system prop names. -/// Example: `{ "Box": {"p", "m", "mt", "display"}, "Text": {"fontSize", "color"} }` -/// -/// Returns deduplicated static usages and dynamic usages found across all JSX elements. -/// Static deduplication key is `(prop_name, serde_json::to_string(&value))`. -/// Dynamic deduplication key is `(binding, prop_name)` — scoped per component. -pub fn scan_jsx<'a>( - program: &Program<'a>, - component_props: &FxHashMap>, - member_expr_bindings: &FxHashMap, -) -> CustomPropScanResult { - let mut scanner = SystemPropScanner { - component_props, - member_expr_bindings, - seen: FxHashSet::default(), - dynamic_seen: FxHashSet::default(), - results: Vec::new(), - dynamic_results: Vec::new(), - _phantom: PhantomData, - }; - scanner.visit_program(program); - - CustomPropScanResult { - static_usages: scanner.results, - dynamic_usages: scanner.dynamic_results, - } -} - -// --------------------------------------------------------------------------- -// SystemPropScanner — Visit-based JSX scanner for system prop usages -// --------------------------------------------------------------------------- - -struct SystemPropScanner<'a, 'b> { - component_props: &'b FxHashMap>, - member_expr_bindings: &'b FxHashMap, - seen: FxHashSet, - dynamic_seen: FxHashSet, - results: Vec, - dynamic_results: Vec, - _phantom: PhantomData<&'a ()>, -} - -impl<'a, 'b> Visit<'a> for SystemPropScanner<'a, 'b> { - fn visit_jsx_opening_element(&mut self, elem: &JSXOpeningElement<'a>) { - let (tag, resolved_binding) = match &elem.name { - JSXElementName::Identifier(id) => (id.name.as_str(), None), - JSXElementName::IdentifierReference(id) => (id.name.as_str(), None), - JSXElementName::MemberExpression(member) => { - match resolve_jsx_member_expr(member, self.member_expr_bindings) { - Some(binding) => (binding.as_str(), Some(binding.clone())), - None => return, - } - } - _ => return, - }; - - let Some(active_props) = self.component_props.get(tag) else { - return; - }; - - let binding = resolved_binding.unwrap_or_else(|| tag.to_string()); - - for attr_item in &elem.attributes { - match attr_item { - JSXAttributeItem::Attribute(attr) => { - let attr_name: Option<&str> = match &attr.name { - JSXAttributeName::Identifier(id) => Some(id.name.as_str()), - JSXAttributeName::NamespacedName(_) => None, - }; - - let Some(prop_name) = attr_name else { - continue; - }; - - if !active_props.contains(prop_name) { - continue; - } - - match eval_jsx_attribute_value(&attr.value) { - PropValueResult::Static(value) => { - let dedup_key = format!( - "{}:{}", - prop_name, - serde_json::to_string(&value) - .unwrap_or_else(|_| "null".to_string()) - ); - if self.seen.insert(dedup_key) { - self.results.push(SystemPropUsage { - prop_name: prop_name.to_string(), - value, - binding: binding.clone(), - }); - } - } - PropValueResult::Dynamic { .. } => { - let dedup_key = format!("{}::{}", binding, prop_name); - if self.dynamic_seen.insert(dedup_key) { - self.dynamic_results.push(DynamicPropUsage { - prop_name: prop_name.to_string(), - binding: binding.clone(), - }); - } - } - PropValueResult::Skip => {} - } - } - JSXAttributeItem::SpreadAttribute(_) => {} - } - } - // Do NOT call walk_jsx_opening_element — we processed attributes ourselves - // and don't need to recursively visit them as AST nodes. - } -} - -// --------------------------------------------------------------------------- -// JSX attribute value evaluation -// --------------------------------------------------------------------------- - -/// Evaluate a JSX attribute value to a static JSON `Value`. -/// Returns `None` for non-static or unsupported forms — this is a silent skip, not an error. -pub(crate) fn eval_jsx_attribute_value(value: &Option) -> PropValueResult { - match value { - // Bare boolean attribute, e.g. `` — treat as `true`. - None => PropValueResult::Static(Value::Bool(true)), - - Some(JSXAttributeValue::StringLiteral(lit)) => { - PropValueResult::Static(Value::String(lit.value.to_string())) - } - - Some(JSXAttributeValue::ExpressionContainer(container)) => { - match &container.expression { - JSXExpression::EmptyExpression(_) => PropValueResult::Skip, - // JSXExpression @inherits Expression — match directly on static literal variants. - JSXExpression::StringLiteral(lit) => { - PropValueResult::Static(Value::String(lit.value.to_string())) - } - JSXExpression::NumericLiteral(lit) => { - PropValueResult::Static(make_json_number(lit.value)) - } - JSXExpression::BooleanLiteral(lit) => { - PropValueResult::Static(Value::Bool(lit.value)) - } - JSXExpression::NullLiteral(_) => PropValueResult::Static(Value::Null), - JSXExpression::UnaryExpression(unary) => { - if unary.operator == oxc::syntax::operator::UnaryOperator::UnaryNegation { - if let Expression::NumericLiteral(lit) = &unary.argument { - return PropValueResult::Static(make_json_number(-lit.value)); - } - } - dynamic_expression(container.expression.to_expression()) - } - JSXExpression::ObjectExpression(obj) => match eval_static_object(obj) { - Some(v) => PropValueResult::Static(v), - None => dynamic_expression(container.expression.to_expression()), - }, - JSXExpression::ParenthesizedExpression(paren) => { - match eval_static_expression(&paren.expression) { - Some(v) => PropValueResult::Static(v), - None => dynamic_expression(&paren.expression), - } - } - JSXExpression::TemplateLiteral(tpl) if tpl.expressions.is_empty() => { - match tpl - .quasis - .first() - .map(|q| Value::String(q.value.raw.to_string())) - { - Some(v) => PropValueResult::Static(v), - None => PropValueResult::Skip, - } - } - // All dynamic / non-static forms — identifier, call expression, - // conditional, member expression, template literal with expressions, etc. - _ => dynamic_expression(container.expression.to_expression()), - } - } - - // Element or fragment as attribute value — not a system prop value. - Some(JSXAttributeValue::Element(_)) | Some(JSXAttributeValue::Fragment(_)) => { - PropValueResult::Skip - } - } -} - -fn dynamic_expression(expr: &Expression<'_>) -> PropValueResult { - let mut expr = expr; - while let Expression::ParenthesizedExpression(paren) = expr { - expr = &paren.expression; - } - let kind = match expr { - Expression::Identifier(_) => DynamicExpressionKind::Identifier, - Expression::ComputedMemberExpression(_) - | Expression::StaticMemberExpression(_) - | Expression::PrivateFieldExpression(_) => DynamicExpressionKind::Member, - Expression::CallExpression(_) => DynamicExpressionKind::Call, - Expression::ConditionalExpression(_) => DynamicExpressionKind::Conditional, - Expression::LogicalExpression(_) => DynamicExpressionKind::Logical, - Expression::TemplateLiteral(_) => DynamicExpressionKind::Template, - Expression::BinaryExpression(_) => DynamicExpressionKind::Binary, - Expression::ObjectExpression(_) => DynamicExpressionKind::ResponsiveObjectDynamic, - Expression::ArrayExpression(_) => DynamicExpressionKind::Array, - _ => DynamicExpressionKind::Other, - }; - let span = expr.span(); - PropValueResult::Dynamic { - kind, - span: UsageSpan { - start: span.start, - end: span.end, - }, - } -} - -// --------------------------------------------------------------------------- -// Static expression evaluation helpers -// --------------------------------------------------------------------------- - -/// Evaluate an `Expression` to a static JSON `Value`. -/// Only handles the static subset defined in the spec. -fn eval_static_expression(expr: &Expression) -> Option { - match expr { - Expression::StringLiteral(lit) => Some(Value::String(lit.value.to_string())), - Expression::NumericLiteral(lit) => Some(make_json_number(lit.value)), - Expression::BooleanLiteral(lit) => Some(Value::Bool(lit.value)), - Expression::NullLiteral(_) => Some(Value::Null), - - Expression::UnaryExpression(unary) => { - if unary.operator == oxc::syntax::operator::UnaryOperator::UnaryNegation { - if let Expression::NumericLiteral(lit) = &unary.argument { - return Some(make_json_number(-lit.value)); - } - } - None - } - - Expression::ObjectExpression(obj) => eval_static_object(obj), - - Expression::ParenthesizedExpression(paren) => eval_static_expression(&paren.expression), - - Expression::TemplateLiteral(tpl) if tpl.expressions.is_empty() => tpl - .quasis - .first() - .map(|q| Value::String(q.value.raw.to_string())), - - _ => None, - } -} - -/// Evaluate an `ObjectExpression` whose keys and values are all statically known. -/// Returns `None` if any property is non-static (computed key, spread, dynamic value). -fn eval_static_object(obj: &oxc::ast::ast::ObjectExpression) -> Option { - let mut map = Map::new(); - - for prop_kind in &obj.properties { - match prop_kind { - ObjectPropertyKind::ObjectProperty(prop) => { - if prop.kind != PropertyKind::Init || prop.computed { - return None; - } - let key = eval_property_key(&prop.key)?; - let val = eval_static_expression(&prop.value)?; - map.insert(key, val); - } - ObjectPropertyKind::SpreadProperty(_) => return None, - } - } - - Some(Value::Object(map)) -} - -/// Evaluate a property key to a `String`. -fn eval_property_key(key: &PropertyKey) -> Option { - match key { - PropertyKey::StaticIdentifier(id) => Some(id.name.to_string()), - PropertyKey::StringLiteral(lit) => Some(lit.value.to_string()), - PropertyKey::NumericLiteral(lit) => Some(lit.value.to_string()), - _ => None, - } -} - -/// Convert an `f64` to a `serde_json::Value::Number`, preserving integer form where possible. -fn make_json_number(v: f64) -> Value { - if v.fract() == 0.0 && v.abs() < (i64::MAX as f64) { - Value::Number(serde_json::Number::from(v as i64)) - } else { - Value::Number( - serde_json::Number::from_f64(v).unwrap_or_else(|| serde_json::Number::from(0)), - ) - } -} - -// --------------------------------------------------------------------------- -// Usage tracking types -// --------------------------------------------------------------------------- - -/// Information about a component's variant/state configuration for usage tracking -#[derive(Debug, Clone, Default)] -pub struct ComponentUsageConfig { - /// Map of variant prop name → (set of option names, optional default) - pub variants: FxHashMap, Option)>, - /// Set of state prop names - pub states: FxHashSet, -} - -/// Variant usage found at a JSX callsite -#[derive(Debug, Clone, serde::Serialize)] -pub struct VariantUsage { - pub component_binding: String, - pub variant_prop: String, - /// The value: a literal string, "__dynamic__" for non-static, "__default__" for prop absence - pub value: String, -} - -/// State usage found at a JSX callsite -#[derive(Debug, Clone, serde::Serialize)] -pub struct StateUsage { - pub component_binding: String, - pub state_name: String, -} - -/// Complete usage scan results from one file -#[derive(Debug, Clone, Default, serde::Serialize)] -pub struct UsageScanResult { - pub system_prop_usages: Vec, - pub dynamic_prop_usages: Vec, - pub residue_sites: Vec, - pub variant_usages: Vec, - pub state_usages: Vec, - pub rendered_components: FxHashSet, - /// A component-like tag was rendered, but its canonical extracted - /// component binding could not be resolved. Internal reachability signal; - /// it is not part of the serialized usage contract. - #[serde(skip)] - pub identity_uncertain: bool, -} - -pub(crate) fn is_component_like_identifier(name: &str) -> bool { - name.chars().next().is_some_and(char::is_uppercase) -} - -// --------------------------------------------------------------------------- -// Usage scanning — public entry point -// --------------------------------------------------------------------------- - -/// Scan JSX elements for system prop values AND variant/state/component usage. -/// This is an extended version of scan_jsx that also tracks behavioral usage. -/// -/// `component_configs` maps binding name → ComponentUsageConfig (variant/state info) -/// `component_props` maps binding name → active system prop names (same as scan_jsx) -pub fn scan_jsx_usage<'a>( - program: &Program<'a>, - component_props: &FxHashMap>, - component_configs: &FxHashMap, - member_expr_bindings: &FxHashMap, -) -> UsageScanResult { - let mut scanner = UsageScanner { - component_props, - component_configs, - member_expr_bindings, - seen: FxHashSet::default(), - result: UsageScanResult::default(), - _phantom: PhantomData, - }; - scanner.visit_program(program); - scanner.result -} - -// --------------------------------------------------------------------------- -// UsageScanner — Visit-based JSX scanner for variant/state/system prop usage -// --------------------------------------------------------------------------- - -struct UsageScanner<'a, 'b> { - component_props: &'b FxHashMap>, - component_configs: &'b FxHashMap, - member_expr_bindings: &'b FxHashMap, - seen: FxHashSet, - result: UsageScanResult, - _phantom: PhantomData<&'a ()>, -} - -impl<'a, 'b> Visit<'a> for UsageScanner<'a, 'b> { - fn visit_jsx_opening_element(&mut self, elem: &JSXOpeningElement<'a>) { - let (tag, resolved_binding) = match &elem.name { - JSXElementName::Identifier(id) => (id.name.as_str(), None), - JSXElementName::IdentifierReference(id) => (id.name.as_str(), None), - JSXElementName::MemberExpression(member) => { - match resolve_jsx_member_expr(member, self.member_expr_bindings) { - Some(binding) => (binding.as_str(), Some(binding.clone())), - None => { - self.result.identity_uncertain = true; - return; - } - } - } - _ => return, - }; - - let has_props = self.component_props.contains_key(tag); - let has_config = self.component_configs.contains_key(tag); - - if !has_props && !has_config { - if is_component_like_identifier(tag) { - self.result.identity_uncertain = true; - } - return; - } - - let binding = resolved_binding.unwrap_or_else(|| tag.to_string()); - - // Track that this component was rendered - self.result.rendered_components.insert(binding.clone()); - - // Gather active system props for this component (if any) - let active_props = self.component_props.get(tag); - - // Track which variant props have been seen (for absence detection) - let mut seen_variant_props: FxHashSet = FxHashSet::default(); - - for attr_item in &elem.attributes { - match attr_item { - JSXAttributeItem::Attribute(attr) => { - let attr_name: Option<&str> = match &attr.name { - JSXAttributeName::Identifier(id) => Some(id.name.as_str()), - JSXAttributeName::NamespacedName(_) => None, - }; - - let Some(prop_name) = attr_name else { - continue; - }; - - // --- System prop collection --- - if let Some(props) = active_props { - if props.contains(prop_name) { - match eval_jsx_attribute_value(&attr.value) { - PropValueResult::Static(value) => { - let dedup_key = format!( - "{}:{}", - prop_name, - serde_json::to_string(&value) - .unwrap_or_else(|_| "null".to_string()) - ); - if self.seen.insert(dedup_key) { - self.result.system_prop_usages.push(SystemPropUsage { - prop_name: prop_name.to_string(), - value, - binding: binding.clone(), - }); - } - } - PropValueResult::Dynamic { kind, span } => { - self.result.residue_sites.push(UsageResidueSite { - binding: binding.clone(), - prop_name: prop_name.to_string(), - kind, - span, - }); - let dedup_key = format!("__dynamic__:{}", prop_name); - if self.seen.insert(dedup_key) { - self.result.dynamic_prop_usages.push(DynamicPropUsage { - prop_name: prop_name.to_string(), - binding: binding.clone(), - }); - } - } - PropValueResult::Skip => {} - } - } - } - - // --- Variant and state collection --- - if let Some(config) = self.component_configs.get(tag) { - if config.variants.contains_key(prop_name) { - seen_variant_props.insert(prop_name.to_string()); - - let variant_value = - classify_jsx_attribute_as_variant_value(&attr.value); - self.result.variant_usages.push(VariantUsage { - component_binding: binding.clone(), - variant_prop: prop_name.to_string(), - value: variant_value, - }); - } - - if config.states.contains(prop_name) { - self.result.state_usages.push(StateUsage { - component_binding: binding.clone(), - state_name: prop_name.to_string(), - }); - } - } - } - JSXAttributeItem::SpreadAttribute(_) => {} - } - } - - // Detect absent variant props — emit __default__ for each unseen variant prop - if let Some(config) = self.component_configs.get(tag) { - for variant_prop in config.variants.keys() { - if !seen_variant_props.contains(variant_prop) { - self.result.variant_usages.push(VariantUsage { - component_binding: binding.clone(), - variant_prop: variant_prop.clone(), - value: "__default__".to_string(), - }); - } - } - } - // Do NOT call walk_jsx_opening_element — we processed attributes ourselves. - } - - fn visit_call_expression(&mut self, call: &CallExpression<'a>) { - // Recognize `createElement(Component, ...)` and `React.createElement(Component, ...)` - // as component render usage, parity with JSX-element and JSX-member-expression paths. - let is_create_element = match &call.callee { - Expression::Identifier(id) => id.name.as_str() == "createElement", - Expression::StaticMemberExpression(member) => match &member.object { - Expression::Identifier(obj) => { - obj.name.as_str() == "React" && member.property.name.as_str() == "createElement" - } - _ => false, - }, - _ => false, - }; - - if is_create_element { - if let Some(first_arg) = call.arguments.first() { - let resolved: Option = match first_arg { - // Bare identifier: createElement(Component, ...) — resolve against the - // active binding maps the same way JSX tags do. - Argument::Identifier(id) => { - let name = id.name.as_str(); - if self.component_props.contains_key(name) - || self.component_configs.contains_key(name) - { - Some(name.to_string()) - } else { - self.result.identity_uncertain = true; - None - } - } - // Member expression: createElement(Family.Slot, ...) — dotted-key lookup - // matches the JSX `` resolution path. - Argument::StaticMemberExpression(member) => { - let resolved = match &member.object { - Expression::Identifier(obj) => { - let dotted_key = format!( - "{}.{}", - obj.name.as_str(), - member.property.name.as_str() - ); - self.member_expr_bindings.get(&dotted_key).cloned() - } - _ => None, - }; - if resolved.is_none() { - self.result.identity_uncertain = true; - } - resolved - } - // String literal → native DOM element, no render tracking. - Argument::StringLiteral(_) => None, - // Any other form (call, conditional, template, etc.) is - // component-like but cannot be attributed safely. - _ => { - self.result.identity_uncertain = true; - None - } - }; - - if let Some(binding) = resolved { - self.result.rendered_components.insert(binding); - } - } - } - - // Continue walking into arguments so nested createElement / JSX children are visited. - oxc::ast_visit::walk::walk_call_expression(self, call); - } -} - -// --------------------------------------------------------------------------- -// Variant value classifier -// --------------------------------------------------------------------------- - -/// Classify a JSX attribute value for variant tracking. -/// -/// - String literal (bare or `{...}`) → return the string -/// - Any non-static expression (identifier, call, conditional, etc.) → "__dynamic__" -/// - Absent value (bare boolean prop like ``) → "__dynamic__" (treat as non-static) -/// -/// Note: absent variant props are handled separately via absence detection after the attribute loop. -/// This function only classifies a present attribute's value. -pub(crate) fn classify_jsx_attribute_as_variant_value(value: &Option) -> String { - match value { - // Bare attribute with no value: `