diff --git a/bun.lock b/bun.lock index 4a264273..9858e9b3 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,22 @@ "@contember/schema": "^2.1.0-beta.1", }, }, + "packages/bindx-compiler": { + "name": "@contember/bindx-compiler", + "version": "0.1.46", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0", + }, + "devDependencies": { + "@babel/plugin-transform-react-jsx": "^7.28.0", + "@contember/bindx-react": "workspace:*", + "@types/babel__core": "^7.20.5", + "@types/babel__traverse": "^7.20.6", + }, + }, "packages/bindx-dataview": { "name": "@contember/bindx-dataview", "version": "0.1.46", @@ -190,6 +206,7 @@ "tailwindcss": "^4.2.1", }, "devDependencies": { + "@contember/bindx-compiler": "workspace:*", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^5.1.2", @@ -207,6 +224,8 @@ "@babel/generator": ["@babel/generator@7.29.7", "", { "dependencies": { "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" } }, "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ=="], + "@babel/helper-annotate-as-pure": ["@babel/helper-annotate-as-pure@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" } }, "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw=="], + "@babel/helper-compilation-targets": ["@babel/helper-compilation-targets@7.29.7", "", { "dependencies": { "@babel/compat-data": "^7.29.7", "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" } }, "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g=="], "@babel/helper-globals": ["@babel/helper-globals@7.29.7", "", {}, "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA=="], @@ -227,6 +246,10 @@ "@babel/parser": ["@babel/parser@7.29.7", "", { "dependencies": { "@babel/types": "^7.29.7" }, "bin": "./bin/babel-parser.js" }, "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg=="], + "@babel/plugin-syntax-jsx": ["@babel/plugin-syntax-jsx@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A=="], + + "@babel/plugin-transform-react-jsx": ["@babel/plugin-transform-react-jsx@7.29.7", "", { "dependencies": { "@babel/helper-annotate-as-pure": "^7.29.7", "@babel/helper-module-imports": "^7.29.7", "@babel/helper-plugin-utils": "^7.29.7", "@babel/plugin-syntax-jsx": "^7.29.7", "@babel/types": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A=="], + "@babel/plugin-transform-react-jsx-self": ["@babel/plugin-transform-react-jsx-self@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw=="], "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], @@ -243,6 +266,8 @@ "@contember/bindx-client": ["@contember/bindx-client@workspace:packages/bindx-client"], + "@contember/bindx-compiler": ["@contember/bindx-compiler@workspace:packages/bindx-compiler"], + "@contember/bindx-dataview": ["@contember/bindx-dataview@workspace:packages/bindx-dataview"], "@contember/bindx-editor": ["@contember/bindx-editor@workspace:packages/bindx-editor"], diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md new file mode 100644 index 00000000..a1e9096d --- /dev/null +++ b/docs/compiler-plan.md @@ -0,0 +1,849 @@ +# Selection Compiler — Experimental Plan + +Build-time extraction of `createComponent()` implicit selections, replacing the runtime +proxy-execution pass with statically emitted metadata. Experimental; lives on +`experiment/selection-compiler`. + +## Goals & principles + +1. **Progressive enhancement, never mandatory.** A compiled app behaves identically to an + uncompiled one. The compiler is an optimization/robustification layer; runtime proxy + collection remains the universal fallback. (React Compiler philosophy, not Relay's + mandatory-codegen model.) +2. **Emit-or-bail per component.** The compiler emits a selection only when it can prove it. + Anything unprovable → the whole component bails to runtime collection, with a machine-readable + reason. Over-approximation (extra fields) is acceptable; under-approximation (missing fields) + is a correctness bug and must be impossible by construction. +3. **Replace only the per-component collection pass.** Cross-component composition + (`analyzeJsx`/`getSelection` walk, slot handling, fragment merging) stays at runtime and is + untouched. This makes the transform **purely local** — per file, per chain; no cross-module + analysis, no type checker. +4. **The runtime collector is the oracle.** Every fixture is validated by comparing the + compiler's output against what runtime proxy collection produces for the same component. + +## What the compiler can do that runtime collection cannot + +- Union **both** branches of every conditional (runtime execution follows one branch). +- Analyze `.map()` and other callbacks without sample data (`.mock()` workaround unneeded). +- Never execute user code during analysis → the entire crash-and-degrade machinery + (tolerant scalar mocks, partial-scope recovery) becomes irrelevant for compiled components. + +## Non-goals (v1) + +- Nested bindx-component composition via static fragment references (`__compose` holes) — bail + for now; phase 2 candidate. +- `interfaces()` mode — bail. +- unplugin/webpack/Next packaging, eslint plugin, SWC/oxc port — later. +- Explicit-selector entity props — already static; nothing to compile. + +## Deliverables + +| # | What | Where | +|---|------|-------| +| A | Runtime support for precompiled selections + validate mode | `packages/bindx`, `packages/bindx-react` | +| B | Analyzer core + Babel plugin + equivalence harness | `packages/bindx-compiler` (new) | +| C | Integration: end-to-end wiring, playground Vite setup, bail-rate measurement | after A+B | + +A and B are independent — they share only the format contract below. + +## Contract: static selection format + +The compiler emits, and the runtime consumes, a plain serializable object. This is the **only** +coupling between A and B: + +```ts +/** Selection for one implicit entity prop. Key = field name. */ +type StaticFieldMap = Record + +type StaticFieldNode = + | true // scalar leaf (or relation touched without nested access) + | { + fields: StaticFieldMap // nested selection → this field is a relation + many?: true // has-many (known from usage or collection params) + params?: { // has-many params; only statically-literal values + filter?: unknown + orderBy?: unknown + limit?: number + offset?: number + totalCount?: boolean + } + } + +/** Emitted as the 2nd argument of .render(): key = implicit entity prop name. */ +type StaticSelection = Record +``` + +Notes: +- Relation-ness is derived from **usage** (nested access ⇒ relation), exactly like the runtime + collector — `entityDef` carries no schema, so neither side may depend on a registry. +- Aliases don't occur in implicit render bodies (only in explicit selectors) — not represented. + +## A — Runtime support (`bindx` + `bindx-react`) + +1. **Converter** `staticSelectionToMeta(map: StaticFieldMap): SelectionMeta` placed next to + `SelectionScope` in `packages/bindx/src/selection/`. Output must be indistinguishable from + `SelectionScope.toSelectionMeta()` for the equivalent access pattern (`fieldName`, `alias`, + `path`, `isRelation`, `isArray`, `nested`, `hasManyParams`). SelectionScope is the reference — + mirror its path semantics exactly; test by comparing against scope-collected output for the + same shapes. +2. **Builder API**: `.render(fn)` gains an optional second parameter + `staticSelection?: StaticSelection`. Compiler-facing only — documented as such. Threaded + through `ComponentBuilderImpl` → `buildComponent`. +3. **`buildComponent`**: when `staticSelection` is present, `ensureImplicitCollected()` builds + `selectionsMap` entries from the converter (per entity prop that appears in the static object) + and **skips the proxy pass entirely**. Entity props absent from the static object (possible + future partial emit) fall back to the proxy pass — v1 may simply treat presence as all-or-nothing, + matching the compiler's emit-or-bail. +4. **Validate mode**: exported `setStaticSelectionValidation(enabled: boolean)` (module-level flag + in bindx-react). When enabled and a static selection is present, ALSO run the proxy pass and + deep-diff the resulting `SelectionMeta` per prop; mismatch → single `console.warn` with + component display name and a readable diff. This is the trust-building mode (dev/CI). +5. Tests: converter equivalence, static path used (proxy pass provably not executed — e.g. render + fn with a side-effect counter), fallback without static arg unchanged, validate mode + both agree/disagree cases. + +## B — Compiler (`packages/bindx-compiler`, new package) + +Dependencies: `@babel/core`, `@babel/parser`, `@babel/traverse`, `@babel/types`. Private package +(not published) for now; wire into root tsconfig project references + workspace. + +### Structure + +- `src/analyze.ts` — pure core: `analyzeSource(code, filename) → ChainResult[]` where + `ChainResult = { loc, entityProps: string[], selection: StaticSelection } | { loc, bailout: BailoutReason }`. +- `src/babelPlugin.ts` — Babel plugin: runs the analyzer, injects the `StaticSelection` object + literal as the 2nd argument of the chain's `.render(...)` call. Bailed chains are left untouched. +- `src/index.ts` — exports both. +- `tests/fixtures/*.tsx` + harness (below). + +### Chain recognition + +- Track `createComponent` via import binding from `@contember/bindx-react` (accept any + `@contember/bindx*` source). Follow the fluent chain syntactically. +- `.entity(name, def)` (2 args) → implicit entity prop `name`. `.entity(name, def, selector)` + (3 args) → explicit; ignore (not collected via proxy today either). +- `.props()`, `.use()`, `.mock()`, `.slots()`, `.roles()` → chain metadata, no effect on analysis + (`.mock()` values are irrelevant — the compiler never executes anything). +- `.condition(fn)` → analyze `fn` like a render body (runtime collection executes it too). +- `.interfaces(...)` → **bail** (interfaces mode). +- `.render(fn)` → analyze `fn`. + +### Analysis rules + +Walk the entire function body (all branches, all nested function expressions — control flow is +irrelevant because union is sound): + +- **Roots**: destructured implicit entity props (`({ article })`) or member access off an + identifier param (`p.article`). Callback params of ``/`` children become new + roots scoped to the callback, rooted at the relation's path. +- **Local aliases**: `const a = article.author` extends the root set. `let`/reassignment of an + entity-rooted binding → bail. +- **Member chains** on a root record the path (`article.author.name` → `author.fields.name`). + Skip `$`-prefixed meta properties and whatever else the runtime collector proxy ignores — + read `packages/bindx-react/src/jsx/proxy.ts` + `SelectionScope` and mirror their skip-list. +- **Recognized components** (imported from `@contember/bindx*`): `Field`/`Attribute`/`Show` + (`field={}` → leaf), `HasOne` (relation + children-callback root), `HasMany` (relation, + `many: true`, JSX props `limit/offset/orderBy/filter/totalCount` → `params` when the values are + **literals** — otherwise bail), `If` and other selection-neutral bindx components → recurse + into children only. +- **Bail triggers** (component-level, with reason codes): entity-rooted value passed to an + unrecognized function call or as a prop to an unrecognized component; spread of an entity root; + computed member access `article[x]`; non-literal HasMany params; `interfaces()`; + render body not an inline function literal (imported/renamed render fn); any expression form + the analyzer cannot confidently classify (**default deny**). +- Plain JSX/host elements and unknown components: recurse into their **children** (matching + `analyzeJsx`'s children-walk), but any entity-rooted value in their **props** other than + children → bail (see above). + +### Equivalence harness (the important part) + +For each fixture: +1. Import the fixture module directly (Bun executes TSX natively) — trigger runtime collection via + the `$propName` fragment getters, read `COMPONENT_SELECTIONS`, normalize via + `convertToQuerySelection`-style plain form. +2. Run `analyzeSource` on the fixture source, normalize the emitted `StaticSelection` the same way. +3. Deep-equal per entity prop. Expected-bail fixtures assert the bailout reason instead. + +Fixture set (minimum): scalar fields; nested has-one; has-many with literal params; both-branch +ternary (document expected superset vs runtime — see below); map over has-many children callback; +alias `const`; `.condition()` accesses; `.use()`/`.mock()`/`.props()` present but irrelevant; +each bail trigger. + +**Ternary caveat**: branch-union fixtures will legitimately differ from the runtime oracle +(compiler = union, runtime = one branch). The harness must assert `runtime ⊆ compiler` for these +instead of strict equality — flag them explicitly in the fixture (e.g. exported marker). + +## C — Integration (after A + B land) + +1. Babel plugin emit → A's `.render(fn, static)` — end-to-end test: transformed source, verify + proxy pass skipped and fetch identical (MockAdapter query spec comparison). +2. Playground: wire the plugin into `packages/example` Vite config via `@vitejs/plugin-react`'s + `babel.plugins` option, behind an env flag; validate mode on. +3. Measure: compile every `createComponent` in `packages/example` + test corpus, report + compiled-vs-bailed percentage and reasons — this decides what phase 2 tackles first. +4. Docs: short section in `docs/selection-collection.md`. + +## Phase 2 — nested-component composition (holes) — IMPLEMENTED + +Status: **implemented** on `experiment/selection-compiler`. Runtime resolution +(`applyCompiledSelection`, `packages/bindx-react/src/jsx/compiledSelection.ts`), compiler hole +emission (`packages/bindx-compiler`), full hole-equivalence + end-to-end tests, and the dataview +relation-column fix have all landed and are green. + +Result (re-measured on the reference app, 257 chains): +**242/257 compiled = 94 %** (phase 1 was 216/257 = 84 %). 26 chains carry 82 holes total. 15 bails +remain: 9 `FUNCTION_PROP_ON_HOLE`, 5 `ENTITY_IN_EXPRESSION_PROP`, 1 `ENTITY_REASSIGNMENT` — the +`ENTITY_ESCAPES_TO_COMPONENT` class that dominated phase 1 is gone. + +> **Soundness correction (was 98 %).** An earlier measurement read 251/257 = 98 % but was +> **unsound**: a hole element's function props / render-prop children were dropped from the emitted +> hole (they are non-literal), yet a hole target's `staticRender` may *invoke* such a closure during +> collection with a collector proxy — the reference app's `SelectField` does exactly +> `{e => props.children(e)}`. The runtime oracle therefore +> collected fields from the closure body (e.g. `it => ` → `author.name`) that +> the compiled path could not → **compiled ⊂ runtime → under-fetch → `UnfetchedFieldError`** in +> compiled apps. Emit-or-bail requires under-approximation to be impossible by construction, so these +> chains now **bail** (`FUNCTION_PROP_ON_HOLE`). The 4-point rate drop is the honest cost of closing +> the under-fetch class; see the phase-2.1 recovery note below. + +### FUNCTION_PROP_ON_HOLE — dropping a closure from a hole must be provably safe + +A function-valued prop (or function children) on a **hole** element is safe to omit from the hole +**iff invoking it at collection time cannot reach a selection scope**. The only gateways are (a) the +closure's OWN parameters — a `staticRender` may invoke it with a collector proxy, and *any* param use +(including passing a param onward to another call) lets the proxy in — and (b) captured entity-rooted +bindings (roots / aliases). So the compiler classifies each dropped inline closure: + +- Body references **no own parameters** (transitively — nested inner functions' params are *their* + own; what matters is whether OUR params or entity roots are reachable) and **no entity roots** → + **SAFE**: omit it, keep the chain compiled. Covers `onClick={() => save()}`, `format={() => null}`. +- Otherwise → **BAIL** `FUNCTION_PROP_ON_HOLE`. Default deny: when unsure, bail. + +Scope: only **inline** arrow / function-expression prop values and render-prop children of hole +elements. Closures that capture roots in a non-hole expression prop already bail as +`ENTITY_IN_EXPRESSION_PROP` (left as is); the new code specifically covers the *param-mediated* +danger. Function **children of non-hole** elements stay statically walked (sound: the runtime either +ignores the closure or collects a subset of the analyzed union). Implemented in +`jsx.ts` (`walkComponentElement` → `assertHoleClosuresSafe`) + `resolve.ts` (`isHoleClosureSafe`). + +**Phase 2.1 (implemented): lifting via `extraProps`.** Rather than dropping (and bailing on) +non-entity values that a target may invoke during collection, phase 2.1 *lifts* them into the hole. +See the dedicated section below. + +Motivation: phase 1 compiled 216/257 chains (84 %); 40 of 41 bails were `ENTITY_ESCAPES_TO_COMPONENT`. +Phase 2 turns those escapes into **holes**: statically-emitted references to the nested component, +resolved at collection time through the component's existing runtime selection surface +(`getSelection` / `staticRender`) — the Relay-fragment-spread equivalent, without executing the +host render body. + +### Contract v2: compiled selection shape + +The 2nd argument of `.render()` changes shape (breaking change within the experiment — update +runtime, emit, fixtures, docs together): + +```ts +interface CompiledSelection { + /** Per implicit entity prop — same StaticFieldMap as phase 1. */ + props: Record + /** Nested components that received entity-derived values. */ + holes?: CompiledHole[] +} + +interface CompiledHole { + /** Thunk, not a direct reference — dodges TDZ for components defined later in the module + (same reason runtime collection is lazy). Resolved inside ensureImplicitCollected. */ + component: () => unknown + /** Target prop name → where the value comes from: host entity prop + member path. + Empty path = the root itself. */ + entityProps: Record + /** Statically-literal non-entity props of the JSX element (strings, numbers, booleans, + literal objects/arrays). Non-literal non-entity props are simply omitted. */ + literalProps?: Record +} +``` + +### Runtime resolution (bindx-react) + +In `ensureImplicitCollected`, when a compiled selection is present: + +1. Build a live `SelectionScope` per entity prop and drive it from `props[name]` (refactor of the + phase-1 converter: keep the scope open instead of immediately snapshotting). +2. For each hole: resolve `component()`; build the value for each `entityProps` entry by creating + the source prop's collector proxy and **replaying the member path via property gets** + (`path.reduce((o, k) => o[k], proxy)`) — identical semantics to the proxy pass by construction + (scalar-vs-relation deferral, `SCOPE_REF`/`FIELD_REF_META` markers all come out right). +3. Feed the assembled props to the target's selection surface, mirroring `analyzeJsx` order: + `getSelection(props, collectNested)` if present, else `staticRender(props)` + `collectSelection` + on its result. Missing props: `getSelection` skips absent props naturally; for `staticRender` + wrap the props object so unknown keys fall back to the tolerant scalar mock. Errors are + contained per hole (same report-and-continue policy as `analyzeJsx`). +4. Target has neither surface (plain React component): the hole contributes nothing — the runtime + proxy pass is equally blind there, so compiled behavior stays exactly equivalent (this is the + documented reference-app dummy-`` blind spot). In validate mode, emit a dev-only warn naming the + component so the blind spot becomes discoverable instead of silent. +5. Finalize scopes → `SelectionMeta` → fragments, as today. The host render fn is still never + executed. + +### Compiler side + +- `ENTITY_ESCAPES_TO_COMPONENT` no longer bails when the escape is a prop on a **component-typed + JSX element with a resolvable identifier** (local or imported — the emit references the + identifier via a thunk in the same module scope). Multiple entity props on one element form one + hole. Children of the element keep being analyzed statically (not part of the hole). +- Still bails: entity in a non-JSX call argument (`ENTITY_ESCAPES_TO_CALL`), entity in a + **non-literal expression prop that isn't a plain path** (e.g. `prop={fn(article)}`), spread onto + an element, member-expression/namespace component tags (v2 keeps it simple: identifier tags only), + and an **unsafe function prop / render-prop child of a hole element** (`FUNCTION_PROP_ON_HOLE` — see + above). +- Emit: object literal with thunks — no longer pure JSON; snapshot tests must cover thunk emission. +- Measure script: report per-chain hole counts; summary gains `compiled (with holes)`. + +### Equivalence harness + +The oracle (runtime proxy pass) DOES resolve nested `getSelection`/`staticRender` components — +so hole-carrying fixtures are directly comparable end-to-end once runtime resolution lands: +compiled (fields + resolved holes) must equal oracle. Plain-component fixtures: both sides blind → +equal by omission. Fixture set: createComponent target, `withCollector` target, plain component +target (with and without sibling dummy ``s), multiple entity props on one element, +entity-derived path (`article.author`) into a target, hole target defined later in the module (TDZ), +literal + non-literal extra props. + +### Related runtime fix (in scope — the reference app workaround removal) — DONE + +`DataGridHasOneColumn`'s `collectSelection` (bindx-dataview `createRelationColumn.tsx`) discarded +the renderer's returned JSX, so nested ``/`` inside relation-column renderers were +never collected (the reference app worked around it with a `.map()` trick). Fixed: `walkRendererJsx` now runs +`collectSelection` on the renderer's returned JSX in addition to the proxy capture, in both the +`buildLeaf` `relatedSelection` computation and the hasOne/hasMany cell configs. The JSX walk drives +the collector proxy (via `HasMany.getSelection`'s `map`), registering nested fields into the parent +scope — mirroring `collectImplicitSelections`. Errors are contained per column. Independent of the +compiler; benefits uncompiled apps too. Regression test: `tests/react/dataview/createRelationColumn.test.tsx` +("nested declarative selection (the reference app regression)"). + +### Explicit non-goal + +Statically analyzing **plain React component bodies** (even same-file) to collect their +`useField`-style reads is deliberately out: compiled selection would then be *stronger* than the +runtime fallback, so an app could work compiled and under-fetch uncompiled — breaking the +progressive-enhancement equivalence guarantee. The path for that blind spot is diagnostics +(validate-mode warn now, eslint rule later), or a future runtime analyzer improvement — not a +compiler-only fix. + +## Phase 2.1 — lifting non-entity hole props via `extraProps` — IMPLEMENTED + +Phase 2 dropped a hole element's function props / render-prop children and identifier-valued props, +bailing (`FUNCTION_PROP_ON_HOLE`) when that drop could under-fetch. Phase 2.1 closes the gap by +**lifting** the value into the hole instead of dropping it: module-scope values and render-scope-free +closures are in scope at the emit site, so they can be passed INTO the hole and handed to the target +at resolution — making compiled ≡ oracle by construction, function or not. + +### Contract addition + +`CompiledHole` gains `extraProps?: Record unknown>` — thunked (TDZ-safe, same reason as +`component`). Hole resolution resolves each thunk and merges the value into the assembled props +(before the entity proxies, which win on collision). A target's `staticRender` that *invokes* a +lifted closure (`props.children(entity)`) therefore collects the same fields the oracle would. + +### The taint lattice (default deny) + +A non-entity value passed to a hole element is classified by where it resolves: + +- **module-scope binding** (import OR top-level const/function — both module scope) → **lift** + `extraProps: { prop: () => Identifier }`. The real value reaches the target at resolution → + oracle-equal regardless of whether it is a function. +- **destructured render param that is a non-entity prop** (a scalar / `.use()` value) → **drop**. + Invariant: at oracle collection these are inert scalar mocks (`createScalarPropMock`) that cannot + reach a selection scope, so dropping is exactly what the oracle contributes. +- **anything else** (render-local `const`/`let`, generic nested-fn params, call results, + unresolvable free identifiers) → **bail** `RENDER_LOCAL_ON_HOLE`. The value may be a real + field-collecting function at oracle time whose invocation with a proxy we cannot see. + +The compiler tracks `scalarParams` (safe-drop) and `locals` (bail) per scope; `locals` is checked +first so a render-local shadowing a module name bails rather than lifting. + +### Inline closure lifting (recovers `FUNCTION_PROP_ON_HOLE`) + +An inline closure prop / render-prop child of a hole element is classified `drop` / `lift` / `bail` +(`classifyHoleClosure`): + +- captures an entity root → **bail** (invoking it reaches the host scope). +- uses no own parameter → **drop** (no proxy gateway; `onClick={() => save()}`). +- uses own parameters and captures **nothing from render scope** (only its params, module bindings, + globals) → **lift** verbatim into `extraProps` — the closure is emittable as-is at module scope, and + the target replays it with a collector proxy (`{it => }` → the field lands). +- uses own parameters but captures a render-scope value (`t` from `.use()`, an entity root) → **bail** + (not reproducible at the module emit site). + +### `cond.*` DSL in JSX prop positions + +`` — the recognized `cond.*` call in a prop has its FieldRef +arguments recorded as touched leaves (via the body analyzer's existing `cond` handling), then the +prop is dropped. **Soundness (verified against `Case.getSelection`)**: a condition object's only +selection surface is `collectConditionFields` = the FieldRef args; the literal comparison value and +the condition wrapper carry nothing else. The runtime `Switch`/`Case`/`Default` `getSelection` +collects exactly those FieldRefs plus the (separately analyzed) children — so compiler and oracle are +**strictly equal**, not merely a superset. + +### JSX-element / fragment prop values + +`draftSlot={}` — the JSX is analyzed statically exactly like children (recurse; entity +references form paths / nested holes as usual) and the prop itself is not emitted. Soundness: the +oracle walks such JSX via the target's slot-walk / `staticRender` collector proxies, so static +analysis yields an equal-or-superset union (under-fetch impossible; over-fetch acceptable). Where the +target renders the slot as children (`withCollector` returning `<>{props.slot}{props.children}`) +the two are exactly equal. + +### Result (re-measured on the reference app, 257 chains) + +**254/257 compiled = 99 %** (phase 2 was 242/257 = 94 %). 38 chains carry 112 holes. The 3 remaining +bails are all genuine: 1 `ENTITY_IN_EXPRESSION_PROP` (a root-capturing event handler on a non-hole +element — the runtime cannot see it either), 1 `FUNCTION_PROP_ON_HOLE` (a render-prop child that +captures the host entity root `footer.linkColumns` — not liftable), 1 `ENTITY_REASSIGNMENT` +(out of scope). The `FUNCTION_PROP_ON_HOLE` class that dominated phase 2's residue is essentially +gone (9 → 1); the navigation-editor `cond`-in-props and publish.tsx `draftSlot` bails disappeared. + +## Phase 2.2 — collector contracts (declarative invocation contracts for withCollector) — IMPLEMENTED + +Status: **implemented** on `experiment/selection-compiler`. Runtime side (`itemOf`/`entityOf`/ +`CollectorContract`/`COLLECTOR_CONTRACT`, `deriveContractStaticRender`, the `withCollector` +contract overload) landed in `012b321`. Compiler side — contract discovery +(`packages/bindx-compiler/src/contracts.ts`) and contract-aware hole formation +(`jsx.ts` `walkContractComponent`) — plus fixtures + oracle-equivalence tests are green. + +Motivation: the last real reference-app bail (footer-editor `LinksSection`) is a render-prop child that +both uses its own param AND captures a host-root path (`footer.linkColumns`) — not droppable +(target invokes it at collection), not liftable (render-scope capture). The root cause is that +the analyzer cannot know an unknown component's invocation contract; `HasMany` works only because +that knowledge is hardcoded. A declared contract solves the CLASS: the analyzer treats the +callback exactly like a `HasMany` children callback — param becomes a root, host captures become +ordinary paths, no hole and no lift needed. + +### API (bindx-react) — pinned, both implementation steps code against this exactly + +```ts +interface CallbackContract { readonly kind: 'itemOf' | 'entityOf'; readonly field: string } +/** Key = callback prop name ('children' included). */ +type CollectorContract = Record + +function itemOf(field: string): CallbackContract // invoked with each item of the has-many relation prop `field` +function entityOf(field: string): CallbackContract // invoked with the entity of the has-one relation prop `field` + +// New overload — contract object instead of a staticRender function: +withCollector(runtime, contract: CollectorContract) +``` + +- From a contract, withCollector **derives the staticRender automatically**: a fragment of + `{v => props[cb](v)}` (resp. ``) per entry, + guarding `typeof props[cb] === 'function'`. Uncompiled runtime collection therefore works + unchanged through all existing machinery (analyzeJsx walk, hole resolution) with zero + duplication — the contract IS the selection surface, declared once. +- The contract is also attached to the component under an exported symbol `COLLECTOR_CONTRACT` + (introspection; not needed by the compiled path). + +### Compiler side + +- **Contract discovery**: hole-candidate tag binding → if local `withCollector(_, )`, + read it directly; if imported, resolve the module specifier (**relative specifiers only** in v1, + plus an optional `alias` option on the plugin/analyzer) and PARSE the target module (cached per + file) to find the exported `withCollector(_, contract)` and extract the literal. Contract + literals are object literals whose values are `itemOf(...)`/`entityOf(...)` calls imported from + `@contember/bindx*` (string-literal args only). Anything else → no contract → existing + hole/bail rules apply. This is a deliberate, bounded exception to the purely-local principle: + parse-only, no execution, no type checker, cache-keyed. +- **With a contract, the element forms NO hole.** Per entity prop: referenced as a contract + `field` → record the relation at its path (`many` for itemOf); the matching callback prop's + closure is analyzed with its FIRST param as a root at that relation (additional params ignored, + mirroring HasMany index handling). Host-root captures inside the closure are ordinary paths. + Entity props not referenced by the contract → recorded as touched leaves (matches the oracle: + the derived staticRender ignores them; only the evaluation touch registers). +- **Non-contract function props on a contract element are droppable without safety checks**: the + derived staticRender provably never invokes them at collection. (This removes the + FUNCTION_PROP_ON_HOLE/RENDER_LOCAL_ON_HOLE class entirely for contract components.) +- Missing callback for a contract entry → relation recorded, no nested (mirrors the guard). + +### Validation + +- Runtime: contract-derived staticRender produces identical collection to an equivalent + hand-written staticRender function (oracle comparison); runtime rendering unaffected. +- Compiler fixtures: same-file contract target; **cross-file** contract target (fixture imports + the component from a sibling fixture module); footer-editor replica (item callback capturing a + host-root field → STRICT oracle equality); entityOf; non-contract function prop dropped; + contract entry with missing callback. +- the reference app: validated on a patched TEMP COPY (scratchpad) of `footer-editor.tsx` + `_shared.tsx` with + `InitializingRepeater` declaring `{ children: itemOf('field') }` — the reference repo itself is NOT + modified; the suggested reference-app patch ships in the report/docs instead. + +### Implemented — discovery mechanics + +`packages/bindx-compiler/src/contracts.ts` (`ContractResolver` + `ContractFileCache`), threaded +through `analyzeProgram(program, { filename, alias, cache })` → `BodyAnalyzer` → `JsxAnalyzer` as a +`ContractLookup = (tag) => CollectorContract | null`. In `jsx.ts`, `walkComponentElement` calls the +lookup first; a hit routes to `walkContractComponent` (no hole), a miss keeps the phase-2/2.1 rules. + +Resolution for a component tag: +1. **Local** `const Tag = withCollector(_, contract)` at module scope (TS wrappers unwrapped, incl. + `... as (...) => ReactNode`) → extract directly. +2. **Imported** binding (`import { Tag } from '...'`, or `Tag as default`): resolve the specifier — + **relative only** (`./x` → `x.tsx|ts|jsx|js` / `x/index.*`, and the ESM `./x.js` → `x.tsx|ts|jsx` + convention this repo uses), plus an optional `alias` (prefix→path) map for non-relative specifiers + (default empty). PARSE the target (no execution, no type checker), find its exported binding + (`export const`, `export { local as Tag }`, and `from`-source re-export chains — `export { X } + from`, `export { X as Y } from`, `export * from` barrel index files — followed depth-limited + (5 hops) with a cycle guard; a star ambiguity or an exhausted budget stays unfollowable → null), + and extract. + +A **contract literal** is an object literal whose every value is `itemOf('…')` / `entityOf('…')` with +a single string-literal arg, the combinators imported from `@contember/bindx*` **in that module**; a +module-level `const` identifier resolving to such a literal is also accepted. Any deviation (spread, +computed/method key, non-literal or missing arg, unknown combinator, unfollowable re-export, +non-relative unaliased import) → **no contract** → existing hole/bail rules (sound: a fallback hole +still resolves at runtime through the derived staticRender). Parsed sibling modules are cached by +**path + mtime** in a `ContractFileCache` shared across `analyzeProgram`/plugin invocations; the +resolver additionally memoizes per tag within a run. + +Contract-aware analysis (`walkContractComponent`): the element forms **no hole**. Per contract entry +`cb → {kind, field}`, the `field` prop is resolved to a relation (`itemOf` ⇒ `consumeMany`/`many`, +`entityOf` ⇒ `consumeRelation`) and the matching callback closure is analyzed with its **first param +as a root at that relation** (extra params inert, mirroring `` index handling); host-root +captures inside the closure are ordinary paths. Entity props **not** referenced by the contract → +touched leaves (matching the oracle's host-eval touch). **Non-contract function props are dropped with +no safety bail** — the derived staticRender provably never invokes them (this removes the +`FUNCTION_PROP_ON_HOLE`/`RENDER_LOCAL_ON_HOLE` class for contract components). A missing callback for +an entry records the relation only. + +### Implemented — the reference app temp-copy validation + +Copied `footer-editor.tsx` + `_shared.tsx` into the scratchpad (relative `./_shared` import +preserved) and patched only the COPY's `InitializingRepeater` to `{ children: itemOf('field') }` +(importing `itemOf`). `measure.ts` on that directory: + +- **Before** (hand-written staticRender, contract not discoverable): `footer-editor.tsx` **L112** + `LinksSection` → `BAIL FUNCTION_PROP_ON_HOLE` (7/8 compiled). The render-prop child both uses its + item param and captures the host root `footer.linkColumns` — not droppable, not liftable. +- **After** (contract declared): **L112 → `OK [footer] (1 hole)`** — no more `FUNCTION_PROP_ON_HOLE` + (8/8 compiled). The outer repeater is now a contract callback; the inner + `` remains a legitimate hole + resolved through `FooterLinkRow`'s own staticRender. + +Full reference-app re-measure is **unchanged** — 254/257 (99%), 3 bails +(1 `ENTITY_IN_EXPRESSION_PROP`, 1 `FUNCTION_PROP_ON_HOLE`, 1 `ENTITY_REASSIGNMENT`) — because the reference app has +not adopted contracts. Adopting the suggested patch would clear the remaining `FUNCTION_PROP_ON_HOLE`. + +Suggested reference-app patch (`packages/admin/app/components/web-builder/forms/_shared.tsx`) — also drop the +now-unused `HasMany` JSX import: + +```diff +-import { Field, HasMany, HasOne, useEntityList, useField, useHasMany, useHasOne, withCollector } from '@contember/bindx-react' ++import { Field, HasOne, itemOf, useEntityList, useField, useHasMany, useHasOne, withCollector } from '@contember/bindx-react' +@@ + export const InitializingRepeater = withCollector( + InitializingRepeaterRuntime, +- (props: InitializingRepeaterProps) => ( +- +- {item => props.children(item, { remove: () => {} })} +- +- ), ++ { children: itemOf('field') }, + ) as (props: InitializingRepeaterProps) => React.ReactNode +``` + +## Phase 3 — Entity root compilation — IMPLEMENTED + +Status: **implemented** on `experiment/selection-compiler`. Runtime side +(`compiledSelection?` prop on ``, `useRootSelection`, `resolveCompiledRootSelection`, +`COMPILED_ROOT_KEY`) landed in `32d2927`. Compiler side — top-level `` scan +(`packages/bindx-compiler/src/entityRoots.ts`), `BodyAnalyzer.analyzeRootChildren`, attribute +emit (`emit.ts` `entitySelectionAttr` + `babelPlugin.ts`), `EntityRootResult` public API, +measure entity-root reporting — plus adapter-oracle equivalence tests +(`tests/entityRoots.test.tsx`) are green. + +### Implemented — scan, analysis, emit + +- **Scan** (`entityRoots.ts`): `findEntityElements` walks the whole program for `` JSX + elements whose tag is a bindx `Entity` import binding (tracked in `ImportBindings.entity`, + kept **separate** from the recognized-component map so a nested `` stays opaque to the + host chain's per-chain walk). `analyzeEntityRootsInProgram` / `analyzeEntityRoots` mirror + `analyzeProgram` / `analyzeSource`; each element is an independent `EntityRootResult`. +- **Analysis**: the children closure's FIRST param is the root itself, so it is analyzed via + `BodyAnalyzer.analyzeRootChildren` — a thin wrapper over `walkCallbackWithItem` (the same entry + ``/`` callbacks use) binding the param at a fresh root SelNode with + `source = 'entity'`. ALL existing machinery (paths, holes + `extraProps` + taint lattice, + collector contracts incl. cross-module discovery, `cond.*` in props, JSX-valued props, + `FUNCTION_PROP_ON_HOLE` / `RENDER_LOCAL_ON_HOLE`) applies unchanged. ``'s own props are + never analyzed. Non-function / absent children → `ENTITY_NO_FUNCTION_CHILDREN` bail (no emit; + runtime walk stays). +- **Nested-in-component soundness**: an `` inside a `createComponent` body is analyzed + twice, independently. The host chain's full-body walk sees `` as an unknown component + and walks its children as a nested function — the closure param shadows host roots, so only + OUTER host-root captures are recorded there; the Entity's own emit contains only paths rooted at + its closure param. Verified: host chain selection and Entity root selection are disjoint and + correct. +- **Emit** (`babelPlugin.ts`): both surfaces are analyzed before any mutation; for each proven + root the plugin pushes `compiledSelection={{ props: { entity: {...} }, holes: [...] }}` onto the + element (idempotent — skips elements already carrying the attribute). + +### Result (measured on the reference app) + +Chains unchanged: **254/257 (99%)**, 3 bails (host analysis untouched). Entity roots: +**84/105 compiled** (114 holes — every compiled root carries ≥1 hole: the reference app's dominant pattern is +`{e => }`, one delegated hole per root). 21 bails: +11 `RENDER_LOCAL_ON_HOLE`, 7 `ENTITY_ESCAPES_TO_CALL`, 2 `ENTITY_NO_FUNCTION_CHILDREN`, +1 `FUNCTION_PROP_ON_HOLE` — the same reason classes as chains. + +### Validation — adapter oracle + +`tests/entityRoots.test.tsx`: the root oracle is the `QuerySpec` the adapter receives. A +`RecordingMockAdapter extends MockAdapter` (test-local, bindx unmodified) captures incoming +queries; each fixture renders the transformed vs untransformed `` and compares the +requested root selection strictly (superset for branch unions). Fixtures: scalars; nested has-one; +compiled `createComponent` inside the closure (fragment merges); a hole (entity-derived value); +collector-contract target; branch union (runtime ⊆ compiled); create-mode; `` nested in a +`createComponent` body (host chain unaffected + Entity emit correct); bail (non-function children → +no attribute). Plus children-not-invoked-during-collection (SCOPE_REF counter) and emit/idempotence. + +### Original plan + +Motivation: after phase 2.2 the compiler covers `createComponent()` chains, but selection +ROOTS still collect at runtime: `` invokes its children render-prop with a collector +proxy on every root mount (`useSelectionCollection` → `collect: collector => children(collector)`) +— the same crash-prone, one-branch execution the compiler eliminated for components. the reference app has 147 +`` usages vs 65 definer-based hooks (already static by construction — nothing to compile +there). DataGrid/DataView roots are explicitly OUT of scope for phase 3 (different walker/marker +system; phase 3.1 candidate). + +### Contract — pinned, both implementation steps code against this exactly + +- `` (both by-mode and create-mode) gains an optional compiler-facing prop + `compiledSelection?: CompiledSelection` (same type as `.render()`'s 2nd arg). The root's + field map lives under the FIXED key `entity` in `compiledSelection.props`; every hole's + `entityProps[*].source` must be `'entity'`. +- Runtime: when the prop is present, Entity does NOT invoke `children(collector)` — the + selection is built from the compiled fields + resolved holes (reuse/extract the shared + resolution used by `applyCompiledSelection`; no fragments needed, just the root + `SelectionMeta`). Validate mode (existing `setStaticSelectionValidation` flag): also run the + runtime walk and apply the same under-fetch-only diff. +- Emit: the babel plugin injects the JSX attribute + `compiledSelection={{ props: { entity: {...} }, holes: [...] }}` on the `` element. + Idempotence: skip elements that already carry the attribute. + +### Compiler side + +- New top-level scan: `` JSX elements (tag resolving to an import from + `@contember/bindx*`) anywhere in the file — including inside plain function components + (routes) and inside `createComponent` render bodies. Each element is its own emit-or-bail + unit, reported separately by measure (`entity roots: N compiled / M bailed`). +- Children must be a single function expression → its first param becomes the root; the entire + existing machinery applies unchanged (paths, holes + extraProps, collector contracts, + cond-in-props, JSX props). Non-function or absent children → no emit (runtime walk stays). +- Captures inside the Entity closure that reference an OUTER host root (Entity nested in a + createComponent body) belong to the HOST chain — the host's full-body union walk already + records them; the Entity emit contains only paths rooted at the closure param. (This makes + the compiler a sound superset of the runtime here — the runtime walk of the host cannot see + into the Entity closure at all.) +- `entity`/`by`/`filter`/`create`/`onPersisted` and other Entity props carry no selection; + they are left untouched and impose no bail (they are not entity-rooted values — `entity` + receives an entityDef, a module value). + +### Validation + +- Oracle for roots = the QuerySpec the adapter receives: render the transformed vs untransformed + module under MockAdapter and compare the requested selection strictly (plus render-works and + validate-mode-silent assertions). Fixture set: scalar fields; nested relations; compiled + createComponent used inside the Entity closure (fragment composition still merges); a hole + (entity-derived value into a nested component); a collector-contract target; branch union + (superset assertion); create-mode Entity. +- Full the reference app measure re-run with root counts. + +## Phase 3.1 — hole-target classification + entity-like roots — IMPLEMENTED + +Status: **implemented** on `experiment/selection-compiler`. Compiler-only — no runtime change +(the `entityLike` attribute rides the wrapper's `{...props}` spread into the real ``, which +already consumes `compiledSelection` from phase 3). Binding resolution shared with contract +discovery was extracted to `src/moduleResolve.ts` (`BindingResolver` + `ModuleCache`, now resolving +plain `function`/`class` declarations too); target classification lives in `src/targetKind.ts` +(`TargetKindResolver`), feeds `src/holeProps.ts` (`holePolicyFor`), and threads through +`analyzeProgram`/`analyzeEntityRootsInProgram`/`BodyAnalyzer`/`JsxAnalyzer`. `entityLike` is an +option on `analyzeSource`/`analyzeProgram`/`analyzeEntityRoots`/the Babel plugin, plus a +`--entity-like=Name,...` measure flag. + +### Result (re-measured on the reference app) + +- **Chains unchanged: 254/257 (99%)**, 3 bails (host analysis untouched) — as required. +- **Entity roots (no flag): 93/105 compiled** (was 84/105 in phase 3). 12 bails: + 7 `ENTITY_ESCAPES_TO_CALL` (genuine, out of scope), 2 `RENDER_LOCAL_ON_HOLE` + (both `~/`-aliased imports the measure run cannot resolve → default-deny `unknown`), + 2 `ENTITY_NO_FUNCTION_CHILDREN`, 1 `ENTITY_IN_EXPRESSION_PROP`. The classification cleared + the phase-3 residue of 11 `RENDER_LOCAL_ON_HOLE` → 2 and 1 `FUNCTION_PROP_ON_HOLE` → 0. +- **Entity roots (`--entity-like=RefreshableEntity`): 124/140 compiled**. The flag surfaces + **35 new roots** (105 → 140) hidden behind the `RefreshableEntity` forwarding wrapper; 31 of + them compile. 16 bails (the extra 4 vs no-flag are new `RefreshableEntity` roots passing + render-locals to `~/`-aliased unresolvable targets — same default-deny class). + +Classification (`TargetKind`): the collector-CONTRACT case is handled separately by +`ContractResolver` (checked first in `jsx.ts`); target-kind covers `createComponent` / +`plain` / `collectorStatic` / `unknown`. `collectorStatic` referenced-prop extraction: an +object-pattern staticRender param yields its destructured keys (rest → `'all'`); an identifier +param `p` yields the `p.x` accesses, with any other use (`p[x]`, `{...p}`, `f(p)`, aliasing) → +`'all'`; a param-less staticRender references nothing. Shadowing is ignored (over-counts → sound). +`entityLike` matching prefers an import's ORIGINAL exported name over its local alias, else the +local declaration name; default/namespace imports carry no matchable name and are skipped. + +Motivation (the reference app entity-root bail audit): 12 of 21 root bails are render-locals / function children +on hole elements whose targets provably ignore them — `createComponent` targets (getSelection never +reads scalar props and never invokes function children; the slot walk ignores non-JSX), plain +function components (no surface at all), and `withCollector` staticRenders that reference only +`props.entity`. The taint lattice bails only because the TARGET KIND is unknown. Separately, +the reference app's `RefreshableEntity` forwarding wrapper hides 82 Entity roots from the root scan entirely. + +### A) Target-kind classification (compiler-only; reuses the contract-discovery parse cache) + +For a hole-candidate tag (local or relative import, same resolution as contracts — including +`from`-source re-export chains through barrel index files, followed depth-limited (5 hops) with a +cycle guard), classify: + +- **`createComponent` chain** → non-entity props (render-locals, identifiers, call results) and + function props/children are droppable with NO safety bail; slot names are extracted from + `.slots([...])` (default `['children']`) — slot-valued JSX props keep being analyzed statically. + Entity props keep forming the hole. +- **plain function component** (not wrapped by withCollector/createComponent) → no selection + surface; everything non-entity droppable; the hole is still emitted (harmless — matches runtime + blindness and keeps the validate-mode blind-spot warn). +- **`withCollector(runtime, staticRenderFn)`** → parse the staticRender body and collect the set + of referenced prop names (destructured params, `props.x` members; rest/spread or aliasing of the + props object → conservative "references everything"). A dropped prop NOT in the referenced set + is safe; referenced render-locals/function props keep the existing bails. +- **withCollector + contract** → already handled (phase 2.2). **Unresolvable/unknown** → existing + conservative rules unchanged. + +### B) `entityLike` option (roots hidden behind forwarding wrappers) + +Analyzer/plugin/measure option `entityLike?: string[]`: component names treated as `` for +root scanning AND emission. The `compiledSelection` attribute is injected on the wrapper element; +it reaches the real `` via the wrapper's `{...props}` spread — that props forwarding is the +opt-in requirement, documented (no runtime change needed). Measure gains a CLI flag +(`--entity-like=Name,...`). + +### Validation + +Fixtures per kind (createComponent target with render-local + function children now compiles and is +adapter-oracle-equal; plain target; collector-static referenced vs unreferenced prop; rest-spread → +conservative; entityLike forwarding-wrapper root end-to-end). the reference app re-measure with +`--entity-like=RefreshableEntity` — expected: root bails 21 → ~9, plus ~82 newly visible roots. + +## Prod hardening + +### First-class Vite plugin + cross-module watch invalidation — IMPLEMENTED + +`bindxCompiler(options?)` (`src/vitePlugin.ts`, exported from `src/index.ts`) replaces injecting the +babel plugin into `@vitejs/plugin-react`'s babel options. It is a real Vite plugin (`enforce: 'pre'`, +so it runs before react's JSX transform; it only injects, never transforms JSX) whose `transform` +runs `@babel/core` with ONLY `bindxCompilerPlugin` + `typescript`/`jsx` parser plugins +(`configFile:false`, `babelrc:false`, `sourceMaps:true`). Cheap gate: `.tsx`/`.jsx` only, skip +`node_modules`, `include`/`exclude` (string-substring or RegExp), and a source pre-filter (must +contain `createComponent` or `` `compiledSelection=` attribute). The runtime +type in `compiledSelection.ts` requires `v: 2`, and an exported guard `isValidCompiledSelection` +defensively checks shape (object, `v === 2`, `props` a plain object of objects, `holes` an array if +present). Both consumers validate before use and, on an invalid literal OR a top-level throw from +resolution, warn once with attribution and fall back to the runtime proxy pass — never crash, never +proceed with a half-read literal. In `componentFactory.ts` `ensureImplicitCollected` this means +falling back to `collectImplicitSelections` (partial compiled entries are cleared first); in +`useRootSelection.ts` the compiled memo returns `null`, driving the children-collector walk (decided +inside the memo so hook order is stable). Per-hole containment inside `applyCompiledSelection` is +unchanged — a single bad hole (including a throwing `extraProps` thunk, now assembled inside the +per-hole try) degrades only that hole, not the whole selection. + +Killswitch: `setCompiledSelectionsEnabled(false)` (module-level in `componentFactory.ts`, exported +from the `bindx-react` public index alongside `setStaticSelectionValidation`) makes both consumers +ignore compiled literals and use the runtime path — incident mitigation without a rebuild. + +Why it matters: the soundness invariant tolerates over-fetch but never under-fetch. A stale/corrupt +literal (e.g. a schema change that predates a re-transform, or a version skew) must not be partially +read into the fetch plan — the version marker + guard + wholesale fallback guarantee that a rejected +literal reproduces exactly what runtime collection would have fetched. + +### Crash containment, diagnostics, and Entity literal hoisting — IMPLEMENTED + +**Crash containment.** An unexpected (non-`BailError`) throw inside analysis is contained per unit as +a new `INTERNAL_ERROR` bail (`resolve.ts` `internalErrorBail`, applied in `analyze.ts` `analyzeChain` +and `entityRoots.ts` `analyzeEntityRoot`) — the runtime proxy pass (always sound) takes over instead +of failing the build. The plugin (`babelPlugin.ts`) additionally wraps discovery and EACH emit: a +crash in `analyzeProgram`/`analyzeEntityRootsInProgram` degrades the whole file to the fallback, and a +crash while injecting one chain/root loses only that injection. `INTERNAL_ERROR` always surfaces one +`console.warn` (file + loc + message), regardless of the diagnostics setting. `BailError` semantics +are untouched — normal bails still flow as before. + +**Diagnostics.** `diagnostics?: 'off' | 'summary' | 'verbose'` (default `'off'`) on both the babel +plugin options and `bindxCompiler` Vite options. A single reporter (`diagnostics.ts` `reportFile`) +decides all console output: `'verbose'` prints one `[bindx-compiler] : BAIL ` per +bail plus a per-file `N compiled, M bailed` line; `'summary'` prints one file line only when the file +has a bail; `INTERNAL_ERROR` always warns (deduped — no extra `BAIL` info line on top of its warn). +The Vite plugin accumulates per-file totals (per-instance, no module-level state, via the babel +plugin's `onReport` callback) and prints one `[bindx-compiler] total: N compiled, M bailed` in +`buildEnd` when diagnostics is not `'off'`. + +**Emit AST-reuse fix.** `emit.ts` deep-clones (`t.cloneNode(expr, true)`) every expression copied out +of the original render tree into a hole's `extraProps` thunk — the same node no longer sits at two +tree positions (fragile against later passes: react-refresh, JSX transform, source maps). `valueToNode` +outputs (`entityProps`/`literalProps`/`params`) are freshly built from plain data, so they need no +clone. + +**Entity literal hoisting.** A proven ``'s CompiledSelection is hoisted to a module-scope +`const _bindxCompiledSelection…` (inserted after the last import) and referenced by the +`compiledSelection={…}` attribute, instead of an inline object literal. Inline literals get a new +identity every parent render, forcing `useRootSelection`'s memo to re-resolve all holes (and +validate-mode to re-warn) each render; a hoisted const is stable. Chains keep their inline +`.render(fn, literal)` second argument (evaluated once at module scope already). The idempotence guard +(`hasCompiledSelectionAttr`) matches on the attribute NAME, so an identifier-valued attribute from a +prior hoist still skips a re-transform. + +### Cleanup pass (post-audit) — IMPLEMENTED + +Type-hygiene and surface-area cleanup with no behavior change: + +- **No casts anywhere.** The three hand-rolled read-only AST walkers (`resolve.ts` `anyIdentifier`, + `targetKind.ts` `referencedMembersOf`, and the inline visitor in `chain.ts`/`entityRoots.ts`) now + share the single `astWalk.ts` `walkAst`, extended with a `'skip' | 'stop'` `WalkControl` return + (skip children / abort the whole walk). Child access uses `Reflect.get` (cast-free) and the node + guard uses `'type' in value`, so the `as unknown as Record<…>` casts are gone. `imports.ts` resolves + component kinds via a `ReadonlyMap` instead of a `Set` + `as ComponentKind`. +- **Contract type dedup.** `CallbackContract`/`CollectorContract` are imported (`import type`) from + `@contember/bindx-react` — the runtime derives them, the compiler parses what they describe, so they + are declared once. A project reference to `../bindx-react` makes `tsc --build` resolve them; both + declarations cross-reference each other. +- **Public API trimmed.** `src/index.ts` now exports only `bindxCompilerPlugin`/`bindxCompiler`, the + `analyze*` entries + `isBailed`/`isEntityRootBailed`, and the public result/option types. Internals + (`ContractResolver`, `TargetKindResolver`, `ModuleCache`, `selectionToAst`, `selectionToPlain`, + `ContractFileCache` alias, …) are reached from their source modules; tests that needed them import + directly (`../src/emit.js`, `../src/selectionTree.js`). +- **Per-instance module cache.** `bindxCompiler` creates one `ModuleCache` per plugin instance and + threads it through the babel plugin (`BindxCompilerOptions.cache`) into analysis, so dev-server + memory is bounded per build and test runs are isolated. The process-lifetime singleton stays the + default for bare babel-plugin usage. +- **Coverage.** Added `optionsPlumbing.test.ts` (alias + entityLike actually take effect through the + babel plugins-with-options tuple) and plugin tests for a hand-written `.render(fn, literal)` second + arg (preserved, not injected over) and the `EXPLICIT_RENDER_FN` bail on a non-inline render arg. + +## Future (explicitly out of scope now) + +Unplugin packaging, eslint plugin reusing the analyzer (bail reasons as lint diagnostics), +oxc/SWC port if Babel cost ever matters, closure lifting with entity-path capture substitution +(phase-2.2 alternative — superseded by contracts unless a non-contract case demands it), +DataGrid/DataView root compilation. diff --git a/docs/selection-collection.md b/docs/selection-collection.md index 8ceed832..cafeb55e 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -354,10 +354,209 @@ const SelectField = withCollector( ) ``` +### Collector contracts — declare invocation, skip the hand-written staticRender + +When a `withCollector` component invokes a callback prop over a relation prop, you can declare that +contract instead of hand-writing a `staticRender`. Declare it once — it drives **both** runtime +collection (the staticRender is derived automatically) **and** the compiler (which then treats the +callback exactly like a ``/`` child, so no build-time hole or lift is needed): + +```tsx +import { itemOf, entityOf, withCollector } from '@contember/bindx-react' + +// `children` is invoked with each item of the has-many `field`. +export const Repeater = withCollector( + function RepeaterRuntime({ field, children }) { /* … render … */ }, + { children: itemOf('field') }, // vs. entityOf('field') for a has-one callback +) +``` + +The contract is `Record` (`children` allowed as a +key), also exposed on the component under the `COLLECTOR_CONTRACT` symbol for introspection. This +solves the case a hole cannot: a callback that both uses its item param **and** captures a host-root +field (e.g. `{item => }`) — under a contract the +host capture is an ordinary root path, and non-contract function props on the element are dropped +safely (the derived staticRender never invokes them). The compiler discovers contracts declared +locally or imported via a **relative** (or aliased) specifier, following `from`-source re-export +chains through barrel index files (depth-limited, cycle-guarded) — see docs/compiler-plan.md, Phase 2.2. + ### `getSelection` — for framework primitives Low-level API for precise control over reported fields. Used by Field, HasOne, HasMany, Attribute. +## Compiled selections (experimental) + +By default, an implicit `createComponent()` discovers its selection at runtime by +executing the render body against collector proxies. The `@contember/bindx-compiler` +Babel plugin can instead prove that selection at build time and emit it as the 2nd +argument of `.render(fn, compiledSelection)`. When present, the runtime uses it directly +and **skips the proxy pass entirely** — no user code runs during collection, so the +crash-and-degrade machinery becomes irrelevant. + +The emitted object has the shape `{ props, holes? }`: `props` is the per-entity-prop +static field map; `holes` describe nested components that received entity-derived values +(see below). + +This is progressive enhancement: a compiled app behaves identically to an uncompiled +one. It is never mandatory. + +### Nested components (holes) + +A host render body often passes an entity-derived value to another component: +``. The compiler cannot inline that component's +selection (it lives in another module, may be defined later, etc.), so it emits a +**hole**: a thunk to the target plus a map of which prop comes from which host entity +prop and member path. At collection time the runtime resolves each hole through the +target's own selection surface — `getSelection` (createComponent) or `staticRender` +(`withCollector`) — replaying the member path on collector proxies. This is the +Relay-fragment-spread equivalent: the target's fields are folded into the host's fetch +**without executing the host render body**. + +Because both the runtime proxy pass and the compiled path drive the *same* collector +proxies for the escaping value, a hole and a runtime-collected escape produce identical +selections. + +**Shared blind spot — plain components.** If the target is a plain React component (no +`getSelection`/`staticRender`), neither the compiler nor the runtime proxy pass can see +the fields it reads (e.g. via `useField`). This is a blind spot on *both* paths, so +compiled and uncompiled behavior stay equivalent. In validate mode the runtime emits a +dev-only warning naming the component so the blind spot is discoverable rather than +silent. The fix is to give the component a selection surface (wrap it with +`withCollector`) or mount sibling ``s — not a compiler-only change (that would +make the compiled app fetch more than the uncompiled one, breaking equivalence). + +### Enabling in Vite + +Wire the plugin into `@vitejs/plugin-react`'s `babel.plugins`, behind an env flag: + +```ts +import react from '@vitejs/plugin-react' +import { bindxCompilerPlugin } from '@contember/bindx-compiler' + +const compilerEnabled = process.env.BINDX_COMPILER === '1' + +export default defineConfig({ + plugins: [ + react(compilerEnabled ? { babel: { plugins: [bindxCompilerPlugin] } } : undefined), + ], +}) +``` + +Run with `BINDX_COMPILER=1`. The plugin only injects arguments; it never imports +from bindx-react. + +### Validate mode + +`setStaticSelectionValidation(true)` (call in your dev entry) makes the runtime ALSO +run the proxy pass alongside the compiled selection and warn on any **under-fetch** — +a field the runtime would fetch that the compiled selection omits. It intentionally +does not warn on the two legitimate divergences where the compiler is more precise: +branch unions (the compiler unions all conditional branches) and has-many +params/many-ness (the runtime never records these in implicit collection). + +### Emit-or-bail + +The compiler emits a selection only when it can prove it. Over-approximation (extra +fields) is acceptable; under-approximation is impossible by construction (default +deny). Anything it cannot classify makes the whole component **bail** with a +machine-readable reason (e.g. `ENTITY_ESCAPES_TO_CALL`, `ENTITY_IN_EXPRESSION_PROP`, +`FUNCTION_PROP_ON_HOLE`, `RENDER_LOCAL_ON_HOLE`, `ENTITY_REASSIGNMENT`, `ENTITY_SPREAD`, +`COMPUTED_MEMBER`, `MEMBER_COMPONENT_TAG`, `NON_LITERAL_HASMANY_PARAM`, `INTERFACES_MODE`, +`UNCLASSIFIED`). Bailed chains are left untouched and fall back to the runtime proxy pass. + +`FUNCTION_PROP_ON_HOLE` / `RENDER_LOCAL_ON_HOLE` guard an under-fetch class: a hole element's +function props / render-prop children / identifier-valued props are non-entity, but a hole target's +`staticRender` may *invoke* them with a collector proxy during collection (the reference app's `SelectField` does +`{e => props.children(e)}`), collecting fields the compiled path +would otherwise miss. **Phase 2.1** resolves most of these by *lifting* the value into the hole's +`extraProps` instead of dropping it: module-scope bindings and render-scope-free closures are in scope +at the emit site, so the real value reaches the target at resolution (`it => ` +lifts; `onClick={() => save()}` drops as inert; a closure capturing `t` from `.use()` or an entity +root bails, as does a render-local const passed onward). See docs/compiler-plan.md, phase 2.1. + +Measure the compiled-vs-bailed rate (and hole counts) over a source tree with +`bun run packages/bindx-compiler/scripts/measure.ts ` (default +`packages/example`). On the largest real bindx app (the reference app, 257 chains) +phase 2.1 compiles **254/257 (99%)** — 38 chains carry 112 holes total — leaving 3 genuine bails +(1 `ENTITY_IN_EXPRESSION_PROP`, 1 `FUNCTION_PROP_ON_HOLE`, 1 `ENTITY_REASSIGNMENT`); phase 2 with +holes compiled 94%, phase 1 without holes 84%. + +### Entity roots (phase 3) + +Selection ROOTS compile too. A `` invokes its children render-prop with a +collector proxy on every root mount — the same crash-prone, one-branch execution the +compiler eliminated for components. The plugin scans for `` elements (tag resolving +to a `@contember/bindx*` import) **anywhere** in a file — plain route components, inside +`createComponent` render bodies, at any nesting — and injects a JSX attribute +`compiledSelection={{ props: { entity: {...} }, holes: [...] }}`. The root's field map lives +under the fixed key `entity`; every hole is rooted at `entity`. When present the runtime +builds the root `SelectionMeta` statically and never calls `children` with a collector +(`useRootSelection`); validate mode still runs the contained walk for the under-fetch diff. + +The children closure's **first param is the root entity accessor itself** (unlike a +`.render()` body, whose param is the props object) — so it is analyzed exactly like a +``/`` children callback. All existing machinery applies unchanged: member +paths, holes + `extraProps`, collector contracts, `cond.*` in props, JSX-valued props, +branch union. ``'s own props (`entity`, `by`, `filter`, `create`, `onPersisted`, +`queryKey`, `loading`, `error`, `notFound`, …) carry no selection and are never analyzed +(`entity` receives an `entityDef` — a module value, not an entity-rooted value). Non-function +or absent children bail `ENTITY_NO_FUNCTION_CHILDREN`; the runtime walk stays. Each `` +element is its own emit-or-bail unit, reported separately by `measure` +(`entity roots: N compiled / M bailed`). + +An `` nested inside a `createComponent` body is analyzed twice, independently and +soundly: the HOST chain's full-body walk records any host-root captures inside the closure +(the closure param **shadows** host roots), while the Entity's own emit contains ONLY paths +rooted at its closure param. The runtime host walk cannot see into the Entity closure at all, +so the compiler is a sound superset here. + +The root oracle is the `QuerySpec` the adapter receives: rendering a transformed vs +untransformed `` under a query-recording `MockAdapter` requests the identical root +selection (superset for branch unions). On the reference app, the plugin compiles +**84/105** `` roots (114 holes; the reference app's dominant pattern is +`{e => }`, one delegated hole per root). The 21 bails are +11 `RENDER_LOCAL_ON_HOLE`, 7 `ENTITY_ESCAPES_TO_CALL`, 2 `ENTITY_NO_FUNCTION_CHILDREN`, +1 `FUNCTION_PROP_ON_HOLE` — the same reason classes as chains, since the same machinery runs. +DataGrid/DataView roots are out of scope (different walker; phase 3.1). + +### Hole-target classification + `entityLike` roots (phase 3.1) + +**createComponent / plain targets no longer bail on render-locals.** A hole element's non-entity +props (identifier render-locals, call results, function props / render-prop children) only risk +under-fetch when the *target* actually invokes/reads them. The compiler now classifies the target +tag — reusing the contract parse cache — into `createComponent`, `plain`, `collectorStatic`, or +`unknown`: + +- **`createComponent`** target — `getSelection` never reads scalar props and never invokes + function slots (the slot walk ignores functions), so render-locals and function props/children + drop with **no bail**; the entity props still form the hole. Slot names come from `.slots([...])` + (default `['children']`); JSX-valued props are analyzed statically as before. +- **`plain`** function/class component — no selection surface, so **every** non-entity prop drops; + the hole is still emitted (matching runtime blindness; keeps the validate-mode blind-spot warn). +- **`collectorStatic`** (`withCollector(runtime, staticRenderFn)`) — the compiler reads the *set of + prop names the staticRender references*. A dropped prop **not** in that set is safe; a referenced + render-local still bails. A staticRender that spreads/aliases its props object (`{...props}`, + rest param, `f(props)`) is treated as "references everything" (conservative). +- **`unknown`** (unresolvable, or resolvable via a non-relative unaliased import) — the existing + conservative taint lattice stands (default deny). Collector **contracts** are still handled by + their own resolver, before target-kind classification. + +**`entityLike` — roots behind forwarding wrappers.** Some apps wrap `` in a thin component +that forwards props (the reference app's `RefreshableEntity` = `withCollector(props => , props => )`). Pass `entityLike: ['RefreshableEntity', …]` to +the analyzer/plugin (or `--entity-like=Name,…` to `measure`) and those tags are scanned + emitted +**exactly like ``**: the `compiledSelection` attribute is injected on the *wrapper* element +and reaches the inner `` through the wrapper's `{...props}` spread — **that props +forwarding is the opt-in requirement** (there is no runtime change; `` already consumes +`compiledSelection`). Matching prefers an import's original exported name over its local alias; a +locally-declared wrapper matches by its declared name; default/namespace imports are skipped. + +On the reference app: chains stay **254/257**; entity roots go **84 → 93/105** from +classification alone, and **`--entity-like=RefreshableEntity`** surfaces **35 previously-hidden +roots** (105 → 140, 124 compiled). + +See [docs/compiler-plan.md](./compiler-plan.md) for the full design. + ## Provider Setup ### `BindxProvider` — generic diff --git a/packages/bindx-compiler/package.json b/packages/bindx-compiler/package.json new file mode 100644 index 00000000..dfe31041 --- /dev/null +++ b/packages/bindx-compiler/package.json @@ -0,0 +1,39 @@ +{ + "name": "@contember/bindx-compiler", + "version": "0.1.46", + "private": true, + "description": "Experimental build-time analyzer + Babel plugin for bindx implicit selections", + "type": "module", + "main": "./src/index.ts", + "exports": { + ".": "./src/index.ts" + }, + "scripts": { + "build": "tsc --build", + "typecheck": "tsc --build", + "test": "bun test tests/", + "measure": "bun run scripts/measure.ts" + }, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0" + }, + "devDependencies": { + "@babel/plugin-transform-react-jsx": "^7.28.0", + "@contember/bindx-react": "workspace:*", + "@types/babel__core": "^7.20.5", + "@types/babel__traverse": "^7.20.6" + }, + "files": [ + "dist", + "src" + ], + "license": "MIT", + "repository": { + "type": "git", + "url": "https://github.com/contember/bindx.git", + "directory": "packages/bindx-compiler" + } +} diff --git a/packages/bindx-compiler/scripts/measure.ts b/packages/bindx-compiler/scripts/measure.ts new file mode 100644 index 00000000..1f045468 --- /dev/null +++ b/packages/bindx-compiler/scripts/measure.ts @@ -0,0 +1,168 @@ +/** + * Bail-rate measurement: run the analyzer over every .tsx file under a directory + * and report how many createComponent chains compile vs bail, and why. The result + * decides what phase 2 tackles first. + * + * Usage: bun run measure [dir] (default: packages/example) + */ +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, isAbsolute, join, relative } from 'node:path' +import { analyzeEntityRoots, analyzeSource, isBailed, isEntityRootBailed, type BailoutReason } from '../src/index.js' + +const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']) + +/** Walk up from the script to the workspace root (package.json with `workspaces`). */ +function findRepoRoot(): string { + let dir = import.meta.dir + for (;;) { + const pkg = join(dir, 'package.json') + if (existsSync(pkg)) { + try { + const json: unknown = JSON.parse(readFileSync(pkg, 'utf8')) + if (json && typeof json === 'object' && 'workspaces' in json) { + return dir + } + } catch { + // ignore unparsable package.json and keep walking up + } + } + const parent = dirname(dir) + if (parent === dir) { + return process.cwd() // no workspace root found — fall back to cwd + } + dir = parent + } +} + +function findTsxFiles(dir: string): string[] { + const out: string[] = [] + for (const entry of readdirSync(dir)) { + if (SKIP_DIRS.has(entry)) { + continue + } + const full = join(dir, entry) + if (statSync(full).isDirectory()) { + out.push(...findTsxFiles(full)) + } else if (entry.endsWith('.tsx')) { + out.push(full) + } + } + return out +} + +/** Parse CLI flags: positional dir + `--entity-like=Name1,Name2` (phase 3.1). */ +function parseArgs(argv: readonly string[]): { dir: string | undefined; entityLike: string[] } { + let dir: string | undefined + const entityLike: string[] = [] + for (const arg of argv) { + if (arg.startsWith('--entity-like=')) { + entityLike.push(...arg.slice('--entity-like='.length).split(',').map(s => s.trim()).filter(Boolean)) + } else if (!arg.startsWith('--') && dir === undefined) { + dir = arg + } + } + return { dir, entityLike } +} + +function main(): void { + const root = findRepoRoot() + const { dir: dirArg, entityLike } = parseArgs(process.argv.slice(2)) + const arg = dirArg ?? 'packages/example' + // Explicit relative paths resolve against the repo root, not the (variable) cwd. + const target = isAbsolute(arg) ? arg : join(root, arg) + if (!existsSync(target)) { + console.error(`measure: target not found: ${target}`) + process.exit(1) + } + const files = findTsxFiles(target).sort() + + let totalChains = 0 + let compiled = 0 + let compiledWithHoles = 0 + let totalHoles = 0 + const byReason = new Map() + + let totalRoots = 0 + let rootsCompiled = 0 + let rootsWithHoles = 0 + let rootHoles = 0 + const rootByReason = new Map() + + for (const file of files) { + const code = readFileSync(file, 'utf8') + let results + let roots + try { + results = analyzeSource(code, file) + roots = analyzeEntityRoots(code, file, { entityLike }) + } catch (error) { + console.log(`${relative(root, file)} PARSE ERROR: ${String(error)}`) + continue + } + if (results.length === 0 && roots.length === 0) { + continue + } + console.log(relative(root, file)) + for (const result of results) { + totalChains++ + if (isBailed(result)) { + byReason.set(result.bailout.code, (byReason.get(result.bailout.code) ?? 0) + 1) + console.log(` L${result.loc.line} BAIL ${result.bailout.code} — ${result.bailout.message}`) + } else { + compiled++ + const holes = result.holes.length + if (holes > 0) { + compiledWithHoles++ + totalHoles += holes + } + const suffix = holes > 0 ? ` (${holes} hole${holes === 1 ? '' : 's'})` : '' + console.log(` L${result.loc.line} OK [${result.entityProps.join(', ')}]${suffix}`) + } + } + for (const result of roots) { + totalRoots++ + if (isEntityRootBailed(result)) { + rootByReason.set(result.bailout.code, (rootByReason.get(result.bailout.code) ?? 0) + 1) + console.log(` ENTITY L${result.loc.line} BAIL ${result.bailout.code} — ${result.bailout.message}`) + } else { + rootsCompiled++ + const holes = result.holes.length + if (holes > 0) { + rootsWithHoles++ + rootHoles += holes + } + console.log(` ENTITY L${result.loc.line} OK (${holes} hole${holes === 1 ? '' : 's'})`) + } + } + } + + const bailed = totalChains - compiled + const pct = (n: number, total: number): string => (total === 0 ? '0' : ((n / total) * 100).toFixed(0)) + + console.log('\n=== Summary ===') + console.log(`files scanned: ${files.length}`) + console.log(`total chains: ${totalChains}`) + console.log(`compiled: ${compiled} (${pct(compiled, totalChains)}%)`) + console.log(` with holes: ${compiledWithHoles}`) + console.log(` total holes: ${totalHoles}`) + console.log(`bailed: ${bailed} (${pct(bailed, totalChains)}%)`) + if (byReason.size > 0) { + console.log('bailed by reason:') + for (const [reason, count] of [...byReason.entries()].sort((a, b) => b[1] - a[1])) { + console.log(` ${reason.padEnd(28)} ${count}`) + } + } + + const rootsBailed = totalRoots - rootsCompiled + console.log(`\nentity roots: ${rootsCompiled} compiled / ${rootsBailed} bailed (of ${totalRoots})`) + console.log(` with holes: ${rootsWithHoles}`) + console.log(` total holes: ${rootHoles}`) + if (rootByReason.size > 0) { + console.log('entity roots bailed by reason:') + for (const [reason, count] of [...rootByReason.entries()].sort((a, b) => b[1] - a[1])) { + console.log(` ${reason.padEnd(28)} ${count}`) + } + } +} + +main() diff --git a/packages/bindx-compiler/src/analyze.ts b/packages/bindx-compiler/src/analyze.ts new file mode 100644 index 00000000..dbfc9f66 --- /dev/null +++ b/packages/bindx-compiler/src/analyze.ts @@ -0,0 +1,149 @@ +/** + * Pure analyzer core: `analyzeSource(code, filename) → ChainResult[]`. + * + * Per-chain, emit-or-bail: a chain yields a proven StaticSelection or a + * machine-readable bailout. Over-approximation (extra fields) is acceptable; + * under-approximation is impossible by construction (default deny on the unknown). + */ +import * as t from '@babel/types' +import { collectImportBindings, collectModuleBindings, type ImportBindings } from './imports.js' +import { findChains, type Chain } from './chain.js' +import { BodyAnalyzer } from './body.js' +import { BailError, internalErrorBail } from './resolve.js' +import { SelNode } from './selectionTree.js' +import { parseProgram } from './parse.js' +import { ModuleCache } from './moduleResolve.js' +import { ContractResolver, type ContractLookup } from './contracts.js' +import { TargetKindResolver, type TargetKindLookup } from './targetKind.js' +import { analyzeEntityRoot, findEntityElements, resolveEntityLikeLocals, type InternalEntityRootResult } from './entityRoots.js' +import type { ChainLoc, ChainResult, EntityRootResult, StaticSelection } from './types.js' + +export { parseProgram } + +export interface InternalChainResult { + readonly chain: Chain + readonly result: ChainResult +} + +/** Options threading the contract resolver (cross-file discovery, non-relative aliases). */ +export interface AnalyzeOptions { + /** Absolute path of the source under analysis; enables relative cross-file contract resolution. */ + readonly filename?: string + /** Prefix→path map for non-relative import specifiers (e.g. `{ '~': '/abs/app' }`). */ + readonly alias?: Record + /** Shared parsed-file cache; defaults to a module-level singleton across plugin instances. */ + readonly cache?: ModuleCache + /** + * Component names treated as `` for root scanning AND emission (phase 3.1). The + * `compiledSelection` attribute is injected on the wrapper element; it must reach the real + * `` inside via the wrapper's `{...props}` spread (the opt-in requirement — no runtime + * change). Only affects entity-root analysis, not `createComponent` chains. + */ + readonly entityLike?: readonly string[] + /** + * Reports the absolute path of every OTHER file consulted during this run (contract discovery, + * hole-target classification, re-export chases). A bundler feeds these to its watcher so a later + * edit to a contract/target module re-transforms the entry file and its injected literal never + * goes stale (a stale literal could otherwise flip into an under-fetch). + */ + readonly onDependency?: (absPath: string) => void +} + +// Shared across analyzeProgram/plugin invocations, keyed internally by path+mtime. +const defaultModuleCache = new ModuleCache() + +interface ProgramContext { + readonly bindings: ImportBindings + readonly moduleBindings: ReadonlySet + readonly lookup: ContractLookup + readonly targetKinds: TargetKindLookup +} + +function programContext(program: t.Program, options: AnalyzeOptions): ProgramContext { + const resolverOptions = { + filename: options.filename, + alias: options.alias ?? {}, + cache: options.cache ?? defaultModuleCache, + onDependency: options.onDependency, + } + const contracts = new ContractResolver(program, resolverOptions) + const targets = new TargetKindResolver(program, resolverOptions) + return { + bindings: collectImportBindings(program), + moduleBindings: collectModuleBindings(program), + lookup: tag => contracts.resolve(tag), + targetKinds: tag => targets.resolve(tag), + } +} + +/** Analyze an already-parsed program; retains Babel node refs for the plugin. */ +export function analyzeProgram(program: t.Program, options: AnalyzeOptions = {}): InternalChainResult[] { + const { bindings, moduleBindings, lookup, targetKinds } = programContext(program, options) + const chains = findChains(program, bindings) + return chains.map(chain => ({ chain, result: analyzeChain(chain, bindings, moduleBindings, lookup, targetKinds) })) +} + +/** Analyze every top-level `` (+ `entityLike`) selection root in a program (phase 3 / 3.1). */ +export function analyzeEntityRootsInProgram(program: t.Program, options: AnalyzeOptions = {}): InternalEntityRootResult[] { + const { bindings, moduleBindings, lookup, targetKinds } = programContext(program, options) + const entityLikeLocals = resolveEntityLikeLocals(program, new Set(options.entityLike ?? [])) + return findEntityElements(program, bindings, entityLikeLocals).map(element => ({ + element, + result: analyzeEntityRoot(element, bindings, moduleBindings, lookup, targetKinds), + })) +} + +function analyzeChain( + chain: Chain, + bindings: ImportBindings, + moduleBindings: ReadonlySet, + lookup: ContractLookup, + targetKinds: TargetKindLookup, +): ChainResult { + const loc = chainLoc(chain.renderCall) + if (chain.earlyBail) { + return { loc, bailout: chain.earlyBail } + } + const propRoots = new Map(chain.entityProps.map(prop => [prop, new SelNode()])) + const analyzer = new BodyAnalyzer(bindings, moduleBindings, lookup, targetKinds) + try { + if (chain.conditionFn) { + analyzer.analyzeFunction(chain.conditionFn, propRoots) + } + if (chain.renderFn) { + analyzer.analyzeFunction(chain.renderFn, propRoots) + } + } catch (error) { + if (error instanceof BailError) { + return { loc, bailout: error.bailout } + } + // Contain an unexpected compiler bug per chain: bail (proxy fallback is sound), never crash the build. + return { loc, bailout: internalErrorBail(error) } + } + + const selection: StaticSelection = {} + for (const [prop, node] of propRoots) { + if (node.hasFields()) { + selection[prop] = node.toFieldMap() + } + } + return { loc, entityProps: chain.entityProps, selection, holes: analyzer.holes } +} + +function chainLoc(call: t.CallExpression): ChainLoc { + return { + start: call.start ?? 0, + end: call.end ?? 0, + line: call.loc?.start.line ?? 0, + column: call.loc?.start.column ?? 0, + } +} + +export function analyzeSource(code: string, filename: string, options: Omit = {}): ChainResult[] { + return analyzeProgram(parseProgram(code, filename), { ...options, filename }).map(r => r.result) +} + +/** Convenience source-level entry for `` root analysis (measure / tests). */ +export function analyzeEntityRoots(code: string, filename: string, options: Omit = {}): EntityRootResult[] { + return analyzeEntityRootsInProgram(parseProgram(code, filename), { ...options, filename }).map(r => r.result) +} diff --git a/packages/bindx-compiler/src/astWalk.ts b/packages/bindx-compiler/src/astWalk.ts new file mode 100644 index 00000000..32b2f9b7 --- /dev/null +++ b/packages/bindx-compiler/src/astWalk.ts @@ -0,0 +1,57 @@ +/** + * Minimal generic AST walker driven by Babel's VISITOR_KEYS. Avoids depending on + * @babel/traverse (whose ESM default-export interop is fragile) for the read-only + * passes; the Babel plugin still uses the standard path-based visitor for mutation. + */ +import * as t from '@babel/types' + +/** Traversal control returned from `enter`: skip this node's children, or stop the whole walk. */ +export type WalkControl = 'skip' | 'stop' + +/** + * Depth-first pre-order walk. Return 'skip' from `enter` to skip a node's children, or 'stop' + * to end the entire walk (used by the predicate/collection passes that abort once decided). + */ +export function walkAst(root: t.Node, enter: (node: t.Node) => WalkControl | void): void { + let stopped = false + + const visit = (node: t.Node): void => { + const control = enter(node) + if (control === 'stop') { + stopped = true + return + } + if (control === 'skip') { + return + } + const keys = t.VISITOR_KEYS[node.type] + if (!keys) { + return + } + for (const key of keys) { + // Read the child by visitor key; Reflect.get keeps this cast-free over arbitrary node shapes. + const child: unknown = Reflect.get(node, key) + if (Array.isArray(child)) { + for (const item of child) { + if (isNode(item)) { + visit(item) + } + if (stopped) { + return + } + } + } else if (isNode(child)) { + visit(child) + } + if (stopped) { + return + } + } + } + + visit(root) +} + +function isNode(value: unknown): value is t.Node { + return typeof value === 'object' && value !== null && 'type' in value && typeof value.type === 'string' +} diff --git a/packages/bindx-compiler/src/babelPlugin.ts b/packages/bindx-compiler/src/babelPlugin.ts new file mode 100644 index 00000000..0c0f8aa1 --- /dev/null +++ b/packages/bindx-compiler/src/babelPlugin.ts @@ -0,0 +1,165 @@ +/** + * Babel plugin: injects the emitted StaticSelection as the 2nd argument of each proven chain's + * `.render(...)` call, and a hoisted `compiledSelection={…}` attribute on each proven ``. + * Bailed units are left untouched, so the runtime proxy pass remains the fallback (progressive + * enhancement). The runtime side of `.render(fn, static)` is deliverable A — this plugin only + * emits and never imports anything from bindx-react. + * + * Crash containment: analysis contains unexpected bugs per unit (INTERNAL_ERROR bail in analyze.ts / + * entityRoots.ts); this plugin additionally wraps discovery and each emit so one internal failure + * degrades to "no injection for that unit" (proxy fallback is sound) instead of failing the build. + */ +import type { NodePath, PluginObj, PluginPass } from '@babel/core' +import * as t from '@babel/types' +import { analyzeEntityRootsInProgram, analyzeProgram } from './analyze.js' +import type { ModuleCache } from './moduleResolve.js' +import { compiledSelectionAttr, entitySelectionObject, selectionToAst } from './emit.js' +import { ENTITY_ROOT_KEY, hasCompiledSelectionAttr } from './entityRoots.js' +import { messageOf } from './resolve.js' +import { isBailed, isEntityRootBailed } from './types.js' +import { reportFile, type DiagnosticEntry, type DiagnosticsMode, type DiagnosticTotals } from './diagnostics.js' + +/** + * Plugin options: + * - `alias` maps non-relative import prefixes to paths for cross-file contract/target discovery. + * - `entityLike` lists forwarding-wrapper component names treated as `` for root scanning + * (phase 3.1). The injected `compiledSelection` reaches the inner `` via the wrapper's + * `{...props}` spread — that forwarding is the opt-in requirement (no runtime change). + * - `diagnostics` controls per-file console reporting (default 'off'). INTERNAL_ERROR always warns. + * - `onReport` receives per-file totals so a bundler layer can print a grand total. + */ +export interface BindxCompilerOptions { + readonly alias?: Record + readonly entityLike?: readonly string[] + /** Reports each cross-file module consulted during analysis (see AnalyzeOptions.onDependency). */ + readonly onDependency?: (absPath: string) => void + /** Per-file console reporting verbosity. Default 'off'. */ + readonly diagnostics?: DiagnosticsMode + /** Called once per transformed file with its compile/bail totals. */ + readonly onReport?: (totals: DiagnosticTotals) => void + /** + * Shared parsed-sibling-module cache. The Vite plugin injects ONE per instance so dev-server + * memory stays bounded per build; bare babel-plugin usage falls back to the module-level default. + */ + readonly cache?: ModuleCache +} + +export function bindxCompilerPlugin(_api?: unknown, options?: BindxCompilerOptions): PluginObj { + const alias = options?.alias ?? {} + const entityLike = options?.entityLike + const onDependency = options?.onDependency + const diagnostics = options?.diagnostics ?? 'off' + const onReport = options?.onReport + const cache = options?.cache + return { + name: 'bindx-selection-compiler', + manipulateOptions(_opts, parserOpts: { plugins: unknown[] }): void { + parserOpts.plugins.push('jsx', 'typescript') + }, + visitor: { + Program(path, state: PluginPass): void { + const filename = state.file.opts.filename ?? undefined + const entries: DiagnosticEntry[] = [] + + // Discovery is wrapped: reading the whole AST first keeps chain and Entity injection + // independent, and a crash here degrades the file to the proxy fallback (never fatal). + const chainResults = discover(() => analyzeProgram(path.node, { filename, alias, onDependency, cache }), entries) + const entityResults = discover(() => analyzeEntityRootsInProgram(path.node, { filename, alias, entityLike, onDependency, cache }), entries) + + for (const { chain, result } of chainResults) { + if (isBailed(result)) { + entries.push(bailEntry(result.loc.line, result.bailout.code, result.bailout.message)) + continue + } + // Presence of a 2nd argument means already-compiled — never double-inject. + if (chain.renderCall.arguments.length >= 2) { + continue + } + tryEmit(entries, result.loc.line, () => { + chain.renderCall.arguments.push(selectionToAst(result.selection, result.holes)) + }) + } + + const hoisted: t.Statement[] = [] + for (const { element, result } of entityResults) { + if (isEntityRootBailed(result)) { + entries.push(bailEntry(result.loc.line, result.bailout.code, result.bailout.message)) + continue + } + // Idempotence: skip elements already carrying a compiledSelection attribute + // (an identifier-valued attribute from a prior hoist also triggers this skip). + if (hasCompiledSelectionAttr(element)) { + continue + } + tryEmit(entries, result.loc.line, () => { + hoistEntitySelection(path, element, result.selection, result.holes, hoisted) + }) + } + insertAfterImports(path.node, hoisted) + + const totals = reportFile(filename, entries, diagnostics) + onReport?.(totals) + path.skip() + }, + }, + } +} + +/** Run discovery, containing an unexpected crash as a file-level INTERNAL_ERROR entry (empty result). */ +function discover(fn: () => T[], entries: DiagnosticEntry[]): T[] { + try { + return fn() + } catch (error) { + entries.push(bailEntry(0, 'INTERNAL_ERROR', messageOf(error))) + return [] + } +} + +/** Run one emit, containing an unexpected crash so only that unit loses its injection. */ +function tryEmit(entries: DiagnosticEntry[], line: number, emit: () => void): void { + try { + emit() + entries.push({ compiled: true, line }) + } catch (error) { + entries.push(bailEntry(line, 'INTERNAL_ERROR', messageOf(error))) + } +} + +function bailEntry(line: number, code: DiagnosticEntry['code'], message: string): DiagnosticEntry { + return { compiled: false, line, code, message } +} + +/** + * Hoist a proven `` root's CompiledSelection to a module-scope const and reference it from the + * attribute — stable identity across parent renders (an inline literal re-resolves every render, so + * `useRootSelection`'s memo would re-resolve all holes and validate-mode would re-warn each time). + */ +function hoistEntitySelection( + path: NodePath, + element: t.JSXElement, + selection: Parameters[1], + holes: Parameters[2], + hoisted: t.Statement[], +): void { + const obj = entitySelectionObject(ENTITY_ROOT_KEY, selection, holes) + const id = path.scope.generateUidIdentifier('bindxCompiledSelection') + hoisted.push(t.variableDeclaration('const', [t.variableDeclarator(id, obj)])) + // Fresh identifier for the reference — never share the binding-site node with the use site. + element.openingElement.attributes.push(compiledSelectionAttr(t.identifier(id.name))) +} + +/** Insert hoisted declarations after the last import (so module-scope refs stay above first use). */ +function insertAfterImports(program: t.Program, decls: readonly t.Statement[]): void { + if (decls.length === 0) { + return + } + let index = 0 + for (let i = 0; i < program.body.length; i++) { + if (t.isImportDeclaration(program.body[i])) { + index = i + 1 + } + } + program.body.splice(index, 0, ...decls) +} + +export default bindxCompilerPlugin diff --git a/packages/bindx-compiler/src/body.ts b/packages/bindx-compiler/src/body.ts new file mode 100644 index 00000000..1e1ecd9d --- /dev/null +++ b/packages/bindx-compiler/src/body.ts @@ -0,0 +1,414 @@ +/** + * Full-body analysis of a render/condition function. Walks every branch and nested + * function (union is sound) recording entity field access into the selection tree, + * and bails on anything unclassifiable (default deny). Mirrors the runtime collector. + */ +import * as t from '@babel/types' +import type { SelNode } from './selectionTree.js' +import type { ImportBindings } from './imports.js' +import { + BailError, type Scope, childScope, consumeLeaf, consumeMany, consumeRelation, + paramNamesOf, referencesRoot, resolve, type RootRef, +} from './resolve.js' +import { JsxAnalyzer } from './jsx.js' +import type { ContractLookup } from './contracts.js' +import type { TargetKindLookup } from './targetKind.js' +import type { AnalyzedHole } from './types.js' + +export class BodyAnalyzer { + private readonly jsx: JsxAnalyzer + /** Nested-component holes collected across the render + condition functions (phase 2). */ + readonly holes: AnalyzedHole[] = [] + + constructor( + private readonly bindings: ImportBindings, + private readonly moduleBindings: ReadonlySet, + contracts: ContractLookup, + targetKinds: TargetKindLookup, + ) { + this.jsx = new JsxAnalyzer(this, bindings, moduleBindings, contracts, targetKinds) + } + + /** Public so JsxAnalyzer can register a hole it discovered. */ + addHole(hole: AnalyzedHole): void { + this.holes.push(hole) + } + + /** Register a function's params against the shared prop roots, then walk its body. */ + analyzeFunction(fn: t.ArrowFunctionExpression | t.FunctionExpression, propRoots: ReadonlyMap): void { + const scope: Scope = { roots: new Map(), propsParams: new Set(), propRoots, scalarParams: new Set(), locals: new Set() } + const param = fn.params[0] + if (param) { + this.registerTopParam(param, scope) + } + if (t.isBlockStatement(fn.body)) { + this.walkStatements(fn.body.body, scope) + } else { + this.walkValue(fn.body, scope) + } + } + + /** + * Analyze a `` children closure (phase 3). Unlike a `.render()` body — whose + * param is the props object and entity props are its members — the closure's FIRST param + * IS the root entity accessor itself, exactly like a `` children callback. So it + * binds directly at `rootNode` (source `sourceKey`); all downstream machinery (paths, + * holes, contracts, cond-in-props) applies unchanged. + */ + analyzeRootChildren(fn: t.ArrowFunctionExpression | t.FunctionExpression, rootNode: SelNode, sourceKey: string): void { + const scope: Scope = { roots: new Map(), propsParams: new Set(), propRoots: new Map([[sourceKey, rootNode]]), scalarParams: new Set(), locals: new Set() } + this.walkCallbackWithItem(fn, { node: rootNode, path: [], source: sourceKey, absPath: [] }, scope) + } + + private registerTopParam(param: t.Node, scope: Scope): void { + const p = t.isAssignmentPattern(param) ? param.left : param + if (t.isIdentifier(p)) { + scope.propsParams.add(p.name) + return + } + if (t.isObjectPattern(p)) { + for (const prop of p.properties) { + if (t.isRestElement(prop)) { + continue // rest captures scalar props only (proxy exposes none enumerably) + } + if (!t.isObjectProperty(prop) || prop.computed || !t.isIdentifier(prop.key)) { + continue + } + const propRoot = scope.propRoots.get(prop.key.name) + if (!propRoot) { + // Non-entity render prop (scalar / .use() value) — an inert scalar mock at + // oracle collection, so it is safe to drop when used as a hole prop. + for (const name of paramNamesOf(prop.value)) { + scope.scalarParams.add(name) + } + continue + } + this.bindPattern(prop.value, { node: propRoot, path: [], source: prop.key.name, absPath: [] }, scope) + } + } + } + + /** Bind a (possibly nested) destructuring pattern of an entity value to roots. */ + private bindPattern(target: t.Node, ref: RootRef, scope: Scope): void { + if (t.isIdentifier(target)) { + scope.roots.set(target.name, ref) + return + } + if (t.isObjectPattern(target)) { + const node = consumeRelation(ref) + for (const prop of target.properties) { + if (t.isRestElement(prop)) { + throw new BailError({ code: 'UNCLASSIFIED', message: 'rest element in entity destructuring' }) + } + if (!t.isObjectProperty(prop) || prop.computed || !t.isIdentifier(prop.key)) { + throw new BailError({ code: 'UNCLASSIFIED', message: 'unsupported entity destructuring' }) + } + this.bindPattern( + prop.value, + { node, path: [prop.key.name], source: ref.source, absPath: [...ref.absPath, prop.key.name] }, + scope, + ) + } + return + } + throw new BailError({ code: 'UNCLASSIFIED', message: 'unsupported entity binding pattern' }) + } + + // ── Statements ────────────────────────────────────────────────────────── + + private walkStatements(stmts: t.Statement[], scope: Scope): void { + for (const stmt of stmts) { + this.walkStatement(stmt, scope) + } + } + + private walkStatement(stmt: t.Statement, scope: Scope): void { + if (t.isVariableDeclaration(stmt)) { + for (const decl of stmt.declarations) { + this.walkDeclarator(stmt.kind, decl, scope) + } + return + } + if (t.isExpressionStatement(stmt)) { + this.walkValue(stmt.expression, scope) + return + } + if (t.isReturnStatement(stmt)) { + if (stmt.argument) { + this.walkValue(stmt.argument, scope) + } + return + } + if (t.isIfStatement(stmt)) { + this.walkValue(stmt.test, scope) + this.walkStatement(stmt.consequent, scope) + if (stmt.alternate) { + this.walkStatement(stmt.alternate, scope) + } + return + } + if (t.isBlockStatement(stmt)) { + this.walkStatements(stmt.body, scope) + return + } + if (t.isEmptyStatement(stmt)) { + return + } + // Loops, switch, try, etc.: sound only if no root escapes into them. + if (referencesRoot(stmt, scope)) { + throw new BailError({ code: 'UNCLASSIFIED', message: `unsupported statement referencing an entity: ${stmt.type}` }) + } + } + + private walkDeclarator(kind: string, decl: t.VariableDeclarator, scope: Scope): void { + if (!decl.init) { + return + } + const res = resolve(decl.init, scope) + if (res.kind === 'ref') { + if (kind !== 'const') { + throw new BailError({ code: 'ENTITY_REASSIGNMENT', message: 'entity alias must be a const binding' }) + } + this.bindPattern(decl.id, res.ref, scope) + return + } + // Render-local binding — not liftable if later used as a hole prop (default deny). + for (const name of Object.keys(t.getBindingIdentifiers(decl.id))) { + scope.locals.add(name) + } + if (res.kind === 'opaque') { + return + } + // Non-entity initializer: may still contain JSX/roots to analyze. + this.walkValue(decl.init, scope) + } + + // ── Expressions ───────────────────────────────────────────────────────── + + /** Public so JsxAnalyzer (JsxHost) can defer value/JSX-child slots back here. */ + walkValue(node: t.Node, scope: Scope): void { + if (t.isJSXElement(node) || t.isJSXFragment(node)) { + this.jsx.walk(node, scope) + return + } + if (t.isCallExpression(node) || t.isOptionalCallExpression(node)) { + this.walkCall(node, scope) + return + } + if (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node)) { + this.walkNestedFunction(node, scope) + return + } + if (t.isConditionalExpression(node)) { + this.walkValue(node.test, scope) + this.walkValue(node.consequent, scope) // union of both branches + this.walkValue(node.alternate, scope) + return + } + if (t.isLogicalExpression(node) || t.isBinaryExpression(node)) { + if (t.isExpression(node.left)) { + this.walkValue(node.left, scope) + } + this.walkValue(node.right, scope) + return + } + if (t.isUnaryExpression(node)) { + this.walkValue(node.argument, scope) + return + } + if (t.isSequenceExpression(node)) { + for (const e of node.expressions) { + this.walkValue(e, scope) + } + return + } + if (t.isTemplateLiteral(node)) { + for (const e of node.expressions) { + if (t.isExpression(e)) { + this.walkValue(e, scope) + } + } + return + } + if (t.isArrayExpression(node)) { + for (const el of node.elements) { + if (el === null) { + continue + } + this.walkSpreadable(el, scope) + } + return + } + if (t.isObjectExpression(node)) { + this.walkObjectExpression(node, scope) + return + } + if (t.isAssignmentExpression(node)) { + if (referencesRoot(node, scope)) { + throw new BailError({ code: 'ENTITY_REASSIGNMENT', message: 'assignment involving an entity value' }) + } + this.walkValue(node.right, scope) + return + } + if (t.isNewExpression(node)) { + if (referencesRoot(node, scope)) { + throw new BailError({ code: 'ENTITY_ESCAPES_TO_CALL', message: 'entity value passed to a constructor' }) + } + return + } + if (t.isTaggedTemplateExpression(node)) { + if (referencesRoot(node, scope)) { + throw new BailError({ code: 'ENTITY_ESCAPES_TO_CALL', message: 'entity value in a tagged template' }) + } + return + } + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node) || t.isIdentifier(node) + || t.isParenthesizedExpression(node) || t.isTSNonNullExpression(node) || t.isTSAsExpression(node)) { + const res = resolve(node, scope) + if (res.kind === 'ref') { + consumeLeaf(res.ref) + } + return + } + // Literals and other leaves: only a problem if they smuggle a root. + if (referencesRoot(node, scope)) { + throw new BailError({ code: 'UNCLASSIFIED', message: `unclassifiable expression referencing an entity: ${node.type}` }) + } + } + + private walkSpreadable(node: t.Node, scope: Scope): void { + if (t.isSpreadElement(node)) { + if (referencesRoot(node.argument, scope)) { + throw new BailError({ code: 'ENTITY_SPREAD', message: 'spread of an entity value' }) + } + this.walkValue(node.argument, scope) + return + } + this.walkValue(node, scope) + } + + private walkObjectExpression(node: t.ObjectExpression, scope: Scope): void { + for (const prop of node.properties) { + if (t.isSpreadElement(prop)) { + if (referencesRoot(prop.argument, scope)) { + throw new BailError({ code: 'ENTITY_SPREAD', message: 'spread of an entity value into an object' }) + } + this.walkValue(prop.argument, scope) + continue + } + if (t.isObjectMethod(prop)) { + this.walkNestedFunction(prop, scope) + continue + } + if (prop.computed && t.isExpression(prop.key)) { + this.walkValue(prop.key, scope) + } + if (t.isExpression(prop.value)) { + this.walkValue(prop.value, scope) + } + } + } + + private walkNestedFunction(fn: t.ArrowFunctionExpression | t.FunctionExpression | t.ObjectMethod, scope: Scope): void { + const child = childScope(scope) + for (const param of fn.params) { + const p = t.isAssignmentPattern(param) ? param.left : param + this.shadowBindings(p, child) + } + if (t.isBlockStatement(fn.body)) { + this.walkStatements(fn.body.body, child) + } else { + this.walkValue(fn.body, child) + } + } + + private shadowBindings(pattern: t.Node, scope: Scope): void { + // A generic nested-fn param shadows any outer binding and is render-local (bail if + // used as a hole prop): it is neither module-scope nor a proven-inert render prop. + for (const name of paramNamesOf(pattern)) { + scope.roots.delete(name) + scope.propsParams.delete(name) + scope.scalarParams.delete(name) + scope.locals.add(name) + } + } + + private walkCall(node: t.CallExpression | t.OptionalCallExpression, scope: Scope): void { + const callee = node.callee + // `X.map(cb)` — has-many iteration + if ((t.isMemberExpression(callee) || t.isOptionalMemberExpression(callee)) && !callee.computed + && t.isIdentifier(callee.property) && callee.property.name === 'map') { + const objRes = resolve(callee.object, scope) + if (objRes.kind === 'ref') { + this.walkMap(node, objRes.ref, scope) + return + } + } + // `cond.method(...)` — condition DSL, args are field reads + if (t.isMemberExpression(callee) && !callee.computed && t.isIdentifier(callee.object) + && this.bindings.cond.has(callee.object.name)) { + for (const arg of node.arguments) { + if (t.isExpression(arg)) { + this.walkValue(arg, scope) + } + } + return + } + // Any other call that receives/targets an entity value executes opaque code → bail. + if (t.isExpression(callee) && resolve(callee, scope).kind === 'ref') { + throw new BailError({ code: 'ENTITY_ESCAPES_TO_CALL', message: 'method call on an entity value' }) + } + for (const arg of node.arguments) { + if (t.isSpreadElement(arg)) { + if (referencesRoot(arg.argument, scope)) { + throw new BailError({ code: 'ENTITY_SPREAD', message: 'spread of an entity value into a call' }) + } + this.walkValue(arg.argument, scope) + continue + } + if (t.isJSXElement(arg) || t.isJSXFragment(arg) || t.isArrowFunctionExpression(arg) || t.isFunctionExpression(arg)) { + this.walkValue(arg, scope) // safe: analyzed, not executed with an entity value + continue + } + if (t.isExpression(arg) && resolve(arg, scope).kind === 'ref') { + throw new BailError({ code: 'ENTITY_ESCAPES_TO_CALL', message: 'entity value passed to an unrecognized call' }) + } + if (t.isExpression(arg)) { + this.walkValue(arg, scope) + } + } + } + + private walkMap(node: t.CallExpression | t.OptionalCallExpression, ref: RootRef, scope: Scope): void { + const item = consumeMany(ref) + const cb = node.arguments[0] + if (cb && (t.isArrowFunctionExpression(cb) || t.isFunctionExpression(cb))) { + this.walkCallbackWithItem(cb, { node: item, path: [], source: ref.source, absPath: ref.absPath }, scope) + return + } + // Non-inline map callback: its field access is invisible → bail. + throw new BailError({ code: 'UNCLASSIFIED', message: '.map() callback is not an inline function' }) + } + + /** Public so JsxAnalyzer can drive HasOne/HasMany children callbacks; itemRef carries the item's origin. */ + walkCallbackWithItem(fn: t.ArrowFunctionExpression | t.FunctionExpression, itemRef: RootRef, scope: Scope): void { + const child = childScope(scope) + const param = fn.params[0] + if (param) { + const p = t.isAssignmentPattern(param) ? param.left : param + this.bindPattern(p, itemRef, child) + } + // Secondary callback params (index, methods) are inert non-entity values — safe to drop. + for (const extra of fn.params.slice(1)) { + const p = t.isAssignmentPattern(extra) ? extra.left : extra + for (const name of paramNamesOf(p)) { + child.scalarParams.add(name) + } + } + if (t.isBlockStatement(fn.body)) { + this.walkStatements(fn.body.body, child) + } else { + this.walkValue(fn.body, child) + } + } +} diff --git a/packages/bindx-compiler/src/chain.ts b/packages/bindx-compiler/src/chain.ts new file mode 100644 index 00000000..31e85eb4 --- /dev/null +++ b/packages/bindx-compiler/src/chain.ts @@ -0,0 +1,124 @@ +/** + * Recognizes `createComponent()....render(fn)` fluent chains and extracts the + * pieces the analyzer needs: implicit entity prop names, the inline render/condition + * function literals, and any early (chain-shape) bail. + */ +import * as t from '@babel/types' +import { walkAst } from './astWalk.js' +import type { ImportBindings } from './imports.js' +import type { Bailout } from './types.js' + +export interface Chain { + /** The `.render(...)` call — the Babel plugin injects the 2nd argument here. */ + readonly renderCall: t.CallExpression + readonly entityProps: readonly string[] + /** Inline render function literal, or null when it could not be resolved inline. */ + readonly renderFn: t.ArrowFunctionExpression | t.FunctionExpression | null + /** Inline `.if(fn)` condition function literal, if present. */ + readonly conditionFn: t.ArrowFunctionExpression | t.FunctionExpression | null + /** A chain-shape bail (interfaces mode, dynamic entity name, non-inline fn). */ + readonly earlyBail: Bailout | null +} + +function asInlineFn(node: t.Node | undefined): t.ArrowFunctionExpression | t.FunctionExpression | null { + if (node && (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node))) { + return node + } + return null +} + +export function findChains(program: t.Program, bindings: ImportBindings): Chain[] { + const chains: Chain[] = [] + + walkAst(program, node => { + if (!isRenderCall(node)) { + return + } + const methods = collectChainMethods(node, bindings) + if (!methods) { + return + } + chains.push(buildChain(node, methods)) + }) + + return chains +} + +interface MethodCall { + readonly name: string + readonly call: t.CallExpression +} + +function isRenderCall(node: t.Node): node is t.CallExpression { + return t.isCallExpression(node) + && t.isMemberExpression(node.callee) + && !node.callee.computed + && t.isIdentifier(node.callee.property) + && node.callee.property.name === 'render' +} + +/** Walks the fluent chain down to `createComponent(...)`; returns null if not our chain. */ +function collectChainMethods(renderCall: t.CallExpression, bindings: ImportBindings): MethodCall[] | null { + const methods: MethodCall[] = [] + let node: t.Node = renderCall + while (t.isCallExpression(node) && t.isMemberExpression(node.callee) && !node.callee.computed && t.isIdentifier(node.callee.property)) { + methods.push({ name: node.callee.property.name, call: node }) + node = node.callee.object + } + if (t.isCallExpression(node) && t.isIdentifier(node.callee) && bindings.createComponent.has(node.callee.name)) { + return methods + } + return null +} + +function buildChain(renderCall: t.CallExpression, methods: MethodCall[]): Chain { + const entityProps: string[] = [] + let renderFn: t.ArrowFunctionExpression | t.FunctionExpression | null = null + let conditionFn: t.ArrowFunctionExpression | t.FunctionExpression | null = null + let earlyBail: Bailout | null = null + + const bail = (b: Bailout): void => { + if (!earlyBail) { + earlyBail = b + } + } + + for (const { name, call } of methods) { + const args = call.arguments + switch (name) { + case 'interfaces': + bail({ code: 'INTERFACES_MODE', message: '.interfaces() mode is not statically analyzable (v1)' }) + break + case 'entity': { + if (args.length >= 3) { + break // explicit selector — already static, nothing to collect + } + const nameArg = args[0] + if (t.isStringLiteral(nameArg)) { + entityProps.push(nameArg.value) + } else { + bail({ code: 'DYNAMIC_ENTITY_NAME', message: '.entity() name must be a string literal' }) + } + break + } + case 'render': { + renderFn = asInlineFn(args[0]) + if (!renderFn) { + bail({ code: 'EXPLICIT_RENDER_FN', message: '.render() argument is not an inline function literal' }) + } + break + } + case 'if': { + conditionFn = asInlineFn(args[0]) + if (!conditionFn) { + bail({ code: 'EXPLICIT_RENDER_FN', message: '.if() argument is not an inline function literal' }) + } + break + } + default: + break // props / use / mock / slots / roles — no effect on analysis + } + } + + return { renderCall, entityProps, renderFn, conditionFn, earlyBail } +} diff --git a/packages/bindx-compiler/src/contracts.ts b/packages/bindx-compiler/src/contracts.ts new file mode 100644 index 00000000..1e8b57d3 --- /dev/null +++ b/packages/bindx-compiler/src/contracts.ts @@ -0,0 +1,109 @@ +/** + * Collector-contract discovery (Phase 2.2). A `withCollector(_, contract)` component + * declares how its callback props are invoked; the analyzer treats such a callback + * exactly like a ``/`` child (param → root, host captures → paths), + * so no hole and no lift is needed. + * + * Binding resolution (local + relative-import discovery, parse cache) is shared with + * hole-target-kind classification — see moduleResolve.ts. This module only adds the + * contract-specific extraction on top of a resolved binding. + */ +import * as t from '@babel/types' +import { + BindingResolver, type BindingResolverOptions, type ModuleView, + findTopLevelVarInit, +} from './moduleResolve.js' +import { unwrap } from './resolve.js' + +// The runtime derives these shapes; the compiler parses what the runtime declares, so reuse the +// single source of truth (packages/bindx-react/src/jsx/collectorContract.tsx) — type-only, erased. +import type { CallbackContract, CollectorContract } from '@contember/bindx-react' +export type { CallbackContract, CollectorContract } + +/** Resolves a component tag to its declared contract, or null (→ existing hole/bail rules). */ +export type ContractLookup = (tag: string) => CollectorContract | null + +export type ContractResolverOptions = BindingResolverOptions + +export class ContractResolver { + private readonly binding: BindingResolver + private readonly memo = new Map() + + constructor(program: t.Program, options: ContractResolverOptions) { + this.binding = new BindingResolver(program, options) + } + + resolve(tag: string): CollectorContract | null { + const cached = this.memo.get(tag) + if (cached !== undefined) { + return cached + } + const resolved = this.binding.resolve(tag) + const contract = resolved ? this.contractFromInit(resolved.init, resolved.view) : null + this.memo.set(tag, contract) + return contract + } + + /** Contract from a binding initializer, iff it is `withCollector(_, )` in `view`. */ + private contractFromInit(init: t.Node, view: ModuleView): CollectorContract | null { + const call = unwrap(init) + if (!t.isCallExpression(call) || !t.isIdentifier(call.callee) || !view.withCollector.has(call.callee.name)) { + return null + } + const arg = call.arguments[1] + return arg && t.isExpression(arg) ? contractFromExpr(arg, view) : null + } +} + +/** Follow an identifier / object literal to a validated contract, or null. */ +function contractFromExpr(exprIn: t.Expression, view: ModuleView): CollectorContract | null { + const expr = unwrap(exprIn) + if (t.isIdentifier(expr)) { + const init = findTopLevelVarInit(view.program, expr.name) + return init ? contractFromExpr(init, view) : null + } + if (t.isObjectExpression(expr)) { + return contractFromObject(expr, view) + } + return null +} + +function contractFromObject(obj: t.ObjectExpression, view: ModuleView): CollectorContract | null { + const contract: CollectorContract = {} + for (const prop of obj.properties) { + if (!t.isObjectProperty(prop) || prop.computed) { + return null // spread / method / computed key → not a static contract + } + const key = propKeyName(prop.key) + if (key === null || !t.isExpression(prop.value)) { + return null + } + const entry = callbackContract(prop.value, view) + if (!entry) { + return null + } + contract[key] = entry + } + return contract +} + +/** `itemOf('field')` / `entityOf('field')` (combinators imported from bindx, string-literal arg). */ +function callbackContract(valueIn: t.Expression, view: ModuleView): CallbackContract | null { + const value = unwrap(valueIn) + if (!t.isCallExpression(value) || !t.isIdentifier(value.callee)) { + return null + } + const kind = view.itemOf.has(value.callee.name) ? 'itemOf' : view.entityOf.has(value.callee.name) ? 'entityOf' : null + if (!kind || value.arguments.length !== 1) { + return null + } + const arg = value.arguments[0] + return arg && t.isStringLiteral(arg) ? { kind, field: arg.value } : null +} + +function propKeyName(key: t.Node): string | null { + if (t.isIdentifier(key)) { + return key.name + } + return t.isStringLiteral(key) ? key.value : null +} diff --git a/packages/bindx-compiler/src/diagnostics.ts b/packages/bindx-compiler/src/diagnostics.ts new file mode 100644 index 00000000..a2a6c0d1 --- /dev/null +++ b/packages/bindx-compiler/src/diagnostics.ts @@ -0,0 +1,66 @@ +/** + * Single decision point for all compiler console output. Per file it emits per-mode lines and + * ALWAYS warns on INTERNAL_ERROR (crash containment, see babelPlugin.ts) — one code path so a + * contained crash never double-prints (no BAIL info line on top of its warn in verbose mode). + */ +import { relative } from 'node:path' +import type { BailoutReason } from './types.js' + +export type DiagnosticsMode = 'off' | 'summary' | 'verbose' + +/** Outcome of one analyzed unit (createComponent chain or `` root) for reporting. */ +export interface DiagnosticEntry { + readonly compiled: boolean + readonly line: number + /** Present iff bailed. INTERNAL_ERROR always warns regardless of mode. */ + readonly code?: BailoutReason + /** Human-readable context surfaced with an INTERNAL_ERROR warn. */ + readonly message?: string +} + +export interface DiagnosticTotals { + readonly compiled: number + readonly bailed: number +} + +const TAG = '[bindx-compiler]' + +function rel(filename: string | undefined): string { + return filename ? relative(process.cwd(), filename) : '' +} + +/** + * Emit console output for one file's outcomes and return its totals. INTERNAL_ERROR entries always + * warn (file + loc); other bails print only under 'verbose' (per bail) or 'summary' (one file line + * when the file has any bail). Files with zero bails stay silent in 'summary'. + */ +export function reportFile(filename: string | undefined, entries: readonly DiagnosticEntry[], mode: DiagnosticsMode): DiagnosticTotals { + const file = rel(filename) + let compiled = 0 + let bailed = 0 + for (const entry of entries) { + if (entry.compiled) { + compiled++ + continue + } + bailed++ + if (entry.code === 'INTERNAL_ERROR') { + // Always surfaced: an internal crash was contained; this unit degrades to the runtime proxy pass. + console.warn(`${TAG} ${file}:${entry.line} INTERNAL_ERROR ${entry.message ?? ''}`.trimEnd()) + } else if (mode === 'verbose') { + console.info(`${TAG} ${file}:${entry.line} BAIL ${entry.code}`) + } + } + if (mode === 'verbose' && compiled + bailed > 0) { + console.info(`${TAG} ${file}: ${compiled} compiled, ${bailed} bailed`) + } else if (mode === 'summary' && bailed > 0) { + const codes = entries.filter(e => !e.compiled && e.code !== undefined).map(e => e.code).join(', ') + console.info(`${TAG} ${file}: ${compiled} compiled, ${bailed} bailed (${codes})`) + } + return { compiled, bailed } +} + +/** Grand-total line for a bundler layer accumulating across files (Vite `buildEnd`). */ +export function reportTotals(totals: DiagnosticTotals): void { + console.info(`${TAG} total: ${totals.compiled} compiled, ${totals.bailed} bailed`) +} diff --git a/packages/bindx-compiler/src/emit.ts b/packages/bindx-compiler/src/emit.ts new file mode 100644 index 00000000..33a391aa --- /dev/null +++ b/packages/bindx-compiler/src/emit.ts @@ -0,0 +1,94 @@ +/** + * Emits a CompiledSelection (v2) as a Babel object-literal AST — the 2nd argument the + * Babel plugin injects into `.render(fn, )`. Shape: `{ v: 2, props: {...}, holes?: [...] }`. + * Unlike phase 1 this is no longer pure JSON: each hole's `component` is an arrow thunk + * referencing the target's module-scope identifier. + */ +import * as t from '@babel/types' +import type { AnalyzedHole, StaticFieldMap, StaticFieldNode, StaticSelection } from './types.js' + +const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/ + +function key(name: string): t.Identifier | t.StringLiteral { + return IDENTIFIER_RE.test(name) ? t.identifier(name) : t.stringLiteral(name) +} + +function nodeToAst(node: StaticFieldNode): t.Expression { + if (node === true) { + return t.booleanLiteral(true) + } + const props: t.ObjectProperty[] = [ + t.objectProperty(t.identifier('fields'), fieldMapToAst(node.fields)), + ] + if (node.many) { + props.push(t.objectProperty(t.identifier('many'), t.booleanLiteral(true))) + } + if (node.params) { + props.push(t.objectProperty(t.identifier('params'), t.valueToNode(node.params))) + } + return t.objectExpression(props) +} + +function fieldMapToAst(map: StaticFieldMap): t.ObjectExpression { + return t.objectExpression( + Object.entries(map).map(([name, node]) => t.objectProperty(key(name), nodeToAst(node))), + ) +} + +function propsToAst(selection: StaticSelection): t.ObjectExpression { + return t.objectExpression( + Object.entries(selection).map(([prop, map]) => t.objectProperty(key(prop), fieldMapToAst(map))), + ) +} + +function holeToAst(hole: AnalyzedHole): t.ObjectExpression { + const properties: t.ObjectProperty[] = [ + // Thunk (not a direct reference) so the runtime resolves the target lazily — TDZ-safe. + t.objectProperty(t.identifier('component'), t.arrowFunctionExpression([], t.identifier(hole.component))), + t.objectProperty(t.identifier('entityProps'), t.valueToNode(hole.entityProps)), + ] + if (hole.literalProps && Object.keys(hole.literalProps).length > 0) { + properties.push(t.objectProperty(t.identifier('literalProps'), t.valueToNode(hole.literalProps))) + } + if (hole.extraProps && Object.keys(hole.extraProps).length > 0) { + // Each lifted value is wrapped in an arrow thunk (TDZ-safe, resolved at collection time). + // Deep-clone: the same expression still sits in the original render body — sharing one node + // object at two tree positions is fragile against later passes (react-refresh, JSX transform). + const entries = Object.entries(hole.extraProps).map( + ([name, expr]) => t.objectProperty(key(name), t.arrowFunctionExpression([], t.cloneNode(expr, true))), + ) + properties.push(t.objectProperty(t.identifier('extraProps'), t.objectExpression(entries))) + } + return t.objectExpression(properties) +} + +export function selectionToAst(selection: StaticSelection, holes: readonly AnalyzedHole[]): t.ObjectExpression { + const properties: t.ObjectProperty[] = [ + // Version marker: the runtime rejects a literal without `v === 2` and falls back to the proxy pass. + t.objectProperty(t.identifier('v'), t.numericLiteral(2)), + t.objectProperty(t.identifier('props'), propsToAst(selection)), + ] + if (holes.length > 0) { + properties.push(t.objectProperty(t.identifier('holes'), t.arrayExpression(holes.map(holeToAst)))) + } + return t.objectExpression(properties) +} + +/** + * Builds the CompiledSelection object literal for a proven `` root (phase 3): the root + * field map lives under the fixed `rootKey`; holes are the same thunk-carrying shape as chains. + * Emitted separately from the attribute so the plugin can hoist it to a module-scope const — + * keeping a stable identity across parent renders (inline object literals re-resolve every render). + */ +export function entitySelectionObject( + rootKey: string, + selection: StaticFieldMap, + holes: readonly AnalyzedHole[], +): t.ObjectExpression { + return selectionToAst({ [rootKey]: selection }, holes) +} + +/** Builds the `compiledSelection={}` JSX attribute referencing the given expression. */ +export function compiledSelectionAttr(value: t.Expression): t.JSXAttribute { + return t.jsxAttribute(t.jsxIdentifier('compiledSelection'), t.jsxExpressionContainer(value)) +} diff --git a/packages/bindx-compiler/src/entityRoots.ts b/packages/bindx-compiler/src/entityRoots.ts new file mode 100644 index 00000000..84c1392d --- /dev/null +++ b/packages/bindx-compiler/src/entityRoots.ts @@ -0,0 +1,188 @@ +/** + * Top-level `` scan (phase 3). Finds `` JSX elements whose tag resolves + * to a bindx import — anywhere in the file: plain function components (routes), inside + * `createComponent` render bodies, at any nesting — and analyzes each as an independent + * emit-or-bail selection ROOT. + * + * The root's field map lives under the fixed key `entity` (see `ENTITY_ROOT_KEY`); every + * hole's `entityProps[*].source` is `'entity'`. ``'s own props (`entity`, `by`, + * `filter`, `create`, `onPersisted`, `queryKey`, `loading`, `error`, `notFound`, …) carry + * no selection and are never analyzed — only the children closure is. + */ +import * as t from '@babel/types' +import { walkAst } from './astWalk.js' +import { BodyAnalyzer } from './body.js' +import { BailError, internalErrorBail } from './resolve.js' +import { SelNode } from './selectionTree.js' +import type { ImportBindings } from './imports.js' +import type { ContractLookup } from './contracts.js' +import type { TargetKindLookup } from './targetKind.js' +import type { ChainLoc, EntityRootResult } from './types.js' + +/** Fixed key under which a compiled `` stores its root field map + hole sources. */ +export const ENTITY_ROOT_KEY = 'entity' + +/** A recognized `` element paired with its analysis result (Babel node retained for emit). */ +export interface InternalEntityRootResult { + readonly element: t.JSXElement + readonly result: EntityRootResult +} + +/** + * Collect every `` JSX element whose tag is a bindx `Entity` import binding, plus any + * `entityLike` forwarding-wrapper elements (see `resolveEntityLikeLocals`). The two are treated + * identically — the wrapper must forward the injected `compiledSelection` to its inner `` + * via `{...props}`. + */ +export function findEntityElements( + program: t.Program, + bindings: ImportBindings, + entityLikeLocals: ReadonlySet = new Set(), +): t.JSXElement[] { + const elements: t.JSXElement[] = [] + walkAst(program, node => { + if (t.isJSXElement(node) && isEntityTag(node.openingElement.name, bindings, entityLikeLocals)) { + elements.push(node) + } + }) + return elements +} + +function isEntityTag(name: t.JSXOpeningElement['name'], bindings: ImportBindings, entityLikeLocals: ReadonlySet): boolean { + return t.isJSXIdentifier(name) && (bindings.entity.has(name.name) || entityLikeLocals.has(name.name)) +} + +/** + * Resolve which LOCAL tag names in `program` correspond to the configured `entityLike` component + * names. Matching prefers an import's ORIGINAL exported name over its local alias (so + * `import { RefreshableEntity as RE }` still matches when `RefreshableEntity` is configured); a + * locally-declared component (const/function/class) matches by its declared name. Default/namespace + * imports carry no matchable exported name and are skipped. + */ +export function resolveEntityLikeLocals(program: t.Program, entityLike: ReadonlySet): Set { + const locals = new Set() + if (entityLike.size === 0) { + return locals + } + // Match imports by original exported name, and top-level declarations by declared name. + for (const node of program.body) { + if (t.isImportDeclaration(node)) { + for (const spec of node.specifiers) { + if (t.isImportSpecifier(spec)) { + const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value + if (entityLike.has(imported)) { + locals.add(spec.local.name) + } + } + } + continue + } + collectLocalDeclNames(node, entityLike, locals) + } + return locals +} + +function collectLocalDeclNames(node: t.Statement, entityLike: ReadonlySet, locals: Set): void { + const decl = t.isExportNamedDeclaration(node) ? node.declaration : node + if (decl && t.isVariableDeclaration(decl)) { + for (const d of decl.declarations) { + if (t.isIdentifier(d.id) && entityLike.has(d.id.name)) { + locals.add(d.id.name) + } + } + return + } + if (decl && (t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) && decl.id && entityLike.has(decl.id.name)) { + locals.add(decl.id.name) + } +} + +export function analyzeEntityRoot( + element: t.JSXElement, + bindings: ImportBindings, + moduleBindings: ReadonlySet, + lookup: ContractLookup, + targetKinds: TargetKindLookup, +): EntityRootResult { + const loc = elementLoc(element) + const childrenFn = entityChildrenFn(element) + if (!childrenFn) { + // Non-function or absent children — the runtime children walk stays; no emit. + return { loc, bailout: { code: 'ENTITY_NO_FUNCTION_CHILDREN', message: ' children is not a single inline function' } } + } + + const rootNode = new SelNode() + const analyzer = new BodyAnalyzer(bindings, moduleBindings, lookup, targetKinds) + try { + analyzer.analyzeRootChildren(childrenFn, rootNode, ENTITY_ROOT_KEY) + } catch (error) { + if (error instanceof BailError) { + return { loc, bailout: error.bailout } + } + // Contain an unexpected compiler bug per root: bail (proxy fallback is sound), never crash the build. + return { loc, bailout: internalErrorBail(error) } + } + + return { loc, selection: rootNode.toFieldMap(), holes: analyzer.holes } +} + +/** + * The single inline function `` renders with. Accepts both the JSX-child form + * (`{e => …}`) and the explicit `children={e => …}` attribute; anything + * else (element children, multiple children, non-function) yields null → bail. + */ +function entityChildrenFn(element: t.JSXElement): t.ArrowFunctionExpression | t.FunctionExpression | null { + const attrFn = childrenAttrFn(element) + if (attrFn) { + return attrFn + } + let found: t.ArrowFunctionExpression | t.FunctionExpression | null = null + for (const child of element.children) { + if (t.isJSXText(child)) { + if (child.value.trim() === '') { + continue + } + return null // non-whitespace text alongside → not a clean function-children form + } + if (t.isJSXExpressionContainer(child) + && (t.isArrowFunctionExpression(child.expression) || t.isFunctionExpression(child.expression))) { + if (found) { + return null // more than one function child + } + found = child.expression + continue + } + return null // element / spread / non-function expression child + } + return found +} + +function childrenAttrFn(element: t.JSXElement): t.ArrowFunctionExpression | t.FunctionExpression | null { + for (const attr of element.openingElement.attributes) { + if (!t.isJSXAttribute(attr) || !t.isJSXIdentifier(attr.name) || attr.name.name !== 'children') { + continue + } + if (t.isJSXExpressionContainer(attr.value) + && (t.isArrowFunctionExpression(attr.value.expression) || t.isFunctionExpression(attr.value.expression))) { + return attr.value.expression + } + return null // children attribute present but not an inline function + } + return null +} + +/** True if the element already carries a `compiledSelection` attribute (idempotence guard). */ +export function hasCompiledSelectionAttr(element: t.JSXElement): boolean { + return element.openingElement.attributes.some( + attr => t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && attr.name.name === 'compiledSelection', + ) +} + +function elementLoc(element: t.JSXElement): ChainLoc { + return { + start: element.start ?? 0, + end: element.end ?? 0, + line: element.loc?.start.line ?? 0, + column: element.loc?.start.column ?? 0, + } +} diff --git a/packages/bindx-compiler/src/holeProps.ts b/packages/bindx-compiler/src/holeProps.ts new file mode 100644 index 00000000..b0d4e377 --- /dev/null +++ b/packages/bindx-compiler/src/holeProps.ts @@ -0,0 +1,126 @@ +/** + * Phase-2.1 lifting + phase-3.1 target-kind policy: classify a hole element's non-entity props + * (closures, identifiers) into `extraProps` (lifted verbatim), a safe drop, or a bail. A hole + * target's `staticRender` may INVOKE such a value with a collector proxy during collection, so + * dropping it can under-fetch — but only IF the target actually reads/invokes that prop. The + * `HolePropPolicy` (derived from the classified target kind) says which props could under-fetch; + * the rest are dropped with no bail. Uncertain props still go through the taint lattice (default + * deny). See docs/compiler-plan.md (Phase 2.1 / 3.1). + */ +import * as t from '@babel/types' +import { BailError, type Scope, classifyHoleClosure } from './resolve.js' +import type { TargetKind } from './targetKind.js' + +export type Closure = t.ArrowFunctionExpression | t.FunctionExpression + +export interface HoleClosureProp { + readonly name: string + readonly fn: Closure +} + +export interface HoleIdentifierProp { + readonly name: string + readonly ident: t.Identifier +} + +/** + * Per-target decision surface: does dropping a given prop risk under-fetch (must run the taint + * lattice), or is the target provably inert for it (safe drop, no bail)? + */ +export interface HolePropPolicy { + closureNeedsCheck(name: string): boolean + identifierNeedsCheck(name: string): boolean +} + +const CHECK_ALL: HolePropPolicy = { closureNeedsCheck: () => true, identifierNeedsCheck: () => true } +const CHECK_NONE: HolePropPolicy = { closureNeedsCheck: () => false, identifierNeedsCheck: () => false } + +/** Derive the hole-prop policy for a classified target kind (default deny → check everything). */ +export function holePolicyFor(kind: TargetKind): HolePropPolicy { + switch (kind.kind) { + case 'createComponent': + // getSelection never invokes function slots (analyzeJsx ignores functions) → closures always + // safe. A slot passed a bare identifier may hold JSX → check identifiers named as slots. + return { + closureNeedsCheck: () => false, + identifierNeedsCheck: name => kind.slots === 'unknown' || kind.slots.has(name), + } + case 'plain': + return CHECK_NONE // no selection surface → runtime is blind to every non-entity prop + case 'collectorStatic': { + const referenced = kind.referenced + const needs = (name: string): boolean => referenced === 'all' || referenced.has(name) + return { closureNeedsCheck: needs, identifierNeedsCheck: needs } + } + case 'unknown': + return CHECK_ALL + } +} + +export interface HolePropInputs { + readonly tag: string + readonly closureProps: ReadonlyArray + readonly identifierProps: ReadonlyArray + readonly childClosure: Closure | null + readonly scope: Scope + readonly moduleBindings: ReadonlySet + readonly policy: HolePropPolicy +} + +/** + * Builds a hole's `extraProps` (target prop → value expression, emitted as an arrow thunk), lifting + * what a target may invoke, dropping what it provably ignores, and bailing on what cannot be + * reproduced at the emit site (default deny). + */ +export function resolveHoleExtraProps(inputs: HolePropInputs): Record { + const { tag, closureProps, identifierProps, childClosure, scope, moduleBindings, policy } = inputs + const extraProps: Record = {} + + const liftClosure = (name: string, fn: Closure): void => { + switch (classifyHoleClosure(fn, scope)) { + case 'drop': return + case 'lift': extraProps[name] = fn; return // captures nothing from render scope → emit verbatim + case 'bail': + throw new BailError({ code: 'FUNCTION_PROP_ON_HOLE', message: `closure ${name} on hole element <${tag}> may be invoked with an entity during collection` }) + } + } + + for (const { name, fn } of closureProps) { + if (policy.closureNeedsCheck(name)) { + liftClosure(name, fn) // target may invoke it → lattice decides lift/drop/bail + } + // else: target provably never invokes it → safe drop + } + if (childClosure && policy.closureNeedsCheck('children')) { + liftClosure('children', childClosure) + } + for (const { name, ident } of identifierProps) { + if (!policy.identifierNeedsCheck(name)) { + continue // target provably ignores it → safe drop + } + switch (classifyIdentifierProp(ident.name, scope, moduleBindings)) { + case 'drop': break + case 'lift': extraProps[name] = ident; break // real module-scope value reaches the target + case 'bail': + throw new BailError({ code: 'RENDER_LOCAL_ON_HOLE', message: `render-local value \`${ident.name}\` passed to hole element <${tag}> may under-fetch` }) + } + } + return extraProps +} + +/** + * Taint lattice for a bare identifier passed to a hole (default deny): + * render-local (incl. shadowing) → bail; inert scalar/.use() param → drop; module-scope → lift. + */ +export function classifyIdentifierProp(name: string, scope: Scope, moduleBindings: ReadonlySet): 'drop' | 'lift' | 'bail' { + if (scope.locals.has(name)) { + return 'bail' // render-local const/let (checked first so it wins over a shadowed module name) + } + if (scope.scalarParams.has(name) || name === 'undefined') { + return 'drop' // inert scalar mock at oracle collection — cannot reach a selection scope + } + if (moduleBindings.has(name)) { + return 'lift' // import / top-level binding — pass the real value through unchanged + } + return 'bail' // free/unresolvable identifier — could be a field-collecting function +} diff --git a/packages/bindx-compiler/src/imports.ts b/packages/bindx-compiler/src/imports.ts new file mode 100644 index 00000000..7c7f603b --- /dev/null +++ b/packages/bindx-compiler/src/imports.ts @@ -0,0 +1,111 @@ +/** + * Resolves local binding names for symbols imported from any `@contember/bindx*` + * package, so the analyzer follows aliased imports (`import { Field as F }`). + */ +import * as t from '@babel/types' + +export type ComponentKind = 'Field' | 'Attribute' | 'Show' | 'HasOne' | 'HasMany' | 'If' + +/** Imported name → component kind. A Map lookup returns `ComponentKind | undefined` — no cast needed. */ +const COMPONENT_KINDS: ReadonlyMap = new Map([ + ['Field', 'Field'], + ['Attribute', 'Attribute'], + ['Show', 'Show'], + ['HasOne', 'HasOne'], + ['HasMany', 'HasMany'], + ['If', 'If'], +]) + +export interface ImportBindings { + /** local names that refer to `createComponent`. */ + readonly createComponent: ReadonlySet + /** local names that refer to the `cond` DSL object. */ + readonly cond: ReadonlySet + /** local component name → recognized bindx component kind. */ + readonly components: ReadonlyMap + /** local names that refer to the `` selection-root component (phase 3). */ + readonly entity: ReadonlySet +} + +function isBindxSource(source: string): boolean { + return source === '@contember/bindx' || source.startsWith('@contember/bindx-') || source.startsWith('@contember/bindx/') +} + +export function collectImportBindings(program: t.Program): ImportBindings { + const createComponent = new Set() + const cond = new Set() + const components = new Map() + const entity = new Set() + + for (const node of program.body) { + if (!t.isImportDeclaration(node) || !isBindxSource(node.source.value)) { + continue + } + for (const spec of node.specifiers) { + if (!t.isImportSpecifier(spec)) { + continue + } + const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value + const local = spec.local.name + const kind = COMPONENT_KINDS.get(imported) + if (imported === 'createComponent') { + createComponent.add(local) + } else if (imported === 'cond') { + cond.add(local) + } else if (imported === 'Entity') { + // Kept separate from `components` so it never becomes a "recognized bindx + // component" in the per-chain JSX walk — a nested must stay an opaque + // element to the host chain (its children closure is walked as a nested fn). + entity.add(local) + } else if (kind) { + components.set(local, kind) + } + } + } + + return { createComponent, cond, components, entity } +} + +/** + * Names bound at module scope (imports + top-level declarations). A hole's component + * thunk (`() => Identifier`) is emitted as a sibling of the `.render()` call, so its tag + * must resolve in module scope; unresolved tags bail instead of forming a hole. + */ +export function collectModuleBindings(program: t.Program): Set { + const names = new Set() + + const addDeclaration = (decl: t.Node): void => { + if (t.isImportDeclaration(decl)) { + for (const spec of decl.specifiers) { + names.add(spec.local.name) + } + return + } + if (t.isVariableDeclaration(decl)) { + for (const d of decl.declarations) { + for (const name of Object.keys(t.getBindingIdentifiers(d.id))) { + names.add(name) + } + } + return + } + if ((t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) && decl.id) { + names.add(decl.id.name) + } + } + + for (const node of program.body) { + addDeclaration(node) + if (t.isExportNamedDeclaration(node) && node.declaration) { + addDeclaration(node.declaration) + } + if (t.isExportDefaultDeclaration(node)) { + const d = node.declaration + if ((t.isFunctionDeclaration(d) || t.isClassDeclaration(d)) && d.id) { + names.add(d.id.name) + } + } + } + + return names +} diff --git a/packages/bindx-compiler/src/index.ts b/packages/bindx-compiler/src/index.ts new file mode 100644 index 00000000..225e4dc3 --- /dev/null +++ b/packages/bindx-compiler/src/index.ts @@ -0,0 +1,33 @@ +export { + analyzeSource, + analyzeProgram, + analyzeEntityRoots, + analyzeEntityRootsInProgram, + type AnalyzeOptions, +} from './analyze.js' +export { bindxCompilerPlugin, default, type BindxCompilerOptions } from './babelPlugin.js' +export { + bindxCompiler, + type BindxCompilerViteOptions, + type BindxCompilerVitePlugin, + type BindxTransformContext, + type BindxTransformResult, +} from './vitePlugin.js' +export type { + StaticSelection, + StaticFieldMap, + StaticFieldNode, + StaticHasManyParams, + AnalyzedHole, + HoleEntityProp, + ChainResult, + AnalyzedChain, + BailedChain, + EntityRootResult, + AnalyzedEntityRoot, + BailedEntityRoot, + BailoutReason, + Bailout, + ChainLoc, +} from './types.js' +export { isBailed, isEntityRootBailed } from './types.js' diff --git a/packages/bindx-compiler/src/jsx.ts b/packages/bindx-compiler/src/jsx.ts new file mode 100644 index 00000000..a3911226 --- /dev/null +++ b/packages/bindx-compiler/src/jsx.ts @@ -0,0 +1,475 @@ +/** + * JSX walking for the body analyzer: recognized bindx components (Field/Attribute/ + * Show/HasOne/HasMany/If), host elements (attribute values are plain leaf slots), + * and unknown components (children analyzed; entity-rooted non-children props bail). + */ +import * as t from '@babel/types' +import type { ComponentKind, ImportBindings } from './imports.js' +import { + BailError, type RootRef, type Scope, consumeLeaf, consumeMany, consumeRelation, + entityPathOf, evaluateLiteral, referencesRoot, resolve, +} from './resolve.js' +import { type Closure, type HoleClosureProp, type HoleIdentifierProp, holePolicyFor, resolveHoleExtraProps } from './holeProps.js' +import type { CollectorContract, ContractLookup } from './contracts.js' +import type { TargetKindLookup } from './targetKind.js' +import type { AnalyzedHole, HoleEntityProp, StaticHasManyParams } from './types.js' + +function asClosure(node: t.Node | null): Closure | null { + return node && (t.isArrowFunctionExpression(node) || t.isFunctionExpression(node)) ? node : null +} + +const HASMANY_PARAM_KEYS = ['filter', 'orderBy', 'limit', 'offset', 'totalCount'] as const + +/** The value/callback walkers the JSX analyzer defers back into (BodyAnalyzer). */ +export interface JsxHost { + walkValue(node: t.Node, scope: Scope): void + walkCallbackWithItem(fn: t.ArrowFunctionExpression | t.FunctionExpression, itemRef: RootRef, scope: Scope): void + addHole(hole: AnalyzedHole): void +} + +export class JsxAnalyzer { + constructor( + private readonly host: JsxHost, + private readonly bindings: ImportBindings, + private readonly moduleBindings: ReadonlySet, + private readonly contracts: ContractLookup, + private readonly targetKinds: TargetKindLookup, + ) {} + + walk(node: t.JSXElement | t.JSXFragment, scope: Scope): void { + if (t.isJSXFragment(node)) { + this.walkChildren(node.children, scope) + return + } + const kind = this.componentKind(node.openingElement.name) + if (kind.type === 'bindx') { + this.walkBindxComponent(kind.kind, node, scope) + } else if (kind.type === 'host') { + this.walkHostElement(node, scope) + } else if (kind.type === 'component') { + this.walkComponentElement(kind.tag, node, scope) + } else { + this.walkMemberTagElement(node, scope) + } + } + + private componentKind(name: t.JSXOpeningElement['name']): + { type: 'bindx'; kind: ComponentKind } | { type: 'host' } | { type: 'component'; tag: string } | { type: 'memberTag' } { + if (t.isJSXIdentifier(name)) { + const kind = this.bindings.components.get(name.name) + if (kind) { + return { type: 'bindx', kind } + } + const first = name.name[0] ?? '' + const isHost = first === first.toLowerCase() && first !== first.toUpperCase() + return isHost ? { type: 'host' } : { type: 'component', tag: name.name } + } + if (t.isJSXMemberExpression(name) && t.isJSXIdentifier(name.property) && name.property.name === 'Fragment') { + return { type: 'host' } + } + return { type: 'memberTag' } // `` / namespaced tags — not a simple identifier + } + + private walkHostElement(node: t.JSXElement, scope: Scope): void { + for (const attr of node.openingElement.attributes) { + if (t.isJSXSpreadAttribute(attr)) { + this.guardSpread(attr, scope, 'host element') + continue + } + const expr = attrExpr(attr) + if (expr) { + this.host.walkValue(expr, scope) // host attributes are plain value slots (leaf) + } + } + this.walkChildren(node.children, scope) + } + + /** + * A component-typed JSX element (``). Entity-rooted props become a phase-2 + * hole resolved through the target's runtime selection surface; children stay statically + * analyzed (not part of the hole). Non-entity props that a target could invoke with a + * collector proxy (module-scope values, render-scope-free closures) are lifted verbatim + * into the hole's `extraProps` so compiled ≡ oracle by construction. + */ + private walkComponentElement(tag: string, node: t.JSXElement, scope: Scope): void { + const contract = this.contracts(tag) + if (contract) { + this.walkContractComponent(tag, node, contract, scope) + return + } + + const entityProps: Record = {} + const literalProps: Record = {} + const closureProps: HoleClosureProp[] = [] + const identifierProps: HoleIdentifierProp[] = [] + + for (const attr of node.openingElement.attributes) { + if (t.isJSXSpreadAttribute(attr)) { + this.guardSpread(attr, scope, 'component') + continue + } + if (!t.isJSXIdentifier(attr.name) || attr.name.name === 'children') { + continue + } + const fn = asClosure(jsxAttrInner(attr.value)) + if (fn) { + closureProps.push({ name: attr.name.name, fn }) // hole-classified below (lift/drop/bail) + continue + } + this.collectComponentProp(attr.name.name, attr.value, scope, entityProps, literalProps, identifierProps) + } + + const childClosure = childrenCallback(node.children) + + if (Object.keys(entityProps).length > 0) { + if (!this.moduleBindings.has(tag)) { + // Tag isn't resolvable at module scope → no thunk can reference it. + throw new BailError({ code: 'ENTITY_ESCAPES_TO_COMPONENT', message: `component <${tag}> does not resolve to a module binding` }) + } + // Classify the target so provably-inert props (render-locals, function children on a + // createComponent/plain target, unreferenced staticRender props) drop without bailing. + const policy = holePolicyFor(this.targetKinds(tag)) + const extraProps = resolveHoleExtraProps({ tag, closureProps, identifierProps, childClosure, scope, moduleBindings: this.moduleBindings, policy }) + this.host.addHole({ + component: tag, + entityProps, + literalProps: Object.keys(literalProps).length > 0 ? literalProps : undefined, + extraProps: Object.keys(extraProps).length > 0 ? extraProps : undefined, + }) + } else { + // Not a hole: a closure prop capturing an entity root would escape into the element + // (the runtime cannot see it either) — preserve the conservative bail. + for (const { fn } of closureProps) { + if (referencesRoot(fn, scope)) { + throw new BailError({ code: 'ENTITY_IN_EXPRESSION_PROP', message: `entity value inside a function prop of <${tag}>` }) + } + } + } + + this.walkChildren(node.children, scope) // the `children` slot is analyzed at runtime + } + + /** + * A component whose tag declares a collector contract (`withCollector(_, { cb: itemOf('field') })`). + * It forms NO hole: each contract entry's callback is analyzed exactly like a / + * child — its first param becomes a root at the relation, host captures inside it are ordinary + * paths. The contract's derived staticRender provably never invokes non-contract function props, + * so those are dropped with no safety bail (removing FUNCTION_PROP_ON_HOLE for contract targets). + */ + private walkContractComponent(_tag: string, node: t.JSXElement, contract: CollectorContract, scope: Scope): void { + const fieldProps = new Set() + const callbackProps = new Set() + for (const [cbName, entry] of Object.entries(contract)) { + callbackProps.add(cbName) + fieldProps.add(entry.field) + } + + for (const [cbName, entry] of Object.entries(contract)) { + const fieldExpr = getAttr(node, entry.field) + const res = fieldExpr ? resolve(fieldExpr, scope) : { kind: 'none' as const } + if (res.kind !== 'ref') { + continue // relation not resolvable → derived staticRender collects nothing for this entry + } + const item = entry.kind === 'itemOf' ? consumeMany(res.ref) : consumeRelation(res.ref) + const cb = this.contractCallback(node, cbName) + if (cb) { + this.host.walkCallbackWithItem(cb, { node: item, path: [], source: res.ref.source, absPath: res.ref.absPath }, scope) + } + // Missing callback → relation only (already recorded by consume*), mirroring the runtime guard. + } + + for (const attr of node.openingElement.attributes) { + if (t.isJSXSpreadAttribute(attr)) { + this.guardSpread(attr, scope, 'contract component') + continue + } + if (!t.isJSXIdentifier(attr.name)) { + continue + } + const name = attr.name.name + if (name === 'children' || fieldProps.has(name) || callbackProps.has(name)) { + continue // contract field / callback props handled above + } + if (asClosure(jsxAttrInner(attr.value))) { + continue // non-contract function prop: the derived staticRender never invokes it → safe drop + } + const expr = attrExpr(attr) + if (expr) { + this.host.walkValue(expr, scope) // entity props not referenced by the contract → touched leaves + } + } + + if (!callbackProps.has('children')) { + this.walkChildren(node.children, scope) // plain children (not a contract callback) + } + } + + /** The inline closure a contract entry is invoked with (`children` closure or a named callback prop). */ + private contractCallback(node: t.JSXElement, cbName: string): t.ArrowFunctionExpression | t.FunctionExpression | null { + if (cbName === 'children') { + return childrenCallback(node.children) + } + const attr = findAttr(node, cbName) + return attr ? asClosure(jsxAttrInner(attr.value)) : null + } + + /** Namespaced / member-expression tags (``) cannot be referenced by a thunk → bail on entity props. */ + private walkMemberTagElement(node: t.JSXElement, scope: Scope): void { + for (const attr of node.openingElement.attributes) { + if (t.isJSXSpreadAttribute(attr)) { + this.guardSpread(attr, scope, 'component') + continue + } + const expr = attrExpr(attr) + if (expr && referencesRoot(expr, scope)) { + throw new BailError({ code: 'MEMBER_COMPONENT_TAG', message: 'entity value passed to a member-expression component tag' }) + } + } + this.walkChildren(node.children, scope) + } + + private collectComponentProp( + propName: string, + value: t.JSXAttribute['value'], + scope: Scope, + entityProps: Record, + literalProps: Record, + identifierProps: Array<{ name: string; ident: t.Identifier }>, + ): void { + if (value === null || value === undefined) { + literalProps[propName] = true // boolean shorthand `` + return + } + if (t.isStringLiteral(value)) { + literalProps[propName] = value.value + return + } + const inner = jsxAttrInner(value) + if (!inner) { + return + } + // JSX-element/fragment prop (`draftSlot={}`): analyze like children, emit nothing. + // The oracle walks it via the target's slot/staticRender proxies → this yields an equal-or-superset union. + if (t.isJSXElement(inner) || t.isJSXFragment(inner)) { + this.host.walkValue(inner, scope) + return + } + // `cond.*` DSL in a prop (`if={cond.eq(cell.kind, 'promo')}`): the only selection it carries + // is the FieldRefs in its args (verified against Case/If getSelection). Record them, emit nothing. + if (this.isCondCall(inner)) { + this.host.walkValue(inner, scope) + return + } + const ep = entityPathOf(inner, scope) // may throw COMPUTED_MEMBER + if (ep.kind === 'path') { + entityProps[propName] = { source: ep.source, path: [...ep.path] } + return + } + // Bare identifier — deferred to hole classification (module-scope lift / scalar drop / bail). + if (t.isIdentifier(inner)) { + identifierProps.push({ name: propName, ident: inner }) + return + } + if (referencesRoot(inner, scope)) { + throw new BailError({ code: 'ENTITY_IN_EXPRESSION_PROP', message: `entity value inside a non-path expression prop \`${propName}\`` }) + } + const lit = evaluateLiteral(inner) + if (lit.ok) { + literalProps[propName] = lit.value + } + // Non-literal non-entity props are silently omitted (recovered at runtime if needed). + } + + /** True for a recognized `cond.method(...)` call (condition DSL) in a prop position. */ + private isCondCall(node: t.Node): boolean { + if (!t.isCallExpression(node) && !t.isOptionalCallExpression(node)) { + return false + } + const callee = node.callee + return t.isMemberExpression(callee) && !callee.computed + && t.isIdentifier(callee.object) && this.bindings.cond.has(callee.object.name) + } + + private walkBindxComponent(kind: ComponentKind, node: t.JSXElement, scope: Scope): void { + for (const attr of node.openingElement.attributes) { + if (t.isJSXSpreadAttribute(attr)) { + this.guardSpread(attr, scope, 'bindx component') + } + } + switch (kind) { + case 'Field': + this.consumeAttrLeaf(node, 'field', scope) + this.walkOtherAttrs(node, new Set(['field', 'children', 'format']), scope) + return + case 'Attribute': + this.consumeAttrLeaf(node, 'field', scope) + this.walkChildren(node.children, scope) + this.walkOtherAttrs(node, new Set(['field', 'children', 'format']), scope) + return + case 'Show': + this.consumeAttrLeaf(node, 'field', scope) + this.walkShowChildren(node.children, scope) + this.walkOtherAttrs(node, new Set(['field', 'children', 'fallback']), scope) + return + case 'HasOne': + this.walkRelationComponent(node, scope, false) + return + case 'HasMany': + this.walkRelationComponent(node, scope, true) + return + case 'If': + this.walkIf(node, scope) + return + } + } + + private walkIf(node: t.JSXElement, scope: Scope): void { + for (const name of ['condition', 'then', 'else'] as const) { + const expr = getAttr(node, name) + if (expr) { + this.host.walkValue(expr, scope) // condition + both branches → union + } + } + this.walkOtherAttrs(node, new Set(['condition', 'then', 'else']), scope) + } + + private walkRelationComponent(node: t.JSXElement, scope: Scope, many: boolean): void { + const fieldExpr = getAttr(node, 'field') + const res = fieldExpr ? resolve(fieldExpr, scope) : { kind: 'none' as const } + if (res.kind !== 'ref') { + this.walkOtherAttrs(node, new Set(['field', 'children']), scope) + return + } + const params = many ? readHasManyParams(node) : undefined + const item = many ? consumeMany(res.ref, params) : consumeRelation(res.ref) + const cb = childrenCallback(node.children) + if (cb) { + this.host.walkCallbackWithItem(cb, { node: item, path: [], source: res.ref.source, absPath: res.ref.absPath }, scope) + } + this.walkOtherAttrs(node, new Set(['field', 'children', ...(many ? HASMANY_PARAM_KEYS : [])]), scope) + } + + private walkShowChildren(children: t.JSXElement['children'], scope: Scope): void { + // Runtime skips function children of ; only plain children are analyzed. + const only = children.filter(c => !t.isJSXText(c) || c.value.trim() !== '') + const first = only[0] + if (only.length === 1 && first && t.isJSXExpressionContainer(first) + && (t.isArrowFunctionExpression(first.expression) || t.isFunctionExpression(first.expression))) { + return + } + this.walkChildren(children, scope) + } + + private walkChildren(children: t.JSXElement['children'], scope: Scope): void { + for (const child of children) { + if (t.isJSXElement(child) || t.isJSXFragment(child)) { + this.walk(child, scope) + } else if (t.isJSXExpressionContainer(child) && t.isExpression(child.expression)) { + this.host.walkValue(child.expression, scope) + } else if (t.isJSXSpreadChild(child) && referencesRoot(child.expression, scope)) { + throw new BailError({ code: 'ENTITY_SPREAD', message: 'spread of an entity value as a child' }) + } + } + } + + private consumeAttrLeaf(node: t.JSXElement, name: string, scope: Scope): void { + const expr = getAttr(node, name) + if (!expr) { + return + } + const res = resolve(expr, scope) + if (res.kind === 'ref') { + consumeLeaf(res.ref) + } + } + + private walkOtherAttrs(node: t.JSXElement, handled: Set, scope: Scope): void { + for (const attr of node.openingElement.attributes) { + if (!t.isJSXAttribute(attr) || !t.isJSXIdentifier(attr.name) || handled.has(attr.name.name)) { + continue + } + const expr = attrExpr(attr) + if (expr) { + this.host.walkValue(expr, scope) // extra attrs are plain value slots (leaf) + } + } + } + + private guardSpread(attr: t.JSXSpreadAttribute, scope: Scope, where: string): void { + if (referencesRoot(attr.argument, scope)) { + throw new BailError({ code: 'ENTITY_SPREAD', message: `spread of an entity value onto a ${where}` }) + } + } +} + +function readHasManyParams(node: t.JSXElement): StaticHasManyParams { + const params: Record = {} + for (const key of HASMANY_PARAM_KEYS) { + const attr = findAttr(node, key) + if (!attr) { + continue + } + if (attr.value === null || attr.value === undefined) { + params[key] = true // boolean shorthand, e.g. `` + continue + } + if (t.isStringLiteral(attr.value)) { + params[key] = attr.value.value + continue + } + const expr = t.isJSXExpressionContainer(attr.value) && t.isExpression(attr.value.expression) + ? attr.value.expression + : null + const lit = expr ? evaluateLiteral(expr) : { ok: false as const } + if (!lit.ok) { + throw new BailError({ code: 'NON_LITERAL_HASMANY_PARAM', message: ` ${key} is not a static literal` }) + } + params[key] = lit.value + } + return params +} + +function childrenCallback(children: t.JSXElement['children']): t.ArrowFunctionExpression | t.FunctionExpression | null { + for (const child of children) { + if (t.isJSXExpressionContainer(child) + && (t.isArrowFunctionExpression(child.expression) || t.isFunctionExpression(child.expression))) { + return child.expression + } + } + return null +} + +function getAttr(node: t.JSXElement, name: string): t.Expression | null { + const attr = findAttr(node, name) + return attr ? attrExpr(attr) : null +} + +function findAttr(node: t.JSXElement, name: string): t.JSXAttribute | null { + for (const attr of node.openingElement.attributes) { + if (t.isJSXAttribute(attr) && t.isJSXIdentifier(attr.name) && attr.name.name === name) { + return attr + } + } + return null +} + +function attrExpr(attr: t.JSXAttribute | t.JSXSpreadAttribute): t.Expression | null { + if (!t.isJSXAttribute(attr) || !attr.value) { + return null + } + if (t.isJSXExpressionContainer(attr.value) && t.isExpression(attr.value.expression)) { + return attr.value.expression + } + return null +} + +/** Underlying value node of a JSX attribute (container expression or nested JSX), or null. */ +function jsxAttrInner(value: t.JSXAttribute['value']): t.Node | null { + if (t.isJSXExpressionContainer(value)) { + return t.isExpression(value.expression) ? value.expression : null + } + if (t.isJSXElement(value) || t.isJSXFragment(value)) { + return value + } + return null +} diff --git a/packages/bindx-compiler/src/moduleResolve.ts b/packages/bindx-compiler/src/moduleResolve.ts new file mode 100644 index 00000000..53678304 --- /dev/null +++ b/packages/bindx-compiler/src/moduleResolve.ts @@ -0,0 +1,394 @@ +/** + * Shared binding resolution — the bounded exception to the purely-local principle used by + * both collector-contract discovery (contracts.ts) and hole-target-kind classification + * (targetKind.ts). Given a component tag it locates the binding's initializer expression and + * the module view it lives in, following LOCAL top-level declarations and RELATIVE imports + * (plus an optional `alias` map). Re-export chains through a `from` source — `export { X } from`, + * `export { X as Y } from`, `export * from` (barrel index files) — are followed too, depth-limited + * and cycle-guarded. Parse-only, no execution, no type checker; cached per path+mtime so sibling + * modules are read at most once per run. + */ +import { existsSync, readFileSync, statSync } from 'node:fs' +import { dirname, resolve as resolvePath } from 'node:path' +import * as t from '@babel/types' +import { parseProgram } from './parse.js' +import { unwrap } from './resolve.js' + +/** Local binding names for the bindx symbols a module needs for contract/target extraction. */ +export interface ModuleView { + readonly program: t.Program + readonly withCollector: ReadonlySet + readonly itemOf: ReadonlySet + readonly entityOf: ReadonlySet + readonly createComponent: ReadonlySet +} + +function isBindxSource(source: string): boolean { + return source === '@contember/bindx' || source.startsWith('@contember/bindx-') || source.startsWith('@contember/bindx/') +} + +/** Collect local binding names for the bindx symbols imported into `program`. */ +export function makeModuleView(program: t.Program): ModuleView { + const withCollector = new Set() + const itemOf = new Set() + const entityOf = new Set() + const createComponent = new Set() + for (const node of program.body) { + if (!t.isImportDeclaration(node) || !isBindxSource(node.source.value)) { + continue + } + for (const spec of node.specifiers) { + if (!t.isImportSpecifier(spec)) { + continue + } + const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value + if (imported === 'withCollector') { + withCollector.add(spec.local.name) + } else if (imported === 'itemOf') { + itemOf.add(spec.local.name) + } else if (imported === 'entityOf') { + entityOf.add(spec.local.name) + } else if (imported === 'createComponent') { + createComponent.add(spec.local.name) + } + } + } + return { program, withCollector, itemOf, entityOf, createComponent } +} + +/** mtime-keyed cache of parsed sibling modules — shared across analyzeProgram / plugin runs. */ +export class ModuleCache { + private readonly cache = new Map() + + get(path: string): ModuleView | null { + let mtime: number + try { + mtime = statSync(path).mtimeMs + } catch { + return null + } + const cached = this.cache.get(path) + if (cached && cached.mtime === mtime) { + return cached.view + } + let view: ModuleView + try { + view = makeModuleView(parseProgram(readFileSync(path, 'utf8'), path)) + } catch { + return null // unreadable/unparseable → no resolution, caller's fallback rules apply + } + this.cache.set(path, { mtime, view }) + return view + } +} + +export interface BindingResolverOptions { + readonly filename?: string + readonly alias: Record + readonly cache: ModuleCache + /** + * Called with the absolute path of every sibling module consulted during resolution — including + * re-export hops and files that fail to parse. Lets a bundler register cross-file watch + * dependencies so editing a contract/target module re-transforms the files that depend on it. + */ + readonly onDependency?: (absPath: string) => void +} + +/** + * A resolved binding: its declaration node and the module view it lives in. `init` is the + * (unwrapped) `const` initializer expression, or a `function`/`class` declaration node — callers + * narrow (a plain function component vs a `withCollector(...)`/chain call expression). + */ +export interface ResolvedBinding { + readonly init: t.Node + readonly view: ModuleView +} + +/** + * Resolves a component tag to its initializer expression + owning module view, memoized per tag. + * Local top-level `const/function` first, then a relative (or alias-mapped) import's export. + */ +export class BindingResolver { + /** Max re-export follows after the entry import — guards runaway/pathological barrel graphs. */ + private static readonly MAX_HOPS = 5 + private readonly self: ModuleView + private readonly memo = new Map() + + constructor(program: t.Program, private readonly options: BindingResolverOptions) { + this.self = makeModuleView(program) + } + + resolve(tag: string): ResolvedBinding | null { + const cached = this.memo.get(tag) + if (cached !== undefined) { + return cached + } + const resolved = this.compute(tag) + this.memo.set(tag, resolved) + return resolved + } + + private compute(tag: string): ResolvedBinding | null { + const local = findTopLevelBinding(this.self.program, tag) + if (local) { + return { init: local, view: this.self } + } + const imp = findImport(this.self.program, tag) + if (!imp) { + return null + } + const path = this.resolveModulePath(imp.source, this.options.filename) + if (!path) { + return null + } + this.options.onDependency?.(path) // cross-file read → a watch dependency of the entry file + const view = this.options.cache.get(path) + if (!view) { + return null + } + return this.chaseExport(view, path, imp.importedName, new Set([path]), BindingResolver.MAX_HOPS) + } + + /** + * Resolve `importedName` within `view` (living at `fromFile`), following `from`-source re-exports. + * `visited` (module paths) breaks cycles and, for `export *`, dedupes diamond re-exports so a single + * leaf reached twice is not mistaken for an ambiguity. Returns null on depth/cycle limit → the + * caller's conservative fallback stands. + */ + private chaseExport(view: ModuleView, fromFile: string, importedName: string, visited: Set, depth: number): ResolvedBinding | null { + const lookup = lookupExport(view.program, importedName) + if (!lookup) { + return null + } + if (lookup.kind === 'local') { + return { init: lookup.node, view } + } + if (depth <= 0) { + return null // depth limit → unfollowable + } + if (lookup.kind === 'reexport') { + return this.followSource(lookup.source, fromFile, lookup.importedName, visited, depth) + } + // `export *`: search each target; first match wins, a second distinct match → ambiguous → unfollowable. + let found: ResolvedBinding | null = null + for (const source of lookup.sources) { + const hit = this.followSource(source, fromFile, importedName, visited, depth) + if (hit) { + if (found) { + return null + } + found = hit + } + } + return found + } + + /** Load the module `source` resolves to (relative to `fromFile`) and continue the chase there. */ + private followSource(source: string, fromFile: string | undefined, importedName: string, visited: Set, depth: number): ResolvedBinding | null { + const path = this.resolveModulePath(source, fromFile) + if (!path) { + return null // unresolvable specifier → stop + } + this.options.onDependency?.(path) // consulted during the re-export chase → a watch dependency + if (visited.has(path)) { + return null // a cycle → stop + } + const view = this.options.cache.get(path) + if (!view) { + return null + } + visited.add(path) + return this.chaseExport(view, path, importedName, visited, depth - 1) + } + + /** Resolve an import/re-export specifier to an existing absolute file (relative or alias-mapped only). */ + private resolveModulePath(source: string, fromFile: string | undefined): string | null { + const base = this.toAbsoluteBase(source, fromFile) + return base ? firstExisting(base) : null + } + + private toAbsoluteBase(source: string, fromFile: string | undefined): string | null { + if (source.startsWith('.')) { + return fromFile ? resolvePath(dirname(fromFile), source) : null + } + for (const [prefix, target] of Object.entries(this.options.alias)) { + if (source === prefix || source.startsWith(`${prefix}/`)) { + return resolvePath(target + source.slice(prefix.length)) + } + } + return null // non-relative, unaliased → bounded exception does not apply + } +} + +/** First existing file for an absolute base: `x.tsx/.ts/.jsx/.js` or `x/index.*`; `.js`→TS source. */ +function firstExisting(abs: string): string | null { + const base = abs.endsWith('.js') ? abs.slice(0, -3) : abs + const exts = abs.endsWith('.js') ? ['.tsx', '.ts', '.jsx'] : ['.tsx', '.ts', '.jsx', '.js'] + const candidates = [...exts.map(ext => base + ext), ...['tsx', 'ts', 'jsx', 'js'].map(ext => `${base}/index.${ext}`)] + for (const candidate of candidates) { + if (existsSync(candidate)) { + return candidate + } + } + return null +} + +export interface ImportRef { + readonly source: string + readonly importedName: string +} + +/** Find where `local` is imported from and under which imported name (`default` for default). */ +export function findImport(program: t.Program, local: string): ImportRef | null { + for (const node of program.body) { + if (!t.isImportDeclaration(node)) { + continue + } + for (const spec of node.specifiers) { + if (t.isImportSpecifier(spec) && spec.local.name === local) { + const imported = t.isIdentifier(spec.imported) ? spec.imported.name : spec.imported.value + return { source: node.source.value, importedName: imported } + } + if (t.isImportDefaultSpecifier(spec) && spec.local.name === local) { + return { source: node.source.value, importedName: 'default' } + } + } + } + return null +} + +/** + * A module-level binding for `name`: the unwrapped `const` initializer, or a `function`/`class` + * declaration node (searching plain + `export` decls). Used by binding resolution to reach plain + * function components as well as `const X = withCollector(...)` / chain expressions. + */ +export function findTopLevelBinding(program: t.Program, name: string): t.Node | null { + for (const node of program.body) { + const decl = t.isExportNamedDeclaration(node) ? node.declaration : node + if (decl && t.isVariableDeclaration(decl)) { + const init = varInit(decl, name) + if (init) { + return init + } + } + if (decl && (t.isFunctionDeclaration(decl) || t.isClassDeclaration(decl)) && decl.id?.name === name) { + return decl + } + } + return null +} + +/** + * How `program` provides `importedName`, for the binding-resolution chase: + * - `local` — a declaration in this module (the resolved node). + * - `reexport` — `export { orig as importedName } from source`, or a passthrough of an import + * (`import { orig } from source; export { orig }`); follow `orig` in `source`. + * - `star` — the `export * from` sources to search (used only when no explicit export matches). + */ +export type ExportLookup = + | { readonly kind: 'local'; readonly node: t.Node } + | { readonly kind: 'reexport'; readonly source: string; readonly importedName: string } + | { readonly kind: 'star'; readonly sources: readonly string[] } + +/** Classify how `importedName` is exported from `program`; null when it is not exported here at all. */ +export function lookupExport(program: t.Program, importedName: string): ExportLookup | null { + if (importedName === 'default') { + for (const node of program.body) { + if (t.isExportDefaultDeclaration(node)) { + const d = node.declaration + if (t.isFunctionDeclaration(d) || t.isClassDeclaration(d)) { + return { kind: 'local', node: d } + } + if (t.isExpression(d)) { + return { kind: 'local', node: unwrap(d) } + } + } + } + return null + } + const starSources: string[] = [] + for (const node of program.body) { + // `export * from` — a namespace re-export (`export * as ns from`) parses as a named export + // with an ExportNamespaceSpecifier, so a bare ExportAllDeclaration is always the star form. + if (t.isExportAllDeclaration(node)) { + starSources.push(node.source.value) + continue + } + if (!t.isExportNamedDeclaration(node)) { + continue + } + if (node.declaration) { + const local = localFromDeclaration(node.declaration, importedName) + if (local) { + return { kind: 'local', node: local } + } + continue + } + const reexport = namedReexport(program, node, importedName) + if (reexport) { + return reexport + } + } + // Explicit exports take precedence over star re-exports (ES semantics); star is the fallback. + return starSources.length > 0 ? { kind: 'star', sources: starSources } : null +} + +/** A `export const/function/class name` declaration node, or null. */ +function localFromDeclaration(declaration: t.Declaration, name: string): t.Node | null { + if (t.isVariableDeclaration(declaration)) { + return varInit(declaration, name) + } + if ((t.isFunctionDeclaration(declaration) || t.isClassDeclaration(declaration)) && declaration.id?.name === name) { + return declaration + } + return null +} + +/** Resolve `export { ... }` specifiers (with or without a `from` source) for `importedName`. */ +function namedReexport(program: t.Program, node: t.ExportNamedDeclaration, importedName: string): ExportLookup | null { + for (const spec of node.specifiers) { + if (!t.isExportSpecifier(spec)) { + continue + } + const exported = t.isIdentifier(spec.exported) ? spec.exported.name : spec.exported.value + if (exported !== importedName) { + continue + } + const orig = spec.local.name + if (node.source) { + return { kind: 'reexport', source: node.source.value, importedName: orig } + } + // No source: a local declaration, or a passthrough of an import (`import { orig }; export { orig }`). + const local = findTopLevelBinding(program, orig) + if (local) { + return { kind: 'local', node: local } + } + const imp = findImport(program, orig) + return imp ? { kind: 'reexport', source: imp.source, importedName: imp.importedName } : null + } + return null +} + +/** Initializer of a module-level `const name = ` (unwrapped), searching plain + `export` decls. */ +export function findTopLevelVarInit(program: t.Program, name: string): t.Expression | null { + for (const node of program.body) { + const decl = t.isExportNamedDeclaration(node) ? node.declaration : node + if (decl && t.isVariableDeclaration(decl)) { + const init = varInit(decl, name) + if (init) { + return init + } + } + } + return null +} + +function varInit(decl: t.VariableDeclaration, name: string): t.Expression | null { + for (const d of decl.declarations) { + if (t.isIdentifier(d.id) && d.id.name === name && d.init) { + const inner = unwrap(d.init) + return t.isExpression(inner) ? inner : null + } + } + return null +} diff --git a/packages/bindx-compiler/src/parse.ts b/packages/bindx-compiler/src/parse.ts new file mode 100644 index 00000000..85e7b91f --- /dev/null +++ b/packages/bindx-compiler/src/parse.ts @@ -0,0 +1,14 @@ +/** + * Shared Babel parse setup. Extracted so both the analyzer and the cross-file + * contract resolver parse identically without an analyze↔contracts import cycle. + */ +import { parse } from '@babel/parser' +import * as t from '@babel/types' + +export function parseProgram(code: string, _filename?: string): t.Program { + const file = parse(code, { + sourceType: 'module', + plugins: ['jsx', 'typescript'], + }) + return file.program +} diff --git a/packages/bindx-compiler/src/resolve.ts b/packages/bindx-compiler/src/resolve.ts new file mode 100644 index 00000000..e18bdcf8 --- /dev/null +++ b/packages/bindx-compiler/src/resolve.ts @@ -0,0 +1,364 @@ +/** + * Root tracking, member-chain resolution and consumption. A "root" is a binding + * that points at an entity level in the selection tree (an entity prop, a relation + * callback param, or a const alias). Resolution mirrors the collector proxy's + * skip-list (see packages/bindx-react/src/jsx/proxyShared.ts + collectorProxy.ts). + */ +import * as t from '@babel/types' +import { walkAst } from './astWalk.js' +import { SelNode } from './selectionTree.js' +import type { Bailout, StaticHasManyParams } from './types.js' + +export class BailError extends Error { + constructor(public readonly bailout: Bailout) { + super(bailout.message) + } +} + +/** Human-readable message for any thrown value. */ +export function messageOf(error: unknown): string { + return error instanceof Error ? error.message : String(error) +} + +/** Wrap an unexpected (non-BailError) crash as a contained INTERNAL_ERROR bail — proxy fallback stays sound. */ +export function internalErrorBail(error: unknown): Bailout { + return { code: 'INTERNAL_ERROR', message: messageOf(error) } +} + +/** + * A binding that resolves to `node` reached via `path` of not-yet-materialized segments. + * `source`/`absPath` track the origin entity prop and the absolute path from it — needed to + * build phase-2 holes (they survive relation-callback roots, where `node`/`path` reset). + */ +export interface RootRef { + readonly node: SelNode + readonly path: readonly string[] + readonly source: string + readonly absPath: readonly string[] +} + +export interface Scope { + readonly roots: Map + readonly propsParams: Set + /** entity prop name → its root SelNode (shared across render + condition fns). */ + readonly propRoots: ReadonlyMap + /** + * Non-entity param bindings (top render props / callback indices). At oracle + * collection these are inert scalar mocks, so an identifier resolving here is + * SAFE to drop from a hole (invoking it cannot reach a selection scope). + */ + readonly scalarParams: Set + /** + * Render-scope local bindings (const/let in the body, generic nested-fn params). + * An identifier resolving here is render-local — NOT liftable (no module-scope + * emit site) and NOT a proven-inert mock — so a hole prop referencing it bails. + */ + readonly locals: Set +} + +export function childScope(scope: Scope): Scope { + return { + roots: new Map(scope.roots), + propsParams: new Set(scope.propsParams), + propRoots: scope.propRoots, + scalarParams: new Set(scope.scalarParams), + locals: new Set(scope.locals), + } +} + +/** Names bound by a function-parameter pattern (public wrapper over collectParamNames). */ +export function paramNamesOf(param: t.Node): Set { + const out = new Set() + collectParamNames(param, out) + return out +} + +export type Resolution = + | { kind: 'ref'; ref: RootRef } + | { kind: 'opaque' } + | { kind: 'none' } + +/** Strip parentheses and TS type wrappers (`x as T`, `x!`, `x satisfies T`). */ +export function unwrap(node: t.Node): t.Node { + if (t.isParenthesizedExpression(node) || t.isTSNonNullExpression(node) || t.isTSAsExpression(node) || t.isTSSatisfiesExpression(node)) { + return unwrap(node.expression) + } + return node +} + +/** + * Classify an expression relative to the current roots. `opaque` = resolves to a + * meta/non-entity value (e.g. `entity.$data`, `entity.id`). Throws BailError on + * computed access off a root. + */ +export function resolve(nodeIn: t.Node, scope: Scope): Resolution { + const node = unwrap(nodeIn) + + if (t.isIdentifier(node)) { + const ref = scope.roots.get(node.name) + return ref ? { kind: 'ref', ref } : { kind: 'none' } + } + + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + if (node.computed) { + // `entity[x]` off a root cannot be statically classified. + if (referencesRoot(node.object, scope)) { + throw new BailError({ code: 'COMPUTED_MEMBER', message: 'computed member access on an entity value' }) + } + return { kind: 'none' } + } + if (!t.isIdentifier(node.property)) { + return { kind: 'none' } + } + const propName = node.property.name + + // props identifier param: `props.article` + if (t.isIdentifier(node.object) && scope.propsParams.has(node.object.name)) { + const propRoot = scope.propRoots.get(propName) + return propRoot ? { kind: 'ref', ref: { node: propRoot, path: [], source: propName, absPath: [] } } : { kind: 'none' } + } + + const objRes = resolve(node.object, scope) + if (objRes.kind !== 'ref') { + return objRes.kind === 'opaque' ? { kind: 'opaque' } : { kind: 'none' } + } + // `$fields`/`$entity` are transparent passthroughs to the same entity. + if (propName === '$fields' || propName === '$entity') { + return objRes + } + // `id`, `$*`, `__*` are accessor/meta props — never recorded as fields. + if (propName === 'id' || propName.startsWith('$') || propName.startsWith('__')) { + return { kind: 'opaque' } + } + return { + kind: 'ref', + ref: { + node: objRes.ref.node, + path: [...objRes.ref.path, propName], + source: objRes.ref.source, + absPath: [...objRes.ref.absPath, propName], + }, + } + } + + return { kind: 'none' } +} + +/** Record a member chain terminal as a scalar leaf (intermediate segments → relations). */ +export function consumeLeaf(ref: RootRef): void { + if (ref.path.length === 0) { + return + } + let node = ref.node + for (let i = 0; i < ref.path.length - 1; i++) { + node = node.child(ref.path[i]!) + } + node.addScalar(ref.path[ref.path.length - 1]!) +} + +/** Materialize a chain fully as relations; returns the entity node it points at. */ +export function consumeRelation(ref: RootRef): SelNode { + let node = ref.node + for (const seg of ref.path) { + node = node.child(seg) + } + return node +} + +/** Materialize a has-many relation; marks the field array + params, returns the item node. */ +export function consumeMany(ref: RootRef, params?: StaticHasManyParams): SelNode { + if (ref.path.length === 0) { + return ref.node + } + let parent = ref.node + for (let i = 0; i < ref.path.length - 1; i++) { + parent = parent.child(ref.path[i]!) + } + const field = ref.path[ref.path.length - 1]! + const item = parent.child(field) + parent.markMany(field) + if (params && Object.keys(params).length > 0) { + parent.setParams(field, params) + } + return item +} + +/** True if any identifier in the subtree satisfies `pred`. Over-approximates (visits member + * property names / object keys too) — sound for default-deny bail decisions. */ +function anyIdentifier(node: t.Node, pred: (name: string) => boolean): boolean { + let found = false + walkAst(node, n => { + if (t.isIdentifier(n) && pred(n.name)) { + found = true + return 'stop' + } + }) + return found +} + +/** Conservative check: does the subtree textually reference any root binding? */ +export function referencesRoot(node: t.Node, scope: Scope): boolean { + return anyIdentifier(node, name => scope.roots.has(name) || scope.propsParams.has(name)) +} + +/** Names bound by a function-parameter pattern (identifier / default / rest / object / array). */ +function collectParamNames(node: t.Node, out: Set): void { + if (t.isIdentifier(node)) { + out.add(node.name) + return + } + if (t.isAssignmentPattern(node)) { + collectParamNames(node.left, out) + return + } + if (t.isRestElement(node)) { + collectParamNames(node.argument, out) + return + } + if (t.isObjectPattern(node)) { + for (const prop of node.properties) { + collectParamNames(t.isRestElement(prop) ? prop.argument : prop.value, out) + } + return + } + if (t.isArrayPattern(node)) { + for (const el of node.elements) { + if (el) { + collectParamNames(el, out) + } + } + } +} + +/** + * How to treat an inline closure (function prop / render-prop child) of a phase-2 hole. + * The hole target's `staticRender` may INVOKE it with a collector proxy during collection. + * + * - `drop`: invoking it cannot reach a selection scope — no reference to its OWN parameters + * (a proxy would flow in there) and no captured entity roots. Safe to omit (`() => save()`). + * - `lift`: it uses its own params (so dropping would under-fetch) but captures NOTHING from + * render scope — only its params, module-scope bindings, and globals. Emitted verbatim into + * `extraProps`; replayed into the target so collection proceeds (`it => `). + * - `bail`: captures an entity root, or captures a render-scope binding (`.use()` value, local) + * that cannot be reproduced at the module-scope emit site. Default deny. + * + * Nested inner functions' own params are theirs, not ours (see docs/compiler-plan.md, Phase 2/2.1). + */ +export type HoleClosureClass = 'drop' | 'lift' | 'bail' + +export function classifyHoleClosure(fn: t.ArrowFunctionExpression | t.FunctionExpression, scope: Scope): HoleClosureClass { + if (referencesRoot(fn, scope)) { + return 'bail' // captured entity root reachable when invoked + } + const ownParams = new Set() + for (const param of fn.params) { + collectParamNames(param, ownParams) + } + if (!anyIdentifier(fn.body, name => ownParams.has(name))) { + return 'drop' // no proxy gateway — invoking it collects nothing + } + // Uses own params → must be preserved; liftable only if it captures no render binding + // (roots already excluded above). Over-approximates via property names → default deny. + const capturesRender = anyIdentifier(fn, name => + !ownParams.has(name) && (scope.scalarParams.has(name) || scope.locals.has(name) || scope.propsParams.has(name)), + ) + return capturesRender ? 'bail' : 'lift' +} + +/** + * Classify a JSX prop value against the roots for phase-2 hole building: + * `path` = a clean entity-rooted identifier/member chain (empty path = the root itself); + * `expr` = references a root but is not a plain path (e.g. `fn(article)`); `none` = no root. + * Throws COMPUTED_MEMBER on `article[x]`. Member segments are recorded literally so the + * runtime's property-get replay reproduces the exact value (id/meta segments included). + */ +export type EntityPath = + | { kind: 'path'; source: string; path: readonly string[] } + | { kind: 'expr' } + | { kind: 'none' } + +export function entityPathOf(nodeIn: t.Node, scope: Scope): EntityPath { + const node = unwrap(nodeIn) + + if (t.isIdentifier(node)) { + const ref = scope.roots.get(node.name) + if (ref) { + return { kind: 'path', source: ref.source, path: [...ref.absPath] } + } + return scope.propsParams.has(node.name) ? { kind: 'expr' } : { kind: 'none' } + } + + if (t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) { + if (node.computed) { + if (referencesRoot(node.object, scope)) { + throw new BailError({ code: 'COMPUTED_MEMBER', message: 'computed member access on an entity value' }) + } + return { kind: 'none' } + } + if (!t.isIdentifier(node.property)) { + return { kind: 'none' } + } + const propName = node.property.name + if (t.isIdentifier(node.object) && scope.propsParams.has(node.object.name)) { + return scope.propRoots.has(propName) ? { kind: 'path', source: propName, path: [] } : { kind: 'none' } + } + const objPath = entityPathOf(node.object, scope) + if (objPath.kind !== 'path') { + return objPath + } + return { kind: 'path', source: objPath.source, path: [...objPath.path, propName] } + } + + return { kind: 'none' } +} + +export type LiteralResult = { ok: true; value: unknown } | { ok: false } + +/** Evaluate an expression to a static literal value, or fail. Used for HasMany params. */ +export function evaluateLiteral(node: t.Node): LiteralResult { + if (t.isStringLiteral(node) || t.isNumericLiteral(node) || t.isBooleanLiteral(node)) { + return { ok: true, value: node.value } + } + if (t.isNullLiteral(node)) { + return { ok: true, value: null } + } + if (t.isUnaryExpression(node) && node.operator === '-' && t.isNumericLiteral(node.argument)) { + return { ok: true, value: -node.argument.value } + } + if (t.isTemplateLiteral(node) && node.expressions.length === 0) { + return { ok: true, value: node.quasis.map(q => q.value.cooked ?? '').join('') } + } + if (t.isArrayExpression(node)) { + const out: unknown[] = [] + for (const el of node.elements) { + if (el === null || t.isSpreadElement(el)) { + return { ok: false } + } + const r = evaluateLiteral(el) + if (!r.ok) { + return { ok: false } + } + out.push(r.value) + } + return { ok: true, value: out } + } + if (t.isObjectExpression(node)) { + const out: Record = {} + for (const prop of node.properties) { + if (!t.isObjectProperty(prop) || prop.computed) { + return { ok: false } + } + const key = t.isIdentifier(prop.key) ? prop.key.name : t.isStringLiteral(prop.key) ? prop.key.value : null + if (key === null || !t.isExpression(prop.value)) { + return { ok: false } + } + const r = evaluateLiteral(prop.value) + if (!r.ok) { + return { ok: false } + } + out[key] = r.value + } + return { ok: true, value: out } + } + return { ok: false } +} diff --git a/packages/bindx-compiler/src/selectionTree.ts b/packages/bindx-compiler/src/selectionTree.ts new file mode 100644 index 00000000..9183f3fc --- /dev/null +++ b/packages/bindx-compiler/src/selectionTree.ts @@ -0,0 +1,88 @@ +/** + * Mutable selection tree mirroring the runtime SelectionScope semantics exactly: + * - a field starts scalar and is upgraded to a relation the moment it is nested; + * - every relation child implicitly includes `id` (SelectionScope.child adds it); + * - `addScalar` is a no-op once the field is a relation. + * + * See packages/bindx-client/src/selection/SelectionScope.ts. + */ +import type { StaticFieldMap, StaticFieldNode, StaticHasManyParams, StaticSelection } from './types.js' + +export class SelNode { + private readonly scalars = new Set() + private readonly relations = new Map() + private readonly manyFields = new Set() + private readonly params = new Map() + + /** Add a scalar field (ignored if already a relation — matches SelectionScope). */ + addScalar(fieldName: string): void { + if (!this.relations.has(fieldName)) { + this.scalars.add(fieldName) + } + } + + /** Get/create a relation child; auto-includes `id`, drops any scalar of the same name. */ + child(fieldName: string): SelNode { + this.scalars.delete(fieldName) + let node = this.relations.get(fieldName) + if (!node) { + node = new SelNode() + node.addScalar('id') + this.relations.set(fieldName, node) + } + return node + } + + markMany(fieldName: string): void { + this.manyFields.add(fieldName) + } + + setParams(fieldName: string, params: StaticHasManyParams): void { + this.params.set(fieldName, params) + } + + hasFields(): boolean { + return this.scalars.size > 0 || this.relations.size > 0 + } + + toFieldMap(): StaticFieldMap { + const map: StaticFieldMap = {} + for (const name of this.scalars) { + map[name] = true + } + for (const [name, child] of this.relations) { + const node: Exclude = { fields: child.toFieldMap() } + if (this.manyFields.has(name)) { + node.many = true + } + const params = this.params.get(name) + if (params && Object.keys(params).length > 0) { + node.params = params + } + map[name] = node + } + return map + } +} + +/** + * Normalize a StaticFieldMap to the plain field-tree form used by the equivalence + * harness: `true` for scalars, nested objects for relations. `many`/`params` are + * dropped because the runtime collector does NOT record them in the implicit path + * (verified against the oracle) — see docs/compiler-plan.md ternary/params notes. + */ +export function fieldMapToPlain(map: StaticFieldMap): Record { + const result: Record = {} + for (const [name, node] of Object.entries(map)) { + result[name] = node === true ? true : fieldMapToPlain(node.fields) + } + return result +} + +export function selectionToPlain(selection: StaticSelection): Record { + const result: Record = {} + for (const [prop, map] of Object.entries(selection)) { + result[prop] = fieldMapToPlain(map) + } + return result +} diff --git a/packages/bindx-compiler/src/targetKind.ts b/packages/bindx-compiler/src/targetKind.ts new file mode 100644 index 00000000..e23736b3 --- /dev/null +++ b/packages/bindx-compiler/src/targetKind.ts @@ -0,0 +1,192 @@ +/** + * Hole-target-kind classification (Phase 3.1). Generalizes contract discovery: given a + * hole-candidate component tag (local or relative import, same resolution + parse cache as + * contracts), classify the target so the taint lattice can drop provably-inert props without + * bailing. Collector-CONTRACT targets are handled separately by ContractResolver (checked + * first in jsx.ts); this covers the remaining kinds. + * + * Default deny: any uncertainty → 'unknown' (the existing conservative hole rules stand). + */ +import * as t from '@babel/types' +import { walkAst } from './astWalk.js' +import { + BindingResolver, type BindingResolverOptions, type ModuleView, +} from './moduleResolve.js' +import { unwrap } from './resolve.js' + +/** + * - `createComponent`: a `createComponent()....render()` chain. Its getSelection never reads + * scalar props and never invokes function slots (analyzeJsx ignores functions), so non-entity + * props and function props/children are droppable with NO bail. `slots` = the walked slot + * props (`.slots([...])`, default `['children']`); a slot passed a bare identifier still needs + * the lattice ('unknown' when `.slots()` is not a static string array → conservative). + * - `plain`: a plain function/arrow component (no selection surface) → everything non-entity + * droppable; the hole is still emitted (validate-mode blind-spot warn depends on it). + * - `collectorStatic`: `withCollector(runtime, staticRenderFn)` → the set of prop names the + * staticRender REFERENCES (or `'all'` when it spreads/aliases the props object). A dropped + * prop not in the set is safe; referenced ones keep the existing bails. + * - `unknown`: unresolvable or unclassifiable → existing conservative rules. + */ +export type TargetKind = + | { readonly kind: 'createComponent'; readonly slots: ReadonlySet | 'unknown' } + | { readonly kind: 'plain' } + | { readonly kind: 'collectorStatic'; readonly referenced: ReadonlySet | 'all' } + | { readonly kind: 'unknown' } + +export type TargetKindLookup = (tag: string) => TargetKind + +const UNKNOWN: TargetKind = { kind: 'unknown' } + +export class TargetKindResolver { + private readonly binding: BindingResolver + private readonly memo = new Map() + + constructor(program: t.Program, options: BindingResolverOptions) { + this.binding = new BindingResolver(program, options) + } + + resolve(tag: string): TargetKind { + const cached = this.memo.get(tag) + if (cached !== undefined) { + return cached + } + const resolved = this.binding.resolve(tag) + const kind = resolved ? classifyInit(resolved.init, resolved.view) : UNKNOWN + this.memo.set(tag, kind) + return kind + } +} + +/** Classify a resolved binding node within its owning module view. */ +function classifyInit(init: t.Node, view: ModuleView): TargetKind { + // A plain `function X() {}` / `class X {}` component has no selection surface. + if (t.isFunctionDeclaration(init) || t.isClassDeclaration(init)) { + return { kind: 'plain' } + } + const expr = unwrap(init) + if (!t.isExpression(expr)) { + return UNKNOWN + } + + const chain = createComponentChain(expr, view) + if (chain) { + return chain + } + + // withCollector(runtime, staticRenderFn) → collectorStatic. A non-function 2nd arg is a + // (possibly broken) contract; contracts are resolved before us, so anything reaching here + // is unknown (keeps the conservative hole/bail rules — e.g. the unparseable-contract case). + if (t.isCallExpression(expr) && t.isIdentifier(expr.callee) && view.withCollector.has(expr.callee.name)) { + const staticArg = expr.arguments[1] + if (staticArg && (t.isArrowFunctionExpression(staticArg) || t.isFunctionExpression(staticArg))) { + return { kind: 'collectorStatic', referenced: collectReferencedProps(staticArg) } + } + return UNKNOWN + } + + // A plain function/arrow component (not wrapped) has no selection surface. + if (t.isArrowFunctionExpression(expr) || t.isFunctionExpression(expr)) { + return { kind: 'plain' } + } + + return UNKNOWN +} + +/** A `createComponent()....render()` fluent chain: extract slot names (or 'unknown'). */ +function createComponentChain(expr: t.Expression, view: ModuleView): TargetKind | null { + if (!isRenderChainTail(expr)) { + return null + } + let slots: ReadonlySet | 'unknown' = new Set(['children']) + let node: t.Node = expr + while (t.isCallExpression(node) && t.isMemberExpression(node.callee) && !node.callee.computed && t.isIdentifier(node.callee.property)) { + if (node.callee.property.name === 'slots') { + slots = slotNames(node.arguments[0]) + } + node = node.callee.object + } + // Chain must bottom out at a `createComponent(...)` call whose callee is a bindx binding. + if (t.isCallExpression(node) && t.isIdentifier(node.callee) && view.createComponent.has(node.callee.name)) { + return { kind: 'createComponent', slots } + } + return null +} + +/** True when `expr` is a `....render(...)` call (the chain tail this compiler recognizes). */ +function isRenderChainTail(expr: t.Expression): boolean { + return t.isCallExpression(expr) && t.isMemberExpression(expr.callee) && !expr.callee.computed + && t.isIdentifier(expr.callee.property) && expr.callee.property.name === 'render' +} + +/** Slot names from a `.slots([...])` argument; 'unknown' when not a static string-literal array. */ +function slotNames(arg: t.Node | undefined): ReadonlySet | 'unknown' { + if (!arg || !t.isArrayExpression(arg)) { + return 'unknown' + } + const names = new Set() + for (const el of arg.elements) { + if (!el || !t.isStringLiteral(el)) { + return 'unknown' + } + names.add(el.value) + } + return names +} + +/** + * The set of prop names a `staticRender` references, or `'all'` (conservative sentinel). An + * object-pattern param's destructured keys are the referenced props (rest → all). An identifier + * param `p` is referenced as `p.x` (record `x`); any other use — `p[x]`, spread `{...p}`, passing + * `p` onward, aliasing — cannot be followed → 'all'. Shadowing is ignored (over-counts → sound). + */ +export function collectReferencedProps(fn: t.ArrowFunctionExpression | t.FunctionExpression): ReadonlySet | 'all' { + const param = fn.params[0] + if (!param) { + return new Set() // no props param → references nothing → all props safe to drop + } + const p = t.isAssignmentPattern(param) ? param.left : param + + if (t.isObjectPattern(p)) { + const names = new Set() + for (const prop of p.properties) { + if (t.isRestElement(prop) || !t.isObjectProperty(prop) || prop.computed) { + return 'all' + } + const key = t.isIdentifier(prop.key) ? prop.key.name : t.isStringLiteral(prop.key) ? prop.key.value : null + if (key === null) { + return 'all' + } + names.add(key) + } + return names + } + + if (t.isIdentifier(p)) { + return referencedMembersOf(fn.body, p.name) + } + return 'all' // array pattern / other → cannot map to prop names +} + +/** Walk `root` collecting `.x` member accesses; any other use of `paramName` → 'all'. */ +function referencedMembersOf(root: t.Node, paramName: string): ReadonlySet | 'all' { + const names = new Set() + let escaped = false + + walkAst(root, node => { + if ((t.isMemberExpression(node) || t.isOptionalMemberExpression(node)) + && t.isIdentifier(node.object) && node.object.name === paramName) { + if (!node.computed && t.isIdentifier(node.property)) { + names.add(node.property.name) // `p.x` — a clean referenced prop + return 'skip' // handled `p.x` wholesale; don't descend into the `p` object identifier + } + escaped = true // `p[x]` — cannot know which prop + return 'stop' + } + if (t.isIdentifier(node) && node.name === paramName) { + escaped = true // a bare reference to props not used as a clean `.x` access + return 'stop' + } + }) + + return escaped ? 'all' : names +} diff --git a/packages/bindx-compiler/src/types.ts b/packages/bindx-compiler/src/types.ts new file mode 100644 index 00000000..df217f90 --- /dev/null +++ b/packages/bindx-compiler/src/types.ts @@ -0,0 +1,137 @@ +/** + * Static selection format — the ONLY coupling between the compiler (deliverable B) + * and the runtime consumer (deliverable A). Mirror of docs/compiler-plan.md. + */ +import type * as t from '@babel/types' + +/** Has-many parameters; only statically-literal values are emitted. */ +export interface StaticHasManyParams { + filter?: unknown + orderBy?: unknown + limit?: number + offset?: number + totalCount?: boolean +} + +/** A single field: scalar leaf (`true`) or a relation with nested selection. */ +export type StaticFieldNode = + | true + | { + fields: StaticFieldMap + many?: true + params?: StaticHasManyParams + } + +/** Selection for one implicit entity prop. Key = field name. */ +export type StaticFieldMap = Record + +/** Per implicit entity prop → its static field map. Emitted as `CompiledSelection.props`. */ +export type StaticSelection = Record + +/** Where a hole's target prop value comes from: host entity prop + member path (empty = root). */ +export interface HoleEntityProp { + readonly source: string + readonly path: readonly string[] +} + +/** + * A nested component that received entity-derived values (phase 2). The compiler emits + * a reference to it as a thunk (`component: () => Identifier`) so the runtime resolves + * its selection surface lazily — no host render body execution, TDZ-safe. + */ +export interface AnalyzedHole { + /** Identifier name of the component tag; emitted as an arrow thunk referencing it. */ + readonly component: string + /** Target prop name → source entity prop + member path. Merged per JSX element. */ + readonly entityProps: Record + /** Statically-literal non-entity props of the element; non-literal ones are omitted. */ + readonly literalProps?: Record + /** + * Non-entity props lifted verbatim into the hole (phase 2.1): the value expression + * (module-scope identifier or render-scope-free closure), emitted as an arrow thunk. + * The target may invoke these during collection, so they must reach it unchanged. + */ + readonly extraProps?: Record +} + +/** + * Machine-readable bail codes. A component bails as a whole when any trigger fires; + * the caller falls back to runtime proxy collection (always sound). + */ +export type BailoutReason = + | 'INTERFACES_MODE' + | 'EXPLICIT_RENDER_FN' + | 'DYNAMIC_ENTITY_NAME' + | 'ENTITY_ESCAPES_TO_CALL' + | 'ENTITY_ESCAPES_TO_COMPONENT' + | 'ENTITY_IN_EXPRESSION_PROP' + | 'FUNCTION_PROP_ON_HOLE' + | 'RENDER_LOCAL_ON_HOLE' + | 'MEMBER_COMPONENT_TAG' + | 'ENTITY_SPREAD' + | 'COMPUTED_MEMBER' + | 'NON_LITERAL_HASMANY_PARAM' + | 'ENTITY_REASSIGNMENT' + | 'ENTITY_NO_FUNCTION_CHILDREN' + | 'UNCLASSIFIED' + // Synthetic: an unexpected (non-BailError) crash inside analysis, contained as a bail so + // the runtime proxy pass (always sound) takes over instead of failing the build. + | 'INTERNAL_ERROR' + +/** A bail with human-readable context. */ +export interface Bailout { + readonly code: BailoutReason + readonly message: string +} + +/** Source location of the recognized chain (the `createComponent(...)` call). */ +export interface ChainLoc { + readonly start: number + readonly end: number + readonly line: number + readonly column: number +} + +/** A chain the compiler proved: its implicit props, static selection, and nested-component holes. */ +export interface AnalyzedChain { + readonly loc: ChainLoc + readonly entityProps: readonly string[] + readonly selection: StaticSelection + readonly holes: readonly AnalyzedHole[] +} + +/** A chain the compiler could not prove; must fall back to runtime collection. */ +export interface BailedChain { + readonly loc: ChainLoc + readonly bailout: Bailout +} + +export type ChainResult = AnalyzedChain | BailedChain + +export function isBailed(result: ChainResult): result is BailedChain { + return 'bailout' in result +} + +/** + * A `` root the compiler proved (phase 3). Its field map lives under the fixed + * key `entity` (see `ENTITY_ROOT_KEY`); every hole is rooted at `entity`. Reported by + * `measure` separately from chains — each `` element is its own emit-or-bail unit. + */ +export interface AnalyzedEntityRoot { + readonly loc: ChainLoc + /** Fields collected off the children closure's first param (the root). */ + readonly selection: StaticFieldMap + readonly holes: readonly AnalyzedHole[] +} + +/** A `` root the compiler could not prove; the runtime children walk stays. */ +export interface BailedEntityRoot { + readonly loc: ChainLoc + readonly bailout: Bailout +} + +export type EntityRootResult = AnalyzedEntityRoot | BailedEntityRoot + +export function isEntityRootBailed(result: EntityRootResult): result is BailedEntityRoot { + return 'bailout' in result +} diff --git a/packages/bindx-compiler/src/vitePlugin.ts b/packages/bindx-compiler/src/vitePlugin.ts new file mode 100644 index 00000000..8b702233 --- /dev/null +++ b/packages/bindx-compiler/src/vitePlugin.ts @@ -0,0 +1,122 @@ +/** + * First-class Vite plugin for the bindx selection compiler. + * + * Runs the babel plugin with `enforce: 'pre'` so the static selection literal is injected BEFORE + * @vitejs/plugin-react performs the JSX transform (this pass only injects; it never transforms JSX). + * + * Cross-module correctness: the analyzer reads OTHER files (collector contracts, hole target-kind, + * re-export barrels) to decide what to emit for file A. Each such read is reported via `onDependency` + * and registered with Vite through `this.addWatchFile`, so editing a contract/target module + * re-transforms A — its injected literal never keeps a stale decision (a stale literal could + * otherwise flip a correct emit into an under-fetch, violating the soundness invariant). + * + * The return type is structural (not `import('vite').Plugin`) so the package keeps vite as an + * OPTIONAL peer and the transform's `this` is a minimal, mockable surface; the shape is still + * assignable to Vite's `Plugin`, so `plugins: [bindxCompiler()]` type-checks. + */ +import { transformAsync, type BabelFileResult } from '@babel/core' +import { bindxCompilerPlugin } from './babelPlugin.js' +import { ModuleCache } from './moduleResolve.js' +import { reportTotals, type DiagnosticsMode, type DiagnosticTotals } from './diagnostics.js' + +/** Config for {@link bindxCompiler}; mirrors the babel plugin options plus file filtering. */ +export interface BindxCompilerViteOptions { + /** Non-relative import prefix → path map for cross-file contract/target discovery. */ + readonly alias?: Record + /** Forwarding-wrapper component names treated as `` roots (phase 3.1). */ + readonly entityLike?: readonly string[] + /** Only transform ids matching one of these (substring or regex). Default: every `.tsx`/`.jsx`. */ + readonly include?: readonly (string | RegExp)[] + /** Skip ids matching one of these (substring or regex). Applied after `include`. */ + readonly exclude?: readonly (string | RegExp)[] + /** Per-file console reporting verbosity; also prints a grand total in `buildEnd`. Default 'off'. */ + readonly diagnostics?: DiagnosticsMode +} + +/** Minimal Vite/Rollup transform-context surface this plugin needs (kept tiny so tests can mock it). */ +export interface BindxTransformContext { + addWatchFile(id: string): void +} + +/** Transform output; `map` reuses Babel's source-map shape (assignable to Rollup's `SourceMapInput`). */ +export interface BindxTransformResult { + readonly code: string + readonly map: BabelFileResult['map'] +} + +/** Structural Vite plugin shape; assignable to `import('vite').Plugin`. */ +export interface BindxCompilerVitePlugin { + readonly name: string + readonly enforce: 'pre' + transform(this: BindxTransformContext, code: string, id: string): Promise + buildEnd(): void +} + +const JSX_FILE = /\.[jt]sx$/ + +function matchesAny(id: string, patterns: readonly (string | RegExp)[]): boolean { + return patterns.some(pattern => (typeof pattern === 'string' ? id.includes(pattern) : pattern.test(id))) +} + +/** Cheap gate: only these markers can produce an emit — skip everything else without parsing. */ +function mayCompile(code: string): boolean { + return code.includes('createComponent') || code.includes(' { + totals.compiled += t.compiled + totals.bailed += t.bailed + } + return { + name: 'bindx-compiler', + enforce: 'pre', + async transform(code: string, id: string): Promise { + const file = id.split('?', 1)[0] ?? id // drop Vite's `?query` suffix before extension checks + if (id.includes('/node_modules/') || !JSX_FILE.test(file)) { + return null + } + if (include && !matchesAny(file, include)) { + return null + } + if (exclude && matchesAny(file, exclude)) { + return null + } + if (!mayCompile(code)) { + return null // no createComponent/() + const result = await transformAsync(code, { + filename: id, + configFile: false, + babelrc: false, + sourceMaps: true, + parserOpts: { plugins: ['typescript', 'jsx'] }, + plugins: [[bindxCompilerPlugin, { alias, entityLike, diagnostics, cache, onReport: accumulate, onDependency: (dep: string) => deps.add(dep) }]], + }) + if (!result?.code) { + return null + } + // Register every cross-file read so a later edit to a contract/target module re-transforms this file. + for (const dep of deps) { + this.addWatchFile(dep) + } + return { code: result.code, map: result.map } + }, + buildEnd(): void { + if (diagnostics !== 'off') { + reportTotals(totals) + } + }, + } +} diff --git a/packages/bindx-compiler/tests/analyzer.test.ts b/packages/bindx-compiler/tests/analyzer.test.ts new file mode 100644 index 00000000..4eab8d1f --- /dev/null +++ b/packages/bindx-compiler/tests/analyzer.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, test } from 'bun:test' +import { analyzeSource, isBailed, type BailoutReason } from '../src/index.js' +import { analyzeFixture } from './harness.js' + +const DIR = import.meta.dir + +function analyze(code: string) { + return analyzeSource(code, 'inline.tsx') +} + +function selectionOf(code: string): Record { + const result = analyze(code)[0]! + if (isBailed(result)) { + throw new Error(`unexpected bail: ${result.bailout.code}`) + } + return result.selection +} + +const PRELUDE = `import { createComponent, Field, HasMany, HasOne } from '@contember/bindx-react'\nimport { schema } from './s'\n` + +describe('StaticSelection emission (raw, not normalized)', () => { + test('has-many literal params + many flag are emitted', () => { + const results = analyzeFixture(DIR, 'hasManyParams.tsx') + const result = results[0]! + expect(isBailed(result)).toBe(false) + if (isBailed(result)) { + return + } + expect(result.selection).toEqual({ + article: { + tags: { + fields: { id: true, name: true, color: true }, + many: true, + params: { limit: 5, offset: 2, orderBy: { name: 'asc' }, totalCount: true }, + }, + }, + }) + }) + + test('scalar leaf is `true`, has-one relation nests with id', () => { + const selection = selectionOf( + `${PRELUDE}export const C = createComponent().entity('article', schema.Article)` + + `.render(({ article }) =>
` + + `{a => }
)`, + ) + expect(selection).toEqual({ + article: { title: true, author: { fields: { id: true, name: true } } }, + }) + }) + + test('.map() marks the relation many without params', () => { + const selection = selectionOf( + `${PRELUDE}export const C = createComponent().entity('article', schema.Article)` + + `.render(({ article }) =>
    {article.tags.map(tag => )}
)`, + ) + expect(selection).toEqual({ article: { tags: { fields: { id: true, name: true }, many: true } } }) + }) + + test('entity props with no field access emit no selection entry', () => { + const selection = selectionOf( + `${PRELUDE}export const C = createComponent().entity('article', schema.Article)` + + `.render(() =>
static
)`, + ) + expect(selection).toEqual({}) + }) +}) + +describe('bail triggers (direct)', () => { + const bail = (body: string): BailoutReason | null => { + const result = analyze( + `${PRELUDE}export const C = createComponent().entity('article', schema.Article).render(${body})`, + )[0]! + return isBailed(result) ? result.bailout.code : null + } + + test('unrecognized call receiving an entity value', () => { + expect(bail(`({ article }) => {fn(article.author)}`)).toBe('ENTITY_ESCAPES_TO_CALL') + }) + + test('method call on an entity value', () => { + expect(bail(`({ article }) => {article.tags.filter(Boolean)}`)).toBe('ENTITY_ESCAPES_TO_CALL') + }) + + test('spread of an entity root', () => { + expect(bail(`({ article }) =>
`)).toBe('ENTITY_SPREAD') + }) + + test('computed member off a root', () => { + expect(bail(`({ article }) => `)).toBe('COMPUTED_MEMBER') + }) + + test('non-literal HasMany param', () => { + expect(bail(`({ article }) => {t => }`)) + .toBe('NON_LITERAL_HASMANY_PARAM') + }) +}) + +describe('non-chains are ignored', () => { + test('a plain createComponent-like call that is not imported is skipped', () => { + const results = analyze(`const createComponent = () => ({});\nconst x = createComponent().render(() => null)`) + expect(results).toHaveLength(0) + }) +}) diff --git a/packages/bindx-compiler/tests/contracts.test.ts b/packages/bindx-compiler/tests/contracts.test.ts new file mode 100644 index 00000000..36fcc534 --- /dev/null +++ b/packages/bindx-compiler/tests/contracts.test.ts @@ -0,0 +1,85 @@ +/** + * Phase-2.2 collector contracts: a contract component forms NO hole; its callback is + * analyzed like a / child. Every host chain is validated against the + * runtime proxy collector (the oracle) for STRICT field-tree equality — including the + * footer-editor shape (item callback capturing a host-root field) that defeated holes. + */ +import { describe, expect, test } from 'bun:test' +import { isBailed, type AnalyzedChain } from '../src/index.js' +import { analyzeFixture, compilerPlain, runtimePlain } from './harness.js' +import * as fixture from './fixtures/contracts.js' + +const DIR = import.meta.dir +const results = analyzeFixture(DIR, 'contracts.tsx') + +// Fixture host exports in source order (all use the `article` implicit prop). +const EXPORTS = ['SameFile', 'CrossFile', 'HostCapture', 'EntityOf', 'DroppedFnProp', 'MissingCallback', 'Unparseable'] as const +const oracle = fixture as unknown as Record + +function chainAt(index: number): AnalyzedChain { + const result = results[index]! + if (isBailed(result)) { + throw new Error(`chain #${index} unexpectedly bailed: ${result.bailout.code}`) + } + return result +} + +describe('collector contracts — discovery + no hole', () => { + test('every host chain is recognized (one per export)', () => { + expect(results.length).toBe(EXPORTS.length) + }) + + test('#0 same-file contract target forms no hole; tags collected as a relation', () => { + const result = chainAt(0) + expect(result.holes).toEqual([]) + expect(compilerPlain(result, 'article')).toMatchObject({ tags: { name: true } }) + }) + + test('#1 cross-file contract target (imported sibling) forms no hole', () => { + const result = chainAt(1) + expect(result.holes).toEqual([]) + expect(compilerPlain(result, 'article')).toMatchObject({ tags: { name: true } }) + }) + + test('#2 footer-editor shape: host-root capture lands at the article root', () => { + const plain = compilerPlain(chainAt(2), 'article') + expect(plain).toMatchObject({ tags: { name: true }, title: true }) + }) + + test('#3 entityOf collects the has-one relation', () => { + expect(compilerPlain(chainAt(3), 'article')).toMatchObject({ author: { name: true } }) + }) + + test('#4 non-contract function prop is dropped with no bail and no hole', () => { + const result = chainAt(4) + expect(result.holes).toEqual([]) + expect(compilerPlain(result, 'article')).toMatchObject({ tags: { name: true } }) + }) + + test('#5 missing callback records the relation only', () => { + const plain = compilerPlain(chainAt(5), 'article') + expect(Object.keys(plain)).toEqual(['tags']) + }) + + test('#6 unparseable contract (spread) falls back to hole rules → FUNCTION_PROP_ON_HOLE bail', () => { + const result = results[6]! + expect(isBailed(result)).toBe(true) + if (isBailed(result)) { + expect(result.bailout.code).toBe('FUNCTION_PROP_ON_HOLE') + } + }) +}) + +describe('collector contracts — oracle equivalence (strict)', () => { + EXPORTS.forEach((name, index) => { + const result = results[index]! + if (isBailed(result)) { + return // bailed chains fall back to runtime collection → trivially equivalent + } + test(`${name} — compiled field tree equals the runtime oracle`, () => { + const compiled = compilerPlain(result, 'article') + const reference = runtimePlain(oracle[name], 'article') + expect(compiled).toEqual(reference) + }) + }) +}) diff --git a/packages/bindx-compiler/tests/dependency.test.ts b/packages/bindx-compiler/tests/dependency.test.ts new file mode 100644 index 00000000..32d9aa02 --- /dev/null +++ b/packages/bindx-compiler/tests/dependency.test.ts @@ -0,0 +1,73 @@ +/** + * Cross-file dependency reporting. The analyzer reads OTHER modules to classify contract targets + * and hole target-kinds; each such read must surface via `onDependency` so a bundler can register a + * watch dependency. Without it, editing a contract/target module leaves a stale injected literal + * (potentially an under-fetch). These tests assert BOTH discovery paths report the sibling's path. + */ +import { describe, expect, test } from 'bun:test' +import { join } from 'node:path' +import { analyzeEntityRoots, analyzeSource } from '../src/index.js' + +const DIR = import.meta.dir +const ROUTE = join(DIR, 'route.tsx') // filename base for relative `./fixtures/...` resolution + +// Contract component (ItemRepeater) imported from a sibling module; contract discovery must read it. +const CONTRACT_SRC = ` +import { createComponent, Field } from '@contember/bindx-react' +import { schema } from './fixtures/_schema.js' +import { ItemRepeater } from './fixtures/_contractTargets.js' +export const Host = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => } + + )) +` + +// createComponent target reached through a named barrel; target-kind classification chases the +// re-export, so both the barrel and the leaf module must be reported. +const TARGET_KIND_SRC = ` +import { Entity, entityDef } from '@contember/bindx-react' +import { CcBody } from './fixtures/_ccBarrel.js' +const ArticleDef = entityDef('Article') +export function Route() { + return ( + + {article => { + const label = 'x'.toUpperCase() + return
+ }} +
+ ) +} +` + +describe('cross-file dependency reporting', () => { + test('contract discovery reports the imported contract module', () => { + const deps = new Set() + analyzeSource(CONTRACT_SRC, ROUTE, { onDependency: path => deps.add(path) }) + expect(deps.has(join(DIR, 'fixtures', '_contractTargets.tsx'))).toBe(true) + }) + + test('target-kind classification reports the barrel and the re-exported leaf module', () => { + const deps = new Set() + analyzeEntityRoots(TARGET_KIND_SRC, ROUTE, { onDependency: path => deps.add(path) }) + expect(deps.has(join(DIR, 'fixtures', '_ccBarrel.ts'))).toBe(true) + expect(deps.has(join(DIR, 'fixtures', '_ccTarget.tsx'))).toBe(true) + }) + + test('no dependency callback fires for a purely-local analysis', () => { + const deps = new Set() + const LOCAL = ` +import { createComponent, Field } from '@contember/bindx-react' +import { schema } from './fixtures/_schema.js' +export const Card = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) +` + analyzeSource(LOCAL, ROUTE, { onDependency: path => deps.add(path) }) + // The schema import is never resolved (no contract/target lookup targets it), so no reads occur. + expect(deps.size).toBe(0) + }) +}) diff --git a/packages/bindx-compiler/tests/endToEnd.test.tsx b/packages/bindx-compiler/tests/endToEnd.test.tsx new file mode 100644 index 00000000..f9046e7e --- /dev/null +++ b/packages/bindx-compiler/tests/endToEnd.test.tsx @@ -0,0 +1,225 @@ +/** + * End-to-end: Babel plugin → A's `.render(fn, static)` runtime. Transforms a real + * createComponent source, loads the transformed module, and proves the static path: + * (a) the render fn is NOT executed during collection, (b) the field data renders + * under via MockAdapter, (c) validate mode raises no warning. + */ +import { GlobalRegistrator } from '@happy-dom/global-registrator' +// Register happy-dom when this file runs standalone (package-local `bun test`); +// the root bunfig preload already registers it for `bun test packages/…`. +if (typeof document === 'undefined') { + GlobalRegistrator.register() +} + +import { afterAll, afterEach, describe, expect, test, spyOn } from 'bun:test' +import { transformSync } from '@babel/core' +import { writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import React from 'react' +import { cleanup, render, waitFor } from '@testing-library/react' +import { + BindxProvider, + MockAdapter, + Entity, + defineSchema, + scalar, + hasOne, + entityDef, + setStaticSelectionValidation, +} from '@contember/bindx-react' +import { bindxCompilerPlugin } from '../src/index.js' + +interface FixtureModule { + readonly Card: unknown + readonly getRenderCalls: () => number +} + +interface HoleFixtureModule { + readonly Host: unknown + readonly getHostRenderCalls: () => number +} + +// A createComponent used implicitly: the render fn increments a module-level +// counter so we can observe whether the proxy pass executed it. +const SOURCE = ` +import { createComponent, Field, entityDef } from '@contember/bindx-react' + +let renderCalls = 0 +export const getRenderCalls = () => renderCalls + +const ArticleDef = entityDef('Article') + +export const Card = createComponent() + .entity('article', ArticleDef) + .render(({ article }) => { + renderCalls++ + return + }) +` + +const TMP_DIR = import.meta.dir +const tmpFiles: string[] = [] +let counter = 0 + +/** Transform with the plugin, write to a fresh temp module, and import it. */ +async function loadTransformed(source: string): Promise { + const out = transformSync(source, { + filename: 'card.tsx', + plugins: [bindxCompilerPlugin], + configFile: false, + babelrc: false, + }) + if (!out?.code) { + throw new Error('transform produced no output') + } + const path = join(TMP_DIR, `.e2e-${counter++}.tsx`) + writeFileSync(path, out.code) + tmpFiles.push(path) + return import(path) as Promise +} + +interface Schema { + Article: { id: string; title: string; author: { id: string; name: string } } + Author: { id: string; name: string } +} +const schema = defineSchema({ + entities: { + Article: { fields: { id: scalar(), title: scalar(), author: hasOne('Author') } }, + Author: { fields: { id: scalar(), name: scalar() } }, + }, +}) +const articleDef = entityDef('Article') + +afterEach(() => { + cleanup() + setStaticSelectionValidation(false) +}) +afterAll(() => { + for (const file of tmpFiles) { + rmSync(file, { force: true }) + } +}) + +describe('end-to-end: transformed module runs the static path', () => { + test('the plugin injects a static selection (sanity)', () => { + const out = transformSync(SOURCE, { + filename: 'card.tsx', + plugins: [bindxCompilerPlugin], + configFile: false, + babelrc: false, + }) + // v2 shape: the injected literal wraps the props map in `props`. + expect(out?.code).toContain('props:') + expect(out?.code).toContain('title: true') + }) + + test('collection skips the render fn, then fetches and renders the field', async () => { + const mod = await loadTransformed(SOURCE) + + // Trigger static collection via the fragment getter — the proxy pass would + // have executed the render fn; the injected static selection must not. + void (mod.Card as Record).$article + expect(mod.getRenderCalls()).toBe(0) + + const adapter = new MockAdapter( + { Article: { 'article-1': { id: 'article-1', title: 'Hello World' } } }, + { delay: 0 }, + ) + const Card = mod.Card as React.ComponentType<{ article: unknown }> + const { container } = render( + + + {article => } + + , + ) + + await waitFor(() => { + expect(container.querySelector('[data-testid="title"]')?.textContent).toBe('Hello World') + }) + // The real render did run the fn; only collection skipped it. + expect(mod.getRenderCalls()).toBeGreaterThan(0) + }) + + test('validate mode raises no warning for the transformed component', async () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + const mod = await loadTransformed(SOURCE) + // Collection now also runs the proxy pass and diffs — must agree. + void (mod.Card as Record).$article + + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) + +// A host whose render passes an entity-derived value (`article.author`) to a nested +// createComponent target. The compiler emits a hole; runtime resolution must fetch the +// target's field WITHOUT executing the host render fn during collection. +const SOURCE_HOLE = ` +import { createComponent, Field, entityDef } from '@contember/bindx-react' + +let hostRenderCalls = 0 +export const getHostRenderCalls = () => hostRenderCalls + +const ArticleDef = entityDef('Article') +const AuthorDef = entityDef('Author') + +const AuthorName = createComponent() + .entity('author', AuthorDef) + .render(({ author }) => ) + +export const Host = createComponent() + .entity('article', ArticleDef) + .render(({ article }) => { + hostRenderCalls++ + return
+ }) +` + +describe('end-to-end: transformed module resolves a hole', () => { + test('collection skips the host render fn, then the nested target field fetches and renders', async () => { + const mod = await loadTransformed(SOURCE_HOLE) + + // (a) Static collection resolves the hole via AuthorName's selection surface — + // the host render fn must not run (proxy pass would have incremented the counter). + void (mod.Host as Record).$article + expect(mod.getHostRenderCalls()).toBe(0) + + const adapter = new MockAdapter( + { + Article: { 'article-1': { id: 'article-1', title: 'Hello World', author: { id: 'author-1', name: 'John' } } }, + Author: { 'author-1': { id: 'author-1', name: 'John' } }, + }, + { delay: 0 }, + ) + const Host = mod.Host as React.ComponentType<{ article: unknown }> + const { container } = render( + + + {article => } + + , + ) + + // (b) The hole put author.name into the fetch plan, so the nested field renders. + await waitFor(() => { + expect(container.querySelector('[data-testid="author"]')?.textContent).toBe('John') + }) + expect(mod.getHostRenderCalls()).toBeGreaterThan(0) + }) + + test('validate mode raises no warning for the hole-carrying component', async () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + const mod = await loadTransformed(SOURCE_HOLE) + // The diff of compiled-vs-proxy selection must agree; createComponent target is + // not a blind spot, so no blind-spot warn either. + void (mod.Host as Record).$article + + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) diff --git a/packages/bindx-compiler/tests/entityRoots.test.tsx b/packages/bindx-compiler/tests/entityRoots.test.tsx new file mode 100644 index 00000000..1ed9a509 --- /dev/null +++ b/packages/bindx-compiler/tests/entityRoots.test.tsx @@ -0,0 +1,423 @@ +/** + * Phase 3 end-to-end: Babel plugin → compiled `` root selection. The oracle for + * roots is the QuerySpec the adapter receives: for each fixture we render the TRANSFORMED + * module (compiledSelection injected, children never walked with a collector) and the + * UNTRANSFORMED module (runtime children walk) under a query-recording MockAdapter, and + * compare the requested root selection. Branch-union fixtures assert runtime ⊆ compiled. + */ +import { GlobalRegistrator } from '@happy-dom/global-registrator' +if (typeof document === 'undefined') { + GlobalRegistrator.register() +} + +import { afterAll, afterEach, describe, expect, test } from 'bun:test' +import { transformSync } from '@babel/core' +import { writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import React from 'react' +import { cleanup, render, waitFor } from '@testing-library/react' +import { + BindxProvider, + MockAdapter, + defineSchema, + scalar, + hasOne, + hasMany, + type Query, + type QueryOptions, + type QueryResult, + type QueryFieldSpec, +} from '@contember/bindx-react' +import { analyzeEntityRoots, analyzeSource, isBailed, isEntityRootBailed, bindxCompilerPlugin } from '../src/index.js' + +// ── Schema + recording adapter ────────────────────────────────────────────── + +interface Schema { + Article: { id: string; title: string; content: string; author: { id: string; name: string } | null; tags: { id: string; name: string }[] } + Author: { id: string; name: string } + Tag: { id: string; name: string } +} +const schema = defineSchema({ + entities: { + Article: { fields: { id: scalar(), title: scalar(), content: scalar(), author: hasOne('Author'), tags: hasMany('Tag') } }, + Author: { fields: { id: scalar(), name: scalar() } }, + Tag: { fields: { id: scalar(), name: scalar() } }, + }, +}) + +const MOCK_DATA = { + Article: { + 'article-1': { + id: 'article-1', title: 'Hello World', content: 'Body', + author: { id: 'author-1', name: 'John' }, + tags: [{ id: 'tag-1', name: 'news' }], + }, + }, + Author: { 'author-1': { id: 'author-1', name: 'John' } }, + Tag: { 'tag-1': { id: 'tag-1', name: 'news' } }, +} + +/** MockAdapter that records every query it receives — the root oracle. */ +class RecordingMockAdapter extends MockAdapter { + readonly captured: Query[] = [] + override async query(queries: readonly Query[], options?: QueryOptions): Promise { + this.captured.push(...queries) + return super.query(queries, options) + } +} + +/** The requested selection as a sorted plain tree (params dropped) — comparable across paths. */ +function normalizeFields(fields: readonly QueryFieldSpec[]): Record { + const out: Record = {} + for (const f of [...fields].sort((a, b) => a.name.localeCompare(b.name))) { + out[f.name] = f.nested ? normalizeFields(f.nested.fields) : true + } + return out +} + +// ── Module loading (transform → temp .tsx → import) ───────────────────────── + +const TMP_DIR = import.meta.dir +const tmpFiles: string[] = [] +let counter = 0 + +function transform(source: string): string { + const out = transformSync(source, { filename: 'route.tsx', plugins: [bindxCompilerPlugin], configFile: false, babelrc: false }) + if (!out?.code) { + throw new Error('transform produced no output') + } + return out.code +} + +async function loadModule(source: string, compiled: boolean): Promise { + const code = compiled ? transform(source) : source + const path = join(TMP_DIR, `.p3-${counter++}.tsx`) + writeFileSync(path, code) + tmpFiles.push(path) + return import(path) as Promise +} + +interface RouteModule { + readonly Route: React.ComponentType + readonly getCollectorCalls?: () => number +} + +/** Render a fixture's under a recording adapter; return the Article root spec. */ +async function captureRootSpec(source: string, compiled: boolean): Promise<{ fields: Record; collectorCalls: number }> { + const mod = await loadModule(source, compiled) + const adapter = new RecordingMockAdapter(structuredClone(MOCK_DATA), { delay: 0 }) + const { container } = render( + + + , + ) + await waitFor(() => { + expect(container.querySelector('[data-testid="ready"]')).not.toBeNull() + }) + const get = adapter.captured.find((q): q is Extract => q.type === 'get' && q.entityType === 'Article') + if (!get) { + throw new Error('no Article get query captured') + } + return { fields: normalizeFields(get.spec.fields), collectorCalls: mod.getCollectorCalls?.() ?? 0 } +} + +/** Assert transformed and untransformed request the same root selection. */ +async function expectRootEquivalent(source: string): Promise> { + const [compiled, runtime] = await Promise.all([captureRootSpec(source, true), captureRootSpec(source, false)]) + expect(compiled.fields).toEqual(runtime.fields) + return compiled.fields +} + +afterEach(() => cleanup()) +afterAll(() => { + for (const file of tmpFiles) { + rmSync(file, { force: true }) + } +}) + +// ── Fixtures ──────────────────────────────────────────────────────────────── + +const SCALARS = ` +import { Entity, Field, entityDef, SCOPE_REF } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +let collectorCalls = 0 +export const getCollectorCalls = () => collectorCalls +export function Route() { + return ( + + {article => { + if (article && typeof article === 'object' && SCOPE_REF in article) collectorCalls++ + return
+ }} +
+ ) +} +` + +const NESTED_HAS_ONE = ` +import { Entity, Field, HasOne, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +export function Route() { + return ( + + {article => ( +
+ + {author => } +
+ )} +
+ ) +} +` + +// A compiled createComponent used inside the closure, receiving the ROOT entity. Its +// fragment merges into the root (strict equality — createComponent targets are not blind). +const COMPILED_COMPONENT = ` +import { Entity, Field, createComponent, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const ArticleExtra = createComponent() + .entity('article', ArticleDef) + .render(({ article }) => ) +export function Route() { + return ( + + {article =>
} +
+ ) +} +` + +// A hole: an entity-DERIVED value (article.author) into a nested createComponent. +const HOLE = ` +import { Entity, Field, createComponent, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const AuthorDef = entityDef('Author') +const AuthorCard = createComponent() + .entity('author', AuthorDef) + .render(({ author }) => ) +export function Route() { + return ( + + {article =>
} +
+ ) +} +` + +// A collector-contract target inside the closure: its item callback captures the root +// relation; forms NO hole (param becomes a root at tags). Strict equality. +const CONTRACT = ` +import { Entity, Field, HasMany, withCollector, itemOf, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const TagsRepeater = withCollector( + function TagsRepeaterRuntime(props) { return props.children ? null : null }, + { children: itemOf('field') }, +) +export function Route() { + return ( + + {article => ( +
+ + {tag => } +
+ )} +
+ ) +} +` + +// Branch union: the runtime walk follows one branch (module const true → title); the +// compiler unions both (title + content). Assert runtime ⊆ compiled. +const BRANCH_UNION = ` +import { Entity, Field, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const FLAG = true +export function Route() { + return ( + + {article =>
{FLAG ? : }
} +
+ ) +} +` + +// Create-mode Entity — no fetch. Assert it renders and skips the collector walk. +const CREATE_MODE = ` +import { Entity, Field, entityDef, SCOPE_REF } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +let collectorCalls = 0 +export const getCollectorCalls = () => collectorCalls +export function Route() { + return ( + + {article => { + if (article && typeof article === 'object' && SCOPE_REF in article) collectorCalls++ + return
+ }} +
+ ) +} +` + +// nested inside a createComponent render body. The HOST chain collects its own +// selection (article-level) unchanged; the Entity emits its own root selection. +const NESTED_IN_COMPONENT = ` +import { Entity, Field, createComponent, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +export const Host = createComponent() + .entity('article', ArticleDef) + .render(({ article }) => ( +
+ + + {inner =>
} +
+
+ )) +export function Route() { + return +} +` + +// Bail: non-function children — the runtime walk stays; no attribute is injected. +const BAIL_NON_FUNCTION = ` +import { Entity, Field, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +export function Route() { + return ( + + + + ) +} +` + +// ── Static analysis (no runtime) ───────────────────────────────────────────── + +describe('phase 3 — entity root static analysis', () => { + test('scalar root collects the touched fields under key "entity"', () => { + const [root] = analyzeEntityRoots(SCALARS, 'scalars.tsx') + expect(root && !isEntityRootBailed(root)).toBe(true) + if (root && !isEntityRootBailed(root)) { + expect(root.selection).toEqual({ title: true, content: true }) + expect(root.holes).toHaveLength(0) + } + }) + + test('a hole roots its source at "entity"', () => { + const [root] = analyzeEntityRoots(HOLE, 'hole.tsx') + expect(root && !isEntityRootBailed(root)).toBe(true) + if (root && !isEntityRootBailed(root)) { + expect(root.holes).toHaveLength(1) + expect(root.holes[0]!.entityProps.author).toEqual({ source: 'entity', path: ['author'] }) + } + }) + + test('non-function children bails ENTITY_NO_FUNCTION_CHILDREN', () => { + const [root] = analyzeEntityRoots(BAIL_NON_FUNCTION, 'bail.tsx') + expect(root && isEntityRootBailed(root)).toBe(true) + if (root && isEntityRootBailed(root)) { + expect(root.bailout.code).toBe('ENTITY_NO_FUNCTION_CHILDREN') + } + }) + + test('nested-in-component: host chain and entity root are analyzed independently', () => { + const chains = analyzeSource(NESTED_IN_COMPONENT, 'nested.tsx') + const roots = analyzeEntityRoots(NESTED_IN_COMPONENT, 'nested.tsx') + // Host chain compiles and collects only its own root fields (title) — NOT the + // Entity closure's `inner.content` (its param shadows the host root). + expect(chains).toHaveLength(1) + expect(isBailed(chains[0]!)).toBe(false) + if (!isBailed(chains[0]!)) { + expect(chains[0]!.selection.article).toEqual({ title: true }) + } + // The Entity emit contains only paths rooted at its own closure param. + expect(roots).toHaveLength(1) + expect(isEntityRootBailed(roots[0]!)).toBe(false) + if (!isEntityRootBailed(roots[0]!)) { + expect(roots[0]!.selection).toEqual({ content: true }) + } + }) +}) + +// ── Emit / idempotence ─────────────────────────────────────────────────────── + +describe('phase 3 — plugin emit', () => { + test('injects compiledSelection onto a proven ', () => { + const out = transform(SCALARS) + expect(out).toContain('compiledSelection') + expect(out).toContain('entity:') + expect(out).toContain('title: true') + }) + + test('a bailed is left untouched (no attribute)', () => { + const out = transform(BAIL_NON_FUNCTION) + expect(out).not.toContain('compiledSelection') + }) + + test('idempotent — an already-compiled is not double-injected', () => { + const once = transform(SCALARS) + const twice = transform(once) + expect(twice.match(/compiledSelection/g)?.length).toBe(1) + }) +}) + +// ── Adapter-oracle equivalence ─────────────────────────────────────────────── + +describe('phase 3 — adapter-oracle equivalence', () => { + test('scalar fields — collection skips children, query equals runtime', async () => { + const compiled = await captureRootSpec(SCALARS, true) + expect(compiled.collectorCalls).toBe(0) // children never invoked with a collector + const fields = await expectRootEquivalent(SCALARS) + expect(fields).toMatchObject({ title: true, content: true }) + }) + + test('nested has-one', async () => { + const fields = await expectRootEquivalent(NESTED_HAS_ONE) + expect(fields.author).toMatchObject({ name: true }) + }) + + test('compiled createComponent inside the closure (fragment merges)', async () => { + const fields = await expectRootEquivalent(COMPILED_COMPONENT) + expect(fields).toMatchObject({ title: true, content: true }) + }) + + test('a hole (entity-derived value into a nested component)', async () => { + const fields = await expectRootEquivalent(HOLE) + expect(fields.author).toMatchObject({ name: true }) + }) + + test('collector-contract target inside the closure', async () => { + const fields = await expectRootEquivalent(CONTRACT) + expect(fields.tags).toMatchObject({ name: true }) + }) + + test('branch union — runtime ⊆ compiled superset', async () => { + const [compiled, runtime] = await Promise.all([captureRootSpec(BRANCH_UNION, true), captureRootSpec(BRANCH_UNION, false)]) + // Compiler unions both branches; runtime follows the FLAG=true branch only. + expect(compiled.fields).toMatchObject({ title: true, content: true }) + expect(runtime.fields).toMatchObject({ title: true }) + expect(runtime.fields).not.toHaveProperty('content') + }) + + test('Entity nested inside a createComponent body renders + fetches correctly', async () => { + const fields = await expectRootEquivalent(NESTED_IN_COMPONENT) + expect(fields).toMatchObject({ content: true }) + }) + + test('create-mode Entity renders and skips the collector walk', async () => { + const mod = await loadModule(CREATE_MODE, true) + const adapter = new RecordingMockAdapter(structuredClone(MOCK_DATA), { delay: 0 }) + const { container } = render( + + + , + ) + await waitFor(() => { + expect(container.querySelector('[data-testid="ready"]')).not.toBeNull() + }) + expect(mod.getCollectorCalls?.()).toBe(0) + }) +}) diff --git a/packages/bindx-compiler/tests/equivalence.test.ts b/packages/bindx-compiler/tests/equivalence.test.ts new file mode 100644 index 00000000..00dd3d37 --- /dev/null +++ b/packages/bindx-compiler/tests/equivalence.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from 'bun:test' +import { isBailed } from '../src/index.js' +import type { FixtureCase } from './fixtureTypes.js' +import { analyzeFixture, compilerPlain, isSubset, runtimePlain } from './harness.js' + +import * as scalars from './fixtures/scalars.js' +import * as nestedHasOne from './fixtures/nestedHasOne.js' +import * as hasManyParams from './fixtures/hasManyParams.js' +import * as ternary from './fixtures/ternary.js' +import * as mapHasMany from './fixtures/mapHasMany.js' +import * as constAlias from './fixtures/constAlias.js' +import * as condition from './fixtures/condition.js' +import * as condProps from './fixtures/condProps.js' +import * as jsxProps from './fixtures/jsxProps.js' +import * as irrelevantChain from './fixtures/irrelevantChain.js' +import * as bails from './fixtures/bails.js' + +interface FixtureModule { + readonly cases: FixtureCase[] +} + +const FIXTURES: ReadonlyArray = [ + ['scalars.tsx', scalars], + ['nestedHasOne.tsx', nestedHasOne], + ['hasManyParams.tsx', hasManyParams], + ['ternary.tsx', ternary], + ['mapHasMany.tsx', mapHasMany], + ['constAlias.tsx', constAlias], + ['condition.tsx', condition], + ['condProps.tsx', condProps], + ['jsxProps.tsx', jsxProps], + ['irrelevantChain.tsx', irrelevantChain], + ['bails.tsx', bails], +] + +const DIR = import.meta.dir + +describe('equivalence: compiler vs runtime oracle', () => { + for (const [file, mod] of FIXTURES) { + describe(file, () => { + const results = analyzeFixture(DIR, file) + + test('chain count matches cases', () => { + expect(results.length).toBe(mod.cases.length) + }) + + mod.cases.forEach((testCase, index) => { + if (testCase.expect === 'bail') { + test(`chain #${index} bails with ${testCase.code}`, () => { + const result = results[index]! + expect(isBailed(result)).toBe(true) + if (isBailed(result)) { + expect(result.bailout.code).toBe(testCase.code) + } + }) + return + } + + test(`chain #${index} (${testCase.prop}) ${testCase.expect ?? 'equal'}`, () => { + const result = results[index]! + expect(isBailed(result)).toBe(false) + const compiler = compilerPlain(result, testCase.prop) + const runtime = runtimePlain(testCase.component, testCase.prop) + if (testCase.expect === 'superset') { + expect(isSubset(runtime, compiler)).toBe(true) + } else { + expect(compiler).toEqual(runtime) + } + }) + }) + }) + } +}) diff --git a/packages/bindx-compiler/tests/fixtureTypes.ts b/packages/bindx-compiler/tests/fixtureTypes.ts new file mode 100644 index 00000000..dd7d7542 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtureTypes.ts @@ -0,0 +1,17 @@ +import type { BailoutReason } from '../src/index.js' + +/** A chain the harness compares field-tree-equal (or superset) against the oracle. */ +export interface EqualCase { + readonly component: unknown + readonly prop: string + /** 'equal' = strict field-tree equality; 'superset' = runtime ⊆ compiler (branch union). */ + readonly expect?: 'equal' | 'superset' +} + +/** A chain the harness expects the compiler to bail on. */ +export interface BailCase { + readonly expect: 'bail' + readonly code: BailoutReason +} + +export type FixtureCase = EqualCase | BailCase diff --git a/packages/bindx-compiler/tests/fixtures/_ccBarrel.ts b/packages/bindx-compiler/tests/fixtures/_ccBarrel.ts new file mode 100644 index 00000000..836b0cb7 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_ccBarrel.ts @@ -0,0 +1,4 @@ +// Named re-export barrel (`export { X } from`) — the shape real-world index.ts barrels use. +export { CcBody } from './_ccTarget.js' +// Aliased form (`export { X as Y } from`) — same target, different exported name. +export { CcBody as AliasedCcBody } from './_ccTarget.js' diff --git a/packages/bindx-compiler/tests/fixtures/_ccStarBarrel.ts b/packages/bindx-compiler/tests/fixtures/_ccStarBarrel.ts new file mode 100644 index 00000000..7fc11fdf --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_ccStarBarrel.ts @@ -0,0 +1,2 @@ +// Star re-export barrel (`export * from`). The chase searches each star target for the name. +export * from './_ccTarget.js' diff --git a/packages/bindx-compiler/tests/fixtures/_ccTarget.tsx b/packages/bindx-compiler/tests/fixtures/_ccTarget.tsx new file mode 100644 index 00000000..f069a88b --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_ccTarget.tsx @@ -0,0 +1,10 @@ +// A createComponent target reached through a barrel re-export. Its render reads only +// `entity.title`; getSelection never touches scalar props, so a render-local passed alongside +// the entity is droppable with no bail — once the barrel chain is followed to this declaration. +import { Field, createComponent, entityDef } from '@contember/bindx-react' + +const ArticleDef = entityDef('Article') + +export const CcBody = createComponent() + .entity('entity', ArticleDef) + .render(({ entity }) => ) diff --git a/packages/bindx-compiler/tests/fixtures/_contractBarrel.ts b/packages/bindx-compiler/tests/fixtures/_contractBarrel.ts new file mode 100644 index 00000000..ad7f6f95 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_contractBarrel.ts @@ -0,0 +1,3 @@ +// A collector-contract component (ItemRepeater) re-exported through a barrel — the contract +// discovery must follow the `from` chain to reach its `withCollector(_, { children: itemOf })`. +export { ItemRepeater } from './_contractTargets.js' diff --git a/packages/bindx-compiler/tests/fixtures/_contractTargets.tsx b/packages/bindx-compiler/tests/fixtures/_contractTargets.tsx new file mode 100644 index 00000000..bbb42cd9 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_contractTargets.tsx @@ -0,0 +1,27 @@ +// Contract components a cross-file host imports. Their `withCollector(_, contract)` +// declaration is what the compiler's contract discovery parses out of this sibling module. +import type { ReactNode } from 'react' +import { itemOf, entityOf, withCollector, type EntityRef, type HasManyRef, type HasOneRef } from '@contember/bindx-react' +import type { Author, Tag } from './_schema.js' + +interface RepeaterProps { + field: HasManyRef + children?: (item: EntityRef) => ReactNode +} + +// itemOf: `children` is invoked per item of the has-many `field`. +export const ItemRepeater = withCollector( + function ItemRepeaterRuntime(_props: RepeaterProps): ReactNode { return null }, + { children: itemOf('field') }, +) + +interface EditorProps { + entity: HasOneRef + children?: (entity: EntityRef) => ReactNode +} + +// entityOf: `children` is invoked with the entity of the has-one `entity`. +export const EntityEditor = withCollector( + function EntityEditorRuntime(_props: EditorProps): ReactNode { return null }, + { children: entityOf('entity') }, +) diff --git a/packages/bindx-compiler/tests/fixtures/_cycleBarrelA.ts b/packages/bindx-compiler/tests/fixtures/_cycleBarrelA.ts new file mode 100644 index 00000000..ac2a360e --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_cycleBarrelA.ts @@ -0,0 +1,2 @@ +// Cyclic star re-export (A ⇄ B): the visited-set guard breaks the loop → unfollowable. +export * from './_cycleBarrelB.js' diff --git a/packages/bindx-compiler/tests/fixtures/_cycleBarrelB.ts b/packages/bindx-compiler/tests/fixtures/_cycleBarrelB.ts new file mode 100644 index 00000000..75028b7f --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_cycleBarrelB.ts @@ -0,0 +1 @@ +export * from './_cycleBarrelA.js' diff --git a/packages/bindx-compiler/tests/fixtures/_deep1.ts b/packages/bindx-compiler/tests/fixtures/_deep1.ts new file mode 100644 index 00000000..762dead4 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_deep1.ts @@ -0,0 +1,2 @@ +// Depth-limit chain link 1 → 2 (star re-export). Target sits past MAX_HOPS. +export * from './_deep2.js' diff --git a/packages/bindx-compiler/tests/fixtures/_deep2.ts b/packages/bindx-compiler/tests/fixtures/_deep2.ts new file mode 100644 index 00000000..e17727af --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_deep2.ts @@ -0,0 +1,2 @@ +// Depth-limit chain link 2 → 3 (star re-export). Target sits past MAX_HOPS. +export * from './_deep3.js' diff --git a/packages/bindx-compiler/tests/fixtures/_deep3.ts b/packages/bindx-compiler/tests/fixtures/_deep3.ts new file mode 100644 index 00000000..29a34b96 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_deep3.ts @@ -0,0 +1,2 @@ +// Depth-limit chain link 3 → 4 (star re-export). Target sits past MAX_HOPS. +export * from './_deep4.js' diff --git a/packages/bindx-compiler/tests/fixtures/_deep4.ts b/packages/bindx-compiler/tests/fixtures/_deep4.ts new file mode 100644 index 00000000..c406c908 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_deep4.ts @@ -0,0 +1,2 @@ +// Depth-limit chain link 4 → 5 (star re-export). Target sits past MAX_HOPS. +export * from './_deep5.js' diff --git a/packages/bindx-compiler/tests/fixtures/_deep5.ts b/packages/bindx-compiler/tests/fixtures/_deep5.ts new file mode 100644 index 00000000..3a1fa663 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_deep5.ts @@ -0,0 +1,2 @@ +// Depth-limit chain link 5 → 6 (star re-export). Target sits past MAX_HOPS. +export * from './_deep6.js' diff --git a/packages/bindx-compiler/tests/fixtures/_deep6.ts b/packages/bindx-compiler/tests/fixtures/_deep6.ts new file mode 100644 index 00000000..d242ade3 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_deep6.ts @@ -0,0 +1,2 @@ +// Final chain link → the target; unreachable because the budget runs out one hop earlier. +export * from './_ccTarget.js' diff --git a/packages/bindx-compiler/tests/fixtures/_schema.ts b/packages/bindx-compiler/tests/fixtures/_schema.ts new file mode 100644 index 00000000..2f5c6c45 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_schema.ts @@ -0,0 +1,33 @@ +// Tiny local schema for fixtures. entityDef carries no schema — matching the +// standalone createComponent path where relation-ness is derived purely from usage. +import { entityDef } from '@contember/bindx-react' + +export interface Author { + id: string + name: string + email: string +} + +export interface Tag { + id: string + name: string + color: string +} + +export interface Article { + id: string + title: string + content: string + status?: string + published?: boolean | null + rating?: number | null + views?: number | null + author: Author | null + tags: Tag[] +} + +export const schema = { + Article: entityDef
('Article'), + Author: entityDef('Author'), + Tag: entityDef('Tag'), +} as const diff --git a/packages/bindx-compiler/tests/fixtures/_targets.tsx b/packages/bindx-compiler/tests/fixtures/_targets.tsx new file mode 100644 index 00000000..90eb6fdf --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_targets.tsx @@ -0,0 +1,22 @@ +// Target components a hole can point at. Imported into holes.tsx so that fixture's +// only chains are the escaping hosts (clean index-based assertions in the harness). +import type { ReactNode } from 'react' +import { createComponent, Field, withCollector, type EntityRef } from '@contember/bindx-react' +import { schema, type Author } from './_schema.js' + +// createComponent target — resolved at runtime via its own implicit selection surface. +export const AuthorCard = createComponent() + .entity('author', schema.Author) + .render(({ author }) => ) + +// withCollector target — resolved via its staticRender surface. +export const AuthorBadge = withCollector( + (props: { author: EntityRef }): ReactNode => , + (props) => , +) + +// Plain React component target — no selection surface (documented runtime blind spot). +export const PlainAuthor = (props: { author: unknown }): ReactNode => {String(props.author)} + +// Plain component that accepts arbitrary props (for multi-prop / literal-prop cases). +export const Panel = (props: Record): ReactNode => {String(props.label)} diff --git a/packages/bindx-compiler/tests/fixtures/bails.tsx b/packages/bindx-compiler/tests/fixtures/bails.tsx new file mode 100644 index 00000000..94470d92 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/bails.tsx @@ -0,0 +1,85 @@ +import type { ReactNode } from 'react' +import { createComponent, Field, HasMany, type EntityRef } from '@contember/bindx-react' +import type { FixtureCase } from '../fixtureTypes.js' +import { schema, type Article } from './_schema.js' + +// One chain per bail trigger, in source order (the harness maps cases[] by order). + +// 1. INTERFACES_MODE +export const BailInterfaces = createComponent() + .interfaces<{ item: { name: string } }>() + .render(({ item }) => {item.fields.name.value}) + +// 2. EXPLICIT_RENDER_FN — render arg is not an inline function literal +const renderArticle = ({ article }: { article: EntityRef
}): ReactNode => + +export const BailExplicitFn = createComponent() + .entity('article', schema.Article) + .render(renderArticle) + +// 3. DYNAMIC_ENTITY_NAME — entity name is not a string literal +const propName = 'article' +export const BailDynamicName = createComponent() + .entity(propName, schema.Article) + .render(({ article }) => ) + +// 4. ENTITY_ESCAPES_TO_CALL — entity value passed to an unrecognized call +const formatAuthor = (value: unknown): string => String(value) +export const BailEntityCall = createComponent() + .entity('article', schema.Article) + .render(({ article }) => {formatAuthor(article.author)}) + +// 5. ENTITY_IN_EXPRESSION_PROP — entity inside a non-path expression prop of a component +const InfoCard = (props: { data: unknown }): ReactNode => {String(props.data)} +export const BailExpressionProp = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + +// 6. MEMBER_COMPONENT_TAG — entity value on a member-expression component tag +const Ns = { Card: InfoCard } +export const BailMemberTag = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + +// 7. ENTITY_SPREAD — spread of an entity root +export const BailSpread = createComponent() + .entity('article', schema.Article) + .render(({ article }) =>
) + +// 8. COMPUTED_MEMBER — computed member access off a root +const key: string = 'title' +export const BailComputedMember = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + +// 9. NON_LITERAL_HASMANY_PARAM — a HasMany param that is not a static literal +const dynamicLimit = 3 +export const BailNonLiteralParam = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => } + + )) + +// 10. ENTITY_REASSIGNMENT — entity-rooted binding is not const +export const BailReassignment = createComponent() + .entity('article', schema.Article) + .render(({ article }) => { + let author = article.author + author = article.author + return + }) + +export const cases: FixtureCase[] = [ + { expect: 'bail', code: 'INTERFACES_MODE' }, + { expect: 'bail', code: 'EXPLICIT_RENDER_FN' }, + { expect: 'bail', code: 'DYNAMIC_ENTITY_NAME' }, + { expect: 'bail', code: 'ENTITY_ESCAPES_TO_CALL' }, + { expect: 'bail', code: 'ENTITY_IN_EXPRESSION_PROP' }, + { expect: 'bail', code: 'MEMBER_COMPONENT_TAG' }, + { expect: 'bail', code: 'ENTITY_SPREAD' }, + { expect: 'bail', code: 'COMPUTED_MEMBER' }, + { expect: 'bail', code: 'NON_LITERAL_HASMANY_PARAM' }, + { expect: 'bail', code: 'ENTITY_REASSIGNMENT' }, +] diff --git a/packages/bindx-compiler/tests/fixtures/condProps.tsx b/packages/bindx-compiler/tests/fixtures/condProps.tsx new file mode 100644 index 00000000..ac1910a0 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/condProps.tsx @@ -0,0 +1,38 @@ +import { createComponent, Field, Switch, Case, Default, cond } from '@contember/bindx-react' +import type { FixtureCase } from '../fixtureTypes.js' +import { schema } from './_schema.js' + +// Item 4: `cond.*` DSL in a JSX prop position (``). +// The only selection a condition carries is the FieldRefs in its args (verified against +// Case.getSelection), so those are recorded as touched leaves and the prop itself is dropped. +// Oracle (Switch/Case/Default getSelection) collects exactly the same → strict equality. + +export const CondInProps = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + + + + + + + + )) + +// Nested combinators — every FieldRef arg (status, rating) is recorded. +export const CondCombinators = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + + + + {null} + + )) + +export const cases: FixtureCase[] = [ + { component: CondInProps, prop: 'article' }, + { component: CondCombinators, prop: 'article' }, +] diff --git a/packages/bindx-compiler/tests/fixtures/condition.tsx b/packages/bindx-compiler/tests/fixtures/condition.tsx new file mode 100644 index 00000000..c4fb0f96 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/condition.tsx @@ -0,0 +1,16 @@ +import { createComponent, Field, cond } from '@contember/bindx-react' +import type { FixtureCase } from '../fixtureTypes.js' +import { schema } from './_schema.js' + +// `.if()` condition fields are collected into the same selection as the render body. +export const Condition = createComponent() + .entity('article', schema.Article) + .if(({ article }) => cond.and( + cond.isTruthy(article.published), + cond.eq(article.status, 'active'), + )) + .render(({ article }) => ) + +export const cases: FixtureCase[] = [ + { component: Condition, prop: 'article' }, +] diff --git a/packages/bindx-compiler/tests/fixtures/constAlias.tsx b/packages/bindx-compiler/tests/fixtures/constAlias.tsx new file mode 100644 index 00000000..8ed6e652 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/constAlias.tsx @@ -0,0 +1,22 @@ +import { createComponent, Field } from '@contember/bindx-react' +import type { FixtureCase } from '../fixtureTypes.js' +import { schema } from './_schema.js' + +// Const aliases extend the root set (both a relation alias and a field alias). +export const ConstAlias = createComponent() + .entity('article', schema.Article) + .render(({ article }) => { + const author = article.author + const heading = article.title + return ( +
+ + + +
+ ) + }) + +export const cases: FixtureCase[] = [ + { component: ConstAlias, prop: 'article' }, +] diff --git a/packages/bindx-compiler/tests/fixtures/contracts.tsx b/packages/bindx-compiler/tests/fixtures/contracts.tsx new file mode 100644 index 00000000..9aa3f3cd --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/contracts.tsx @@ -0,0 +1,97 @@ +import type { ReactNode } from 'react' +import { createComponent, Field, itemOf, withCollector, type EntityRef, type HasManyRef } from '@contember/bindx-react' +import { schema, type Tag } from './_schema.js' +import { ItemRepeater, EntityEditor } from './_contractTargets.js' + +// Contract hosts (Phase 2.2). Each chain maps 1:1 to a case in contracts.test.ts by source +// order; every chain uses the `article` implicit prop. Contract components form NO hole — +// their callback is analyzed like a / child. + +interface LocalRepeaterProps { + field: HasManyRef + onSelect?: () => void + children?: (item: EntityRef) => ReactNode +} + +// Same-file contract target — discovered via a top-level `withCollector(_, contract)` declaration. +const LocalRepeater = withCollector( + function LocalRepeaterRuntime(_props: LocalRepeaterProps): ReactNode { return null }, + { children: itemOf('field') }, +) + +// Contract-shaped but UNPARSEABLE (spread in the object literal) → discovery yields no contract, +// so the element falls back to hole/bail rules (the render-prop child captures a host root → bail). +const spreadBase = {} +const BrokenRepeater = withCollector( + function BrokenRepeaterRuntime(_props: LocalRepeaterProps): ReactNode { return null }, + { ...spreadBase, children: itemOf('field') }, +) + +// 0. same-file contract target +export const SameFile = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => } + + )) + +// 1. cross-file contract target (imported from ./_contractTargets) +export const CrossFile = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => } + + )) + +// 2. footer-editor replica — item callback uses its param AND captures a host-root field. STRICT. +export const HostCapture = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => ( + <> + + + + )} + + )) + +// 3. entityOf variant +export const EntityOf = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {author => } + + )) + +// 4. non-contract function prop on a contract element → dropped, no safety bail +export const DroppedFnProp = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + console.log('x')}> + {tag => } + + )) + +// 5. contract entry with a missing callback → relation only +export const MissingCallback = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + +// 6. negative — unparseable contract falls back to hole rules; host-root capture in the child → bail +export const Unparseable = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => ( + <> + + + + )} + + )) diff --git a/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx b/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx new file mode 100644 index 00000000..2a555f7a --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx @@ -0,0 +1,23 @@ +import { createComponent, Field, HasMany } from '@contember/bindx-react' +import type { FixtureCase } from '../fixtureTypes.js' +import { schema } from './_schema.js' + +// Has-many with literal params. The runtime collector drops params in the implicit +// path (verified against the oracle), so field-tree equality holds; params are +// asserted separately in analyzer.test.ts. +export const HasManyParams = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => ( +
+ + +
+ )} +
+ )) + +export const cases: FixtureCase[] = [ + { component: HasManyParams, prop: 'article' }, +] diff --git a/packages/bindx-compiler/tests/fixtures/holeClosures.tsx b/packages/bindx-compiler/tests/fixtures/holeClosures.tsx new file mode 100644 index 00000000..68ae08fb --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/holeClosures.tsx @@ -0,0 +1,76 @@ +import type { ReactNode } from 'react' +import { createComponent, Field, HasOne, withCollector, type EntityRef } from '@contember/bindx-react' +import { schema, type Author } from './_schema.js' + +// Phase-2.1 closure lifting: an inline render-prop child of a HOLE element that captures nothing +// from render scope (only its own params + module bindings) is LIFTED verbatim into the hole's +// extraProps — the target's staticRender replays it with a collector proxy, so collection proceeds +// and stays oracle-equal. A child that captures a render-scope value (a `.use()` output, an entity +// root) cannot be reproduced at the module emit site → it BAILS (FUNCTION_PROP_ON_HOLE, runtime +// fallback). See docs/compiler-plan.md (Phase 2.1). + +interface SelectFieldProps { + field: EntityRef + children: (entity: EntityRef) => ReactNode +} + +// Mirrors a real-world SelectField: a withCollector primitive whose staticRender reaches the field +// ONLY through the render-prop child (it INVOKES it with a collector proxy). +export const SelectField = withCollector( + (_props: SelectFieldProps): ReactNode => null, + (props: SelectFieldProps): ReactNode => ( + + {entity => props.children(entity)} + + ), +) + +interface AuthorSummaryProps { + author: EntityRef + onClick?: () => void + format?: () => ReactNode +} + +// Reads the field in its OWN staticRender, independent of children — so dropping the safe +// extra function props (below) loses nothing. +export const AuthorSummary = withCollector( + (props: AuthorSummaryProps): ReactNode => , + (props: AuthorSummaryProps): ReactNode => , +) + +const sideEffect = (): void => {} + +// LIFTED — the render-prop child uses only its OWN param `it` + module scope (no render capture), +// so it is emitted verbatim into the hole's extraProps. SelectField.staticRender replays it with a +// collector proxy → author.name is collected. The chain COMPILES and is ORACLE-EQUAL end-to-end. +export const LiftedRenderProp = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {it => } + + )) + +// BAILS — the render-prop child captures `t` (a `.use()` value) from render scope, which cannot be +// reproduced at the module-scope emit site. Default deny → FUNCTION_PROP_ON_HOLE (runtime fallback). +export const CapturingRenderProp = createComponent() + .entity('article', schema.Article) + .use(() => ({ t: (): string => 'x' })) + .render(({ article, t }) => ( + + {it => t()} />} + + )) + +// SAFE (drop) — the hole's extra function props take no params and capture no entity roots, so +// invoking them at collection time cannot reach a selection scope. The chain still COMPILES with +// the hole, which resolves author.name through AuthorSummary's own staticRender. +export const SafeHoleClosures = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + sideEffect()} + format={() => null} + /> + )) diff --git a/packages/bindx-compiler/tests/fixtures/holes.tsx b/packages/bindx-compiler/tests/fixtures/holes.tsx new file mode 100644 index 00000000..c0b90523 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/holes.tsx @@ -0,0 +1,64 @@ +import type { ReactNode } from 'react' +import { createComponent, Field, HasOne } from '@contember/bindx-react' +import { schema } from './_schema.js' +import { AuthorCard, AuthorBadge, PlainAuthor, Panel } from './_targets.js' + +// Phase-2 holes: entity-derived values passed to component-typed elements. Every chain +// here is an escaping host; each maps 1:1 to a case in holes.test.ts by source order. + +const handler = (): void => {} +const someVar = 'runtime-only' + +// 0. createComponent target — hole author = article.author +export const ToCreateComponent = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + +// 1. withCollector target +export const ToWithCollector = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + +// 2. plain component target WITH a sibling dummy (host still collects title) +export const ToPlainWithSibling = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( +
+ + +
+ )) + +// 3. plain component target WITHOUT a sibling field +export const ToPlainNoSibling = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + +// 4. multiple entity props on one element → merged into ONE hole +export const MultipleEntityProps = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + +// 5. entity-derived value through a HasOne callback root → source article, path [author] +export const DerivedPathViaCallback = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {author => } + + )) + +// 6. literal + module-scope extra props (literals kept; module-scope identifiers `cb`/`dyn` +// lifted into extraProps so the real values reach the target — phase 2.1) +export const LiteralAndNonLiteralProps = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + )) + +// 7. target defined LATER in the module (thunk dodges TDZ) +export const ToLaterDefined = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + +const LaterPanel = (props: { author: unknown }): ReactNode => {String(props.author)} diff --git a/packages/bindx-compiler/tests/fixtures/irrelevantChain.tsx b/packages/bindx-compiler/tests/fixtures/irrelevantChain.tsx new file mode 100644 index 00000000..a64af273 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/irrelevantChain.tsx @@ -0,0 +1,20 @@ +import { createComponent, Field } from '@contember/bindx-react' +import type { FixtureCase } from '../fixtureTypes.js' +import { schema } from './_schema.js' + +// .props()/.use()/.mock() present but irrelevant to selection analysis. +export const IrrelevantChain = createComponent() + .entity('article', schema.Article) + .props<{ className?: string }>() + .use(() => ({ t: (key: string): string => `t:${key}` })) + .mock({ className: 'x', t: (key: string): string => key }) + .render(({ article, className, t }) => ( +
+

{t('heading')}

+ +
+ )) + +export const cases: FixtureCase[] = [ + { component: IrrelevantChain, prop: 'article' }, +] diff --git a/packages/bindx-compiler/tests/fixtures/jsxProps.tsx b/packages/bindx-compiler/tests/fixtures/jsxProps.tsx new file mode 100644 index 00000000..1b6f94c9 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/jsxProps.tsx @@ -0,0 +1,33 @@ +import type { ReactNode } from 'react' +import { createComponent, Field, withCollector } from '@contember/bindx-react' +import type { FixtureCase } from '../fixtureTypes.js' +import { schema } from './_schema.js' + +// Item 5: a JSX-element / fragment prop value (`slot={}`) is analyzed statically like +// children (recurse; entity refs form paths as usual) and the prop itself is not emitted. The +// oracle walks the same JSX via the target's staticRender proxies → static analysis is an +// equal-or-superset union. SlotPanel's staticRender renders `slot` as children, so both agree. + +const SlotPanel = withCollector( + (_props: { slot?: ReactNode; children?: ReactNode }): ReactNode => null, + (props: { slot?: ReactNode; children?: ReactNode }): ReactNode => <>{props.slot}{props.children}, +) + +export const JsxElementProp = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + }> + + + )) + +export const JsxFragmentProp = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + } /> + )) + +export const cases: FixtureCase[] = [ + { component: JsxElementProp, prop: 'article' }, + { component: JsxFragmentProp, prop: 'article' }, +] diff --git a/packages/bindx-compiler/tests/fixtures/mapHasMany.tsx b/packages/bindx-compiler/tests/fixtures/mapHasMany.tsx new file mode 100644 index 00000000..ca2ed1eb --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/mapHasMany.tsx @@ -0,0 +1,20 @@ +import { createComponent, Field } from '@contember/bindx-react' +import type { FixtureCase } from '../fixtureTypes.js' +import { schema } from './_schema.js' + +// `.map()` over a has-many children callback (no component). +export const MapHasMany = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( +
    + {article.tags.map(tag => ( +
  • + +
  • + ))} +
+ )) + +export const cases: FixtureCase[] = [ + { component: MapHasMany, prop: 'article' }, +] diff --git a/packages/bindx-compiler/tests/fixtures/nestedHasOne.tsx b/packages/bindx-compiler/tests/fixtures/nestedHasOne.tsx new file mode 100644 index 00000000..703de08a --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/nestedHasOne.tsx @@ -0,0 +1,25 @@ +import { createComponent, Field, HasOne } from '@contember/bindx-react' +import type { FixtureCase } from '../fixtureTypes.js' +import { schema } from './_schema.js' + +// Nested has-one via callback root and via direct member chain. +export const NestedHasOne = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( +
+ + + {author => ( +
+ + +
+ )} +
+ +
+ )) + +export const cases: FixtureCase[] = [ + { component: NestedHasOne, prop: 'article' }, +] diff --git a/packages/bindx-compiler/tests/fixtures/scalars.tsx b/packages/bindx-compiler/tests/fixtures/scalars.tsx new file mode 100644 index 00000000..93fc046b --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/scalars.tsx @@ -0,0 +1,20 @@ +import { createComponent, Field, Show } from '@contember/bindx-react' +import type { FixtureCase } from '../fixtureTypes.js' +import { schema } from './_schema.js' + +// Scalar leaves via , direct render access, and . +export const Scalars = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( +
+ + + + + +
+ )) + +export const cases: FixtureCase[] = [ + { component: Scalars, prop: 'article' }, +] diff --git a/packages/bindx-compiler/tests/fixtures/ternary.tsx b/packages/bindx-compiler/tests/fixtures/ternary.tsx new file mode 100644 index 00000000..36918071 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/ternary.tsx @@ -0,0 +1,19 @@ +import { createComponent, Field } from '@contember/bindx-react' +import type { FixtureCase } from '../fixtureTypes.js' +import { schema } from './_schema.js' + +// Both-branch ternary. The compiler unions both branches; the runtime executes +// only one, so the harness asserts runtime ⊆ compiler (superset), not equality. +export const Ternary = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( +
+ {article.status === 'published' + ? + : } +
+ )) + +export const cases: FixtureCase[] = [ + { component: Ternary, prop: 'article', expect: 'superset' }, +] diff --git a/packages/bindx-compiler/tests/hardening.test.ts b/packages/bindx-compiler/tests/hardening.test.ts new file mode 100644 index 00000000..1b956342 --- /dev/null +++ b/packages/bindx-compiler/tests/hardening.test.ts @@ -0,0 +1,156 @@ +/** + * Prod hardening: crash containment (#1), diagnostics reporting (#2), and Entity literal + * hoisting (#4). Analysis contains an unexpected (non-BailError) crash as an INTERNAL_ERROR bail; + * the plugin degrades that unit to "no injection" (proxy fallback is sound) and always warns. + */ +import { afterEach, describe, expect, spyOn, test } from 'bun:test' +import { transformSync } from '@babel/core' +import { analyzeSource, bindxCompilerPlugin, isBailed } from '../src/index.js' +import { BodyAnalyzer } from '../src/body.js' +import type { PluginItem } from '@babel/core' + +function transform(code: string, options: Record = {}): string { + const plugin: PluginItem = [bindxCompilerPlugin, options] + const out = transformSync(code, { filename: 'input.tsx', plugins: [plugin], configFile: false, babelrc: false, retainLines: true }) + if (!out?.code) { + throw new Error('transform produced no output') + } + return out.code +} + +// Two chains: `ok` compiles, `crash` will be forced to throw a non-BailError during analysis. +const TWO_CHAINS = ` +import { createComponent, Field } from '@contember/bindx-react' +import { schema } from './s' +export const Ok = createComponent().entity('ok', schema.Article).render(({ ok }) => ) +export const Bad = createComponent().entity('crash', schema.Article).render(({ crash }) => ) +` + +// One compiled chain + one spread bail (ENTITY_SPREAD at line 5) — deterministic diagnostics input. +const COMPILE_AND_BAIL = ` +import { createComponent, Field } from '@contember/bindx-react' +import { schema } from './s' +export const Ok = createComponent().entity('article', schema.Article).render(({ article }) => ) +export const Bad = createComponent().entity('article', schema.Article).render(({ article }) =>
) +` + +const ENTITY_ROOT = ` +import { Entity, Field, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +export function Route() { + return {article =>
}
+} +` + +afterEach(() => { + // bun restores spies via mockRestore in each test; nothing global to reset here. +}) + +describe('#1 crash containment', () => { + test('analysis wrapper converts a non-BailError into an INTERNAL_ERROR bail (never throws)', () => { + const spy = spyOn(BodyAnalyzer.prototype, 'analyzeFunction').mockImplementation(() => { + throw new Error('boom') + }) + try { + const results = analyzeSource(TWO_CHAINS, 'input.tsx') + expect(results).toHaveLength(2) + for (const result of results) { + expect(isBailed(result)).toBe(true) + if (isBailed(result)) { + expect(result.bailout.code).toBe('INTERNAL_ERROR') + expect(result.bailout.message).toBe('boom') + } + } + } finally { + spy.mockRestore() + } + }) + + test('plugin contains a per-chain crash: build succeeds, other chains still compile', () => { + const original = BodyAnalyzer.prototype.analyzeFunction + // Throw only for the `crash` chain (its propRoots is keyed by the entity prop name). + const spy = spyOn(BodyAnalyzer.prototype, 'analyzeFunction').mockImplementation(function (this: BodyAnalyzer, fn, propRoots) { + if (propRoots.has('crash')) { + throw new Error('boom') + } + return original.call(this, fn, propRoots) + }) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + try { + const output = transform(TWO_CHAINS) + // The `ok` chain compiled (v2 literal injected); the `crash` chain got no injection. + expect(output).toContain('v: 2') + expect(output).toContain('ok: {') + expect(output).not.toContain('crash: {') + // INTERNAL_ERROR always warns (default diagnostics 'off') with file + loc + message. + expect(warn).toHaveBeenCalledTimes(1) + expect(warn.mock.calls[0]?.[0]).toContain('INTERNAL_ERROR') + expect(warn.mock.calls[0]?.[0]).toContain('input.tsx') + expect(warn.mock.calls[0]?.[0]).toContain('boom') + } finally { + spy.mockRestore() + warn.mockRestore() + } + }) +}) + +describe('#2 diagnostics option', () => { + test('default off prints nothing', () => { + const info = spyOn(console, 'info').mockImplementation(() => {}) + try { + transform(COMPILE_AND_BAIL) + expect(info).not.toHaveBeenCalled() + } finally { + info.mockRestore() + } + }) + + test('verbose prints one BAIL line per bail plus a compiled count', () => { + const info = spyOn(console, 'info').mockImplementation(() => {}) + try { + transform(COMPILE_AND_BAIL, { diagnostics: 'verbose' }) + const lines = info.mock.calls.map(c => String(c[0])) + expect(lines).toContain('[bindx-compiler] input.tsx:5 BAIL ENTITY_SPREAD') + expect(lines).toContain('[bindx-compiler] input.tsx: 1 compiled, 1 bailed') + } finally { + info.mockRestore() + } + }) + + test('summary prints one file line only when the file has a bail', () => { + const info = spyOn(console, 'info').mockImplementation(() => {}) + try { + transform(COMPILE_AND_BAIL, { diagnostics: 'summary' }) + const lines = info.mock.calls.map(c => String(c[0])) + expect(lines).toEqual(['[bindx-compiler] input.tsx: 1 compiled, 1 bailed (ENTITY_SPREAD)']) + } finally { + info.mockRestore() + } + }) + + test('summary stays silent for an all-compiled file', () => { + const info = spyOn(console, 'info').mockImplementation(() => {}) + try { + transform(ENTITY_ROOT, { diagnostics: 'summary' }) + expect(info).not.toHaveBeenCalled() + } finally { + info.mockRestore() + } + }) +}) + +describe('#4 entity literal hoisting', () => { + test('the compiled literal is a module-scope const referenced by the attribute', () => { + const output = transform(ENTITY_ROOT) + // A hoisted const holds the literal; the attribute references its identifier (stable identity). + expect(output).toMatch(/const\s+_bindxCompiledSelection\s*=\s*\{/) + expect(output).toMatch(/compiledSelection=\{_bindxCompiledSelection\}/) + }) + + test('double transform is a no-op (idempotent) even with the identifier-valued attribute', () => { + const once = transform(ENTITY_ROOT) + const twice = transform(once) + expect(twice.match(/compiledSelection/g)?.length).toBe(1) + expect(twice.match(/_bindxCompiledSelection\s*=/g)?.length).toBe(1) + }) +}) diff --git a/packages/bindx-compiler/tests/harness.ts b/packages/bindx-compiler/tests/harness.ts new file mode 100644 index 00000000..d9b58256 --- /dev/null +++ b/packages/bindx-compiler/tests/harness.ts @@ -0,0 +1,73 @@ +/** + * Equivalence harness: the runtime proxy collector is the oracle. For each fixture + * we (a) trigger real collection and normalize via convertToQuerySelection, and + * (b) run analyzeSource and normalize identically, then compare per entity prop. + */ +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { COMPONENT_SELECTIONS, convertToQuerySelection, type SelectionMeta } from '@contember/bindx-react' +import { analyzeSource, isBailed, type ChainResult } from '../src/index.js' +import { selectionToPlain } from '../src/selectionTree.js' + +export function analyzeFixture(dir: string, file: string): ChainResult[] { + const path = join(dir, 'fixtures', file) + // Pass the real path so relative cross-file contract specifiers resolve on disk. + return analyzeSource(readFileSync(path, 'utf8'), path) +} + +/** Runtime oracle: trigger `$prop` collection, normalize to the plain field tree. */ +export function runtimePlain(component: unknown, prop: string): Record { + // Fragment access triggers static collection (same idiom as createComponentUse.test.tsx). + void (component as Record)[`$${prop}`] + const selections = (component as Record>)[COMPONENT_SELECTIONS] + const selection = selections?.get(prop)?.selection + if (!selection) { + return {} + } + return stripParams(convertToQuerySelection(selection)) +} + +/** Compiler side: normalize the emitted StaticSelection to the same plain field tree. */ +export function compilerPlain(result: ChainResult, prop: string): Record { + if (isBailed(result)) { + return {} + } + const plain = selectionToPlain(result.selection) + const forProp = plain[prop] + return (forProp && typeof forProp === 'object') ? forProp as Record : {} +} + +/** Drop `__params` (never present in the implicit path, but defensive). */ +function stripParams(obj: Record): Record { + const out: Record = {} + for (const [key, value] of Object.entries(obj)) { + if (key === '__params') { + continue + } + out[key] = value && typeof value === 'object' ? stripParams(value as Record) : value + } + return out +} + +/** Deep subset: is every key/leaf of `sub` present in `sup` (runtime ⊆ compiler)? */ +export function isSubset(sub: Record, sup: Record): boolean { + for (const [key, value] of Object.entries(sub)) { + if (!(key in sup)) { + return false + } + const other = sup[key] + if (value === true) { + if (other !== true && !(other && typeof other === 'object')) { + return false + } + } else if (value && typeof value === 'object') { + if (!other || typeof other !== 'object') { + return false + } + if (!isSubset(value as Record, other as Record)) { + return false + } + } + } + return true +} diff --git a/packages/bindx-compiler/tests/holeClosures.test.ts b/packages/bindx-compiler/tests/holeClosures.test.ts new file mode 100644 index 00000000..3661e39d --- /dev/null +++ b/packages/bindx-compiler/tests/holeClosures.test.ts @@ -0,0 +1,144 @@ +/** + * Phase-2.1 closure lifting. A render-prop child of a hole element is dropped from the emitted + * hole by default, but the target's staticRender may invoke it with a collector proxy during + * collection. A child capturing nothing from render scope (own params + module bindings only) is + * LIFTED verbatim into `extraProps` so collection proceeds oracle-equal; one capturing a render + * value (`.use()` output, entity root) BAILS; a param-less, root-free extra prop stays safe to drop. + */ +import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { transformSync } from '@babel/core' +import { analyzeSource, bindxCompilerPlugin, isBailed } from '../src/index.js' +import { runtimePlain } from './harness.js' +import * as oracleModule from './fixtures/holeClosures.js' + +const DIR = import.meta.dir +const FIXTURE = join(DIR, 'fixtures', 'holeClosures.tsx') + +describe('hole closure safety (fixtures/holeClosures.tsx)', () => { + const code = readFileSync(FIXTURE, 'utf8') + const results = analyzeSource(code, 'holeClosures.tsx') + + test('three host chains recognized (lift, bail, drop)', () => { + expect(results.length).toBe(3) + }) + + test('#0 LiftedRenderProp compiles with the render-prop child lifted into extraProps', () => { + const result = results[0]! + expect(isBailed(result)).toBe(false) + if (!isBailed(result)) { + const hole = result.holes[0]! + expect(hole.component).toBe('SelectField') + expect(hole.entityProps).toEqual({ field: { source: 'article', path: ['author'] } }) + expect(Object.keys(hole.extraProps ?? {})).toEqual(['children']) + } + }) + + test('#1 CapturingRenderProp bails FUNCTION_PROP_ON_HOLE (captures a .use() value)', () => { + const result = results[1]! + expect(isBailed(result)).toBe(true) + if (isBailed(result)) { + expect(result.bailout.code).toBe('FUNCTION_PROP_ON_HOLE') + } + }) + + test('#2 SafeHoleClosures compiles with the AuthorSummary hole (safe props omitted)', () => { + const result = results[2]! + expect(isBailed(result)).toBe(false) + if (!isBailed(result)) { + const hole = result.holes[0]! + expect(hole.component).toBe('AuthorSummary') + expect(hole.entityProps).toEqual({ author: { source: 'article', path: ['author'] } }) + expect(hole.extraProps).toBeUndefined() // both function props dropped as safe + } + }) +}) + +describe('hole closure lifting — plugin injection', () => { + function transform(code: string): string { + const out = transformSync(code, { filename: 'inline.tsx', plugins: [bindxCompilerPlugin], configFile: false, babelrc: false }) + if (!out?.code) { + throw new Error('transform produced no output') + } + return out.code + } + + // Non-capturing render-prop child — lifted verbatim into the hole. + const LIFT = ` +import { createComponent, Field, HasOne, withCollector, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const Select = withCollector((props) => null, (props) => ( + {entity => props.children(entity)} +)) +export const Host = createComponent().entity('article', ArticleDef).render(({ article }) => ( + +)) +` + + // Render-prop child capturing a render root — cannot be lifted, chain bails. + const BAIL = ` +import { createComponent, Field, HasOne, withCollector, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const Select = withCollector((props) => null, (props) => ( + {entity => props.children(entity)} +)) +export const Host = createComponent().entity('article', ArticleDef).render(({ article }) => ( + +)) +` + + test('lifted chain emits extraProps with the closure verbatim', () => { + const out = transform(LIFT).replace(/\s+/g, ' ') + expect(out).toContain('holes:') + expect(out).toContain('extraProps:') + expect(out).toContain('children: () => it => ') + }) + + test('bailed capturing chain gets no injected 2nd argument (runtime fallback)', () => { + const out = transform(BAIL) + expect(out).not.toContain('props:') + expect(out).not.toContain('holes:') + }) +}) + +// End-to-end: the COMPILED module must produce the same field tree as the ORACLE. The lifted chain +// resolves the render-prop child through SelectField's staticRender; the capturing chain bails → +// runtime proxy fallback (trivially equal); the safe chain drops its extra props. +describe('hole closure lifting — equivalence vs runtime oracle', () => { + const tmpPath = join(DIR, 'fixtures', '.holeClosures-compiled.tsx') + let compiled: Record = {} + const oracle = oracleModule as unknown as Record + + beforeAll(async () => { + const source = readFileSync(FIXTURE, 'utf8') + const out = transformSync(source, { filename: 'holeClosures.tsx', plugins: [bindxCompilerPlugin], configFile: false, babelrc: false }) + if (!out?.code) { + throw new Error('transform produced no output') + } + writeFileSync(tmpPath, out.code) + compiled = (await import(tmpPath)) as Record + }) + + afterAll(() => { + rmSync(tmpPath, { force: true }) + }) + + test('LiftedRenderProp — compiled (lifted closure) equals the oracle (both collect author.name)', () => { + const reference = runtimePlain(oracle.LiftedRenderProp, 'article') + expect(reference).toMatchObject({ author: { name: true } }) + expect(runtimePlain(compiled.LiftedRenderProp, 'article')).toEqual(reference) + }) + + test('CapturingRenderProp — bailed compiled path equals the oracle (both collect author.name)', () => { + const reference = runtimePlain(oracle.CapturingRenderProp, 'article') + expect(reference).toMatchObject({ author: { name: true } }) + expect(runtimePlain(compiled.CapturingRenderProp, 'article')).toEqual(reference) + }) + + test('SafeHoleClosures — compiled hole equals the oracle (both collect author.name)', () => { + const reference = runtimePlain(oracle.SafeHoleClosures, 'article') + expect(reference).toMatchObject({ author: { name: true } }) + expect(runtimePlain(compiled.SafeHoleClosures, 'article')).toEqual(reference) + }) +}) diff --git a/packages/bindx-compiler/tests/holes.test.ts b/packages/bindx-compiler/tests/holes.test.ts new file mode 100644 index 00000000..27ebfdc3 --- /dev/null +++ b/packages/bindx-compiler/tests/holes.test.ts @@ -0,0 +1,249 @@ +/** + * Phase-2 hole analysis + emit. Asserts the analyzer's `holes` metadata (component name, + * entityProps source/path, literalProps) and the Babel plugin's thunk emission. Full + * end-to-end equivalence (compiled fields + resolved holes vs runtime oracle) is + * integration-gated — see the skipped block at the bottom. + */ +import { readFileSync, rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { afterAll, beforeAll, describe, expect, test } from 'bun:test' +import { transformSync } from '@babel/core' +import { analyzeSource, bindxCompilerPlugin, isBailed, type AnalyzedChain, type BailoutReason } from '../src/index.js' +import { runtimePlain } from './harness.js' +import * as oracleModule from './fixtures/holes.js' + +const DIR = import.meta.dir +const PRELUDE = `import { createComponent, Field, HasOne } from '@contember/bindx-react'\n` + + `import { schema } from './s'\n` + + `const Card = (p: any) => null\n` + +function analyzed(code: string): AnalyzedChain { + const result = analyzeSource(code, 'inline.tsx')[0]! + if (isBailed(result)) { + throw new Error(`unexpected bail: ${result.bailout.code}`) + } + return result +} + +function bailCode(code: string): BailoutReason | null { + const result = analyzeSource(code, 'inline.tsx')[0]! + return isBailed(result) ? result.bailout.code : null +} + +function chain(body: string): string { + return `${PRELUDE}export const C = createComponent().entity('article', schema.Article).render(${body})` +} + +describe('hole analyzer (fixtures/holes.tsx)', () => { + const code = readFileSync(join(DIR, 'fixtures', 'holes.tsx'), 'utf8') + const results = analyzeSource(code, 'holes.tsx') + + function chainAt(index: number): AnalyzedChain { + const result = results[index]! + if (isBailed(result)) { + throw new Error(`chain #${index} unexpectedly bailed: ${result.bailout.code}`) + } + return result + } + + function holesAt(index: number): AnalyzedChain['holes'] { + return chainAt(index).holes + } + + test('all 8 host chains are recognized and compiled', () => { + expect(results.length).toBe(8) + expect(results.every(r => !isBailed(r))).toBe(true) + }) + + test('#0 createComponent target — one hole, author = article.author', () => { + expect(holesAt(0)).toEqual([ + { component: 'AuthorCard', entityProps: { author: { source: 'article', path: ['author'] } }, literalProps: undefined }, + ]) + }) + + test('#1 withCollector target', () => { + expect(holesAt(1)[0]?.component).toBe('AuthorBadge') + }) + + test('#2 plain target with sibling — hole present AND sibling still collected', () => { + const result = chainAt(2) + expect(result.selection).toEqual({ article: { title: true } }) + expect(result.holes[0]).toEqual({ + component: 'PlainAuthor', + entityProps: { author: { source: 'article', path: ['author'] } }, + literalProps: undefined, + }) + }) + + test('#3 plain target without sibling — no field selection, hole present', () => { + const result = chainAt(3) + expect(result.selection).toEqual({}) + expect(result.holes[0]?.component).toBe('PlainAuthor') + }) + + test('#4 multiple entity props on one element → one merged hole', () => { + expect(holesAt(4)).toEqual([ + { + component: 'Panel', + entityProps: { + primary: { source: 'article', path: ['author'] }, + secondary: { source: 'article', path: [] }, + }, + literalProps: undefined, + }, + ]) + }) + + test('#5 entity value through a HasOne callback — absPath preserved as [author]', () => { + expect(holesAt(5)[0]?.entityProps).toEqual({ author: { source: 'article', path: ['author'] } }) + }) + + test('#6 literal props kept; module-scope identifier props lifted into extraProps', () => { + const hole = holesAt(6)[0]! + expect(hole.entityProps).toEqual({ author: { source: 'article', path: ['author'] } }) + expect(hole.literalProps).toEqual({ label: 'hi', count: 5, obj: { a: 1 } }) + // `cb={handler}` and `dyn={someVar}` are module-scope bindings → lifted (real value reaches target). + expect(Object.keys(hole.extraProps ?? {}).sort()).toEqual(['cb', 'dyn']) + }) + + test('#7 target defined later in the module still forms a hole', () => { + expect(holesAt(7)[0]?.component).toBe('LaterPanel') + }) +}) + +describe('hole analyzer (inline edge cases)', () => { + test('root itself passed as prop → empty path', () => { + expect(analyzed(chain(`({ article }) => `)).holes).toEqual([ + { component: 'Card', entityProps: { data: { source: 'article', path: [] } }, literalProps: undefined }, + ]) + }) + + test('same-element bindx component () is NOT a hole', () => { + const result = analyzed(chain(`({ article }) => {a => }`)) + expect(result.holes).toEqual([]) + expect(result.selection).toEqual({ article: { author: { fields: { id: true, name: true } } } }) + }) + + test('unresolved component tag with an entity prop bails ENTITY_ESCAPES_TO_COMPONENT', () => { + // `Undeclared` is referenced but never imported/declared at module scope. + const code = `${PRELUDE}export const C = createComponent().entity('article', schema.Article)` + + `.render(({ article }) => )` + expect(bailCode(code)).toBe('ENTITY_ESCAPES_TO_COMPONENT') + }) + + test('non-path expression prop bails ENTITY_IN_EXPRESSION_PROP', () => { + expect(bailCode(chain(`({ article }) => `))).toBe('ENTITY_IN_EXPRESSION_PROP') + }) + + test('member-expression tag with an entity prop bails MEMBER_COMPONENT_TAG', () => { + const code = `${PRELUDE}const Ns = { Card }\nexport const C = createComponent().entity('article', schema.Article)` + + `.render(({ article }) => )` + expect(bailCode(code)).toBe('MEMBER_COMPONENT_TAG') + }) + + test('component without entity props is not a hole (children still analyzed)', () => { + const result = analyzed(chain(`({ article }) => `)) + expect(result.holes).toEqual([]) + expect(result.selection).toEqual({ article: { title: true } }) + }) +}) + +describe('hole emit (Babel plugin thunk)', () => { + function transform(code: string): string { + const out = transformSync(code, { filename: 'inline.tsx', plugins: [bindxCompilerPlugin], configFile: false, babelrc: false }) + if (!out?.code) { + throw new Error('transform produced no output') + } + return out.code + } + + test('emits { props, holes } with a component thunk and entityProps', () => { + const out = transform(chain(`({ article }) => `)) + expect(out).toContain('props:') + expect(out).toContain('holes:') + expect(out.replace(/\s+/g, ' ')).toContain('component: () => Card') + expect(out).toContain('entityProps:') + expect(out).toContain('source: "article"') + expect(out).toContain('literalProps:') + expect(out).toContain('label: "hi"') + }) + + test('thunk references a later-defined identifier verbatim (TDZ dodge)', () => { + const code = `${PRELUDE.replace('const Card = (p: any) => null\n', '')}` + + `export const C = createComponent().entity('article', schema.Article).render(({ article }) => )\n` + + `const Later = (p: any) => null\n` + const out = transform(code) + expect(out.replace(/\s+/g, ' ')).toContain('component: () => Later') + }) + + test('no holes → holes key omitted, props always present', () => { + const out = transform(chain(`({ article }) => `)) + expect(out).toContain('props:') + expect(out).not.toContain('holes:') + }) +}) + +// Full end-to-end equivalence: the COMPILED path (Babel plugin → v2 runtime, holes +// resolved by applyCompiledSelection) must produce the same field tree as the ORACLE +// (untransformed module, runtime proxy pass resolves nested targets). Both sides replay +// `article.author` gets on collector proxies, so equality holds by construction — +// createComponent/withCollector targets resolve, plain targets are blind on both sides. +describe('hole equivalence vs runtime oracle', () => { + // Fixture exports in source order (all use the `article` implicit prop). + const EXPORTS = [ + 'ToCreateComponent', + 'ToWithCollector', + 'ToPlainWithSibling', + 'ToPlainNoSibling', + 'MultipleEntityProps', + 'DerivedPathViaCallback', + 'LiteralAndNonLiteralProps', + 'ToLaterDefined', + ] as const + + // Compiled temp module lives beside the fixture so its `./_targets`/`./_schema` + // relative imports resolve to the same singletons the oracle uses. + const tmpPath = join(DIR, 'fixtures', '.holes-compiled.tsx') + let compiledModule: Record = {} + const oracle = oracleModule as unknown as Record + + beforeAll(async () => { + const source = readFileSync(join(DIR, 'fixtures', 'holes.tsx'), 'utf8') + const out = transformSync(source, { filename: 'holes.tsx', plugins: [bindxCompilerPlugin], configFile: false, babelrc: false }) + if (!out?.code) { + throw new Error('transform produced no output') + } + writeFileSync(tmpPath, out.code) + compiledModule = (await import(tmpPath)) as Record + }) + + afterAll(() => { + rmSync(tmpPath, { force: true }) + }) + + // Sanity: the meaningful targets must actually collect through the hole, otherwise + // the equality checks below could pass trivially with both sides empty. + test('createComponent target collects author.name through the hole', () => { + expect(runtimePlain(oracle.ToCreateComponent, 'article')).toMatchObject({ author: { name: true } }) + expect(runtimePlain(compiledModule.ToCreateComponent, 'article')).toMatchObject({ author: { name: true } }) + }) + + test('withCollector target collects author.name through the hole', () => { + expect(runtimePlain(compiledModule.ToWithCollector, 'article')).toMatchObject({ author: { name: true } }) + }) + + test('plain target with a sibling keeps the sibling selection; blind target still touches author', () => { + // PlainAuthor never reads a field, but the bare `article.author` get registers a + // touched leaf on BOTH sides — the sibling adds title. Symmetric over-fetch. + expect(runtimePlain(compiledModule.ToPlainWithSibling, 'article')).toEqual({ author: true, title: true }) + expect(runtimePlain(oracle.ToPlainWithSibling, 'article')).toEqual({ author: true, title: true }) + }) + + for (const name of EXPORTS) { + test(`${name} — compiled field tree equals the runtime oracle`, () => { + const compiled = runtimePlain(compiledModule[name], 'article') + const reference = runtimePlain(oracle[name], 'article') + expect(compiled).toEqual(reference) + }) + } +}) diff --git a/packages/bindx-compiler/tests/interop.test.tsx b/packages/bindx-compiler/tests/interop.test.tsx new file mode 100644 index 00000000..05290d86 --- /dev/null +++ b/packages/bindx-compiler/tests/interop.test.tsx @@ -0,0 +1,137 @@ +/** + * #3 emit AST-reuse regression net. The compiler copies a lifted render-prop closure into a hole's + * `extraProps`; that closure ALSO stays in the render body. Before the fix both positions shared one + * AST node — corrupt under a later pass. Here we deliberately force that: run the compiler with + * `ast: true`, then run `@babel/plugin-transform-react-jsx` over the SAME AST (`cloneInputAst:false`) + * so the two positions share nodes. With the deep clone the output parses, LOADS, and the component + * still collects the lifted field; a shared node would corrupt one of the two copies. + * + * (One transformSync can't chain the two: the compiler's Program `path.skip()` also halts react-jsx + * in a merged pass, so we thread the AST across two passes — a stronger shared-node stress than one.) + */ +import { GlobalRegistrator } from '@happy-dom/global-registrator' +if (typeof document === 'undefined') { + GlobalRegistrator.register() +} + +import { afterAll, describe, expect, test } from 'bun:test' +import { transformSync, transformFromAstSync } from '@babel/core' +import reactJsx from '@babel/plugin-transform-react-jsx' +import * as t from '@babel/types' +import type { File } from '@babel/types' +import { writeFileSync, rmSync } from 'node:fs' +import { join } from 'node:path' +import { COMPONENT_SELECTIONS, convertToQuerySelection, type SelectionMeta } from '@contember/bindx-react' +import { bindxCompilerPlugin, type AnalyzedHole } from '../src/index.js' +import { selectionToAst } from '../src/emit.js' + +// A hole whose target INVOKES its render-prop child (SelectField.staticRender calls props.children), +// so the child `it => ` is LIFTED verbatim into the hole's extraProps. +const SOURCE = ` +import { createComponent, Field, HasOne, withCollector } from '@contember/bindx-react' +import { schema } from './fixtures/_schema.js' + +const SelectField = withCollector( + () => null, + (props) => {entity => props.children(entity)}, +) + +export const Host = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + {it => } + )) +` + +/** Compiler pass (AST out) → react-jsx pass over the SAME nodes → emitted code text. */ +function compileThenJsx(source: string): string { + const first = transformSync(source, { + filename: 'host.tsx', configFile: false, babelrc: false, + parserOpts: { plugins: ['typescript', 'jsx'] }, + plugins: [bindxCompilerPlugin], ast: true, code: false, + }) + const ast: File | null | undefined = first?.ast + if (!ast) { + throw new Error('compiler pass produced no AST') + } + const out = transformFromAstSync(ast, undefined, { + filename: 'host.tsx', configFile: false, babelrc: false, cloneInputAst: false, + plugins: [[reactJsx, { runtime: 'automatic' }]], + }) + if (!out?.code) { + throw new Error('react-jsx pass produced no output') + } + return out.code +} + +const tmpFiles: string[] = [] +let counter = 0 + +async function loadCompiled(source: string): Promise { + const code = compileThenJsx(source) + const path = join(import.meta.dir, `.interop-${counter++}.tsx`) + writeFileSync(path, code) + tmpFiles.push(path) + return import(path) as Promise +} + +afterAll(() => { + for (const file of tmpFiles) { + rmSync(file, { force: true }) + } +}) + +interface HostModule { + readonly Host: unknown +} + +describe('#3 compiler → react-jsx interop (shared-AST stress)', () => { + test('output loads and the lifted closure still collects article.author.name', async () => { + const mod = await loadCompiled(SOURCE) + // Fragment access triggers static collection off the injected literal (extraProps thunk replayed). + void (mod.Host as Record).$article + const selections = (mod.Host as Record>)[COMPONENT_SELECTIONS] + const selection = selections?.get('article')?.selection + expect(selection).toBeDefined() + const query = selection ? convertToQuerySelection(selection) : {} + // The hole put author.name into the collected selection — proves neither copy was corrupted. + expect(query).toMatchObject({ author: { name: true } }) + }) +}) + +/** Find an object property's expression value by key. */ +function findProp(obj: t.ObjectExpression, name: string): t.Expression | undefined { + for (const p of obj.properties) { + if (t.isObjectProperty(p) && t.isIdentifier(p.key) && p.key.name === name && t.isExpression(p.value)) { + return p.value + } + } + return undefined +} + +describe('#3 emit deep-clones extraProps (node identity)', () => { + test('the emitted extraProps expression is independent of the source node', () => { + const source = t.arrowFunctionExpression([], t.identifier('original')) + const hole: AnalyzedHole = { component: 'C', entityProps: {}, extraProps: { children: source } } + const ast = selectionToAst({}, [hole]) + + // Mutate the ORIGINAL node after emit — a shared node would leak this into the output. + if (t.isIdentifier(source.body)) { + source.body.name = 'MUTATED' + } + + const holesProp = findProp(ast, 'holes') + const holeObj = holesProp && t.isArrayExpression(holesProp) && t.isObjectExpression(holesProp.elements[0] ?? null) + ? holesProp.elements[0] + : undefined + const extra = holeObj && t.isObjectExpression(holeObj) ? findProp(holeObj, 'extraProps') : undefined + const thunk = extra && t.isObjectExpression(extra) ? findProp(extra, 'children') : undefined + expect(thunk && t.isArrowFunctionExpression(thunk)).toBe(true) + if (!thunk || !t.isArrowFunctionExpression(thunk)) { + throw new Error('extraProps thunk not found') + } + // The thunk body is a deep CLONE — a distinct object still holding the pre-mutation name. + expect(thunk.body).not.toBe(source) + expect(t.isArrowFunctionExpression(thunk.body) && t.isIdentifier(thunk.body.body) ? thunk.body.body.name : null).toBe('original') + }) +}) diff --git a/packages/bindx-compiler/tests/optionsPlumbing.test.ts b/packages/bindx-compiler/tests/optionsPlumbing.test.ts new file mode 100644 index 00000000..5fb4e900 --- /dev/null +++ b/packages/bindx-compiler/tests/optionsPlumbing.test.ts @@ -0,0 +1,79 @@ +/** + * Options plumbing through babel's plugins-with-options tuple (`plugins: [[bindxCompilerPlugin, opts]]`). + * The analyzer-level equivalents live in reexport.test.tsx (alias) and targetKinds.test.tsx + * (entityLike); this asserts the babel plugin itself forwards those options into analysis — i.e. an + * emit only happens when the option is present, so a dropped option would be caught here. + */ +import { describe, expect, test } from 'bun:test' +import { transformSync } from '@babel/core' +import { join } from 'node:path' +import { bindxCompilerPlugin, type BindxCompilerOptions } from '../src/index.js' + +const DIR = import.meta.dir +const ROUTE = join(DIR, 'route.tsx') // filename base for relative `./fixtures/...` + alias resolution + +function transform(code: string, options?: BindxCompilerOptions): string { + const plugin = options ? [bindxCompilerPlugin, options] : bindxCompilerPlugin + const out = transformSync(code, { filename: ROUTE, plugins: [plugin], configFile: false, babelrc: false }) + if (!out?.code) { + throw new Error('transform produced no output') + } + return out.code +} + +// createComponent target reached ONLY via a non-relative `@barrel` alias. Following it classifies the +// target so the render-local (`label`) is dropped and the root compiles; without the alias the target +// is unknown → the render-local bails the root → no injection. (Fixtures shared with reexport.test.tsx.) +const ALIASED_BARREL = ` +import { Entity, entityDef } from '@contember/bindx-react' +import { AliasedCcBody } from '@barrel' +const ArticleDef = entityDef('Article') +export function Route() { + return ( + + {article => { + const label = 'x'.toUpperCase() + return
+ }} +
+ ) +} +` +const ALIAS_OPTS: BindxCompilerOptions = { alias: { '@barrel': join(DIR, 'fixtures', '_ccBarrel') } } + +// entityLike wrapper: scanned as an root only when its name is configured. Without the option +// only the wrapper's internal `` are seen (they bail), so nothing is injected. +const ENTITY_LIKE = ` +import { Entity, Field, withCollector, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const RefreshableWrapper = withCollector( + function RefreshableWrapperRuntime(props) { return }, + props => , +) +export function Route() { + return ( + + {article =>
} +
+ ) +} +` +const ENTITY_LIKE_OPTS: BindxCompilerOptions = { entityLike: ['RefreshableWrapper'] } + +describe('babel plugin — options plumbing through the tuple', () => { + test('alias option reaches analysis: aliased barrel target resolves → compiledSelection injected', () => { + expect(transform(ALIASED_BARREL, ALIAS_OPTS)).toContain('compiledSelection') + }) + + test('without the alias the same source bails (no injection) — proves the option is load-bearing', () => { + expect(transform(ALIASED_BARREL)).not.toContain('compiledSelection') + }) + + test('entityLike option reaches analysis: wrapper scanned as a root → compiledSelection injected', () => { + expect(transform(ENTITY_LIKE, ENTITY_LIKE_OPTS)).toContain('compiledSelection') + }) + + test('without entityLike the wrapper is invisible (no injection)', () => { + expect(transform(ENTITY_LIKE)).not.toContain('compiledSelection') + }) +}) diff --git a/packages/bindx-compiler/tests/plugin.test.ts b/packages/bindx-compiler/tests/plugin.test.ts new file mode 100644 index 00000000..49603fc4 --- /dev/null +++ b/packages/bindx-compiler/tests/plugin.test.ts @@ -0,0 +1,99 @@ +import { describe, expect, test } from 'bun:test' +import { transformSync } from '@babel/core' +import { analyzeSource, bindxCompilerPlugin, isBailed } from '../src/index.js' + +function transform(code: string): string { + const out = transformSync(code, { + filename: 'input.tsx', + plugins: [bindxCompilerPlugin], + configFile: false, + babelrc: false, + retainLines: true, + }) + if (!out?.code) { + throw new Error('transform produced no output') + } + return out.code +} + +const SOURCE = ` +import { createComponent, Field, HasOne, HasMany } from '@contember/bindx-react' +import { schema } from './s' +export const Card = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( +
+ + {a => } + {tag => } +
+ )) +` + +describe('babel plugin injection', () => { + test('injects the CompiledSelection (v2 { props }) as the 2nd argument of .render()', () => { + const output = transform(SOURCE) + // The emitted literal is the render call's 2nd argument, now wrapped in `props`. + // Version marker first — the runtime rejects any literal without `v: 2`. + expect(output).toContain('v: 2') + expect(output).toContain('props:') + expect(output).toContain('title: true') + expect(output).toContain('author: {') + expect(output).toContain('fields: {') + expect(output).toContain('many: true') + expect(output).toContain('params: {') + expect(output).toContain('limit: 5') + // This chain has no holes → the holes key is omitted. + expect(output).not.toContain('holes:') + }) + + test('bailed chains are left untouched (no 2nd argument)', () => { + const bailSource = ` +import { createComponent, Field } from '@contember/bindx-react' +import { schema } from './s' +export const C = createComponent() + .entity('article', schema.Article) + .render(({ article }) =>
) +` + const output = transform(bailSource) + // No static object was injected — the spread bails the whole chain. + expect(output).not.toContain('props:') + expect(output).not.toContain('title: true') + }) + + test('re-running the plugin does not double-inject', () => { + const once = transform(SOURCE) + const twice = transform(once) + const count = (s: string): number => s.split('title: true').length - 1 + expect(count(twice)).toBe(count(once)) + }) + + test('a hand-written 2nd argument to .render() is preserved and not injected over', () => { + const handWritten = ` +import { createComponent, Field } from '@contember/bindx-react' +import { schema } from './s' +export const C = createComponent() + .entity('article', schema.Article) + .render(({ article }) => , { handWritten: true }) +` + const output = transform(handWritten) + // A present 2nd arg (arguments.length >= 2) suppresses injection — the hand value stands, no v:2 literal. + expect(output).toContain('handWritten: true') + expect(output).not.toContain('v: 2') + }) + + test('.render() with a non-inline-function argument bails EXPLICIT_RENDER_FN (no injection)', () => { + const src = ` +import { createComponent, Field } from '@contember/bindx-react' +import { schema } from './s' +const renderFn = ({ article }) => +export const C = createComponent().entity('article', schema.Article).render(renderFn) +` + const [result] = analyzeSource(src, 'input.tsx') + expect(result && isBailed(result)).toBe(true) + if (result && isBailed(result)) { + expect(result.bailout.code).toBe('EXPLICIT_RENDER_FN') + } + expect(transform(src)).not.toContain('v: 2') + }) +}) diff --git a/packages/bindx-compiler/tests/reexport.test.tsx b/packages/bindx-compiler/tests/reexport.test.tsx new file mode 100644 index 00000000..8276d267 --- /dev/null +++ b/packages/bindx-compiler/tests/reexport.test.tsx @@ -0,0 +1,206 @@ +/** + * Re-export chain following (Phase 3.1 extension). Binding resolution now follows `from`-source + * re-exports — `export { X } from`, `export { X as Y } from`, `export * from` (barrel index files) — + * so a createComponent/contract target hidden behind a barrel is classified instead of bailing. + * Depth-limit and cycle guards keep the chase bounded; an unfollowable chain preserves the + * conservative bail. Adapter-oracle equivalence proves the compiled root query matches the runtime + * walk for the barrel'd createComponent target. + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test' +import { cleanup } from '@testing-library/react' +import { join } from 'node:path' +import { analyzeEntityRoots, analyzeSource, isBailed, isEntityRootBailed } from '../src/index.js' +import { compilerPlain } from './harness.js' +import { captureRootSpec, cleanupTmpFiles, expectRootEquivalent } from './rootOracle.js' + +const DIR = import.meta.dir +const ROUTE = join(DIR, 'route.tsx') // filename base for relative `./fixtures/...` resolution + +afterEach(() => cleanup()) +afterAll(() => cleanupTmpFiles()) + +// createComponent target behind a NAMED barrel (`export { CcBody } from './_ccTarget.js'`). The +// render-local (`extra`) + function prop (`onClick`) are droppable once the chain is followed. +const CC_NAMED_BARREL = ` +import { Entity, entityDef, SCOPE_REF } from '@contember/bindx-react' +import { CcBody } from './fixtures/_ccBarrel.js' +const ArticleDef = entityDef('Article') +let collectorCalls = 0 +export const getCollectorCalls = () => collectorCalls +export function Route() { + return ( + + {article => { + if (article && typeof article === 'object' && SCOPE_REF in article) collectorCalls++ + const label = 'x'.toUpperCase() + return
label} />
+ }} +
+ ) +} +` + +// Same target through a STAR barrel (`export * from './_ccTarget.js'`). +const CC_STAR_BARREL = ` +import { Entity, entityDef, SCOPE_REF } from '@contember/bindx-react' +import { CcBody } from './fixtures/_ccStarBarrel.js' +const ArticleDef = entityDef('Article') +let collectorCalls = 0 +export const getCollectorCalls = () => collectorCalls +export function Route() { + return ( + + {article => { + if (article && typeof article === 'object' && SCOPE_REF in article) collectorCalls++ + const label = 'x'.toUpperCase() + return
+ }} +
+ ) +} +` + +// Same target reached via an ALIASED entry import (`import { AliasedCcBody } from '@barrel'`, alias → +// the barrel) whose re-export uses `export { CcBody as AliasedCcBody } from`. Static-only (a runtime +// import of `@barrel` would not resolve without a bundler alias). +const CC_ALIASED_BARREL = ` +import { Entity, entityDef } from '@contember/bindx-react' +import { AliasedCcBody } from '@barrel' +const ArticleDef = entityDef('Article') +export function Route() { + return ( + + {article => { + const label = 'x'.toUpperCase() + return
+ }} +
+ ) +} +` +const ALIAS_OPTS = { alias: { '@barrel': join(DIR, 'fixtures', '_ccBarrel') } } + +// Cyclic barrel (A ⇄ B, neither actually exports CcBody) → visited-guard → unfollowable → the +// render-local on the (unknown-kind) hole keeps the conservative RENDER_LOCAL_ON_HOLE bail. +const CC_CYCLE_BARREL = ` +import { Entity, entityDef } from '@contember/bindx-react' +import { CcBody } from './fixtures/_cycleBarrelA.js' +const ArticleDef = entityDef('Article') +export function Route() { + return ( + + {article => { + const label = 'x'.toUpperCase() + return
+ }} +
+ ) +} +` + +// Star chain longer than MAX_HOPS (_deep1 → … → _deep6 → _ccTarget). The target sits one hop past +// the budget → unfollowable → conservative bail preserved. +const CC_DEEP_BARREL = ` +import { Entity, entityDef } from '@contember/bindx-react' +import { CcBody } from './fixtures/_deep1.js' +const ArticleDef = entityDef('Article') +export function Route() { + return ( + + {article => { + const label = 'x'.toUpperCase() + return
+ }} +
+ ) +} +` + +// Collector-contract component (ItemRepeater) behind a named barrel — contract discovery must +// follow the `from` chain to reach its `withCollector(_, { children: itemOf('field') })`. +const CONTRACT_BARREL = ` +import { createComponent, Field } from '@contember/bindx-react' +import { schema } from './fixtures/_schema.js' +import { ItemRepeater } from './fixtures/_contractBarrel.js' +export const Host = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => } + + )) +` + +describe('re-export following — target-kind classification (static)', () => { + test('createComponent target behind a named barrel: render-local + fn prop drop, no bail', () => { + const [root] = analyzeEntityRoots(CC_NAMED_BARREL, ROUTE) + expect(root && !isEntityRootBailed(root)).toBe(true) + if (root && !isEntityRootBailed(root)) { + expect(root.holes).toHaveLength(1) + expect(root.holes[0]!.component).toBe('CcBody') + expect(root.holes[0]!.entityProps).toEqual({ entity: { source: 'entity', path: [] } }) + expect(root.holes[0]!.extraProps).toBeUndefined() + } + }) + + test('createComponent target behind a star barrel: render-local dropped, no bail', () => { + const [root] = analyzeEntityRoots(CC_STAR_BARREL, ROUTE) + expect(root && !isEntityRootBailed(root)).toBe(true) + if (root && !isEntityRootBailed(root)) { + expect(root.holes[0]!.component).toBe('CcBody') + expect(root.holes[0]!.extraProps).toBeUndefined() + } + }) + + test('createComponent target behind an aliased `X as Y` barrel: render-local dropped, no bail', () => { + const [root] = analyzeEntityRoots(CC_ALIASED_BARREL, ROUTE, ALIAS_OPTS) + expect(root && !isEntityRootBailed(root)).toBe(true) + if (root && !isEntityRootBailed(root)) { + expect(root.holes[0]!.component).toBe('AliasedCcBody') + expect(root.holes[0]!.extraProps).toBeUndefined() + } + }) + + test('cyclic barrel → unfollowable → conservative RENDER_LOCAL_ON_HOLE bail preserved', () => { + const [root] = analyzeEntityRoots(CC_CYCLE_BARREL, ROUTE) + expect(root && isEntityRootBailed(root)).toBe(true) + if (root && isEntityRootBailed(root)) { + expect(root.bailout.code).toBe('RENDER_LOCAL_ON_HOLE') + } + }) + + test('barrel chain past MAX_HOPS → unfollowable → conservative bail preserved', () => { + const [root] = analyzeEntityRoots(CC_DEEP_BARREL, ROUTE) + expect(root && isEntityRootBailed(root)).toBe(true) + if (root && isEntityRootBailed(root)) { + expect(root.bailout.code).toBe('RENDER_LOCAL_ON_HOLE') + } + }) +}) + +describe('re-export following — collector contract behind a barrel', () => { + test('contract discovery follows the barrel: no hole, relation collected', () => { + const [result] = analyzeSource(CONTRACT_BARREL, ROUTE) + expect(result).toBeDefined() + if (result && !isBailed(result)) { + expect(result.holes).toEqual([]) + expect(compilerPlain(result, 'article')).toMatchObject({ tags: { name: true } }) + } else { + throw new Error('contract-behind-barrel unexpectedly bailed') + } + }) +}) + +describe('re-export following — adapter-oracle equivalence', () => { + test('named barrel createComponent target: query equal, children not invoked', async () => { + const compiled = await captureRootSpec(CC_NAMED_BARREL, DIR, true) + expect(compiled.collectorCalls).toBe(0) + const fields = await expectRootEquivalent(CC_NAMED_BARREL, DIR) + expect(fields).toMatchObject({ title: true }) + }) + + test('star barrel createComponent target: query equal', async () => { + const fields = await expectRootEquivalent(CC_STAR_BARREL, DIR) + expect(fields).toMatchObject({ title: true }) + }) +}) diff --git a/packages/bindx-compiler/tests/rootOracle.tsx b/packages/bindx-compiler/tests/rootOracle.tsx new file mode 100644 index 00000000..1e962dc7 --- /dev/null +++ b/packages/bindx-compiler/tests/rootOracle.tsx @@ -0,0 +1,140 @@ +/** + * Shared adapter-oracle harness for entity-root / entityLike compilation tests. The oracle is the + * QuerySpec the adapter receives: render the TRANSFORMED module (compiledSelection injected, + * children never walked with a collector) and the UNTRANSFORMED one (runtime children walk) under + * a query-recording MockAdapter, and compare the requested root selection. + */ +import { GlobalRegistrator } from '@happy-dom/global-registrator' +if (typeof document === 'undefined') { + GlobalRegistrator.register() +} + +import { transformSync } from '@babel/core' +import { rmSync, writeFileSync } from 'node:fs' +import { join } from 'node:path' +import { expect } from 'bun:test' +import React from 'react' +import { render, waitFor } from '@testing-library/react' +import { + BindxProvider, + MockAdapter, + defineSchema, + scalar, + hasOne, + hasMany, + type Query, + type QueryOptions, + type QueryResult, + type QueryFieldSpec, +} from '@contember/bindx-react' +import { bindxCompilerPlugin, type BindxCompilerOptions } from '../src/index.js' + +interface Schema { + Article: { id: string; title: string; content: string; author: { id: string; name: string } | null; tags: { id: string; name: string }[] } + Author: { id: string; name: string } + Tag: { id: string; name: string } +} + +export const schema = defineSchema({ + entities: { + Article: { fields: { id: scalar(), title: scalar(), content: scalar(), author: hasOne('Author'), tags: hasMany('Tag') } }, + Author: { fields: { id: scalar(), name: scalar() } }, + Tag: { fields: { id: scalar(), name: scalar() } }, + }, +}) + +const MOCK_DATA = { + Article: { + 'article-1': { + id: 'article-1', title: 'Hello World', content: 'Body', + author: { id: 'author-1', name: 'John' }, + tags: [{ id: 'tag-1', name: 'news' }], + }, + }, + Author: { 'author-1': { id: 'author-1', name: 'John' } }, + Tag: { 'tag-1': { id: 'tag-1', name: 'news' } }, +} + +/** MockAdapter that records every query it receives — the root oracle. */ +export class RecordingMockAdapter extends MockAdapter { + readonly captured: Query[] = [] + override async query(queries: readonly Query[], options?: QueryOptions): Promise { + this.captured.push(...queries) + return super.query(queries, options) + } +} + +/** The requested selection as a sorted plain tree (params dropped) — comparable across paths. */ +function normalizeFields(fields: readonly QueryFieldSpec[]): Record { + const out: Record = {} + for (const f of [...fields].sort((a, b) => a.name.localeCompare(b.name))) { + out[f.name] = f.nested ? normalizeFields(f.nested.fields) : true + } + return out +} + +const tmpFiles: string[] = [] +let counter = 0 + +export function transform(source: string, dir: string, options?: BindxCompilerOptions): string { + const plugin = options ? [bindxCompilerPlugin, options] : bindxCompilerPlugin + const out = transformSync(source, { filename: join(dir, 'route.tsx'), plugins: [plugin], configFile: false, babelrc: false }) + if (!out?.code) { + throw new Error('transform produced no output') + } + return out.code +} + +export interface RouteModule { + readonly Route: React.ComponentType + readonly getCollectorCalls?: () => number +} + +async function loadModule(source: string, dir: string, compiled: boolean, options?: BindxCompilerOptions): Promise { + const code = compiled ? transform(source, dir, options) : source + const path = join(dir, `.tk-${counter++}.tsx`) + writeFileSync(path, code) + tmpFiles.push(path) + return import(path) as Promise +} + +export interface RootSpec { + readonly fields: Record + readonly collectorCalls: number +} + +/** Render a fixture's under a recording adapter; return the Article root spec. */ +export async function captureRootSpec(source: string, dir: string, compiled: boolean, options?: BindxCompilerOptions): Promise { + const mod = await loadModule(source, dir, compiled, options) + const adapter = new RecordingMockAdapter(structuredClone(MOCK_DATA), { delay: 0 }) + const { container } = render( + + + , + ) + await waitFor(() => { + expect(container.querySelector('[data-testid="ready"]')).not.toBeNull() + }) + const get = adapter.captured.find((q): q is Extract => q.type === 'get' && q.entityType === 'Article') + if (!get) { + throw new Error('no Article get query captured') + } + return { fields: normalizeFields(get.spec.fields), collectorCalls: mod.getCollectorCalls?.() ?? 0 } +} + +/** Assert transformed and untransformed request the same root selection; return that selection. */ +export async function expectRootEquivalent(source: string, dir: string, options?: BindxCompilerOptions): Promise> { + const [compiled, runtime] = await Promise.all([ + captureRootSpec(source, dir, true, options), + captureRootSpec(source, dir, false, options), + ]) + expect(compiled.fields).toEqual(runtime.fields) + return compiled.fields +} + +export function cleanupTmpFiles(): void { + for (const file of tmpFiles) { + rmSync(file, { force: true }) + } + tmpFiles.length = 0 +} diff --git a/packages/bindx-compiler/tests/targetKinds.test.tsx b/packages/bindx-compiler/tests/targetKinds.test.tsx new file mode 100644 index 00000000..2d1c6a16 --- /dev/null +++ b/packages/bindx-compiler/tests/targetKinds.test.tsx @@ -0,0 +1,291 @@ +/** + * Phase 3.1: hole-target-kind classification + entityLike roots. Static-analysis assertions cover + * the drop-vs-bail decisions per target kind; adapter-oracle equivalence (render transformed vs + * untransformed under a recording MockAdapter) proves the compiled root query equals the runtime + * walk. Fixtures mirror real-world shapes (createComponent + render-local, PublishedRevisionIdProvider + * function children, withCollector staticRender, RefreshableEntity forwarding wrapper). + */ +import { afterAll, afterEach, describe, expect, test } from 'bun:test' +import { cleanup } from '@testing-library/react' +import { analyzeEntityRoots, isEntityRootBailed } from '../src/index.js' +import { captureRootSpec, cleanupTmpFiles, expectRootEquivalent, transform } from './rootOracle.js' + +const DIR = import.meta.dir + +afterEach(() => cleanup()) +afterAll(() => cleanupTmpFiles()) + +// ── Fixtures ────────────────────────────────────────────────────────────────── + +// createComponent target receiving a render-local (`label`) + a function prop (`onClick`) alongside +// the entity. Both are droppable with NO bail (getSelection ignores non-entity props / functions). +const CC_RENDER_LOCAL = ` +import { Entity, Field, createComponent, entityDef, SCOPE_REF } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const Body = createComponent() + .entity('entity', ArticleDef) + .render(({ entity }) => ) +let collectorCalls = 0 +export const getCollectorCalls = () => collectorCalls +export function Route() { + return ( + + {article => { + if (article && typeof article === 'object' && SCOPE_REF in article) collectorCalls++ + const label = 'x'.toUpperCase() + return
label} />
+ }} +
+ ) +} +` + +// createComponent target with FUNCTION children (PublishedRevisionIdProvider shape): the target's +// render calls children(scalar). getSelection walks the children slot but analyzeJsx ignores a +// function → nothing collected from it; the target's own implicit selection (title) still lands. +const CC_FUNCTION_CHILDREN = ` +import { Entity, Field, createComponent, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const Provider = createComponent() + .entity('page', ArticleDef) + .render(({ page, children }) => <>{children('scalar')}) +export function Route() { + return ( + + {article => ( + + {scalarVal =>
{scalarVal}
} +
+ )} +
+ ) +} +` + +// Plain function-component target with a render-local. No selection surface → everything non-entity +// droppable; the hole is still emitted (blind, matching runtime). A sibling gives the root +// something to fetch; both paths collect exactly {title}. +const PLAIN_RENDER_LOCAL = ` +import { Entity, Field, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +function PlainBody(props) { return {String(props.label)} } +export function Route() { + return ( + + {article => { + const label = 'x'.toUpperCase() + return
+ }} +
+ ) +} +` + +// withCollector staticRender referencing ONLY the entity prop. A dropped render-local (`extra`, +// not referenced) is safe → compiles, STRICT oracle equality (author.name via the staticRender). +const COLLECTOR_UNREFERENCED = ` +import { Entity, Field, withCollector, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const Badge = withCollector( + (props) => , + ({ entity }) => , +) +export function Route() { + return ( + + {article => { + const local = 'x'.toUpperCase() + return
+ }} +
+ ) +} +` + +// withCollector staticRender that DOES reference the dropped prop name (`extra`) → the render-local +// passed to it may under-fetch → still bails RENDER_LOCAL_ON_HOLE. +const COLLECTOR_REFERENCED = ` +import { Entity, Field, withCollector, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const Badge = withCollector( + (props) => , + ({ entity, extra }) => <>{extra}, +) +export function Route() { + return ( + + {article => { + const local = 'x'.toUpperCase() + return
+ }} +
+ ) +} +` + +// withCollector staticRender with a rest-spread param → the referenced set is the conservative +// "all" sentinel → the dropped render-local is treated as referenced → bails. +const COLLECTOR_REST_SPREAD = ` +import { Entity, Field, withCollector, entityDef } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +const Badge = withCollector( + (props) => , + ({ entity, ...rest }) => , +) +export function Route() { + return ( + + {article => { + const local = 'x'.toUpperCase() + return
+ }} +
+ ) +} +` + +// entityLike forwarding wrapper (RefreshableEntity shape): a withCollector whose runtime + static +// render spread props into a real . Treated as for root scanning; the injected +// compiledSelection reaches the inner Entity via {...props}. +const ENTITY_LIKE = ` +import { Entity, Field, withCollector, entityDef, SCOPE_REF } from '@contember/bindx-react' +const ArticleDef = entityDef('Article') +let collectorCalls = 0 +export const getCollectorCalls = () => collectorCalls +const RefreshableWrapper = withCollector( + function RefreshableWrapperRuntime(props) { return }, + props => , +) +export function Route() { + return ( + + {article => { + if (article && typeof article === 'object' && SCOPE_REF in article) collectorCalls++ + return
+ }} +
+ ) +} +` + +const ENTITY_LIKE_OPTS = { entityLike: ['RefreshableWrapper'] } + +// ── Static analysis ───────────────────────────────────────────────────────── + +describe('phase 3.1 — target-kind classification (static)', () => { + test('createComponent target: render-local + function prop drop, no bail, one hole', () => { + const [root] = analyzeEntityRoots(CC_RENDER_LOCAL, 'cc.tsx') + expect(root && !isEntityRootBailed(root)).toBe(true) + if (root && !isEntityRootBailed(root)) { + expect(root.holes).toHaveLength(1) + expect(root.holes[0]!.component).toBe('Body') + expect(root.holes[0]!.entityProps).toEqual({ entity: { source: 'entity', path: [] } }) + expect(root.holes[0]!.extraProps).toBeUndefined() // extra + onClick dropped + } + }) + + test('createComponent target with function children compiles (children dropped)', () => { + const [root] = analyzeEntityRoots(CC_FUNCTION_CHILDREN, 'ccfn.tsx') + expect(root && !isEntityRootBailed(root)).toBe(true) + if (root && !isEntityRootBailed(root)) { + expect(root.holes).toHaveLength(1) + expect(root.holes[0]!.component).toBe('Provider') + expect(root.holes[0]!.extraProps).toBeUndefined() + } + }) + + test('plain target: render-local dropped, hole still emitted', () => { + const [root] = analyzeEntityRoots(PLAIN_RENDER_LOCAL, 'plain.tsx') + expect(root && !isEntityRootBailed(root)).toBe(true) + if (root && !isEntityRootBailed(root)) { + expect(root.selection).toEqual({ title: true }) + expect(root.holes).toHaveLength(1) + expect(root.holes[0]!.component).toBe('PlainBody') + expect(root.holes[0]!.extraProps).toBeUndefined() + } + }) + + test('collectorStatic referencing only entity: dropped render-local is safe (no bail)', () => { + const [root] = analyzeEntityRoots(COLLECTOR_UNREFERENCED, 'cu.tsx') + expect(root && !isEntityRootBailed(root)).toBe(true) + if (root && !isEntityRootBailed(root)) { + expect(root.holes[0]!.extraProps).toBeUndefined() + } + }) + + test('collectorStatic referencing the dropped prop → bails RENDER_LOCAL_ON_HOLE', () => { + const [root] = analyzeEntityRoots(COLLECTOR_REFERENCED, 'cr.tsx') + expect(root && isEntityRootBailed(root)).toBe(true) + if (root && isEntityRootBailed(root)) { + expect(root.bailout.code).toBe('RENDER_LOCAL_ON_HOLE') + } + }) + + test('collectorStatic with rest-spread param → conservative → bails', () => { + const [root] = analyzeEntityRoots(COLLECTOR_REST_SPREAD, 'crs.tsx') + expect(root && isEntityRootBailed(root)).toBe(true) + if (root && isEntityRootBailed(root)) { + expect(root.bailout.code).toBe('RENDER_LOCAL_ON_HOLE') + } + }) + + test('entityLike wrapper is scanned as a root only under the option', () => { + // Without the flag only the wrapper's two internal `` are found (they bail — + // children arrive via spread, not an inline function); the wrapper itself is invisible. + const noFlag = analyzeEntityRoots(ENTITY_LIKE, 'el.tsx') + expect(noFlag).toHaveLength(2) + expect(noFlag.every(r => isEntityRootBailed(r) && r.bailout.code === 'ENTITY_NO_FUNCTION_CHILDREN')).toBe(true) + // With the flag the wrapper element is added and compiles from its inline children closure. + const withFlag = analyzeEntityRoots(ENTITY_LIKE, 'el.tsx', ENTITY_LIKE_OPTS) + expect(withFlag).toHaveLength(3) + const compiled = withFlag.filter(r => !isEntityRootBailed(r)) + expect(compiled).toHaveLength(1) + if (compiled[0] && !isEntityRootBailed(compiled[0])) { + expect(compiled[0].selection).toEqual({ title: true, content: true }) + } + }) + + test('entityLike emit lands the attribute on the wrapper element', () => { + const out = transform(ENTITY_LIKE, DIR, ENTITY_LIKE_OPTS).replace(/\s+/g, ' ') + expect(out).toContain('compiledSelection') + expect(out).toMatch(/]*compiledSelection=/) + }) +}) + +// ── Adapter-oracle equivalence ──────────────────────────────────────────────── + +describe('phase 3.1 — adapter-oracle equivalence', () => { + test('createComponent target + render-local: query equal, children not invoked', async () => { + const compiled = await captureRootSpec(CC_RENDER_LOCAL, DIR, true) + expect(compiled.collectorCalls).toBe(0) + const fields = await expectRootEquivalent(CC_RENDER_LOCAL, DIR) + expect(fields).toMatchObject({ title: true }) + }) + + test('createComponent target with function children: query equal', async () => { + const fields = await expectRootEquivalent(CC_FUNCTION_CHILDREN, DIR) + expect(fields).toMatchObject({ title: true }) + }) + + test('plain target with render-local: both blind, query equal', async () => { + const fields = await expectRootEquivalent(PLAIN_RENDER_LOCAL, DIR) + expect(fields).toMatchObject({ title: true }) + expect(fields.author).toBeUndefined() // plain target is blind — no author fetched + }) + + test('collectorStatic (unreferenced render-local dropped): strict equality, author.name collected', async () => { + const fields = await expectRootEquivalent(COLLECTOR_UNREFERENCED, DIR) + expect(fields.author).toMatchObject({ name: true }) + }) + + test('entityLike wrapper: attribute rides {...props}; query equal, children not invoked', async () => { + const compiled = await captureRootSpec(ENTITY_LIKE, DIR, true, ENTITY_LIKE_OPTS) + expect(compiled.collectorCalls).toBe(0) + const [comp, runtime] = await Promise.all([ + captureRootSpec(ENTITY_LIKE, DIR, true, ENTITY_LIKE_OPTS), + captureRootSpec(ENTITY_LIKE, DIR, false, ENTITY_LIKE_OPTS), + ]) + expect(comp.fields).toEqual(runtime.fields) + expect(comp.fields).toMatchObject({ title: true, content: true }) + }) +}) diff --git a/packages/bindx-compiler/tests/vitePlugin.test.ts b/packages/bindx-compiler/tests/vitePlugin.test.ts new file mode 100644 index 00000000..ab9f903f --- /dev/null +++ b/packages/bindx-compiler/tests/vitePlugin.test.ts @@ -0,0 +1,74 @@ +/** + * Vite plugin unit test. Drives the plugin's `transform` with a mock context capturing + * `addWatchFile` and asserts (a) the static selection literal is injected for a source importing a + * cross-module contract target AND the target's path is registered as a watch dependency, and + * (b) a file with neither `createComponent` nor ` ( + + {tag => } + + )) +` + +const PLAIN = ` +export const answer = 42 +export function useThing(): number { return answer } +` + +function makeContext(): { context: BindxTransformContext; watched: string[] } { + const watched: string[] = [] + return { context: { addWatchFile: (id: string) => { watched.push(id) } }, watched } +} + +async function runTransform(code: string, id: string, context: BindxTransformContext): Promise { + return bindxCompiler().transform.call(context, code, id) +} + +describe('bindxCompiler vite plugin', () => { + test('is enforced pre with a stable name', () => { + const plugin = bindxCompiler() + expect(plugin.enforce).toBe('pre') + expect(plugin.name).toBe('bindx-compiler') + }) + + test('injects the selection literal and watches the cross-module contract target', async () => { + const { context, watched } = makeContext() + const result = await runTransform(CROSS_MODULE, ROUTE, context) + expect(result).not.toBeNull() + // The proven chain gets a 2nd `.render(...)` argument (`{ props: ... }`); tags collected from the contract. + expect(result?.code).toContain('props:') + expect(result?.code).toContain('name: true') + // The contract module read during discovery is registered as a watch dependency. + expect(watched).toContain(join(DIR, 'fixtures', '_contractTargets.tsx')) + }) + + test('returns null (untouched) for a file without createComponent or { + const { context, watched } = makeContext() + const result = await runTransform(PLAIN, join(DIR, 'plain.tsx'), context) + expect(result).toBeNull() + expect(watched).toHaveLength(0) + }) + + test('skips non-jsx and node_modules ids without watching', async () => { + const { context, watched } = makeContext() + expect(await runTransform(CROSS_MODULE, join(DIR, 'plain.ts'), context)).toBeNull() + expect(await runTransform(CROSS_MODULE, '/x/node_modules/pkg/route.tsx', context)).toBeNull() + expect(watched).toHaveLength(0) + }) +}) diff --git a/packages/bindx-compiler/tsconfig.json b/packages/bindx-compiler/tsconfig.json new file mode 100644 index 00000000..e128c29a --- /dev/null +++ b/packages/bindx-compiler/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src/**/*"], + "references": [ + { "path": "../bindx-react" } + ] +} diff --git a/packages/bindx-dataview/src/createRelationColumn.tsx b/packages/bindx-dataview/src/createRelationColumn.tsx index d841484a..bc8c3bfe 100644 --- a/packages/bindx-dataview/src/createRelationColumn.tsx +++ b/packages/bindx-dataview/src/createRelationColumn.tsx @@ -13,10 +13,28 @@ import React from 'react' import type { FieldRef, FilterArtifact, FilterHandler, EntityAccessor, SelectionMeta } from '@contember/bindx' import { SelectionScope } from '@contember/bindx' -import { createCollectorProxy } from '@contember/bindx-react' +import { createCollectorProxy, collectSelection } from '@contember/bindx-react' import type { ColumnTypeDef } from './columnTypes.js' import { accessField } from './columnTypes.js' +/** + * Walks a relation renderer's returned JSX so nested declarative ``/`` + * register their selections through the collector proxy — mirrors how + * `collectImplicitSelections` walks render output. The top-level `renderer(proxy)` call + * only captures the immediately-accessed relation; without this walk, fields inside a + * nested relation callback are never fetched. Errors are contained per column. + */ +function walkRendererJsx(jsx: React.ReactNode): void { + try { + collectSelection(jsx) + } catch (error) { + console.error( + '[bindx] Relation column renderer JSX analysis failed — nested fields may be missing from the query.', + error, + ) + } +} + /** If a render result is a FieldRef-like object with `.value`, extract the string value. */ function unwrapRenderResult(result: React.ReactNode): React.ReactNode { if (result && typeof result === 'object' && 'value' in result) { @@ -97,7 +115,8 @@ export function createRelationColumn( if (relatedEntityName && renderer) { const scope = new SelectionScope() const proxy = createCollectorProxy(scope, relatedEntityName) - renderer(proxy) + // Proxy access captures top-level fields; the JSX walk captures nested relations. + walkRendererJsx(renderer(proxy)) relatedSelection = scope.toSelectionMeta() } @@ -177,7 +196,8 @@ export interface RelationColumnComponent { export const hasOneCellConfig: RelationCellConfig = { collectSelection: (renderer, fieldRef) => { - renderer(fieldRef) + // Walk the returned JSX so nested / register into the parent scope. + walkRendererJsx(renderer(fieldRef)) }, renderCell: (accessor, fieldName, renderer) => { const related = getRelatedAccessor(accessor, fieldName) @@ -189,7 +209,7 @@ export const hasOneCellConfig: RelationCellConfig = { export const hasManyCellConfig: RelationCellConfig = { collectSelection: (renderer, fieldRef) => { const ref = fieldRef as { map?: (fn: (item: unknown, index: number) => unknown) => unknown[] } - ref.map?.((item) => { renderer(item); return null }) + ref.map?.((item) => { walkRendererJsx(renderer(item)); return null }) }, renderCell: (accessor, fieldName, renderer) => { const ref = accessField(accessor, fieldName) as { items?: EntityAccessor[] } | null diff --git a/packages/bindx-react/src/hooks/useRootSelection.ts b/packages/bindx-react/src/hooks/useRootSelection.ts new file mode 100644 index 00000000..3052c4ff --- /dev/null +++ b/packages/bindx-react/src/hooks/useRootSelection.ts @@ -0,0 +1,98 @@ +import { useEffect, useMemo, type ReactNode } from 'react' +import { buildQueryFromSelection } from '@contember/bindx' +import type { EntityAccessor } from '../jsx/types.js' +import { useSchemaRegistry } from './BackendAdapterContext.js' +import { useSelectionCollection, type SelectionCollectionResult } from './useSelectionCollection.js' +import { isValidCompiledSelection, resolveCompiledRootSelection, type CompiledSelection } from '../jsx/compiledSelection.js' +import { isCompiledSelectionsEnabled, isStaticSelectionValidationEnabled, validateCompiledRootSelection } from '../jsx/componentFactory.js' + +/** + * Params for {@link useRootSelection}. + */ +export interface UseRootSelectionParams { + readonly entityType: string + readonly depsKey: string + readonly children: (entity: EntityAccessor) => ReactNode + /** Compiler-injected root selection. Absent ⇒ the runtime children walk runs. */ + readonly compiledSelection?: CompiledSelection +} + +/** + * Root selection for . With a compiled selection the root SelectionMeta is + * built statically and `children` is NEVER invoked with a collector; without it the + * runtime children-collector walk runs as before. Validate mode additionally runs + * the walk (contained) and diffs it against the compiled root for under-fetches. + */ +export function useRootSelection(params: UseRootSelectionParams): SelectionCollectionResult { + const { entityType, depsKey, children, compiledSelection } = params + const schemaRegistry = useSchemaRegistry() + const validateMode = isStaticSelectionValidationEnabled() + const compiledEnabled = isCompiledSelectionsEnabled() + + // Compiled root selection — present ⇒ skip the children(collector) walk (unless validating). + // Killswitch off or a malformed/throwing literal yields null ⇒ the runtime walk runs. Decided + // INSIDE the memo so the hook sequence below stays identical across the compiled/uncompiled path. + const compiledResult = useMemo((): SelectionCollectionResult | null => { + if (!compiledSelection || !compiledEnabled) { + return null + } + if (!isValidCompiledSelection(compiledSelection)) { + console.warn( + `[bindx] compiled selection for is malformed (version/shape check failed) — ` + + 'falling back to the runtime children walk.', + ) + return null + } + try { + const selection = resolveCompiledRootSelection({ + compiled: compiledSelection, + entityType, + schemaRegistry, + validateMode, + }) + return { selection, queryKey: JSON.stringify(buildQueryFromSelection(selection)) } + } catch (error) { + console.warn( + `[bindx] compiled selection for failed to resolve — ` + + 'falling back to the runtime children walk.', + error, + ) + return null + } + }, [compiledSelection, compiledEnabled, entityType, schemaRegistry, validateMode]) + + // Runtime walk. Its collect no-ops when compiled & not validating, so children is + // never called with a collector. In validate mode the walk runs to feed the diff; + // a crash there is contained so it cannot break the compiled path. + const runtimeResult = useSelectionCollection({ + entityType, + depsKey, + collect: collector => { + if (compiledResult && !validateMode) { + return null + } + try { + return children(collector as EntityAccessor) + } catch (error) { + if (!compiledResult) { + throw error + } + console.error( + `[bindx] validate-mode children walk of crashed — ` + + 'under-fetch diff skipped; the compiled selection still stands.', + error, + ) + return null + } + }, + }) + + // Validate mode: warn on fields the runtime walk requests but the compiled root omits. + useEffect(() => { + if (compiledResult && validateMode) { + validateCompiledRootSelection(compiledResult.selection, runtimeResult.selection, entityType) + } + }, [compiledResult, runtimeResult, validateMode, entityType]) + + return compiledResult ?? runtimeResult +} diff --git a/packages/bindx-react/src/index.ts b/packages/bindx-react/src/index.ts index 951b8501..f62d7276 100644 --- a/packages/bindx-react/src/index.ts +++ b/packages/bindx-react/src/index.ts @@ -37,6 +37,10 @@ export type { FluentDefiner, HasManyOptions, InferSelection, + // Static (precompiled) selection + StaticSelection, + StaticFieldMap, + StaticFieldNode, QuerySpec, QueryFieldSpec, // Query types (typed filter/orderBy) @@ -151,6 +155,7 @@ export { // Selection utilities createFragment, buildQueryFromSelection, + staticSelectionToMeta, // Handles EntityHandle, HasOneHandle, @@ -275,6 +280,9 @@ export type { BuildProps, BuildFragmentProps, InitialBuilderState, + // Compiled selection contract (v2) + CompiledSelection, + CompiledHole, } from './jsx/index.js' // ============================================================================ @@ -356,6 +364,11 @@ export { // Component (unified API) createComponent, withCollector, + COLLECTOR_CONTRACT, + itemOf, + entityOf, + type CallbackContract, + type CollectorContract, isBindxComponent, mergeFragments, COMPONENT_MARKER, @@ -365,6 +378,8 @@ export { getComponentBrand, setBrandValidation, validateBrand, + setStaticSelectionValidation, + setCompiledSelectionsEnabled, } from './jsx/index.js' // Entity Scope diff --git a/packages/bindx-react/src/jsx/analyzer.ts b/packages/bindx-react/src/jsx/analyzer.ts index f2d087f8..def0c976 100644 --- a/packages/bindx-react/src/jsx/analyzer.ts +++ b/packages/bindx-react/src/jsx/analyzer.ts @@ -113,13 +113,21 @@ export function analyzeJsx(node: ReactNode, selection: SelectionMetaCollector): } } +/** + * Resolves a human-readable name for a component for error/warn attribution, + * falling back to 'anonymous' when nothing usable is present. + */ +export function resolveComponentName(component: unknown): string { + const c = component as { displayName?: string; name?: string; type?: { displayName?: string; name?: string } } + return c.displayName ?? c.type?.displayName ?? (c.name || c.type?.name) ?? 'anonymous' +} + /** * Reports a selection-analysis failure of a single component with attribution, * so one broken component doesn't silently cost its siblings their selection. */ function reportAnalysisError(component: unknown, error: unknown): void { - const c = component as { displayName?: string; name?: string; type?: { displayName?: string; name?: string } } - const name = c.displayName ?? c.type?.displayName ?? (c.name || c.type?.name) ?? 'anonymous' + const name = resolveComponentName(component) console.error( `[bindx] Selection analysis of <${name}> failed — its fields were NOT added to the fetch plan. ` + 'Fix the error below; fields it uses may be missing from queries until then.', diff --git a/packages/bindx-react/src/jsx/collectionHelpers.ts b/packages/bindx-react/src/jsx/collectionHelpers.ts new file mode 100644 index 00000000..6aee423e --- /dev/null +++ b/packages/bindx-react/src/jsx/collectionHelpers.ts @@ -0,0 +1,55 @@ +/** + * Shared building blocks for the selection collection pass — used by both the + * runtime proxy collector (componentFactory) and compiled-selection/hole + * resolution (compiledSelection). Extracted to keep a clean module boundary + * and avoid a cycle between those two. + */ +import type { FluentFragment, SelectionMeta, AnyBrand } from '@contember/bindx' +import { ComponentBrand } from '@contember/bindx' + +/** + * Creates a FluentFragment from selection metadata. + */ +export function createFragment( + selection: SelectionMeta, + componentBrand: ComponentBrand, + roles: readonly string[], +): FluentFragment { + return { + __meta: selection, + __resultType: {} as object, + __modelType: undefined as unknown, + __isFragment: true, + __brand: componentBrand, + __brands: new Set([componentBrand.brandSymbol]), + __roles: roles.length > 0 ? roles : undefined, + } +} + +/** + * Creates a tolerant stand-in for scalar (non-entity) props during collection. + * Render bodies may call it (`t('key')`), read nested properties + * (`labels.heading`) or coerce it to a primitive — all are no-ops so the + * collection pass keeps capturing entity field accesses (see issue #57). + */ +export function createScalarPropMock(): unknown { + const mock: unknown = new Proxy(function () {}, { + get(_target, prop): unknown { + if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') { + return (): string => '' + } + if (prop === Symbol.iterator) { + return function* (): Generator {} + } + // undefined keeps JSON.stringify from recursing via a callable toJSON + if (prop === 'toJSON') { + return undefined + } + return mock + }, + apply(): unknown { + return mock + }, + }) + return mock +} diff --git a/packages/bindx-react/src/jsx/collectorContract.tsx b/packages/bindx-react/src/jsx/collectorContract.tsx new file mode 100644 index 00000000..910be94e --- /dev/null +++ b/packages/bindx-react/src/jsx/collectorContract.tsx @@ -0,0 +1,70 @@ +import type { ReactNode } from 'react' +import type { EntityRef, HasManyRef, HasOneRef } from './types.js' +import { HasMany } from './components/HasMany.js' +import { HasOne } from './components/HasOne.js' + +/** + * Declares how a callback prop is invoked, so the analyzer can treat it like a HasMany/HasOne child. + * Source of truth: the build-time compiler imports these types (see + * packages/bindx-compiler/src/contracts.ts) to parse what this module derives. + */ +export interface CallbackContract { + readonly kind: 'itemOf' | 'entityOf' + readonly field: string +} + +/** Maps a callback prop name (`children` included) to the relation it is invoked over. */ +export type CollectorContract = Record + +/** Symbol under which a contract is attached to a component for introspection. */ +export const COLLECTOR_CONTRACT: unique symbol = Symbol('bindx.collectorContract') + +/** Callback prop `field` receives each item of the has-many relation prop `field`. */ +export function itemOf(field: string): CallbackContract { + return { kind: 'itemOf', field } +} + +/** Callback prop `field` receives the entity of the has-one relation prop `field`. */ +export function entityOf(field: string): CallbackContract { + return { kind: 'entityOf', field } +} + +type RelationCallback = (value: EntityRef) => ReactNode + +// At the collection boundary props carry collector proxies; these predicates narrow them without `as`. +function isRelationCallback(value: unknown): value is RelationCallback { + return typeof value === 'function' +} + +function isHasManyRef(value: unknown): value is HasManyRef { + return typeof value === 'object' && value !== null +} + +function isHasOneRef(value: unknown): value is HasOneRef { + return typeof value === 'object' && value !== null +} + +/** + * Derives a staticRender from a contract: one ``/`` per entry, replaying + * the callback prop as its child. A missing callback still registers the relation with no + * nested selection — mirroring HasMany/HasOne when their children collect nothing. + */ +export function deriveContractStaticRender(contract: CollectorContract): (props: Record) => ReactNode { + const entries = Object.entries(contract) + return (props: Record): ReactNode => ( + <> + {entries.map(([callbackName, { kind, field }]) => { + const fieldValue = props[field] + const callbackValue = props[callbackName] + const child = isRelationCallback(callbackValue) ? callbackValue : (): ReactNode => null + + if (kind === 'itemOf') { + if (!isHasManyRef(fieldValue)) return null + return {item => child(item)} + } + if (!isHasOneRef(fieldValue)) return null + return {entity => child(entity)} + })} + + ) +} diff --git a/packages/bindx-react/src/jsx/collectorProxy.ts b/packages/bindx-react/src/jsx/collectorProxy.ts index 153ed110..27648f2f 100644 --- a/packages/bindx-react/src/jsx/collectorProxy.ts +++ b/packages/bindx-react/src/jsx/collectorProxy.ts @@ -12,7 +12,7 @@ import { FIELD_REF_META, SCOPE_REF, } from './types.js' -import { wrapEntityRefWithFieldAccessProxy } from './proxyShared.js' +import { wrapEntityRefWithFieldAccessProxy, REACT_ELEMENT_PROBE_KEYS } from './proxyShared.js' /** * Combined ref type for collector that satisfies all accessor interfaces. @@ -300,8 +300,14 @@ function wrapCollectorRefWithFieldAccessProxy( return Reflect.get(target, prop) } - if (isHasOneRelation) { - // For has-one relations, match runtime EntityHandle proxy behavior: + // React element probes (e.g. `{article.title}` rendered directly as a + // child) must not upgrade this scalar ref to a relation. + if (REACT_ELEMENT_PROBE_KEYS.has(prop)) { + return Reflect.get(target, prop) + } + + if (isHasOneRelation) { + // For has-one relations, match runtime EntityHandle proxy behavior: // Only pass through id, $-prefixed, and __-prefixed properties. // Everything else is field access on the related entity. if (prop === 'id' || prop.startsWith('$') || prop.startsWith('__')) { diff --git a/packages/bindx-react/src/jsx/compiledSelection.ts b/packages/bindx-react/src/jsx/compiledSelection.ts new file mode 100644 index 00000000..d36df475 --- /dev/null +++ b/packages/bindx-react/src/jsx/compiledSelection.ts @@ -0,0 +1,351 @@ +/** + * Compiled-selection contract (v2) and its runtime resolution. + * + * The selection compiler emits a {@link CompiledSelection} as the 2nd argument + * of `.render()`. Phase 1 covered per-prop static field maps; phase 2 adds + * `holes` — statically-emitted references to nested components that received + * entity-derived values. Holes are resolved here at collection time through the + * target's existing runtime selection surface (`getSelection` / `staticRender`), + * the Relay-fragment-spread equivalent, WITHOUT executing the host render body. + * + * Phase 2.1 adds {@link CompiledHole.extraProps}: non-entity values (module-scope + * bindings, render-scope-free closures) lifted into the hole so a target that + * invokes them during collection stays oracle-equal — see docs/compiler-plan.md. + */ +import type { ReactNode } from 'react' +import type { SelectionMeta } from '@contember/bindx' +import { SchemaRegistry, SelectionScope, ComponentBrand, driveSelectionScope } from '@contember/bindx' +import type { StaticFieldMap } from '@contember/bindx' +import { createCollectorProxy } from './proxy.js' +import { collectSelection, resolveComponentName } from './analyzer.js' +import { createFragment, createScalarPropMock } from './collectionHelpers.js' +import type { SelectionPropMeta } from './componentBuilder.types.js' +import type { EntityConfig } from './componentFactory.js' + +// ============================================================================ +// Contract v2 +// ============================================================================ + +/** + * Precompiled selection injected by the selection compiler. Breaking-change + * shape within the experiment — hand-write only in tests that simulate emit. + */ +export interface CompiledSelection { + /** Contract version marker — the runtime falls back to the proxy pass unless this is exactly 2. */ + v: 2 + /** Per implicit entity prop — same {@link StaticFieldMap} as phase 1. */ + props: Record + /** Nested components that received entity-derived values. */ + holes?: CompiledHole[] +} + +function isPlainObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** + * Defensive runtime shape check for a compiler-emitted literal. A stale or corrupt + * literal (wrong version, non-object props, malformed holes) must never be half-read + * into the fetch plan — consumers fall back to the runtime proxy pass when this fails. + */ +export function isValidCompiledSelection(value: unknown): value is CompiledSelection { + if (!isPlainObject(value) || value['v'] !== 2 || !isPlainObject(value['props'])) { + return false + } + for (const propValue of Object.values(value['props'])) { + if (!isPlainObject(propValue)) { + return false + } + } + return value['holes'] === undefined || Array.isArray(value['holes']) +} + +/** + * A nested component composition the compiler could not inline statically. + * Resolved at collection time through the target's selection surface. + */ +export interface CompiledHole { + /** + * Thunk, not a direct reference — dodges TDZ for components defined later in + * the module (same reason runtime collection is lazy). + */ + component: () => unknown + /** + * Target prop name → where its value comes from: a host entity prop plus a + * member path. Empty path = the root entity prop itself. + */ + entityProps: Record + /** + * Statically-literal non-entity props of the JSX element. Non-literal + * non-entity props are simply omitted. + */ + literalProps?: Record + /** + * Non-entity props lifted verbatim from the emit site: module-scope values + * (imports / top-level bindings) and render-scope-free inline closures. Each + * is a thunk (TDZ-safe, same reason as {@link component}) resolved to the real + * value at collection time, so a target's `staticRender` that *invokes* one + * (e.g. `props.children(entity)`) collects the same fields the oracle would. + */ + extraProps?: Record unknown> +} + +// ============================================================================ +// Runtime resolution +// ============================================================================ + +export interface ApplyCompiledSelectionParams { + readonly compiled: CompiledSelection + readonly selectionsMap: Map + readonly componentBrand: ComponentBrand + readonly roles: readonly string[] + readonly implicitConfigs: [string, EntityConfig][] + readonly schemaRegistry: SchemaRegistry> | null + readonly componentDisplayName: string + readonly validateMode: boolean +} + +/** + * Builds `selectionsMap` entries from a compiled selection — resolving the + * per-prop SelectionMeta (fields + holes) then wrapping each in a fragment. + * Replaces the proxy pass entirely; the host render function is never executed. + */ +export function applyCompiledSelection(params: ApplyCompiledSelectionParams): void { + const { selectionsMap, componentBrand, roles } = params + const metas = resolveCompiledSelection({ + compiled: params.compiled, + implicitConfigs: params.implicitConfigs, + schemaRegistry: params.schemaRegistry, + componentDisplayName: params.componentDisplayName, + validateMode: params.validateMode, + }) + for (const [propName, selection] of metas) { + selectionsMap.set(propName, { + selection, + fragment: createFragment(selection, componentBrand, roles), + }) + } +} + +export interface ResolveCompiledSelectionParams { + readonly compiled: CompiledSelection + readonly implicitConfigs: readonly [string, EntityConfig][] + readonly schemaRegistry: SchemaRegistry> | null + readonly componentDisplayName: string + readonly validateMode: boolean +} + +/** + * Core resolution shared by createComponent (fragments) and (root + * selection): seeds one live scope per entity prop from the static field maps, + * resolves every hole into the matching scope, and returns the snapshotted + * SelectionMeta per prop that captured fields. The host render fn is never run. + */ +export function resolveCompiledSelection(params: ResolveCompiledSelectionParams): Map { + const { compiled } = params + const propScopes = new Map() + + const scopeFor = (propName: string): SelectionScope => { + let scope = propScopes.get(propName) + if (!scope) { + scope = new SelectionScope() + propScopes.set(propName, scope) + } + return scope + } + + const ctx: HoleResolutionContext = { + scopeFor, + implicitConfigsMap: new Map(params.implicitConfigs), + schemaRegistry: params.schemaRegistry, + componentDisplayName: params.componentDisplayName, + validateMode: params.validateMode, + } + + // 1. Seed a live (open) scope per entity prop from the static field maps. + for (const [propName, fieldMap] of Object.entries(compiled.props)) { + driveSelectionScope(scopeFor(propName), fieldMap) + } + + // 2. Resolve each hole into the same scopes (fragment-spread equivalent). + for (const hole of compiled.holes ?? []) { + resolveHole(hole, ctx) + } + + // 3. Snapshot every scope that captured fields. + const metas = new Map() + for (const [propName, scope] of propScopes) { + if (scope.hasFields()) { + metas.set(propName, scope.toSelectionMeta()) + } + } + return metas +} + +/** Fixed key under which stores its compiled root field map + hole sources. */ +export const COMPILED_ROOT_KEY = 'entity' + +export interface ResolveCompiledRootSelectionParams { + readonly compiled: CompiledSelection + readonly entityType: string + readonly schemaRegistry: SchemaRegistry> | null + readonly validateMode: boolean +} + +/** + * Builds the single root SelectionMeta for a compiled : fields under the + * fixed `entity` key plus every hole (all rooted at `entity`). No fragments — + * is a selection root, not a composable component. + */ +export function resolveCompiledRootSelection(params: ResolveCompiledRootSelectionParams): SelectionMeta { + const metas = resolveCompiledSelection({ + compiled: params.compiled, + implicitConfigs: [[COMPILED_ROOT_KEY, { entityName: params.entityType }]], + schemaRegistry: params.schemaRegistry, + componentDisplayName: `Entity(${params.entityType})`, + validateMode: params.validateMode, + }) + return metas.get(COMPILED_ROOT_KEY) ?? { fields: new Map() } +} + +interface HoleResolutionContext { + readonly scopeFor: (propName: string) => SelectionScope + readonly implicitConfigsMap: Map + readonly schemaRegistry: SchemaRegistry> | null + readonly componentDisplayName: string + readonly validateMode: boolean +} + +/** + * Resolves one hole: assembles the target's props (entity values replayed from + * the source scopes + literal props) and feeds them to its selection surface, + * mirroring `analyzeJsx` order. Errors are contained per hole. + */ +function resolveHole(hole: CompiledHole, ctx: HoleResolutionContext): void { + let target: unknown + try { + target = hole.component() + } catch (error) { + reportHoleError(undefined, ctx.componentDisplayName, error) + return + } + if (target === null || (typeof target !== 'object' && typeof target !== 'function')) { + return + } + + try { + // Inside the try: a throwing extraProps thunk (module-init/TDZ) degrades this one hole, not the whole resolution. + const props = assembleHoleProps(hole, ctx) + if (hasGetSelection(target)) { + // Entity values carry SCOPE_REF, so getSelection merges into the source + // scopes as a side effect; the returned fields are irrelevant here. + target.getSelection(props, collectNested) + } else if (hasStaticRender(target)) { + const jsx = target.staticRender(wrapPropsWithScalarFallback(props)) + if (jsx !== null && jsx !== undefined) { + collectSelection(jsx) + } + } else if (ctx.validateMode) { + // Plain React target: the runtime proxy pass is equally blind here, so + // compiled behavior stays equivalent — surface the blind spot in dev/CI. + console.warn( + `[bindx] <${resolveComponentName(target)}> inside <${ctx.componentDisplayName}> exposes no ` + + 'selection surface (getSelection/staticRender) — its fields are a selection blind spot.', + ) + } + } catch (error) { + reportHoleError(target, ctx.componentDisplayName, error) + } +} + +/** Real nested-collection walk for `getSelection` targets. */ +function collectNested(node: ReactNode): SelectionMeta { + return collectSelection(node) +} + +/** + * Assembles the target's props: each entity prop is the source prop's collector + * proxy with the member path replayed via property gets; literal props merge in. + */ +function assembleHoleProps(hole: CompiledHole, ctx: HoleResolutionContext): Record { + const props: Record = { ...hole.literalProps } + // Lifted values reach the target before entity proxies (which must win on collision). + for (const [prop, thunk] of Object.entries(hole.extraProps ?? {})) { + props[prop] = thunk() + } + for (const [targetProp, origin] of Object.entries(hole.entityProps)) { + const proxy = createSourceProxy(origin.source, ctx) + props[targetProp] = replayPath(proxy, origin.path) + } + return props +} + +/** + * Creates the collector proxy for a source entity prop, using the same scope + + * entity-name + schema config that the proxy pass uses for that prop. + */ +function createSourceProxy(sourceProp: string, ctx: HoleResolutionContext): unknown { + const scope = ctx.scopeFor(sourceProp) + const config = ctx.implicitConfigsMap.get(sourceProp) + const entityName = config?.entityName ?? null + const registry = config?.schema + ? new SchemaRegistry(config.schema) + : ctx.schemaRegistry + return createCollectorProxy(scope, entityName, registry) +} + +/** Replays a member path via property gets — identical to the proxy pass. */ +function replayPath(root: unknown, path: readonly string[]): unknown { + let current = root + for (const key of path) { + if (current === null || (typeof current !== 'object' && typeof current !== 'function')) { + return current + } + current = (current as Record)[key] + } + return current +} + +/** + * Wraps a staticRender target's props so unknown keys fall back to the tolerant + * scalar mock (holes carry no children/render-prop closures). + */ +function wrapPropsWithScalarFallback(props: Record): Record { + return new Proxy(props, { + get(target, key): unknown { + if (typeof key === 'symbol' || key in target) { + return Reflect.get(target, key) + } + return createScalarPropMock() + }, + }) +} + +interface GetSelectionSurface { + getSelection(props: Record, collectNested: (node: ReactNode) => SelectionMeta): unknown +} + +interface StaticRenderSurface { + staticRender(props: Record): ReactNode +} + +function hasGetSelection(target: object): target is GetSelectionSurface { + return 'getSelection' in target && typeof (target as GetSelectionSurface).getSelection === 'function' +} + +function hasStaticRender(target: object): target is StaticRenderSurface { + return 'staticRender' in target && typeof (target as StaticRenderSurface).staticRender === 'function' +} + +/** + * Reports a hole-resolution failure with attribution, matching the + * report-and-continue policy of `analyzeJsx`'s per-component error handling. + */ +function reportHoleError(target: unknown, hostName: string, error: unknown): void { + const name = target === undefined ? 'anonymous' : resolveComponentName(target) + console.error( + `[bindx] Hole resolution of <${name}> inside <${hostName}> failed — its fields were NOT added to the fetch plan. ` + + 'Fix the error below; fields it uses may be missing from queries until then.', + error, + ) +} diff --git a/packages/bindx-react/src/jsx/componentBuilder.ts b/packages/bindx-react/src/jsx/componentBuilder.ts index 045ab966..a3faf09f 100644 --- a/packages/bindx-react/src/jsx/componentBuilder.ts +++ b/packages/bindx-react/src/jsx/componentBuilder.ts @@ -19,6 +19,7 @@ import type { ComponentBuilderState, CreateComponentOptions, } from './componentBuilder.types.js' +import type { CompiledSelection } from './compiledSelection.js' export type { SelectionPropMeta } from './componentBuilder.types.js' import type { Condition } from './conditions.js' import { buildComponent, type EntityConfig } from './componentFactory.js' @@ -50,6 +51,7 @@ export class ComponentBuilderImpl< private readonly conditionFn: ((props: Record) => Condition) | null = null, private readonly slotNames: readonly string[] = ['children'], private readonly useFns: readonly ((props: Record) => object)[] = [], + private readonly mockValues: Record = {}, ) {} entity( @@ -69,6 +71,7 @@ export class ComponentBuilderImpl< this.conditionFn, this.slotNames, this.useFns, + this.mockValues, ) } @@ -99,6 +102,7 @@ export class ComponentBuilderImpl< this.conditionFn, this.slotNames, this.useFns, + this.mockValues, ) } @@ -112,6 +116,7 @@ export class ComponentBuilderImpl< this.conditionFn, this.slotNames, this.useFns, + this.mockValues, ) } @@ -124,6 +129,21 @@ export class ComponentBuilderImpl< this.conditionFn, this.slotNames, [...this.useFns, useFn], + this.mockValues, + ) + } + + mock(values: Record): ComponentBuilderImpl { + // Later calls merge over earlier ones + return new ComponentBuilderImpl( + this.schemaRegistry, + this.entityConfigs, + this.roles, + this.hasInterfacesMode, + this.conditionFn, + this.slotNames, + this.useFns, + { ...this.mockValues, ...values }, ) } @@ -136,6 +156,7 @@ export class ComponentBuilderImpl< conditionFn, this.slotNames, this.useFns, + this.mockValues, ) } @@ -148,10 +169,14 @@ export class ComponentBuilderImpl< this.conditionFn, names, this.useFns, + this.mockValues, ) } - render(renderFn: (props: Record) => ReactNode): unknown { + render( + renderFn: (props: Record) => ReactNode, + compiled?: CompiledSelection, + ): unknown { return buildComponent( this.entityConfigs, this.roles, @@ -161,6 +186,8 @@ export class ComponentBuilderImpl< this.conditionFn, this.slotNames, this.useFns, + this.mockValues, + compiled ?? null, ) } } diff --git a/packages/bindx-react/src/jsx/componentBuilder.types.ts b/packages/bindx-react/src/jsx/componentBuilder.types.ts index 3215ee48..1752c3d1 100644 --- a/packages/bindx-react/src/jsx/componentBuilder.types.ts +++ b/packages/bindx-react/src/jsx/componentBuilder.types.ts @@ -19,6 +19,7 @@ import type { } from '@contember/bindx' import type { SelectionProvider } from './types.js' import type { Condition } from './conditions.js' +import type { CompiledSelection } from './compiledSelection.js' // ============================================================================ // Symbols (re-exported from createComponent.ts) @@ -482,6 +483,34 @@ export interface ComponentBuilder< useFn: (props: BuildRenderProps) => TUse, ): ComponentBuilder> + /** + * Supply deterministic stand-in values for scalar / `.use()` props used + * EXCLUSIVELY during static selection analysis — never at runtime render, + * where real props and real `.use()` outputs always win. + * + * The generic tolerant stand-in survives most code but under-collects when a + * mocked value drives control flow: indexing a real object with a mocked key + * (`LABELS[key].x` crashes), branching on a scalar (nondeterministic branch), + * or `.map()` over a scalar array (callback never runs, so entity fields + * accessed inside it are silently missed). A concrete mock fixes all three. + * + * Later calls merge over earlier ones. Entity props cannot be mocked (type error). + * Does not change the builder state type. + * + * @example + * ```typescript + * createComponent() + * .entity('article', schema.Article) + * .props<{ labelKey: string; tabs: { key: string }[] }>() + * .use(() => ({ t: useTranslator() })) + * .mock({ labelKey: 'k1', tabs: [{ key: 'x' }], t: key => key }) + * .render(({ article, labelKey, tabs, t }) => ...) + * ``` + */ + mock( + values: Partial, keyof TState['__entityProps']>>, + ): ComponentBuilder + /** * Add a condition that must be true for the component to render. * If the condition is false, the component renders null. @@ -533,9 +562,17 @@ export interface ComponentBuilder< * Build the component with the render function. * * @param renderFn - React render function receiving typed props + * @param compiled - Precompiled selection injected by the selection compiler + * (per-prop static field maps + nested-component holes). Compiler-facing + * only — do NOT hand-write it. When present, the runtime skips the proxy + * collection pass and uses this instead; enable + * {@link setStaticSelectionValidation} in dev/CI to cross-check it. * @returns Bindx component with fragment properties */ - render(renderFn: (props: BuildRenderProps) => ReactNode): BindxComponent + render( + renderFn: (props: BuildRenderProps) => ReactNode, + compiled?: CompiledSelection, + ): BindxComponent } // ============================================================================ diff --git a/packages/bindx-react/src/jsx/componentFactory.ts b/packages/bindx-react/src/jsx/componentFactory.ts index af488dd2..289afe6b 100644 --- a/packages/bindx-react/src/jsx/componentFactory.ts +++ b/packages/bindx-react/src/jsx/componentFactory.ts @@ -32,6 +32,8 @@ import type { SelectionProvider, SelectionFieldMeta } from './types.js' import { FIELD_REF_META, BINDX_COMPONENT, SCOPE_REF } from './types.js' import { createCollectorProxy } from './proxy.js' import { collectSelection } from './analyzer.js' +import { createFragment, createScalarPropMock } from './collectionHelpers.js' +import { applyCompiledSelection, isValidCompiledSelection, type CompiledSelection } from './compiledSelection.js' import { type Condition, evaluateCondition } from './conditions.js' import { useAccessor } from '../hooks/useAccessor.js' @@ -54,6 +56,42 @@ export const COMPONENT_BRAND = Symbol('COMPONENT_BRAND') */ export const COMPONENT_SELECTIONS = Symbol('COMPONENT_SELECTIONS') +// ============================================================================ +// Static Selection Validation +// ============================================================================ + +/** + * When enabled, components carrying a precompiled static selection ALSO run the + * runtime proxy pass and warn on any per-prop mismatch. Trust-building mode for + * dev/CI; off by default so production skips the proxy pass entirely. + */ +let staticSelectionValidationEnabled = false + +/** + * Enables or disables static-selection validate mode (module-level flag). + */ +export function setStaticSelectionValidation(enabled: boolean): void { + staticSelectionValidationEnabled = enabled +} + +/** Reads the module-level validate-mode flag (used by 's root compilation). */ +export function isStaticSelectionValidationEnabled(): boolean { + return staticSelectionValidationEnabled +} + +/** Killswitch: when off, all consumers ignore compiled literals and use the runtime proxy path. */ +let compiledSelectionsEnabled = true + +/** Disables compiled selections at runtime — incident mitigation without a rebuild. */ +export function setCompiledSelectionsEnabled(enabled: boolean): void { + compiledSelectionsEnabled = enabled +} + +/** Reads the module-level compiled-selections killswitch (used by both consumers). */ +export function isCompiledSelectionsEnabled(): boolean { + return compiledSelectionsEnabled +} + // ============================================================================ // Entity Config (Runtime) // ============================================================================ @@ -102,6 +140,8 @@ export function buildComponent( conditionFn: ((props: TProps) => Condition) | null, slotNames: readonly string[], useFns: readonly ((props: TProps) => object)[], + mockValues: Record, + compiled: CompiledSelection | null, ): unknown { const selectionsMap = new Map() const componentDisplayName = `BindxComponent(${[...entityConfigs.keys()].join(', ')})` @@ -133,6 +173,55 @@ export function buildComponent( // Tri-state: 'collecting' terminates self-recursive components let collectionState: 'idle' | 'collecting' | 'done' = 'idle' + // Drops any implicit/hole-derived entries so a partial compiled pass can't leak into the fallback. + function resetToExplicitSelections(): void { + for (const key of [...selectionsMap.keys()]) { + if (!explicitEntityPropNames.includes(key)) { + selectionsMap.delete(key) + } + } + } + + // Applies the compiled literal; returns false (⇒ caller falls back to the proxy pass) on a + // malformed literal or a top-level throw. Per-hole failures stay contained inside applyCompiledSelection. + function tryApplyCompiled(): boolean { + if (!isValidCompiledSelection(compiled)) { + console.warn( + `[bindx] compiled selection for <${componentDisplayName}> is malformed (version/shape check failed) — ` + + 'falling back to runtime collection.', + ) + return false + } + try { + applyCompiledSelection({ + compiled, + selectionsMap, + componentBrand, + roles, + implicitConfigs, + schemaRegistry, + componentDisplayName, + validateMode: staticSelectionValidationEnabled, + }) + } catch (error) { + resetToExplicitSelections() + console.warn( + `[bindx] compiled selection for <${componentDisplayName}> failed to resolve — ` + + 'falling back to runtime collection.', + error, + ) + return false + } + if (staticSelectionValidationEnabled) { + validateCompiledSelection( + selectionsMap, componentDisplayName, + implicitConfigs, renderFn, componentBrand, roles, + hasInterfacesMode, schemaRegistry, conditionFn, mockValues, + ) + } + return true + } + // Runs only from the static-analysis surface (getSelection, $propName fragment // getters) — never from render. Render bodies stay pure runtime code. function ensureImplicitCollected(): void { @@ -144,7 +233,12 @@ export function buildComponent( } collectionState = 'collecting' try { - collectImplicitSelections(implicitConfigs, renderFn, selectionsMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn) + // Precompiled selection present ⇒ build entries from it, skip the proxy pass. + // Killswitch off or a malformed/throwing literal falls back to the proxy pass wholesale (never under-fetch). + if (compiled && isCompiledSelectionsEnabled() && tryApplyCompiled()) { + return + } + collectImplicitSelections(implicitConfigs, renderFn, selectionsMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn, mockValues) } catch (error) { // Analysis is deterministic, so retrying is pointless — degrade loudly // to the fields captured before the throw (scopes record eagerly). @@ -286,34 +380,6 @@ function createExplicitPropMock(): unknown { }) } -/** - * Creates a tolerant stand-in for scalar (non-entity) props during collection. - * Render bodies may call it (`t('key')`), read nested properties - * (`labels.heading`) or coerce it to a primitive — all are no-ops so the - * collection pass keeps capturing entity field accesses (see issue #57). - */ -function createScalarPropMock(): unknown { - const mock: unknown = new Proxy(function () {}, { - get(_target, prop): unknown { - if (prop === Symbol.toPrimitive || prop === 'toString' || prop === 'valueOf') { - return (): string => '' - } - if (prop === Symbol.iterator) { - return function* (): Generator {} - } - // undefined keeps JSON.stringify from recursing via a callable toJSON - if (prop === 'toJSON') { - return undefined - } - return mock - }, - apply(): unknown { - return mock - }, - }) - return mock -} - /** * Collects selections from JSX for implicit entity props. * @@ -329,6 +395,7 @@ function collectImplicitSelections( hasInterfacesMode: boolean, schemaRegistry: SchemaRegistry> | null, conditionFn: ((props: TProps) => Condition) | null, + mockValues: Record, ): void { const propScopes = new Map() const implicitConfigsMap = new Map(implicitConfigs) @@ -368,6 +435,12 @@ function collectImplicitSelections( return createCollectorProxy(scope, entityName, resolvedRegistry) } + // Deterministic analysis-time value; must win over the interfaces-mode + // branch so a mocked scalar is never mistaken for an interface entity prop. + if (propName in mockValues) { + return mockValues[propName] + } + // In interfaces mode, any unknown prop could be an interface entity prop // Create or reuse a scope for it and return a collector proxy if (hasInterfacesMode) { @@ -425,26 +498,108 @@ function collectImplicitSelections( } // ============================================================================ -// Fragment Creation +// Static Selection Application & Validation // ============================================================================ /** - * Creates a FluentFragment from selection metadata. + * Validate mode: also run the proxy pass (which resolves nested holes by + * rendering them inline) and warn (once) when the compiled selection + * under-fetches relative to what runtime collection would produce. Diffs over + * the runtime props so hole-derived entity props are covered too. */ -function createFragment( - selection: SelectionMeta, +function validateCompiledSelection( + compiledMap: Map, + componentDisplayName: string, + implicitConfigs: [string, EntityConfig][], + renderFn: (props: TProps) => ReactNode, componentBrand: ComponentBrand, roles: readonly string[], -): FluentFragment { - return { - __meta: selection, - __resultType: {} as object, - __modelType: undefined as unknown, - __isFragment: true, - __brand: componentBrand, - __brands: new Set([componentBrand.brandSymbol]), - __roles: roles.length > 0 ? roles : undefined, + hasInterfacesMode: boolean, + schemaRegistry: SchemaRegistry> | null, + conditionFn: ((props: TProps) => Condition) | null, + mockValues: Record, +): void { + const runtimeMap = new Map() + try { + collectImplicitSelections(implicitConfigs, renderFn, runtimeMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn, mockValues) + } catch { + // Proxy pass crashed — the compiled selection already stands; nothing to compare. + return + } + + const lines: string[] = [] + for (const propName of runtimeMap.keys()) { + const compiledSelectionMeta = compiledMap.get(propName)?.selection + const runtimeSelectionMeta = runtimeMap.get(propName)?.selection + diffUnderfetchedFields(compiledSelectionMeta, runtimeSelectionMeta, [propName], lines) + } + + if (lines.length > 0) { + console.warn( + `[bindx] static selection under-fetches for <${componentDisplayName}>:\n${lines.join('\n')}`, + ) + } +} + +/** + * Validate mode for a compiled root: warn (once per collection) on fields + * the runtime children-collector walk requests but the compiled root omits. Reuses + * the same under-fetch-only diff as createComponent; the warn names the entity type. + */ +export function validateCompiledRootSelection( + compiledSelection: SelectionMeta, + runtimeSelection: SelectionMeta, + entityType: string, +): void { + const lines: string[] = [] + diffUnderfetchedFields(compiledSelection, runtimeSelection, [entityType], lines) + if (lines.length > 0) { + console.warn( + `[bindx] static selection under-fetches for :\n${lines.join('\n')}`, + ) + } +} + +/** + * Under-fetch diff: warn only for fields the runtime (proxy) selection requests + * that the static selection omits — the sole mismatch class that is a fetch bug. + * Fields present only in static (branch unions) and params/alias/isArray-only + * differences are intentionally NOT reported: the compiler unions all branches + * and the runtime never records has-many params in implicit collection. Keyed by + * `fieldName` (not alias) so a params-driven alias never reads as a missing field. + */ +function diffUnderfetchedFields( + staticMeta: SelectionMeta | undefined, + runtimeMeta: SelectionMeta | undefined, + path: string[], + lines: string[], +): void { + if (!runtimeMeta) { + return + } + const staticByField = indexByFieldName(staticMeta) + for (const runtimeField of runtimeMeta.fields.values()) { + const staticField = staticByField.get(runtimeField.fieldName) + if (!staticField) { + lines.push(` missing from static (under-fetch): ${[...path, runtimeField.fieldName].join('.')}`) + continue + } + // Both sides fetch this relation — recurse into what the runtime nests. + if (runtimeField.nested) { + diffUnderfetchedFields(staticField.nested, runtimeField.nested, [...path, runtimeField.fieldName], lines) + } + } +} + +/** Index a selection's top-level fields by field name (aliases collapse). */ +function indexByFieldName(meta: SelectionMeta | undefined): Map { + const byField = new Map() + if (meta) { + for (const field of meta.fields.values()) { + byField.set(field.fieldName, field) + } } + return byField } // ============================================================================ diff --git a/packages/bindx-react/src/jsx/components/Entity.tsx b/packages/bindx-react/src/jsx/components/Entity.tsx index 19062ca0..1dd74a2b 100644 --- a/packages/bindx-react/src/jsx/components/Entity.tsx +++ b/packages/bindx-react/src/jsx/components/Entity.tsx @@ -2,7 +2,8 @@ import React, { memo, useCallback, useEffect, useLayoutEffect, useMemo, useRef, import { useBindxContext, useSchemaRegistry } from '../../hooks/BackendAdapterContext.js' import { annotateElement } from '../devAnnotations.js' import { useEntity } from '../../hooks/useEntity.js' -import { useSelectionCollection } from '../../hooks/useSelectionCollection.js' +import { useRootSelection } from '../../hooks/useRootSelection.js' +import type { CompiledSelection } from '../compiledSelection.js' import type { EntityAccessor, EntityRef, SelectionMeta } from '../types.js' import { type EntityDef, type EntityUniqueWhere, type AnyBrand, type FieldError, EntityHandle, type SnapshotStore, type ActionDispatcher, type SchemaRegistry, type CommonEntity } from '@contember/bindx' @@ -19,6 +20,12 @@ interface EntityBaseProps> { children: (entity: EntityRef>) => React.ReactNode /** Error fallback */ error?: (error: FieldError) => React.ReactNode + /** + * Precompiled root selection — injected by the bindx compiler, do not hand-write. + * When present the root selection is built statically (fields under the fixed + * `entity` key + resolved holes) and `children` is not invoked with a collector. + */ + compiledSelection?: CompiledSelection } /** @@ -72,6 +79,7 @@ interface EntityByModeProps { loading?: React.ReactNode error?: (error: FieldError) => React.ReactNode notFound?: React.ReactNode + compiledSelection?: CompiledSelection } interface EntityCreateModeProps { @@ -79,6 +87,7 @@ interface EntityCreateModeProps { children: (entity: EntityAccessor) => React.ReactNode error?: (error: FieldError) => React.ReactNode onPersisted?: (id: string) => void + compiledSelection?: CompiledSelection } // ==================== EntityByMode Component ==================== @@ -94,6 +103,7 @@ function EntityByMode({ loading, error: errorFallback, notFound, + compiledSelection, }: EntityByModeProps): ReactElement | null { const { store, dispatcher } = useBindxContext() const schemaRegistry = useSchemaRegistry() @@ -101,11 +111,13 @@ function EntityByMode({ // Stable key for the 'by' clause const byKey = useMemo(() => JSON.stringify(by), [by]) - // Phase 1: Collect JSX selection - const { selection, queryKey: selectionQueryKey } = useSelectionCollection({ + // Phase 1: Build the root selection — statically when compiled, else via the + // children-collector walk. + const { selection, queryKey: selectionQueryKey } = useRootSelection({ entityType, depsKey: byKey, - collect: collector => children(collector as EntityAccessor), + children, + compiledSelection, }) // Combine internal selection key with user-supplied invalidation key so @@ -181,6 +193,7 @@ function EntityCreateMode({ entityType, children, onPersisted, + compiledSelection, }: EntityCreateModeProps): ReactElement { const { store } = useBindxContext() const tempIdRef = useRef(null) @@ -216,7 +229,7 @@ function EntityCreateMode({ } return ( - + {children} ) @@ -232,11 +245,13 @@ function EntityCreateModeInner({ tempId, children, onPersisted, + compiledSelection, }: { entityType: string tempId: string children: (entity: EntityAccessor) => React.ReactNode onPersisted?: (id: string) => void + compiledSelection?: CompiledSelection }): ReactElement { const { store, dispatcher } = useBindxContext() const schemaRegistry = useSchemaRegistry() @@ -283,11 +298,13 @@ function EntityCreateModeInner({ } }, [persistedId, onPersisted]) - // Selection collection still works for building mutations - useSelectionCollection({ + // Selection collection still works for building mutations. A compiled selection + // skips the children(collector) walk here too. + useRootSelection({ entityType, depsKey: tempId, - collect: collector => children(collector as EntityAccessor), + children, + compiledSelection, }) // Create EntityHandle (no selection for create mode - all fields accessible) @@ -414,6 +431,7 @@ function EntityImpl>( children={createProps.children as (entity: EntityAccessor) => React.ReactNode} error={createProps.error} onPersisted={createProps.onPersisted} + compiledSelection={createProps.compiledSelection} /> ) } @@ -428,6 +446,7 @@ function EntityImpl>( loading={byProps.loading} error={byProps.error} notFound={byProps.notFound} + compiledSelection={byProps.compiledSelection} /> ) } diff --git a/packages/bindx-react/src/jsx/index.ts b/packages/bindx-react/src/jsx/index.ts index fe40976c..b578fab5 100644 --- a/packages/bindx-react/src/jsx/index.ts +++ b/packages/bindx-react/src/jsx/index.ts @@ -101,9 +101,27 @@ export { // Standalone createComponent function export { createComponent } from './standaloneCreateComponent.js' +// Static (precompiled) selection validate-mode toggle +export { setStaticSelectionValidation } from './componentFactory.js' + +// Compiled-selections killswitch — disable compiled literals at runtime without a rebuild +export { setCompiledSelectionsEnabled } from './componentFactory.js' + +// Compiled selection contract (v2) — emitted by the selection compiler +export type { CompiledSelection, CompiledHole } from './compiledSelection.js' + // withCollector — attach staticRender to a component for selection collection export { withCollector } from './withCollector.js' +// Declarative collector contracts — an alternative to a hand-written staticRender +export { + COLLECTOR_CONTRACT, + itemOf, + entityOf, + type CallbackContract, + type CollectorContract, +} from './collectorContract.js' + export type { SelectionPropMeta, BindxComponentBase, diff --git a/packages/bindx-react/src/jsx/proxyShared.ts b/packages/bindx-react/src/jsx/proxyShared.ts index 4d999fcd..dfd8e62a 100644 --- a/packages/bindx-react/src/jsx/proxyShared.ts +++ b/packages/bindx-react/src/jsx/proxyShared.ts @@ -16,6 +16,15 @@ export const ENTITY_ACCESSOR_PROPERTIES = new Set([ '__entityType', '__entityName', '__schema', ]) +/** + * String keys React probes on any object it treats as a JSX child: `$$typeof` + * (isValidElement) and the `@@iterator` fallback (getIteratorFn). A collector + * proxy must return undefined for these — delegating to field access would + * upgrade a scalar to a bogus relation. Symbol probes (Symbol.iterator, …) pass + * through the symbol branch already. + */ +export const REACT_ELEMENT_PROBE_KEYS = new Set(['$$typeof', '@@iterator']) + /** * Wraps an object with $fields in a Proxy that supports direct field access. * - `entity.fieldName` is equivalent to `entity.$fields.fieldName` @@ -29,6 +38,11 @@ export function wrapEntityRefWithFieldAccessProxy(ref: { $fields: EntityField return Reflect.get(target, prop) } + // React element probes must not be captured as fields + if (REACT_ELEMENT_PROBE_KEYS.has(prop)) { + return Reflect.get(target, prop) + } + // Known accessor properties - pass through if (ENTITY_ACCESSOR_PROPERTIES.has(prop)) { return Reflect.get(target, prop) diff --git a/packages/bindx-react/src/jsx/withCollector.ts b/packages/bindx-react/src/jsx/withCollector.ts index 6b26d417..4885d5c9 100644 --- a/packages/bindx-react/src/jsx/withCollector.ts +++ b/packages/bindx-react/src/jsx/withCollector.ts @@ -1,4 +1,6 @@ import type { ReactNode } from 'react' +import type { CollectorContract } from './collectorContract.js' +import { COLLECTOR_CONTRACT, deriveContractStaticRender } from './collectorContract.js' /** * Attaches a static render function to a component for selection collection. @@ -25,21 +27,40 @@ import type { ReactNode } from 'react' * ) * ) * - * // Programmatic field access (no JSX needed) - * export const Uploader = withCollector( - * function Uploader({ field, fileType }) { ... }, - * (props) => { - * const entity = props.field.$entity - * for (const ext of props.fileType.extractors) entity[ext.fieldName] - * return null - * } + * // Declarative contract instead of a hand-written staticRender: `children` is invoked + * // per item of the `field` has-many relation. The staticRender is derived automatically. + * export const InitializingRepeater = withCollector( + * function InitializingRepeater({ field, children, ... }) { ... }, + * { children: itemOf('field') } * ) * ``` */ export function withCollector ReactNode>( component: TComponent, staticRender: (props: Parameters[0]) => ReactNode, +): TComponent +export function withCollector ReactNode>( + component: TComponent, + contract: CollectorContract, +): TComponent +export function withCollector ReactNode>( + component: TComponent, + contractOrStaticRender: ((props: Parameters[0]) => ReactNode) | CollectorContract, ): TComponent { - (component as TComponent & { staticRender: typeof staticRender }).staticRender = staticRender + // A function is a staticRender; a plain object is a declarative contract. + if (typeof contractOrStaticRender === 'function') { + const staticRender = contractOrStaticRender + ;(component as TComponent & { staticRender: typeof staticRender }).staticRender = staticRender + return component + } + + const contract = contractOrStaticRender + const staticRender = deriveContractStaticRender(contract) + const target = component as TComponent & { + staticRender: typeof staticRender + [COLLECTOR_CONTRACT]: CollectorContract + } + target.staticRender = staticRender + target[COLLECTOR_CONTRACT] = contract return component } diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 304bacd9..015e70f5 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -162,8 +162,8 @@ export type { } from './schema/index.js' // Selection utilities -export { createFragment, buildQueryFromSelection, SELECTION_META, createSelectionBuilder, SelectionMetaCollector, mergeSelections, createEmptySelection, SelectionScope } from './selection/index.js' -export type { HasManyParams } from './selection/index.js' +export { createFragment, buildQueryFromSelection, SELECTION_META, createSelectionBuilder, SelectionMetaCollector, mergeSelections, createEmptySelection, SelectionScope, staticSelectionToMeta, driveSelectionScope } from './selection/index.js' +export type { HasManyParams, StaticSelection, StaticFieldMap, StaticFieldNode } from './selection/index.js' // Handles export { EntityHandle, HasOneHandle, HasManyListHandle, FieldHandle } from './handles/index.js' diff --git a/packages/bindx/src/selection/index.ts b/packages/bindx/src/selection/index.ts index f61f5e23..7f46f6e7 100644 --- a/packages/bindx/src/selection/index.ts +++ b/packages/bindx/src/selection/index.ts @@ -29,3 +29,10 @@ export { createFragment } from './createFragment.js' export { buildQueryFromSelection, collectPaths, type QuerySpec, type QueryFieldSpec } from './buildQuery.js' export { SelectionMetaCollector, mergeSelections, createEmptySelection } from './SelectionMetaCollector.js' export { SelectionScope, type HasManyParams } from './SelectionScope.js' +export { + staticSelectionToMeta, + driveSelectionScope, + type StaticSelection, + type StaticFieldMap, + type StaticFieldNode, +} from './staticSelectionToMeta.js' diff --git a/packages/bindx/src/selection/staticSelectionToMeta.ts b/packages/bindx/src/selection/staticSelectionToMeta.ts new file mode 100644 index 00000000..e05baad9 --- /dev/null +++ b/packages/bindx/src/selection/staticSelectionToMeta.ts @@ -0,0 +1,66 @@ +/** + * Converts a precompiled static selection into runtime `SelectionMeta`. + * + * The compiler emits this plain, serializable shape as the 2nd argument of + * `.render()`; the runtime consumes it in place of the proxy collection pass. + * Output is indistinguishable from `SelectionScope.toSelectionMeta()` for the + * equivalent access pattern — this file drives a `SelectionScope` so both paths + * share the exact same alias/path/`id`-seeding semantics. + */ +import type { SelectionMeta } from './types.js' +import { SelectionScope, type HasManyParams } from './SelectionScope.js' + +/** Selection for one implicit entity prop. Key = field name. */ +export type StaticFieldMap = Record + +export type StaticFieldNode = + | true // scalar leaf (or relation touched without nested access) + | { + fields: StaticFieldMap // nested selection → this field is a relation + many?: true // has-many (known from usage or collection params) + params?: { // has-many params; only statically-literal values + filter?: unknown + orderBy?: unknown + limit?: number + offset?: number + totalCount?: boolean + } + } + +/** Emitted as the 2nd argument of .render(): key = implicit entity prop name. */ +export type StaticSelection = Record + +/** + * Drives a caller-provided (open) scope from a static field map, mirroring the + * runtime collector's scope operations: nested access ⇒ relation (and `child()` + * seeds `id`). Keeping the scope open lets callers (e.g. hole resolution) merge + * additional selection into the same scope before snapshotting. + */ +export function driveSelectionScope(scope: SelectionScope, map: StaticFieldMap): void { + for (const [fieldName, node] of Object.entries(map)) { + if (node === true) { + scope.addScalar(fieldName) + continue + } + // child() seeds `id` and registers the relation, exactly like the collector + const childScope = scope.child(fieldName) + if (node.many) { + scope.markAsArray(fieldName) + } + if (node.params) { + const params: HasManyParams = node.params + scope.setHasManyParams(fieldName, params) + } + driveSelectionScope(childScope, node.fields) + } +} + +/** + * Converts a static field map for a single entity prop into `SelectionMeta`. + * Thin wrapper over {@link driveSelectionScope}: new scope → drive → snapshot. + */ +export function staticSelectionToMeta(map: StaticFieldMap): SelectionMeta { + const scope = new SelectionScope() + driveSelectionScope(scope, map) + return scope.toSelectionMeta() +} diff --git a/packages/example/main.tsx b/packages/example/main.tsx index 15accccd..f00c862e 100644 --- a/packages/example/main.tsx +++ b/packages/example/main.tsx @@ -1,6 +1,13 @@ import './styles.css' import { createRoot } from 'react-dom/client' +import { setStaticSelectionValidation } from '@contember/bindx-react' import { App } from './App.js' +// With the compiler enabled, cross-check emitted selections against the runtime +// proxy pass in dev — warns on any under-fetch. See docs/compiler-plan.md. +if (import.meta.env.DEV && __BINDX_COMPILER__) { + setStaticSelectionValidation(true) +} + const root = createRoot(document.getElementById('root')!) root.render() diff --git a/packages/example/package.json b/packages/example/package.json index 52c37073..04050d95 100644 --- a/packages/example/package.json +++ b/packages/example/package.json @@ -30,6 +30,7 @@ "tailwindcss": "^4.2.1" }, "devDependencies": { + "@contember/bindx-compiler": "workspace:*", "@types/react": "^19", "@types/react-dom": "^19", "@vitejs/plugin-react": "^5.1.2", diff --git a/packages/example/tsconfig.json b/packages/example/tsconfig.json index 21f93152..08244423 100644 --- a/packages/example/tsconfig.json +++ b/packages/example/tsconfig.json @@ -10,6 +10,7 @@ {"path": "../bindx-react"}, {"path": "../bindx-dataview"}, {"path": "../bindx-ui"}, - {"path": "../bindx-editor"} + {"path": "../bindx-editor"}, + {"path": "../bindx-compiler"} ] } diff --git a/packages/example/vite-env.d.ts b/packages/example/vite-env.d.ts index 11f02fe2..20cb285f 100644 --- a/packages/example/vite-env.d.ts +++ b/packages/example/vite-env.d.ts @@ -1 +1,4 @@ /// + +// Injected by vite `define` — true when the selection compiler is enabled. +declare const __BINDX_COMPILER__: boolean diff --git a/packages/example/vite.config.ts b/packages/example/vite.config.ts index 857f8f30..b505af76 100644 --- a/packages/example/vite.config.ts +++ b/packages/example/vite.config.ts @@ -3,9 +3,25 @@ import react from '@vitejs/plugin-react' import tailwindcss from '@tailwindcss/vite' import path from 'path' import { bindxUI } from '../bindx-ui/src/vite-plugin.js' +// Relative src import (like bindxUI above) so vite bundles the config and maps +// the package's `.js` specifiers to `.ts`; the workspace dep is declared for types. +import { bindxCompiler } from '../bindx-compiler/src/index.js' + +// Experimental: compile implicit selections at build time. Opt-in via env so the +// runtime proxy pass stays the default. See docs/compiler-plan.md. +const compilerEnabled = process.env['BINDX_COMPILER'] === '1' export default defineConfig({ - plugins: [tailwindcss(), react(), bindxUI({ dir: './ui-overrides' })], + plugins: [ + tailwindcss(), + // Runs before react() (enforce: 'pre') and tracks cross-module deps via addWatchFile. + ...(compilerEnabled ? [bindxCompiler()] : []), + react(), + bindxUI({ dir: './ui-overrides' }), + ], + define: { + __BINDX_COMPILER__: JSON.stringify(compilerEnabled), + }, root: __dirname, server: { port: 15180, diff --git a/tests/browser/articleEditor.test.ts b/tests/browser/articleEditor.test.ts index 85e2f48f..6618f4fb 100644 --- a/tests/browser/articleEditor.test.ts +++ b/tests/browser/articleEditor.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'bun:test' -import { browserTest, el, tid, waitFor } from './browser.js' +import { browserTest, clickUntil, el, tid, waitFor } from './browser.js' browserTest('Article Editor', () => { test('section renders with all sub-components', () => { @@ -24,10 +24,12 @@ browserTest('Article Editor', () => { // Type in the search input to filter, then click the filtered option waitFor(() => el('[role="dialog"] input').exists) el('[role="dialog"] input').fill('Jane') - waitFor(() => el('[role="dialog"] button[class]').exists) - el('[role="dialog"] button[class]').click() - - waitFor(() => !el('article-save-button').isDisabled) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('Jane')) + clickUntil( + () => el('[role="dialog"] button[class]'), + () => !el('article-save-button').isDisabled, + ) expect(el('article-dirty-notice').exists).toBe(true) }) @@ -46,10 +48,12 @@ browserTest('Article Editor', () => { // Search for the tag and click it waitFor(() => el('[role="dialog"] input').exists) el('[role="dialog"] input').fill('TypeScript') - waitFor(() => el('[role="dialog"] button[class]').exists) - el('[role="dialog"] button[class]').click() - - waitFor(() => el('tag-badge-TypeScript').exists) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('TypeScript')) + clickUntil( + () => el('[role="dialog"] button[class]'), + () => el('tag-badge-TypeScript').exists, + ) }) }, 'article-editor') diff --git a/tests/browser/authorSelect.test.ts b/tests/browser/authorSelect.test.ts index 9665e7aa..fe1e8ad8 100644 --- a/tests/browser/authorSelect.test.ts +++ b/tests/browser/authorSelect.test.ts @@ -1,5 +1,5 @@ import { test, expect } from 'bun:test' -import { browserTest, el, tid, waitFor } from './browser.js' +import { browserTest, clickUntil, el, tid, waitFor } from './browser.js' browserTest('Article with Author Select', () => { test('section renders', () => { @@ -21,10 +21,12 @@ browserTest('Article with Author Select', () => { // Type to filter and click an option waitFor(() => el('[role="dialog"] input').exists) el('[role="dialog"] input').fill('Bob') - waitFor(() => el('[role="dialog"] button[class]').exists) - el('[role="dialog"] button[class]').click() - - waitFor(() => !el('author-select-save-button').isDisabled) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('Bob')) + clickUntil( + () => el('[role="dialog"] button[class]'), + () => !el('author-select-save-button').isDisabled, + ) expect(el('current-author-display').text).toContain('Changes will be applied on save') }) diff --git a/tests/browser/browser.ts b/tests/browser/browser.ts index 435743ed..e375403b 100644 --- a/tests/browser/browser.ts +++ b/tests/browser/browser.ts @@ -111,6 +111,37 @@ export function el(selector: string): ElementHandle { } } +/** + * Click until the expected outcome materializes — popover option lists re-render + * async (debounced fetches), so a click can land on a node mid-remount and get lost. + * Checks the condition before re-clicking, so a registered click is never repeated. + */ +export function clickUntil( + target: () => ElementHandle, + condition: () => boolean, + { attempts = 3, settle = 3_000 }: { attempts?: number; settle?: number } = {}, +): void { + for (let i = 0; i < attempts; i++) { + try { + if (condition()) return + } catch { + // not ready yet + } + try { + target().click() + } catch { + // target gone — the previous click may have registered and closed the popover + } + try { + waitFor(condition, { timeout: settle }) + return + } catch { + // outcome didn't materialize — re-click + } + } + waitFor(condition, { timeout: settle }) +} + /** * Build a `[data-testid="..."]` selector for compound selectors. * Usage: `el(\`\${tid('parent')} button\`)` diff --git a/tests/react/dataview/createRelationColumn.test.tsx b/tests/react/dataview/createRelationColumn.test.tsx index 501f4523..e5cb59a3 100644 --- a/tests/react/dataview/createRelationColumn.test.tsx +++ b/tests/react/dataview/createRelationColumn.test.tsx @@ -26,6 +26,9 @@ import { hasOne, hasMany, createCollectorProxy, + convertToQuerySelection, + HasMany, + Field, } from '@contember/bindx-react' import { SelectionScope, SchemaRegistry } from '@contember/bindx' @@ -36,11 +39,13 @@ import { SelectionScope, SchemaRegistry } from '@contember/bindx' interface Organization { id: string name: string + tags: Tag[] } interface Tag { id: string label: string + type: string } interface Project { @@ -70,12 +75,14 @@ const testSchema = defineSchema({ fields: { id: scalar(), name: scalar(), + tags: hasMany('Tag'), }, }, Tag: { fields: { id: scalar(), label: scalar(), + type: scalar(), }, }, }, @@ -251,3 +258,76 @@ describe('createRelationColumn — hasMany', () => { expect(leaf.renderFilter).toBeTypeOf('function') }) }) + +// ============================================================================ +// Nested declarative selection inside a relation renderer (npi regression) +// ============================================================================ + +// Repro of the npi workaround: a relation-column renderer that returns declarative +// / JSX. Before the JSX-walk fix, collectSelection discarded the +// returned JSX, so nested fields never reached the query (only the .map() proxy trick +// worked). The renderer's returned JSX must now be walked and merged into the scope. +describe('createRelationColumn — nested declarative selection (npi regression)', () => { + const HasOneColumn = createRelationColumn(hasOneColumnDef, hasOneCellConfig) + const HasManyColumn = createRelationColumn(hasManyColumnDef, hasManyCellConfig) + + test('hasOne renderer returning lands nested fields in the query selection', () => { + const scope = new SelectionScope() + const proxy = createCollectorProxy(scope, 'Project', schemaRegistry) + const jsx = ( + + {(org: any) => ( + + {(tag: any) => } + + )} + + ) + + // Drive the leaf's collection into the parent scope (as useDataGridSetup does). + const leaf = extractColumnLeaves(jsx)[0]! + leaf.collectSelection?.(proxy) + + const query = convertToQuerySelection(scope.toSelectionMeta()) + const organization = query['organization'] as Record | undefined + const tags = organization?.['tags'] as Record | undefined + expect(tags?.['label']).toBe(true) + }) + + test('nested fields also populate the leaf relatedSelection (filter fetch)', () => { + const scope = new SelectionScope() + const proxy = createCollectorProxy(scope, 'Project', schemaRegistry) + const jsx = ( + + {(org: any) => ( + + {(tag: any) => <>} + + )} + + ) + + const leaf = extractColumnLeaves(jsx)[0]! + const related = convertToQuerySelection(leaf.relatedSelection!) + const tags = related['tags'] as Record | undefined + expect(tags?.['label']).toBe(true) + expect(tags?.['type']).toBe(true) + }) + + test('hasMany renderer returning nested lands nested fields in the query selection', () => { + const scope = new SelectionScope() + const proxy = createCollectorProxy(scope, 'Project', schemaRegistry) + const jsx = ( + + {(_tag: any) => } + + ) + + const leaf = extractColumnLeaves(jsx)[0]! + leaf.collectSelection?.(proxy) + + const query = convertToQuerySelection(scope.toSelectionMeta()) + const tags = query['tags'] as Record | undefined + expect(tags?.['type']).toBe(true) + }) +}) diff --git a/tests/react/jsx/collectorContract.test.tsx b/tests/react/jsx/collectorContract.test.tsx new file mode 100644 index 00000000..87349300 --- /dev/null +++ b/tests/react/jsx/collectorContract.test.tsx @@ -0,0 +1,207 @@ +// Tests for declarative collector contracts (Phase 2.2): withCollector(runtime, contract) +// derives a staticRender that collects IDENTICALLY to an equivalent hand-written one +// (oracle comparison), tolerates a missing callback, and never affects runtime rendering. +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { cleanup, waitFor } from '@testing-library/react' +import React from 'react' +import { + createComponent, + withCollector, + itemOf, + entityOf, + COLLECTOR_CONTRACT, + Field, + HasMany, + HasOne, + Entity, + useHasMany, + COMPONENT_SELECTIONS, + type SelectionMeta, + type HasManyRef, + type HasOneRef, + type EntityRef, +} from '@contember/bindx-react' +import { schema, renderWithBindx, type Article, type Author, type Tag } from '../../shared' + +afterEach(() => { + cleanup() +}) + +// Triggers static collection via the `$` fragment getter, then reads the +// stored SelectionMeta — same mechanism the parent Entity walk uses. +function getComponentSelection(component: unknown, propName: string): SelectionMeta | undefined { + const fragment = (component as Record)[`$${propName}`] + if (!fragment) return undefined + const selections = (component as Record>)[COMPONENT_SELECTIONS] + return selections?.get(propName)?.selection +} + +function fieldNames(meta: SelectionMeta): string[] { + return [...meta.fields.values()].map(f => f.fieldName) +} + +// ── Contract components under test and their hand-written staticRender oracles ── + +interface RepeaterProps { + field: HasManyRef + children?: (item: EntityRef) => React.ReactNode +} + +const ContractRepeater = withCollector( + function ContractRepeater({ field, children }: RepeaterProps): React.ReactNode { + const accessor = useHasMany(field) + return <>{accessor.map(item => {children?.(item)})} + }, + { children: itemOf('field') }, +) + +const ManualRepeater = withCollector( + function ManualRepeater(_props: RepeaterProps): React.ReactNode { return null }, + (props: RepeaterProps) => ( + {item => props.children?.(item)} + ), +) + +interface EditorProps { + entity: HasOneRef + children?: (entity: EntityRef) => React.ReactNode +} + +const ContractEditor = withCollector( + function ContractEditor(_props: EditorProps): React.ReactNode { return null }, + { children: entityOf('entity') }, +) + +const ManualEditor = withCollector( + function ManualEditor(_props: EditorProps): React.ReactNode { return null }, + (props: EditorProps) => ( + {entity => props.children?.(entity)} + ), +) + +describe('collector contract — oracle equivalence', () => { + test('itemOf: has-many item callback with nested Field access matches hand-written staticRender', () => { + const ContractHost = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => } + + )) + + const ManualHost = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => } + + )) + + const contract = getComponentSelection(ContractHost, 'article') + const oracle = getComponentSelection(ManualHost, 'article') + expect(contract).toEqual(oracle!) + expect(fieldNames(contract!)).toContain('tags') + }) + + test('itemOf: host-root capture inside the callback (footer-editor shape) matches oracle', () => { + // The callback uses its own item param AND a field of the HOST entity — the exact + // shape that defeated the analyzer. Under a contract the host capture is an ordinary root path. + const ContractHost = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => ( + <> + + + + )} + + )) + + const ManualHost = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {tag => ( + <> + + + + )} + + )) + + const contract = getComponentSelection(ContractHost, 'article') + const oracle = getComponentSelection(ManualHost, 'article') + expect(contract).toEqual(oracle!) + // The host-root capture landed at the article root, the item field under the relation. + expect(fieldNames(contract!)).toContain('title') + expect(fieldNames(contract!)).toContain('tags') + }) + + test('entityOf: has-one entity callback matches hand-written staticRender', () => { + const ContractHost = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {author => } + + )) + + const ManualHost = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {author => } + + )) + + const contract = getComponentSelection(ContractHost, 'article') + const oracle = getComponentSelection(ManualHost, 'article') + expect(contract).toEqual(oracle!) + expect(fieldNames(contract!)).toContain('author') + }) +}) + +describe('collector contract — robustness', () => { + test('missing callback registers the relation with no nested selection and does not crash', () => { + const Host = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + + const selection = getComponentSelection(Host, 'article') + expect(fieldNames(selection!)).toContain('tags') + }) + + test('COLLECTOR_CONTRACT exposes the declared contract for introspection', () => { + const holder = ContractRepeater as unknown as Record + expect(holder[COLLECTOR_CONTRACT]).toEqual({ children: { kind: 'itemOf', field: 'field' } }) + + const editorHolder = ContractEditor as unknown as Record + expect(editorHolder[COLLECTOR_CONTRACT]).toEqual({ children: { kind: 'entityOf', field: 'entity' } }) + }) +}) + +describe('collector contract — runtime rendering (contract only affects collection)', () => { + test('a contract component renders via its runtime function under ', async () => { + const { container } = renderWithBindx( + + {article => ( +
+ + {tag => } + +
+ )} +
, + ) + + await waitFor(() => { + const text = container.querySelector('[data-testid="tags"]')?.textContent ?? '' + expect(text).toContain('JavaScript') + expect(text).toContain('React') + }) + }) +}) diff --git a/tests/react/jsx/collectorProbeKeys.test.tsx b/tests/react/jsx/collectorProbeKeys.test.tsx new file mode 100644 index 00000000..51dc6d70 --- /dev/null +++ b/tests/react/jsx/collectorProbeKeys.test.tsx @@ -0,0 +1,39 @@ +// Regression: rendering a raw ref as a JSX child (`{article.title}` instead of +// ) makes React probe `$$typeof` on the collector +// proxy via isValidElement. That probe used to upgrade the scalar to a bogus +// relation `{ id, $$typeof }` (invalid GraphQL). Reproduced here at the collector +// level to keep the misuse fully typed. +import '../../setup' +import { describe, test, expect } from 'bun:test' +import { isValidElement } from 'react' +import { createCollectorProxy } from '@contember/bindx-react' +import { SelectionScope } from '@contember/bindx' + +interface Author { + id: string + name: string +} +interface Article { + id: string + title: string + author: Author +} + +describe('collector proxy React probe keys', () => { + test('isValidElement probe on a scalar ref does not upgrade it to a relation', () => { + const scope = new SelectionScope() + const entity = createCollectorProxy
(scope, 'Article', null) + + // Accessing the scalar registers it; the probe must not change that. + const titleRef = entity.$fields.title + expect(isValidElement(titleRef)).toBe(false) + + const meta = scope.toSelectionMeta() + const title = meta.fields.get('title') + expect(title).toBeDefined() + expect(title!.isRelation).toBe(false) + expect(title!.nested).toBeUndefined() + // No bogus `$$typeof` field leaked into the selection. + expect([...meta.fields.keys()]).not.toContain('$$typeof') + }) +}) diff --git a/tests/react/jsx/createComponentMock.test.tsx b/tests/react/jsx/createComponentMock.test.tsx new file mode 100644 index 00000000..241713f7 --- /dev/null +++ b/tests/react/jsx/createComponentMock.test.tsx @@ -0,0 +1,155 @@ +// Tests for createComponent().mock() — deterministic stand-in values used ONLY +// during static selection analysis, never at runtime render. See issue #57. +import '../../setup' +import { describe, test, expect, afterEach } from 'bun:test' +import { cleanup, waitFor } from '@testing-library/react' +import React from 'react' +import { createComponent, Field, Entity, COMPONENT_SELECTIONS, type SelectionMeta } from '@contember/bindx-react' +import { schema, renderWithBindx, getByTestId } from '../../shared' + +afterEach(() => { + cleanup() +}) + +// Triggers static collection via the `$` fragment getter (same +// mechanism the parent Entity walk uses through getSelection). +function getComponentSelection(component: unknown, propName: string): SelectionMeta | undefined { + const fragment = (component as Record)[`$${propName}`] + if (!fragment) return undefined + const selections = (component as Record>)[COMPONENT_SELECTIONS] + return selections?.get(propName)?.selection +} + +describe('createComponent().mock()', () => { + test('mocked key indexes a real object, so fields after it are still collected', () => { + // Without the mock, LABELS[] coerces to '' → undefined → `.x` + // throws mid-analysis, so `title` (accessed after) would be missed. + const LABELS: Record = { k1: { x: 'label-one' } } + + const Comp = createComponent() + .entity('article', schema.Article) + .props<{ labelKey: string }>() + .mock({ labelKey: 'k1' }) + .render(({ article, labelKey }) => ( +
+ {LABELS[labelKey]!.x} + +
+ )) + + const selection = getComponentSelection(Comp, 'article') + expect(selection).toBeDefined() + expect([...selection!.fields.keys()]).toContain('title') + }) + + test('sample-array mock invokes .map(), collecting fields used inside the callback', () => { + // The generic mock never runs a .map() callback, so `content` (accessed only + // inside it) would be silently missing. A sample array makes the callback run. + const Comp = createComponent() + .entity('article', schema.Article) + .props<{ tabs: { key: string }[] }>() + .mock({ tabs: [{ key: 'x' }] }) + .render(({ article, tabs }) => ( +
+ + {tabs.map(tab => ( + + ))} +
+ )) + + const selection = getComponentSelection(Comp, 'article') + expect(selection).toBeDefined() + const fields = [...selection!.fields.keys()] + expect(fields).toContain('title') + expect(fields).toContain('content') + }) + + test('deterministic branching selects the chosen branch fields', () => { + const Comp = createComponent() + .entity('article', schema.Article) + .props<{ variant: string }>() + .mock({ variant: 'b' }) + .render(({ article, variant }) => ( + variant === 'b' + ? + : + )) + + const selection = getComponentSelection(Comp, 'article') + expect(selection).toBeDefined() + const fields = [...selection!.fields.keys()] + expect(fields).toContain('title') + expect(fields).not.toContain('publishedAt') + }) + + test('runtime render ignores mocks — real prop values win', async () => { + const Comp = createComponent() + .entity('article', schema.Article) + .props<{ label: string }>() + .mock({ label: 'mocked-label' }) + .render(({ article, label }) => ( +
+ {label} + +
+ )) + + const { container } = renderWithBindx( + + {article => } + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + // Real prop wins at render; the mock is analysis-only. + expect(getByTestId(container, 'label').textContent).toBe('real-label') + }) + + test('mock applies to a .use() output name; runtime uses the real .use() value', async () => { + // LABELS[t('k1')].x would crash with the generic mock (t returns a proxy). + const LABELS: Record = { k1: { x: 'label-one' } } + + const Comp = createComponent() + .entity('article', schema.Article) + .use(() => ({ t: (key: string): string => key })) // real: identity + .mock({ t: () => 'k1' }) // analysis-only: always 'k1' + .render(({ article, t }) => ( +
+ {t('probe')} + {LABELS[t('k1')]!.x} + +
+ )) + + // Static analysis: mock makes t('k1') === 'k1', so the index resolves and + // title is collected without hitting the degraded path. + const selection = getComponentSelection(Comp, 'article') + expect(selection).toBeDefined() + expect([...selection!.fields.keys()]).toContain('title') + + const { container } = renderWithBindx( + + {article => } + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + // Real .use() identity function wins at render, not the mock. + expect(getByTestId(container, 'probe').textContent).toBe('probe') + expect(getByTestId(container, 'index').textContent).toBe('label-one') + }) + + test('mocking an entity prop is a type error', () => { + createComponent() + .entity('article', schema.Article) + .props<{ label: string }>() + // @ts-expect-error - entity props cannot be mocked + .mock({ article: {} }) + .render(({ article }) => ) + }) +}) diff --git a/tests/react/jsx/entityCompiledSelection.test.tsx b/tests/react/jsx/entityCompiledSelection.test.tsx new file mode 100644 index 00000000..90505e93 --- /dev/null +++ b/tests/react/jsx/entityCompiledSelection.test.tsx @@ -0,0 +1,289 @@ +// Runtime side of Phase 3 — compiled root selection. The compiler injects +// `compiledSelection={{ v: 2, props: { entity: {...} }, holes: [...] }}`; here those literals +// are hand-written to simulate the emit. See docs/compiler-plan.md (Phase 3). +import '../../setup' +import { describe, test, expect, afterEach, spyOn } from 'bun:test' +import { cleanup, waitFor } from '@testing-library/react' +import React from 'react' +import { + createComponent, + withCollector, + Entity, + Field, + HasOne, + SCOPE_REF, + setStaticSelectionValidation, + type EntityRef, + type CompiledSelection, +} from '@contember/bindx-react' +import { + renderWithBindx, + getByTestId, + queryByTestId, + schema, + createMockData, + type Article, +} from '../../shared' + +afterEach(() => { + cleanup() + // Validate mode is a module-level flag — never leak it into other tests. + setStaticSelectionValidation(false) +}) + +/** True when `children` was handed a collector proxy (collection pass), not a real handle. */ +function isCollector(value: unknown): boolean { + return typeof value === 'object' && value !== null && SCOPE_REF in value +} + +describe('compiled — collection is skipped', () => { + test('renders WITHOUT ever invoking children with a collector', async () => { + let collectorCalls = 0 + const compiledSelection: CompiledSelection = { v: 2, props: { entity: { title: true } } } + + const { container } = renderWithBindx( + + {article => { + if (isCollector(article)) { + collectorCalls++ + } + return + }} + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + expect(collectorCalls).toBe(0) + }) + + test('without compiledSelection the collector pass runs (relative baseline)', async () => { + let collectorCalls = 0 + + const { container } = renderWithBindx( + + {article => { + if (isCollector(article)) { + collectorCalls++ + } + return + }} + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + // The runtime path collects via at least one collector invocation — the exact + // pass the compiled path elides (see the assertion above: 0). + expect(collectorCalls).toBeGreaterThan(0) + }) +}) + +describe('compiled — fetch + render under MockAdapter', () => { + test('scalar fields', async () => { + const compiledSelection: CompiledSelection = { v: 2, props: { entity: { title: true, content: true } } } + + const { container } = renderWithBindx( + + {article => ( + <> + + + + )} + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + expect(getByTestId(container, 'content').textContent).toBe('This is the content') + }) + + test('nested has-one', async () => { + const compiledSelection: CompiledSelection = { + v: 2, + props: { entity: { title: true, author: { fields: { name: true } } } }, + } + + const { container } = renderWithBindx( + + {article => ( + + {author => } + + )} + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'author-name').textContent).toBe('John Doe') + }) + }) + + test('a hole (nested createComponent receiving an entity-derived value)', async () => { + const AuthorCard = createComponent() + .entity('author', schema.Author) + .render(({ author }) => ) + + const compiledSelection: CompiledSelection = { + v: 2, + props: { entity: { title: true } }, + holes: [{ + component: () => AuthorCard, + entityProps: { author: { source: 'entity', path: ['author'] } }, + }], + } + + const { container } = renderWithBindx( + // The render body would throw if executed for collection — proves the hole + // (not the children walk) drove author.name into the fetch plan. + + {article => ( + <> + + + + )} + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'author-name').textContent).toBe('John Doe') + }) + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + + test('create-mode Entity', async () => { + let collectorCalls = 0 + const compiledSelection: CompiledSelection = { v: 2, props: { entity: { title: true } } } + + const { container } = renderWithBindx( + // Create mode does not fetch; assert it renders and skips the collector walk. + + {article => { + if (isCollector(article)) { + collectorCalls++ + } + return + }} + , + ) + + await waitFor(() => { + expect(queryByTestId(container, 'draft')).not.toBeNull() + }) + // Compiled create mode also skips the collector walk. + expect(collectorCalls).toBe(0) + }) +}) + +describe('compiled — validate mode', () => { + test('agreeing compiled and runtime selections emit no warning', async () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + const compiledSelection: CompiledSelection = { v: 2, props: { entity: { title: true } } } + + const { container } = renderWithBindx( + + {article => } + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + test('a field missing from the compiled selection emits one under-fetch warning', async () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + // A collector-only probe: its staticRender reads `content` (so the validate + // walk collects it into the root) while its runtime renders nothing (so the + // real render never touches `content` on the compiled — and thus content-less — + // root handle). Compiled omits `content` ⇒ under-fetch ⇒ one warning. + interface ContentProbeProps { item: EntityRef
} + const ContentProbe = withCollector( + function ContentProbe(_props: ContentProbeProps): React.ReactNode { return null }, + (props: ContentProbeProps) => , + ) + + const compiledSelection: CompiledSelection = { v: 2, props: { entity: { title: true } } } + + const { container } = renderWithBindx( + + {article => ( + <> + + + + )} + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + await waitFor(() => { + expect(warn).toHaveBeenCalled() + }) + expect(warn).toHaveBeenCalledTimes(1) + const message = String(warn.mock.calls[0]![0]) + expect(message).toContain('content') + expect(message).toContain('Article') + warn.mockRestore() + }) +}) + +describe(' — no compiledSelection is unchanged (sanity)', () => { + test('runtime children walk still drives the fetch', async () => { + const { container } = renderWithBindx( + + {article => ( + + {author => } + + )} + , + { mockData: createMockData() }, + ) + + await waitFor(() => { + expect(getByTestId(container, 'author-name').textContent).toBe('John Doe') + }) + }) +}) + +describe('compiled — malformed literal falls back to the runtime walk', () => { + test('warns and renders via the children collector walk, no crash', async () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + let collectorCalls = 0 + // Stale/corrupt emit — wrong version. Must be rejected; the runtime walk collects instead. + const malformed = { v: 1, props: { entity: { title: true } } } as unknown as CompiledSelection + + const { container } = renderWithBindx( + + {article => { + if (isCollector(article)) { + collectorCalls++ + } + return + }} + , + ) + + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + expect(warn).toHaveBeenCalled() + // Fallback ran: children was invoked with a collector (the pass the compiled path skips). + expect(collectorCalls).toBeGreaterThan(0) + warn.mockRestore() + }) +}) diff --git a/tests/react/jsx/staticSelection.test.tsx b/tests/react/jsx/staticSelection.test.tsx new file mode 100644 index 00000000..99620f29 --- /dev/null +++ b/tests/react/jsx/staticSelection.test.tsx @@ -0,0 +1,653 @@ +// Tests for compiled selections (deliverable A): the converter, the compiled +// build path (proxy pass skipped), end-to-end fetch, validate mode, and phase-2 +// nested-component hole resolution. CompiledSelection literals are hand-written +// here to simulate the compiler's emit. +import '../../setup' +import { describe, test, expect, afterEach, spyOn } from 'bun:test' +import { cleanup, waitFor } from '@testing-library/react' +import React from 'react' +import { + createComponent, + withCollector, + Field, + HasOne, + HasMany, + Entity, + COMPONENT_SELECTIONS, + staticSelectionToMeta, + setStaticSelectionValidation, + setCompiledSelectionsEnabled, + type SelectionMeta, + type StaticFieldMap, + type EntityRef, + type CompiledSelection, +} from '@contember/bindx-react' +import { SelectionScope } from '@contember/bindx' +import { schema, renderWithBindx, getByTestId, type Article, type Author } from '../../shared' + +afterEach(() => { + cleanup() + // Module-level flags — never leak them into other tests. + setStaticSelectionValidation(false) + setCompiledSelectionsEnabled(true) +}) + +// Triggers static collection via the `$` fragment getter, then reads +// the stored SelectionMeta — same mechanism the parent Entity walk uses. +function getComponentSelection(component: unknown, propName: string): SelectionMeta | undefined { + const fragment = (component as Record)[`$${propName}`] + if (!fragment) return undefined + const selections = (component as Record>)[COMPONENT_SELECTIONS] + return selections?.get(propName)?.selection +} + +function fieldNames(meta: SelectionMeta): string[] { + return [...meta.fields.values()].map(f => f.fieldName) +} + +function relation(meta: SelectionMeta, fieldName: string): SelectionMeta { + const field = [...meta.fields.values()].find(f => f.fieldName === fieldName) + if (!field?.nested) throw new Error(`no nested relation "${fieldName}"`) + return field.nested +} + +describe('staticSelectionToMeta — converter equivalence', () => { + test('scalars, has-one nesting and has-many match proxy collection', () => { + // Runtime oracle: selection collected from a real component's proxy pass. + const Comp = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( +
+ + + {a => } + {t => } +
+ )) + const runtime = getComponentSelection(Comp, 'article') + + const map: StaticFieldMap = { + title: true, + content: true, + author: { fields: { name: true } }, + tags: { fields: { name: true }, many: true }, + } + expect(staticSelectionToMeta(map)).toEqual(runtime!) + }) + + test('relation touched without nested access matches proxy collection', () => { + // String(article.author) touches the relation but reads no nested field → + // runtime records it as a scalar leaf, exactly like the `true` node. + const Comp = createComponent() + .entity('article', schema.Article) + .render(({ article }) =>
{String(article.author)}
) + const runtime = getComponentSelection(Comp, 'article') + + expect(staticSelectionToMeta({ author: true })).toEqual(runtime!) + }) + + test('has-many with params matches an equivalently built SelectionScope', () => { + // Standalone createComponent has no schema registry, so its proxy pass does + // not capture has-many params — SelectionScope is the canonical reference here. + const scope = new SelectionScope() + const tags = scope.child('tags') + scope.markAsArray('tags') + scope.setHasManyParams('tags', { limit: 5, filter: { active: true } }) + tags.addScalar('name') + const reference = scope.toSelectionMeta() + + const map: StaticFieldMap = { + tags: { fields: { name: true }, many: true, params: { limit: 5, filter: { active: true } } }, + } + const actual = staticSelectionToMeta(map) + expect(actual).toEqual(reference) + // Params drive alias generation — the has-many key must be the hashed alias, not "tags". + expect([...actual.fields.keys()][0]).not.toBe('tags') + expect([...actual.fields.values()][0]!.hasManyParams).toEqual({ limit: 5, filter: { active: true } }) + }) +}) + +describe('compiled build path (props only)', () => { + test('compiled selection is used and the proxy pass (render fn) is skipped', () => { + let renderCalls = 0 + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => { + renderCalls++ + return + }, + { v: 2, props: { article: { title: true } } }, + ) + + const selection = getComponentSelection(Comp, 'article') + // Proxy pass would have executed the render fn; the compiled path must not. + expect(renderCalls).toBe(0) + expect([...selection!.fields.keys()]).toEqual(['title']) + }) + + test('no compiled argument leaves proxy collection behavior unchanged (sanity)', () => { + const Comp = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + + const selection = getComponentSelection(Comp, 'article') + expect([...selection!.fields.keys()]).toContain('title') + }) + + test('end-to-end: a compiled-selection component under fetches and renders', async () => { + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => , + { v: 2, props: { article: { title: true } } }, + ) + + const { container } = renderWithBindx( + + {article => } + , + ) + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + }) +}) + +describe('validate mode', () => { + test('agreeing compiled and runtime selections emit no warning', () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => , + { v: 2, props: { article: { title: true } } }, + ) + + getComponentSelection(Comp, 'article') + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + test('compiled superset (extra fields, e.g. branch union) emits no warning', () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + // Compiled declares more than the render body reads (as branch unions do). + // Over-fetch is acceptable — only under-fetch warns. + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => , + { v: 2, props: { article: { title: true, content: true, status: true } } }, + ) + + getComponentSelection(Comp, 'article') + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + test('has-many params/many-ness only in compiled (divergence 1) emits no warning', () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + // The compiler emits has-many params; the runtime does not record them in + // implicit collection. Same fields, so a params-only difference must not warn. + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => {t => }, + { v: 2, props: { article: { tags: { fields: { name: true }, many: true, params: { limit: 5 } } } } }, + ) + + getComponentSelection(Comp, 'article') + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + test('disagreeing selections emit one warning naming the missing field', () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + // Compiled omits `content`, but the render body reads it → runtime finds it. + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => ( +
+ + +
+ ), + { v: 2, props: { article: { title: true } } }, + ) + + getComponentSelection(Comp, 'article') + expect(warn).toHaveBeenCalledTimes(1) + const message = String(warn.mock.calls[0]![0]) + expect(message).toContain('content') + expect(message).toContain('BindxComponent(article)') + warn.mockRestore() + }) + + test('a hole-carrying component is not flagged in validate mode', () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + const AuthorCard = createComponent() + .entity('author', schema.Author) + .render(({ author }) => ) + + // Real render body matches the hole, so the proxy oracle agrees with the + // compiled (fields + resolved hole) selection — no under-fetch. + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => , + { + v: 2, + props: { article: {} }, + holes: [{ + component: () => AuthorCard, + entityProps: { author: { source: 'article', path: ['author'] } }, + }], + }, + ) + + getComponentSelection(Comp, 'article') + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) +}) + +describe('compiled selection v2 — nested-component holes', () => { + test('hole → createComponent target equals the proxy-pass selection of an inline host', () => { + const AuthorCard = createComponent() + .entity('author', schema.Author) + .render(({ author }) => ( +
+ + +
+ )) + + // Oracle: a hand-rolled host that renders the target inline, collected via + // the runtime proxy pass. + const InlineHost = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + const oracle = getComponentSelection(InlineHost, 'article') + + // Compiled: same composition expressed as a hole; render must never run. + const CompiledHost = createComponent() + .entity('article', schema.Article) + .render( + () => { throw new Error('render must not run on the compiled path') }, + { + v: 2, + props: { article: {} }, + holes: [{ + component: () => AuthorCard, + entityProps: { author: { source: 'article', path: ['author'] } }, + }], + }, + ) + const compiled = getComponentSelection(CompiledHost, 'article') + + expect(compiled).toEqual(oracle!) + // Sanity: fields landed under the `author` relation. + expect(fieldNames(relation(compiled!, 'author')).sort()).toEqual(['email', 'id', 'name']) + }) + + test('hole with empty path passes the root entity to the target', () => { + const Summary = createComponent() + .entity('item', schema.Article) + .render(({ item }) => ) + + const CompiledHost = createComponent() + .entity('article', schema.Article) + .render( + () => { throw new Error('render must not run') }, + { + v: 2, + props: { article: {} }, + holes: [{ + component: () => Summary, + entityProps: { item: { source: 'article', path: [] } }, + }], + }, + ) + const selection = getComponentSelection(CompiledHost, 'article') + // Root-level path ⇒ merged fields land at the article root, not nested. + expect(fieldNames(selection!)).toContain('title') + }) + + test('hole → withCollector/staticRender target', () => { + interface TitleCollectorProps { item: EntityRef
} + const TitleCollector = withCollector( + function TitleCollector(_props: TitleCollectorProps): React.ReactNode { return null }, + (props: TitleCollectorProps) => ( + <> + + + + ), + ) + + const CompiledHost = createComponent() + .entity('article', schema.Article) + .render( + () => { throw new Error('render must not run') }, + { + v: 2, + props: { article: {} }, + holes: [{ + component: () => TitleCollector, + entityProps: { item: { source: 'article', path: [] } }, + }], + }, + ) + const selection = getComponentSelection(CompiledHost, 'article') + expect(fieldNames(selection!).sort()).toEqual(['content', 'title']) + }) + + test('hole with entity-derived path merges fields under the relation', () => { + const AuthorName = createComponent() + .entity('author', schema.Author) + .render(({ author }) => ) + + const CompiledHost = createComponent() + .entity('article', schema.Article) + .render( + () => { throw new Error('render must not run') }, + { + v: 2, + props: { article: { title: true } }, + holes: [{ + component: () => AuthorName, + entityProps: { author: { source: 'article', path: ['author'] } }, + }], + }, + ) + const selection = getComponentSelection(CompiledHost, 'article') + // The host's own static field coexists with the hole's nested contribution. + expect(fieldNames(selection!)).toContain('title') + expect(fieldNames(relation(selection!, 'author'))).toContain('name') + }) + + test('hole target defined AFTER the host (thunk avoids TDZ)', () => { + const CompiledHost = createComponent() + .entity('article', schema.Article) + .render( + () => { throw new Error('render must not run') }, + { + v: 2, + props: { article: {} }, + holes: [{ + // LateAuthorCard is declared below — the thunk defers resolution + // until collection time, dodging the temporal dead zone. + component: () => LateAuthorCard, + entityProps: { author: { source: 'article', path: ['author'] } }, + }], + }, + ) + + const LateAuthorCard = createComponent() + .entity('author', schema.Author) + .render(({ author }) => ) + + const selection = getComponentSelection(CompiledHost, 'article') + expect(fieldNames(relation(selection!, 'author'))).toContain('name') + }) + + test('multiple entityProps on one hole', () => { + const Pair = createComponent() + .entity('author', schema.Author) + .entity('place', schema.Location) + .render(({ author, place }) => ( + <> + + + + )) + + const CompiledHost = createComponent() + .entity('article', schema.Article) + .render( + () => { throw new Error('render must not run') }, + { + v: 2, + props: { article: {} }, + holes: [{ + component: () => Pair, + entityProps: { + author: { source: 'article', path: ['author'] }, + place: { source: 'article', path: ['location'] }, + }, + }], + }, + ) + const selection = getComponentSelection(CompiledHost, 'article') + expect(fieldNames(relation(selection!, 'author'))).toContain('name') + expect(fieldNames(relation(selection!, 'location'))).toContain('label') + }) + + test('plain component target contributes nothing; validate warns, non-validate is silent', () => { + function PlainThing(_props: { value: unknown }): React.ReactNode { return null } + + const build = (): unknown => createComponent() + .entity('article', schema.Article) + .render( + () => null, + { + v: 2, + props: { article: { title: true } }, + holes: [{ + component: () => PlainThing, + entityProps: { value: { source: 'article', path: ['author'] } }, + }], + }, + ) + + // validate OFF: no warn, no crash. + const warnOff = spyOn(console, 'warn').mockImplementation(() => {}) + expect(fieldNames(getComponentSelection(build(), 'article')!)).toContain('title') + expect(warnOff).not.toHaveBeenCalled() + warnOff.mockRestore() + + // validate ON: exactly one warn naming the blind-spot component. + setStaticSelectionValidation(true) + const warnOn = spyOn(console, 'warn').mockImplementation(() => {}) + getComponentSelection(build(), 'article') + expect(warnOn).toHaveBeenCalledTimes(1) + expect(String(warnOn.mock.calls[0]![0])).toContain('PlainThing') + warnOn.mockRestore() + }) + + test('a throwing hole target is contained — siblings and static fields survive', () => { + const error = spyOn(console, 'error').mockImplementation(() => {}) + + const throwingTarget = { + getSelection(): never { throw new Error('boom') }, + } + const AuthorCard = createComponent() + .entity('author', schema.Author) + .render(({ author }) => ) + + const CompiledHost = createComponent() + .entity('article', schema.Article) + .render( + () => { throw new Error('render must not run') }, + { + v: 2, + props: { article: { title: true } }, + holes: [ + { + component: () => throwingTarget, + entityProps: { author: { source: 'article', path: ['author'] } }, + }, + { + component: () => AuthorCard, + entityProps: { author: { source: 'article', path: ['author'] } }, + }, + ], + }, + ) + const selection = getComponentSelection(CompiledHost, 'article') + + // Static field and the sibling hole both survived the throwing one. + expect(fieldNames(selection!)).toContain('title') + expect(fieldNames(relation(selection!, 'author'))).toContain('name') + expect(error).toHaveBeenCalled() + error.mockRestore() + }) + + test('hole extraProps: a lifted render-prop closure collects through the target staticRender', () => { + // Mirrors npi's SelectField (and the compiler's phase-2.1 lift): the render-prop child is + // passed via extraProps, and the target's staticRender INVOKES it with the relation entity. + interface SelectFieldProps { + entity: EntityRef + children: (entity: EntityRef) => React.ReactNode + } + const SelectField = withCollector( + function SelectField(_props: SelectFieldProps): React.ReactNode { return null }, + (props: SelectFieldProps) => <>{props.children(props.entity)}, + ) + + const CompiledHost = createComponent() + .entity('article', schema.Article) + .render( + () => { throw new Error('render must not run') }, + { + v: 2, + props: { article: {} }, + holes: [{ + component: () => SelectField, + entityProps: { entity: { source: 'article', path: ['author'] } }, + // The lifted closure captures nothing; the target replays it with a proxy. + extraProps: { children: () => (entity: EntityRef) => }, + }], + }, + ) + + const selection = getComponentSelection(CompiledHost, 'article') + expect(fieldNames(relation(selection!, 'author'))).toContain('name') + }) + + test('render fn is never executed even when holes are present', () => { + let renderCalls = 0 + const AuthorCard = createComponent() + .entity('author', schema.Author) + .render(({ author }) => ) + + const CompiledHost = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => { + renderCalls++ + return + }, + { + v: 2, + props: { article: { title: true } }, + holes: [{ + component: () => AuthorCard, + entityProps: { author: { source: 'article', path: ['author'] } }, + }], + }, + ) + + getComponentSelection(CompiledHost, 'article') + expect(renderCalls).toBe(0) + }) +}) + +describe('compiled selection — runtime hardening (validation, containment, killswitch)', () => { + test('malformed compiled literal warns and falls back to the proxy collection pass', () => { + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + let renderCalls = 0 + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => { + renderCalls++ + return + }, + // Stale/corrupt emit — wrong version. Runtime must reject it and re-run the proxy pass. + { v: 1, props: { article: { title: true } } } as unknown as CompiledSelection, + ) + + const selection = getComponentSelection(Comp, 'article') + expect(warn).toHaveBeenCalled() + // The fallback ran the render fn and collected what IT read (content), not the literal's `title`. + expect(renderCalls).toBeGreaterThan(0) + expect(fieldNames(selection!)).toEqual(['content']) + warn.mockRestore() + }) + + test('a throwing extraProps thunk is contained — its hole degrades, siblings and own props survive', () => { + const error = spyOn(console, 'error').mockImplementation(() => {}) + + interface SelectFieldProps { + entity: EntityRef + children: (entity: EntityRef) => React.ReactNode + } + const SelectField = withCollector( + function SelectField(_props: SelectFieldProps): React.ReactNode { return null }, + (props: SelectFieldProps) => <>{props.children(props.entity)}, + ) + const AuthorCard = createComponent() + .entity('author', schema.Author) + .render(({ author }) => ) + + const CompiledHost = createComponent() + .entity('article', schema.Article) + .render( + () => { throw new Error('render must not run') }, + { + v: 2, + props: { article: { title: true } }, + holes: [ + { + component: () => SelectField, + entityProps: { entity: { source: 'article', path: ['author'] } }, + // TDZ / module-init failure: the thunk throws while the hole's props are assembled. + extraProps: { children: () => { throw new Error('thunk boom') } }, + }, + { + component: () => AuthorCard, + entityProps: { author: { source: 'article', path: ['author'] } }, + }, + ], + }, + ) + + const selection = getComponentSelection(CompiledHost, 'article') + // Own static prop and the sibling hole both survived the throwing thunk. + expect(fieldNames(selection!)).toContain('title') + expect(fieldNames(relation(selection!, 'author'))).toContain('name') + expect(error).toHaveBeenCalled() + error.mockRestore() + }) + + test('killswitch: setCompiledSelectionsEnabled(false) forces the proxy pass', () => { + setCompiledSelectionsEnabled(false) + let renderCalls = 0 + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => { + renderCalls++ + return + }, + { v: 2, props: { article: { title: true } } }, + ) + + const selection = getComponentSelection(Comp, 'article') + // Compiled path disabled ⇒ the render fn ran and its field (content) was collected, not the literal's `title`. + expect(renderCalls).toBeGreaterThan(0) + expect(fieldNames(selection!)).toEqual(['content']) + }) +}) diff --git a/tsconfig.json b/tsconfig.json index 9908f1a1..0f7ebbfd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,6 +12,7 @@ { "path": "./packages/bindx-uploader" }, { "path": "./packages/bindx-generator" }, { "path": "./packages/bindx-generator/tests" }, + { "path": "./packages/bindx-compiler" }, { "path": "./packages/example" } ], "files": []