From 4b879626823a6b4625e3b3875b59e572bb3f0993 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 14:05:14 +0200 Subject: [PATCH 01/34] feat(bindx-react): createComponent().mock() for analysis-time prop mocks .mock() supplies deterministic values for scalar / .use() props used only during static selection analysis, never at runtime render. It fixes cases the generic tolerant stand-in mishandles: indexing a real object with a mocked key (crash + partial selection), branching on a scalar value (nondeterministic branch), and .map() over a scalar array (callback never runs, so entity fields accessed inside it are silently missed). The custom mock wins over the interfaces-mode branch in the collection propsProxy so a mocked scalar is not mistaken for an interface entity prop. Entity props cannot be mocked (compile-time error). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- .../bindx-react/src/jsx/componentBuilder.ts | 22 +++ .../src/jsx/componentBuilder.types.ts | 28 ++++ .../bindx-react/src/jsx/componentFactory.ts | 10 +- tests/react/jsx/createComponentMock.test.tsx | 155 ++++++++++++++++++ 4 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 tests/react/jsx/createComponentMock.test.tsx diff --git a/packages/bindx-react/src/jsx/componentBuilder.ts b/packages/bindx-react/src/jsx/componentBuilder.ts index 045ab96..d8a458c 100644 --- a/packages/bindx-react/src/jsx/componentBuilder.ts +++ b/packages/bindx-react/src/jsx/componentBuilder.ts @@ -50,6 +50,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 +70,7 @@ export class ComponentBuilderImpl< this.conditionFn, this.slotNames, this.useFns, + this.mockValues, ) } @@ -99,6 +101,7 @@ export class ComponentBuilderImpl< this.conditionFn, this.slotNames, this.useFns, + this.mockValues, ) } @@ -112,6 +115,7 @@ export class ComponentBuilderImpl< this.conditionFn, this.slotNames, this.useFns, + this.mockValues, ) } @@ -124,6 +128,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 +155,7 @@ export class ComponentBuilderImpl< conditionFn, this.slotNames, this.useFns, + this.mockValues, ) } @@ -148,6 +168,7 @@ export class ComponentBuilderImpl< this.conditionFn, names, this.useFns, + this.mockValues, ) } @@ -161,6 +182,7 @@ export class ComponentBuilderImpl< this.conditionFn, this.slotNames, this.useFns, + this.mockValues, ) } } diff --git a/packages/bindx-react/src/jsx/componentBuilder.types.ts b/packages/bindx-react/src/jsx/componentBuilder.types.ts index 3215ee4..0cf7158 100644 --- a/packages/bindx-react/src/jsx/componentBuilder.types.ts +++ b/packages/bindx-react/src/jsx/componentBuilder.types.ts @@ -482,6 +482,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. diff --git a/packages/bindx-react/src/jsx/componentFactory.ts b/packages/bindx-react/src/jsx/componentFactory.ts index af488dd..1d8dd3b 100644 --- a/packages/bindx-react/src/jsx/componentFactory.ts +++ b/packages/bindx-react/src/jsx/componentFactory.ts @@ -102,6 +102,7 @@ export function buildComponent( conditionFn: ((props: TProps) => Condition) | null, slotNames: readonly string[], useFns: readonly ((props: TProps) => object)[], + mockValues: Record, ): unknown { const selectionsMap = new Map() const componentDisplayName = `BindxComponent(${[...entityConfigs.keys()].join(', ')})` @@ -144,7 +145,7 @@ export function buildComponent( } collectionState = 'collecting' try { - collectImplicitSelections(implicitConfigs, renderFn, selectionsMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn) + 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). @@ -329,6 +330,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 +370,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) { diff --git a/tests/react/jsx/createComponentMock.test.tsx b/tests/react/jsx/createComponentMock.test.tsx new file mode 100644 index 0000000..241713f --- /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 }) => ) + }) +}) From 85b68d7ef26c65c6966548c697f55344d574f65c Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 14:25:49 +0200 Subject: [PATCH 02/34] docs: selection compiler experiment plan Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 190 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 docs/compiler-plan.md diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md new file mode 100644 index 0000000..acfb8f3 --- /dev/null +++ b/docs/compiler-plan.md @@ -0,0 +1,190 @@ +# 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`. + +## Future (explicitly out of scope now) + +Fragment-reference emit for nested components (`__compose` runtime holes — the Relay-spread +equivalent), unplugin packaging, eslint plugin reusing the analyzer (bail reasons as lint +diagnostics), oxc/SWC port if Babel cost ever matters. From 7369580feab71d5c0bee93bdbf0a3a4dfad3e5f1 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 14:36:59 +0200 Subject: [PATCH 03/34] feat(bindx-react): runtime support for precompiled static selections Deliverable A of the selection compiler: consume a compiler-emitted static selection instead of the runtime proxy collection pass. - staticSelectionToMeta(map) in packages/bindx/src/selection/ converts a StaticFieldMap into SelectionMeta by driving a SelectionScope, so output is indistinguishable from SelectionScope.toSelectionMeta() (alias/path/id-seeding). - .render(fn, staticSelection?) gains a compiler-facing 2nd arg, threaded into buildComponent. When present, ensureImplicitCollected() builds selectionsMap entries via the converter + createFragment and skips the proxy pass entirely (all-or-nothing for v1). - setStaticSelectionValidation(enabled) module-level flag: when on and a static selection exists, also run the proxy pass and deep-diff per prop, emitting one console.warn with the display name and a readable field-level diff on mismatch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- packages/bindx-react/src/index.ts | 6 + .../bindx-react/src/jsx/componentBuilder.ts | 7 +- .../src/jsx/componentBuilder.types.ts | 10 +- .../bindx-react/src/jsx/componentFactory.ts | 126 ++++++++++++ packages/bindx-react/src/jsx/index.ts | 3 + packages/bindx/src/index.ts | 4 +- packages/bindx/src/selection/index.ts | 6 + .../src/selection/staticSelectionToMeta.ts | 63 ++++++ tests/react/jsx/staticSelection.test.tsx | 181 ++++++++++++++++++ 9 files changed, 402 insertions(+), 4 deletions(-) create mode 100644 packages/bindx/src/selection/staticSelectionToMeta.ts create mode 100644 tests/react/jsx/staticSelection.test.tsx diff --git a/packages/bindx-react/src/index.ts b/packages/bindx-react/src/index.ts index 951b850..2c94198 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, @@ -365,6 +370,7 @@ export { getComponentBrand, setBrandValidation, validateBrand, + setStaticSelectionValidation, } from './jsx/index.js' // Entity Scope diff --git a/packages/bindx-react/src/jsx/componentBuilder.ts b/packages/bindx-react/src/jsx/componentBuilder.ts index d8a458c..85fb09c 100644 --- a/packages/bindx-react/src/jsx/componentBuilder.ts +++ b/packages/bindx-react/src/jsx/componentBuilder.ts @@ -10,6 +10,7 @@ import type { SchemaRegistry, SelectionBuilder, EntityDef, + StaticSelection, } from '@contember/bindx' import { ComponentBrand, @@ -172,7 +173,10 @@ export class ComponentBuilderImpl< ) } - render(renderFn: (props: Record) => ReactNode): unknown { + render( + renderFn: (props: Record) => ReactNode, + staticSelection?: StaticSelection, + ): unknown { return buildComponent( this.entityConfigs, this.roles, @@ -183,6 +187,7 @@ export class ComponentBuilderImpl< this.slotNames, this.useFns, this.mockValues, + staticSelection ?? null, ) } } diff --git a/packages/bindx-react/src/jsx/componentBuilder.types.ts b/packages/bindx-react/src/jsx/componentBuilder.types.ts index 0cf7158..58e25b8 100644 --- a/packages/bindx-react/src/jsx/componentBuilder.types.ts +++ b/packages/bindx-react/src/jsx/componentBuilder.types.ts @@ -16,6 +16,7 @@ import type { SelectionMeta, EntityDef, ResolveEntity, + StaticSelection, } from '@contember/bindx' import type { SelectionProvider } from './types.js' import type { Condition } from './conditions.js' @@ -561,9 +562,16 @@ export interface ComponentBuilder< * Build the component with the render function. * * @param renderFn - React render function receiving typed props + * @param staticSelection - Precompiled selection injected by the selection + * compiler. 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, + staticSelection?: StaticSelection, + ): BindxComponent } // ============================================================================ diff --git a/packages/bindx-react/src/jsx/componentFactory.ts b/packages/bindx-react/src/jsx/componentFactory.ts index 1d8dd3b..9c46b4a 100644 --- a/packages/bindx-react/src/jsx/componentFactory.ts +++ b/packages/bindx-react/src/jsx/componentFactory.ts @@ -17,6 +17,7 @@ import type { AnyBrand, EntityRef, SchemaDefinition, + StaticSelection, } from '@contember/bindx' import { SchemaRegistry, @@ -24,6 +25,7 @@ import { ComponentBrand, createSelectionBuilder, SelectionScope, + staticSelectionToMeta, } from '@contember/bindx' import type { SelectionPropMeta, @@ -54,6 +56,24 @@ 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 +} + // ============================================================================ // Entity Config (Runtime) // ============================================================================ @@ -103,6 +123,7 @@ export function buildComponent( slotNames: readonly string[], useFns: readonly ((props: TProps) => object)[], mockValues: Record, + staticSelection: StaticSelection | null, ): unknown { const selectionsMap = new Map() const componentDisplayName = `BindxComponent(${[...entityConfigs.keys()].join(', ')})` @@ -145,6 +166,18 @@ export function buildComponent( } collectionState = 'collecting' try { + // Precompiled selection present ⇒ build entries from it, skip the proxy pass. + if (staticSelection) { + applyStaticSelections(staticSelection, selectionsMap, componentBrand, roles) + if (staticSelectionValidationEnabled) { + validateStaticSelections( + staticSelection, selectionsMap, componentDisplayName, + implicitConfigs, renderFn, componentBrand, roles, + hasInterfacesMode, schemaRegistry, conditionFn, mockValues, + ) + } + return + } collectImplicitSelections(implicitConfigs, renderFn, selectionsMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn, mockValues) } catch (error) { // Analysis is deterministic, so retrying is pointless — degrade loudly @@ -432,6 +465,99 @@ function collectImplicitSelections( finalizeScopes() } +// ============================================================================ +// Static Selection Application & Validation +// ============================================================================ + +/** + * Builds selectionsMap entries from a precompiled static selection — one per + * entity prop present in the static object. Replaces the proxy pass entirely. + */ +function applyStaticSelections( + staticSelection: StaticSelection, + selectionsMap: Map, + componentBrand: ComponentBrand, + roles: readonly string[], +): void { + for (const [propName, fieldMap] of Object.entries(staticSelection)) { + const selection = staticSelectionToMeta(fieldMap) + selectionsMap.set(propName, { + selection, + fragment: createFragment(selection, componentBrand, roles), + }) + } +} + +/** + * Validate mode: also run the proxy pass and warn (once) when the precompiled + * selection disagrees with what runtime collection would produce. + */ +function validateStaticSelections( + staticSelection: StaticSelection, + staticMap: Map, + componentDisplayName: string, + implicitConfigs: [string, EntityConfig][], + renderFn: (props: TProps) => ReactNode, + componentBrand: ComponentBrand, + roles: readonly string[], + 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 static selection already stands; nothing to compare. + return + } + + const lines: string[] = [] + for (const propName of Object.keys(staticSelection)) { + const staticSelectionMeta = staticMap.get(propName)?.selection + const runtimeSelectionMeta = runtimeMap.get(propName)?.selection + diffSelectionMeta(staticSelectionMeta, runtimeSelectionMeta, [propName], lines) + } + + if (lines.length > 0) { + console.warn( + `[bindx] static selection mismatch for <${componentDisplayName}>:\n${lines.join('\n')}`, + ) + } +} + +/** + * Recursively diffs two selections by field alias, appending human-readable + * lines for fields present on only one side (dotted paths for nested relations). + */ +function diffSelectionMeta( + staticMeta: SelectionMeta | undefined, + runtimeMeta: SelectionMeta | undefined, + path: string[], + lines: string[], +): void { + const staticFields = staticMeta?.fields ?? new Map() + const runtimeFields = runtimeMeta?.fields ?? new Map() + + for (const alias of staticFields.keys()) { + if (!runtimeFields.has(alias)) { + lines.push(` only in static: ${[...path, alias].join('.')}`) + } + } + for (const [alias, runtimeField] of runtimeFields) { + const staticField = staticFields.get(alias) + if (!staticField) { + lines.push(` only in runtime: ${[...path, alias].join('.')}`) + continue + } + // Both sides have this relation — recurse into nested selections. + if (staticField.nested || runtimeField.nested) { + diffSelectionMeta(staticField.nested, runtimeField.nested, [...path, alias], lines) + } + } +} + // ============================================================================ // Fragment Creation // ============================================================================ diff --git a/packages/bindx-react/src/jsx/index.ts b/packages/bindx-react/src/jsx/index.ts index fe40976..01fc2fb 100644 --- a/packages/bindx-react/src/jsx/index.ts +++ b/packages/bindx-react/src/jsx/index.ts @@ -101,6 +101,9 @@ export { // Standalone createComponent function export { createComponent } from './standaloneCreateComponent.js' +// Static (precompiled) selection validate-mode toggle +export { setStaticSelectionValidation } from './componentFactory.js' + // withCollector — attach staticRender to a component for selection collection export { withCollector } from './withCollector.js' diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 304bacd..7e185cc 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 } 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 f61f5e2..093da97 100644 --- a/packages/bindx/src/selection/index.ts +++ b/packages/bindx/src/selection/index.ts @@ -29,3 +29,9 @@ 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, + 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 0000000..546a30d --- /dev/null +++ b/packages/bindx/src/selection/staticSelectionToMeta.ts @@ -0,0 +1,63 @@ +/** + * 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 + +/** + * Populates a scope from a static field map, mirroring the runtime collector's + * scope operations: nested access ⇒ relation (and `child()` seeds `id`). + */ +function populateScope(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) + } + populateScope(childScope, node.fields) + } +} + +/** + * Converts a static field map for a single entity prop into `SelectionMeta`. + */ +export function staticSelectionToMeta(map: StaticFieldMap): SelectionMeta { + const scope = new SelectionScope() + populateScope(scope, map) + return scope.toSelectionMeta() +} diff --git a/tests/react/jsx/staticSelection.test.tsx b/tests/react/jsx/staticSelection.test.tsx new file mode 100644 index 0000000..61a24b7 --- /dev/null +++ b/tests/react/jsx/staticSelection.test.tsx @@ -0,0 +1,181 @@ +// Tests for precompiled static selections (deliverable A): the converter, +// the static build path (proxy pass skipped), end-to-end fetch, and validate mode. +import '../../setup' +import { describe, test, expect, afterEach, spyOn } from 'bun:test' +import { cleanup, waitFor } from '@testing-library/react' +import React from 'react' +import { + createComponent, + Field, + HasOne, + HasMany, + Entity, + COMPONENT_SELECTIONS, + staticSelectionToMeta, + setStaticSelectionValidation, + type SelectionMeta, + type StaticFieldMap, +} from '@contember/bindx-react' +import { SelectionScope } from '@contember/bindx' +import { schema, renderWithBindx, getByTestId } from '../../shared' + +afterEach(() => { + cleanup() + // Validate mode is a module-level flag — never leak it into other tests. + setStaticSelectionValidation(false) +}) + +// 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 +} + +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('static build path', () => { + test('static 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 + }, + { article: { title: true } }, + ) + + const selection = getComponentSelection(Comp, 'article') + // Proxy pass would have executed the render fn; the static path must not. + expect(renderCalls).toBe(0) + expect([...selection!.fields.keys()]).toEqual(['title']) + }) + + test('no static 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 static-selection component under fetches and renders', async () => { + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => , + { article: { title: true } }, + ) + + const { container } = renderWithBindx( + + {article => } + , + ) + await waitFor(() => { + expect(getByTestId(container, 'title').textContent).toBe('Hello World') + }) + }) +}) + +describe('validate mode', () => { + test('agreeing static and runtime selections emit no warning', () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => , + { article: { title: true } }, + ) + + 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(() => {}) + + // Static omits `content`, but the render body reads it → runtime finds it. + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) => ( +
+ + +
+ ), + { 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() + }) +}) From 0fcb90092db2cd1884741c3c86eaa626935d6664 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 14:59:24 +0200 Subject: [PATCH 04/34] feat(bindx-compiler): static selection analyzer + babel plugin (experimental) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New private workspace package implementing deliverable B of the selection compiler plan: a pure analyzer core (analyzeSource) that proves a createComponent chain's implicit selection or bails with a machine-readable reason (default deny), plus a Babel plugin that injects the emitted StaticSelection as the 2nd argument of .render(). - Analyzer mirrors the runtime collector (SelectionScope + collector proxy): usage-derived relation-ness, auto-id on relations, skip-list ($/id/__), $fields/$entity transparency, full-body union over all branches + nested fns. - Recognized components: Field/Attribute/Show/HasOne/HasMany/If + cond DSL + .map(); HasMany literal params emitted (dropped by the oracle, so equivalence compares field trees only). - Bail taxonomy: interfaces, non-inline render/if fn, dynamic entity name, entity escaping to call/component, spread, computed member, non-literal HasMany param, entity reassignment, unclassified. - Equivalence harness validates every fixture against the runtime oracle (runtime = truth); ternary fixture asserts runtime ⊆ compiler. 39 tests pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- bun.lock | 17 + packages/bindx-compiler/package.json | 37 ++ packages/bindx-compiler/src/analyze.ts | 78 ++++ packages/bindx-compiler/src/astWalk.ts | 37 ++ packages/bindx-compiler/src/babelPlugin.ts | 38 ++ packages/bindx-compiler/src/body.ts | 364 ++++++++++++++++++ packages/bindx-compiler/src/chain.ts | 124 ++++++ packages/bindx-compiler/src/emit.ts | 40 ++ packages/bindx-compiler/src/imports.ts | 52 +++ packages/bindx-compiler/src/index.ts | 17 + packages/bindx-compiler/src/jsx.ts | 256 ++++++++++++ packages/bindx-compiler/src/resolve.ts | 216 +++++++++++ packages/bindx-compiler/src/selectionTree.ts | 88 +++++ packages/bindx-compiler/src/types.ts | 77 ++++ .../bindx-compiler/tests/analyzer.test.ts | 103 +++++ .../bindx-compiler/tests/equivalence.test.ts | 69 ++++ packages/bindx-compiler/tests/fixtureTypes.ts | 17 + .../bindx-compiler/tests/fixtures/_schema.ts | 33 ++ .../bindx-compiler/tests/fixtures/bails.tsx | 78 ++++ .../tests/fixtures/condition.tsx | 16 + .../tests/fixtures/constAlias.tsx | 22 ++ .../tests/fixtures/hasManyParams.tsx | 23 ++ .../tests/fixtures/irrelevantChain.tsx | 20 + .../tests/fixtures/mapHasMany.tsx | 20 + .../tests/fixtures/nestedHasOne.tsx | 25 ++ .../bindx-compiler/tests/fixtures/scalars.tsx | 20 + .../bindx-compiler/tests/fixtures/ternary.tsx | 19 + packages/bindx-compiler/tests/harness.ts | 71 ++++ packages/bindx-compiler/tests/plugin.test.ts | 65 ++++ packages/bindx-compiler/tsconfig.json | 13 + tsconfig.json | 1 + 31 files changed, 2056 insertions(+) create mode 100644 packages/bindx-compiler/package.json create mode 100644 packages/bindx-compiler/src/analyze.ts create mode 100644 packages/bindx-compiler/src/astWalk.ts create mode 100644 packages/bindx-compiler/src/babelPlugin.ts create mode 100644 packages/bindx-compiler/src/body.ts create mode 100644 packages/bindx-compiler/src/chain.ts create mode 100644 packages/bindx-compiler/src/emit.ts create mode 100644 packages/bindx-compiler/src/imports.ts create mode 100644 packages/bindx-compiler/src/index.ts create mode 100644 packages/bindx-compiler/src/jsx.ts create mode 100644 packages/bindx-compiler/src/resolve.ts create mode 100644 packages/bindx-compiler/src/selectionTree.ts create mode 100644 packages/bindx-compiler/src/types.ts create mode 100644 packages/bindx-compiler/tests/analyzer.test.ts create mode 100644 packages/bindx-compiler/tests/equivalence.test.ts create mode 100644 packages/bindx-compiler/tests/fixtureTypes.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_schema.ts create mode 100644 packages/bindx-compiler/tests/fixtures/bails.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/condition.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/constAlias.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/hasManyParams.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/irrelevantChain.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/mapHasMany.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/nestedHasOne.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/scalars.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/ternary.tsx create mode 100644 packages/bindx-compiler/tests/harness.ts create mode 100644 packages/bindx-compiler/tests/plugin.test.ts create mode 100644 packages/bindx-compiler/tsconfig.json diff --git a/bun.lock b/bun.lock index 4a26427..d9c86b8 100644 --- a/bun.lock +++ b/bun.lock @@ -36,6 +36,21 @@ "@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": { + "@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", @@ -243,6 +258,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/packages/bindx-compiler/package.json b/packages/bindx-compiler/package.json new file mode 100644 index 0000000..4f7da0e --- /dev/null +++ b/packages/bindx-compiler/package.json @@ -0,0 +1,37 @@ +{ + "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/" + }, + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/parser": "^7.28.0", + "@babel/traverse": "^7.28.0", + "@babel/types": "^7.28.0" + }, + "devDependencies": { + "@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/src/analyze.ts b/packages/bindx-compiler/src/analyze.ts new file mode 100644 index 0000000..95b7535 --- /dev/null +++ b/packages/bindx-compiler/src/analyze.ts @@ -0,0 +1,78 @@ +/** + * 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 { parse } from '@babel/parser' +import * as t from '@babel/types' +import { collectImportBindings, type ImportBindings } from './imports.js' +import { findChains, type Chain } from './chain.js' +import { BodyAnalyzer } from './body.js' +import { BailError } from './resolve.js' +import { SelNode } from './selectionTree.js' +import type { ChainLoc, ChainResult, StaticSelection } from './types.js' + +export interface InternalChainResult { + readonly chain: Chain + readonly result: ChainResult +} + +export function parseProgram(code: string, _filename: string): t.Program { + const file = parse(code, { + sourceType: 'module', + plugins: ['jsx', 'typescript'], + }) + return file.program +} + +/** Analyze an already-parsed program; retains Babel node refs for the plugin. */ +export function analyzeProgram(program: t.Program): InternalChainResult[] { + const bindings = collectImportBindings(program) + const chains = findChains(program, bindings) + return chains.map(chain => ({ chain, result: analyzeChain(chain, bindings) })) +} + +function analyzeChain(chain: Chain, bindings: ImportBindings): 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) + 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 } + } + throw error + } + + const selection: StaticSelection = {} + for (const [prop, node] of propRoots) { + if (node.hasFields()) { + selection[prop] = node.toFieldMap() + } + } + return { loc, entityProps: chain.entityProps, selection } +} + +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): ChainResult[] { + return analyzeProgram(parseProgram(code, 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 0000000..eb0aacc --- /dev/null +++ b/packages/bindx-compiler/src/astWalk.ts @@ -0,0 +1,37 @@ +/** + * 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' + +/** Depth-first pre-order walk. Return `false` from `enter` to skip a node's children. */ +export function walkAst(root: t.Node, enter: (node: t.Node) => boolean | void): void { + const visit = (node: t.Node): void => { + if (enter(node) === false) { + return + } + const keys = t.VISITOR_KEYS[node.type] + if (!keys) { + return + } + for (const key of keys) { + // Index access is required to traverse arbitrary node shapes generically. + const child: unknown = (node as unknown as Record)[key] + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && isNode(item)) { + visit(item) + } + } + } else if (child && typeof child === 'object' && isNode(child)) { + visit(child) + } + } + } + visit(root) +} + +function isNode(value: object): value is t.Node { + return typeof (value as { type?: unknown }).type === 'string' +} diff --git a/packages/bindx-compiler/src/babelPlugin.ts b/packages/bindx-compiler/src/babelPlugin.ts new file mode 100644 index 0000000..157c5b1 --- /dev/null +++ b/packages/bindx-compiler/src/babelPlugin.ts @@ -0,0 +1,38 @@ +/** + * Babel plugin: injects the emitted StaticSelection as the 2nd argument of each + * proven chain's `.render(...)` call. Bailed chains 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 the argument and never imports anything from bindx-react. + */ +import type { PluginObj } from '@babel/core' +import { analyzeProgram } from './analyze.js' +import { selectionToAst } from './emit.js' +import { isBailed } from './types.js' + +export function bindxCompilerPlugin(): PluginObj { + return { + name: 'bindx-selection-compiler', + manipulateOptions(_opts, parserOpts: { plugins: unknown[] }): void { + parserOpts.plugins.push('jsx', 'typescript') + }, + visitor: { + Program(path): void { + for (const { chain, result } of analyzeProgram(path.node)) { + if (isBailed(result)) { + continue + } + // Presence of a 2nd argument means already-compiled — never double-inject. + if (chain.renderCall.arguments.length >= 2) { + continue + } + chain.renderCall.arguments.push(selectionToAst(result.selection)) + } + path.skip() + }, + }, + } +} + +export default bindxCompilerPlugin diff --git a/packages/bindx-compiler/src/body.ts b/packages/bindx-compiler/src/body.ts new file mode 100644 index 0000000..07c75af --- /dev/null +++ b/packages/bindx-compiler/src/body.ts @@ -0,0 +1,364 @@ +/** + * 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, + referencesRoot, resolve, type RootRef, +} from './resolve.js' +import { JsxAnalyzer } from './jsx.js' + +export class BodyAnalyzer { + private readonly jsx: JsxAnalyzer + + constructor(private readonly bindings: ImportBindings) { + this.jsx = new JsxAnalyzer(this, bindings) + } + + /** 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 } + 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) + } + } + + 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) { + continue // scalar prop + } + this.bindPattern(prop.value, { node: propRoot, path: [] }, 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] }, 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 + } + 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 { + if (t.isIdentifier(pattern)) { + scope.roots.delete(pattern.name) + scope.propsParams.delete(pattern.name) + } + // Nested-pattern params of arbitrary functions never introduce entity roots. + } + + 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, item, 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. */ + walkCallbackWithItem(fn: t.ArrowFunctionExpression | t.FunctionExpression, item: SelNode, 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, { node: item, path: [] }, child) + } + 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 0000000..31e85eb --- /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/emit.ts b/packages/bindx-compiler/src/emit.ts new file mode 100644 index 0000000..139b5a3 --- /dev/null +++ b/packages/bindx-compiler/src/emit.ts @@ -0,0 +1,40 @@ +/** + * Emits a StaticSelection as a Babel object-literal AST — the 2nd argument the + * Babel plugin injects into `.render(fn, )`. + */ +import * as t from '@babel/types' +import type { 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))), + ) +} + +export function selectionToAst(selection: StaticSelection): t.ObjectExpression { + return t.objectExpression( + Object.entries(selection).map(([prop, map]) => t.objectProperty(key(prop), fieldMapToAst(map))), + ) +} diff --git a/packages/bindx-compiler/src/imports.ts b/packages/bindx-compiler/src/imports.ts new file mode 100644 index 0000000..5a1c5ff --- /dev/null +++ b/packages/bindx-compiler/src/imports.ts @@ -0,0 +1,52 @@ +/** + * 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' + +const COMPONENT_NAMES: ReadonlySet = new Set([ + 'Field', 'Attribute', 'Show', 'HasOne', 'HasMany', '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 +} + +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() + + 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 + if (imported === 'createComponent') { + createComponent.add(local) + } else if (imported === 'cond') { + cond.add(local) + } else if (COMPONENT_NAMES.has(imported)) { + components.set(local, imported as ComponentKind) + } + } + } + + return { createComponent, cond, components } +} diff --git a/packages/bindx-compiler/src/index.ts b/packages/bindx-compiler/src/index.ts new file mode 100644 index 0000000..0690f24 --- /dev/null +++ b/packages/bindx-compiler/src/index.ts @@ -0,0 +1,17 @@ +export { analyzeSource, analyzeProgram, parseProgram, type InternalChainResult } from './analyze.js' +export { bindxCompilerPlugin, default } from './babelPlugin.js' +export { selectionToAst } from './emit.js' +export { fieldMapToPlain, selectionToPlain } from './selectionTree.js' +export type { + StaticSelection, + StaticFieldMap, + StaticFieldNode, + StaticHasManyParams, + ChainResult, + AnalyzedChain, + BailedChain, + BailoutReason, + Bailout, + ChainLoc, +} from './types.js' +export { isBailed } from './types.js' diff --git a/packages/bindx-compiler/src/jsx.ts b/packages/bindx-compiler/src/jsx.ts new file mode 100644 index 0000000..cd32619 --- /dev/null +++ b/packages/bindx-compiler/src/jsx.ts @@ -0,0 +1,256 @@ +/** + * 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 { SelNode } from './selectionTree.js' +import type { ComponentKind, ImportBindings } from './imports.js' +import { + BailError, type Scope, consumeLeaf, consumeMany, consumeRelation, evaluateLiteral, referencesRoot, resolve, +} from './resolve.js' +import type { StaticHasManyParams } from './types.js' + +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, item: SelNode, scope: Scope): void +} + +export class JsxAnalyzer { + constructor(private readonly host: JsxHost, private readonly bindings: ImportBindings) {} + + 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 { + this.walkUnknownComponent(node, scope) + } + } + + private componentKind(name: t.JSXOpeningElement['name']): + { type: 'bindx'; kind: ComponentKind } | { type: 'host' } | { type: 'unknown' } { + if (t.isJSXIdentifier(name)) { + const kind = this.bindings.components.get(name.name) + if (kind) { + return { type: 'bindx', kind } + } + const first = name.name[0] ?? '' + return first === first.toLowerCase() && first !== first.toUpperCase() ? { type: 'host' } : { type: 'unknown' } + } + if (t.isJSXMemberExpression(name) && t.isJSXIdentifier(name.property) && name.property.name === 'Fragment') { + return { type: 'host' } + } + return { type: 'unknown' } + } + + 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) + } + + private walkUnknownComponent(node: t.JSXElement, scope: Scope): void { + for (const attr of node.openingElement.attributes) { + if (t.isJSXSpreadAttribute(attr)) { + this.guardSpread(attr, scope, 'component') + continue + } + // Non-children props are not walked by the runtime; a root here is unprovable. + const expr = attrExpr(attr) + if (expr && referencesRoot(expr, scope)) { + throw new BailError({ code: 'ENTITY_ESCAPES_TO_COMPONENT', message: 'entity value passed to an unrecognized component' }) + } + } + this.walkChildren(node.children, scope) // the `children` slot is analyzed at runtime + } + + 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, item, 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 +} diff --git a/packages/bindx-compiler/src/resolve.ts b/packages/bindx-compiler/src/resolve.ts new file mode 100644 index 0000000..8d6c266 --- /dev/null +++ b/packages/bindx-compiler/src/resolve.ts @@ -0,0 +1,216 @@ +/** + * 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 { SelNode } from './selectionTree.js' +import type { Bailout, StaticHasManyParams } from './types.js' + +export class BailError extends Error { + constructor(public readonly bailout: Bailout) { + super(bailout.message) + } +} + +/** A binding that resolves to `node` reached via `path` of not-yet-materialized segments. */ +export interface RootRef { + readonly node: SelNode + readonly path: 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 +} + +export function childScope(scope: Scope): Scope { + return { + roots: new Map(scope.roots), + propsParams: new Set(scope.propsParams), + propRoots: scope.propRoots, + } +} + +export type Resolution = + | { kind: 'ref'; ref: RootRef } + | { kind: 'opaque' } + | { kind: 'none' } + +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: [] } } : { 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] } } + } + + 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 +} + +/** Conservative check: does the subtree textually reference any root binding? */ +export function referencesRoot(node: t.Node, scope: Scope): boolean { + let found = false + const visit = (n: t.Node): void => { + if (found) { + return + } + if (t.isIdentifier(n) && (scope.roots.has(n.name) || scope.propsParams.has(n.name))) { + found = true + return + } + for (const key of t.VISITOR_KEYS[n.type] ?? []) { + const child: unknown = (n as unknown as Record)[key] + if (Array.isArray(child)) { + for (const item of child) { + if (item && typeof item === 'object' && typeof (item as { type?: unknown }).type === 'string') { + visit(item as t.Node) + } + } + } else if (child && typeof child === 'object' && typeof (child as { type?: unknown }).type === 'string') { + visit(child as t.Node) + } + } + } + visit(node) + return found +} + +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 0000000..9183f3f --- /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/types.ts b/packages/bindx-compiler/src/types.ts new file mode 100644 index 0000000..943e72c --- /dev/null +++ b/packages/bindx-compiler/src/types.ts @@ -0,0 +1,77 @@ +/** + * Static selection format — the ONLY coupling between the compiler (deliverable B) + * and the runtime consumer (deliverable A). Mirror of docs/compiler-plan.md. + */ + +/** 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 + +/** Emitted as the 2nd argument of `.render()`: key = implicit entity prop name. */ +export type StaticSelection = 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_SPREAD' + | 'COMPUTED_MEMBER' + | 'NON_LITERAL_HASMANY_PARAM' + | 'ENTITY_REASSIGNMENT' + | 'UNCLASSIFIED' + +/** 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 and their static selection. */ +export interface AnalyzedChain { + readonly loc: ChainLoc + readonly entityProps: readonly string[] + readonly selection: StaticSelection +} + +/** 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 +} diff --git a/packages/bindx-compiler/tests/analyzer.test.ts b/packages/bindx-compiler/tests/analyzer.test.ts new file mode 100644 index 0000000..4eab8d1 --- /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/equivalence.test.ts b/packages/bindx-compiler/tests/equivalence.test.ts new file mode 100644 index 0000000..d9ec3d1 --- /dev/null +++ b/packages/bindx-compiler/tests/equivalence.test.ts @@ -0,0 +1,69 @@ +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 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], + ['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 0000000..dd7d754 --- /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/_schema.ts b/packages/bindx-compiler/tests/fixtures/_schema.ts new file mode 100644 index 0000000..2f5c6c4 --- /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/bails.tsx b/packages/bindx-compiler/tests/fixtures/bails.tsx new file mode 100644 index 0000000..86ffcae --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/bails.tsx @@ -0,0 +1,78 @@ +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_ESCAPES_TO_COMPONENT — entity value to a non-children prop of an unknown component +const Unknown = (props: { data: unknown }): ReactNode => {String(props.data)} +export const BailEntityComponent = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + +// 6. ENTITY_SPREAD — spread of an entity root +export const BailSpread = createComponent() + .entity('article', schema.Article) + .render(({ article }) =>
) + +// 7. COMPUTED_MEMBER — computed member access off a root +const key: string = 'title' +export const BailComputedMember = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ) + +// 8. 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 => } + + )) + +// 9. 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_ESCAPES_TO_COMPONENT' }, + { 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/condition.tsx b/packages/bindx-compiler/tests/fixtures/condition.tsx new file mode 100644 index 0000000..c4fb0f9 --- /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 0000000..8ed6e65 --- /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/hasManyParams.tsx b/packages/bindx-compiler/tests/fixtures/hasManyParams.tsx new file mode 100644 index 0000000..2a555f7 --- /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/irrelevantChain.tsx b/packages/bindx-compiler/tests/fixtures/irrelevantChain.tsx new file mode 100644 index 0000000..a64af27 --- /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/mapHasMany.tsx b/packages/bindx-compiler/tests/fixtures/mapHasMany.tsx new file mode 100644 index 0000000..ca2ed1e --- /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 0000000..703de08 --- /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 0000000..93fc046 --- /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 0000000..3691807 --- /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/harness.ts b/packages/bindx-compiler/tests/harness.ts new file mode 100644 index 0000000..91bf086 --- /dev/null +++ b/packages/bindx-compiler/tests/harness.ts @@ -0,0 +1,71 @@ +/** + * 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, selectionToPlain, type ChainResult } from '../src/index.js' + +export function analyzeFixture(dir: string, file: string): ChainResult[] { + const code = readFileSync(join(dir, 'fixtures', file), 'utf8') + return analyzeSource(code, file) +} + +/** 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/plugin.test.ts b/packages/bindx-compiler/tests/plugin.test.ts new file mode 100644 index 0000000..476788a --- /dev/null +++ b/packages/bindx-compiler/tests/plugin.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, test } from 'bun:test' +import { transformSync } from '@babel/core' +import { bindxCompilerPlugin } 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 StaticSelection as the 2nd argument of .render()', () => { + const output = transform(SOURCE) + // The emitted literal is the render call's 2nd argument. + 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') + }) + + 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('title: true') + expect(output).not.toContain('fields: {') + }) + + 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)) + }) +}) diff --git a/packages/bindx-compiler/tsconfig.json b/packages/bindx-compiler/tsconfig.json new file mode 100644 index 0000000..941e449 --- /dev/null +++ b/packages/bindx-compiler/tsconfig.json @@ -0,0 +1,13 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "composite": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "emitDeclarationOnly": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["./src/**/*"] +} diff --git a/tsconfig.json b/tsconfig.json index 9908f1a..0f7ebbf 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": [] From abc0eab01208898f0cd4853a9121fdaa757f551f Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 15:05:05 +0200 Subject: [PATCH 05/34] fix(bindx-react): skip React element probe keys in collector proxy Rendering a raw ref as a JSX child (`{article.title}`) let React's isValidElement probe `$$typeof` on the collector proxy, which delegated to field access and upgraded the scalar to a bogus relation `{ id, $$typeof }` (invalid GraphQL). Skip `$$typeof` and the `@@iterator` string fallback in both collector proxy wrappers so probes return undefined instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- .../bindx-react/src/jsx/collectorProxy.ts | 12 ++++-- packages/bindx-react/src/jsx/proxyShared.ts | 14 +++++++ tests/react/jsx/collectorProbeKeys.test.tsx | 37 +++++++++++++++++++ 3 files changed, 60 insertions(+), 3 deletions(-) create mode 100644 tests/react/jsx/collectorProbeKeys.test.tsx diff --git a/packages/bindx-react/src/jsx/collectorProxy.ts b/packages/bindx-react/src/jsx/collectorProxy.ts index 153ed11..27648f2 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/proxyShared.ts b/packages/bindx-react/src/jsx/proxyShared.ts index 4d999fc..dfd8e62 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/tests/react/jsx/collectorProbeKeys.test.tsx b/tests/react/jsx/collectorProbeKeys.test.tsx new file mode 100644 index 0000000..5e0342c --- /dev/null +++ b/tests/react/jsx/collectorProbeKeys.test.tsx @@ -0,0 +1,37 @@ +// Regression: rendering a raw ref as a JSX child (`{article.title}` instead of +// ) must not pollute the collected selection. +// React's isValidElement probes `$$typeof` on the collector proxy; that probe +// used to upgrade the scalar to a bogus relation `{ id, $$typeof }`. +import '../../setup' +import { describe, test, expect } from 'bun:test' +import React from 'react' +import { createComponent, Field, COMPONENT_SELECTIONS, type SelectionMeta } from '@contember/bindx-react' +import { schema } from '../../shared' + +function collect(component: unknown, prop: string): SelectionMeta | undefined { + void (component as Record)[`$${prop}`] + const selections = (component as Record>)[COMPONENT_SELECTIONS] + return selections?.get(prop)?.selection +} + +describe('collector proxy React probe keys', () => { + test('a raw scalar ref rendered as a child stays a scalar (no $$typeof relation)', () => { + const Comp = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( +
+ + {article.status} +
+ )) + + const selection = collect(Comp, 'article')! + const status = selection.fields.get('status') + expect(status).toBeDefined() + // The probe must not have turned `status` into a relation. + expect(status!.isRelation).toBe(false) + expect(status!.nested).toBeUndefined() + // No bogus `$$typeof` field anywhere in the selection. + expect([...selection.fields.keys()]).not.toContain('$$typeof') + }) +}) From 9910807d52ec55728eb61dd8c2df34f1cc362e39 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 15:07:09 +0200 Subject: [PATCH 06/34] feat(bindx-react): validate mode warns only on under-fetch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Static-selection validate mode previously warned on any per-prop mismatch, including the two legitimate compiler-vs-runtime divergences: branch unions (compiler emits the superset) and has-many params/many-ness (the runtime never records them in implicit collection). Diff now reports only fields the runtime proxy selection requests that the static selection omits — the sole under-fetch bug class — keyed by field name so a params-driven alias is not read as missing. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- .../bindx-react/src/jsx/componentFactory.ts | 47 ++++++++++++------- tests/react/jsx/staticSelection.test.tsx | 36 ++++++++++++++ 2 files changed, 65 insertions(+), 18 deletions(-) diff --git a/packages/bindx-react/src/jsx/componentFactory.ts b/packages/bindx-react/src/jsx/componentFactory.ts index 9c46b4a..e704d1d 100644 --- a/packages/bindx-react/src/jsx/componentFactory.ts +++ b/packages/bindx-react/src/jsx/componentFactory.ts @@ -517,45 +517,56 @@ function validateStaticSelections( for (const propName of Object.keys(staticSelection)) { const staticSelectionMeta = staticMap.get(propName)?.selection const runtimeSelectionMeta = runtimeMap.get(propName)?.selection - diffSelectionMeta(staticSelectionMeta, runtimeSelectionMeta, [propName], lines) + diffUnderfetchedFields(staticSelectionMeta, runtimeSelectionMeta, [propName], lines) } if (lines.length > 0) { console.warn( - `[bindx] static selection mismatch for <${componentDisplayName}>:\n${lines.join('\n')}`, + `[bindx] static selection under-fetches for <${componentDisplayName}>:\n${lines.join('\n')}`, ) } } /** - * Recursively diffs two selections by field alias, appending human-readable - * lines for fields present on only one side (dotted paths for nested relations). + * 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 diffSelectionMeta( +function diffUnderfetchedFields( staticMeta: SelectionMeta | undefined, runtimeMeta: SelectionMeta | undefined, path: string[], lines: string[], ): void { - const staticFields = staticMeta?.fields ?? new Map() - const runtimeFields = runtimeMeta?.fields ?? new Map() - - for (const alias of staticFields.keys()) { - if (!runtimeFields.has(alias)) { - lines.push(` only in static: ${[...path, alias].join('.')}`) - } + if (!runtimeMeta) { + return } - for (const [alias, runtimeField] of runtimeFields) { - const staticField = staticFields.get(alias) + const staticByField = indexByFieldName(staticMeta) + for (const runtimeField of runtimeMeta.fields.values()) { + const staticField = staticByField.get(runtimeField.fieldName) if (!staticField) { - lines.push(` only in runtime: ${[...path, alias].join('.')}`) + lines.push(` missing from static (under-fetch): ${[...path, runtimeField.fieldName].join('.')}`) continue } - // Both sides have this relation — recurse into nested selections. - if (staticField.nested || runtimeField.nested) { - diffSelectionMeta(staticField.nested, runtimeField.nested, [...path, alias], lines) + // 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/tests/react/jsx/staticSelection.test.tsx b/tests/react/jsx/staticSelection.test.tsx index 61a24b7..7413c09 100644 --- a/tests/react/jsx/staticSelection.test.tsx +++ b/tests/react/jsx/staticSelection.test.tsx @@ -154,6 +154,42 @@ describe('validate mode', () => { warn.mockRestore() }) + test('static superset (extra fields, e.g. branch union) emits no warning', () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + // Static 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 }) => , + { article: { title: true, content: true, status: true } }, + ) + + getComponentSelection(Comp, 'article') + expect(warn).not.toHaveBeenCalled() + warn.mockRestore() + }) + + test('has-many params/many-ness only in static (divergence 1) emits no warning', () => { + setStaticSelectionValidation(true) + const warn = spyOn(console, 'warn').mockImplementation(() => {}) + + // Runtime `.map()` collection records neither params nor many-ness; the + // compiler emits both. Same fields, so this must not warn. + const Comp = createComponent() + .entity('article', schema.Article) + .render( + ({ article }) =>
{article.tags.map(t => )}
, + { 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(() => {}) From b47ff87611397114e27d53e7f197dc8c8de690ab Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 15:17:48 +0200 Subject: [PATCH 07/34] feat(bindx-compiler): end-to-end integration, playground wiring, bail-rate measure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Deliverable C of the selection compiler plan. - End-to-end test: transform a createComponent source with the Babel plugin, load the emitted module, and prove the static path — render fn skipped during collection, field renders under via MockAdapter, validate mode silent. - Playground: wire the plugin into @vitejs/plugin-react's babel.plugins behind BINDX_COMPILER=1; enable validate mode in the dev entry when compiled. Off by default — build verified both ways. - measure script + `measure` package script: bail-rate over a .tsx tree (default packages/example). Reports per-chain results and a summary. - Docs: 'Compiled selections (experimental)' section in selection-collection.md. - Fold in typecheck refinements to the task-1/3 tests (HasMany over .map(), collector-level probe reproduction). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- bun.lock | 1 + docs/selection-collection.md | 56 +++++++ packages/bindx-compiler/package.json | 3 +- packages/bindx-compiler/scripts/measure.ts | 80 ++++++++++ .../bindx-compiler/tests/endToEnd.test.tsx | 143 ++++++++++++++++++ packages/example/main.tsx | 7 + packages/example/package.json | 1 + packages/example/tsconfig.json | 3 +- packages/example/vite-env.d.ts | 3 + packages/example/vite.config.ts | 16 +- tests/react/jsx/collectorProbeKeys.test.tsx | 56 +++---- tests/react/jsx/staticSelection.test.tsx | 6 +- 12 files changed, 342 insertions(+), 33 deletions(-) create mode 100644 packages/bindx-compiler/scripts/measure.ts create mode 100644 packages/bindx-compiler/tests/endToEnd.test.tsx diff --git a/bun.lock b/bun.lock index d9c86b8..ef2e008 100644 --- a/bun.lock +++ b/bun.lock @@ -205,6 +205,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/docs/selection-collection.md b/docs/selection-collection.md index 8ceed83..a1f83ea 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -358,6 +358,62 @@ const SelectField = withCollector( 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, staticSelection)`. 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. + +This is progressive enhancement: a compiled app behaves identically to an uncompiled +one. It is never mandatory. + +### 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_SPREAD`, +`COMPUTED_MEMBER`, `NON_LITERAL_HASMANY_PARAM`, `INTERFACES_MODE`, `UNCLASSIFIED`). +Bailed chains are left untouched and fall back to the runtime proxy pass. + +Measure the compiled-vs-bailed rate over a source tree with +`bun run packages/bindx-compiler/scripts/measure.ts ` (default +`packages/example`). + +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 index 4f7da0e..f866cd0 100644 --- a/packages/bindx-compiler/package.json +++ b/packages/bindx-compiler/package.json @@ -11,7 +11,8 @@ "scripts": { "build": "tsc --build", "typecheck": "tsc --build", - "test": "bun test tests/" + "test": "bun test tests/", + "measure": "bun run scripts/measure.ts" }, "dependencies": { "@babel/core": "^7.28.0", diff --git a/packages/bindx-compiler/scripts/measure.ts b/packages/bindx-compiler/scripts/measure.ts new file mode 100644 index 0000000..44e1603 --- /dev/null +++ b/packages/bindx-compiler/scripts/measure.ts @@ -0,0 +1,80 @@ +/** + * 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 { readdirSync, readFileSync, statSync } from 'node:fs' +import { join, relative } from 'node:path' +import { analyzeSource, isBailed, type BailoutReason } from '../src/index.js' + +const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']) + +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 +} + +function main(): void { + const target = process.argv[2] ?? 'packages/example' + const root = process.cwd() + const files = findTsxFiles(target).sort() + + let totalChains = 0 + let compiled = 0 + const byReason = new Map() + + for (const file of files) { + const code = readFileSync(file, 'utf8') + let results + try { + results = analyzeSource(code, file) + } catch (error) { + console.log(`${relative(root, file)} PARSE ERROR: ${String(error)}`) + continue + } + if (results.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++ + console.log(` L${result.loc.line} OK [${result.entityProps.join(', ')}]`) + } + } + } + + const bailed = totalChains - compiled + const pct = (n: number): string => (totalChains === 0 ? '0' : ((n / totalChains) * 100).toFixed(0)) + + console.log('\n=== Summary ===') + console.log(`files scanned: ${files.length}`) + console.log(`total chains: ${totalChains}`) + console.log(`compiled: ${compiled} (${pct(compiled)}%)`) + console.log(`bailed: ${bailed} (${pct(bailed)}%)`) + 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}`) + } + } +} + +main() diff --git a/packages/bindx-compiler/tests/endToEnd.test.tsx b/packages/bindx-compiler/tests/endToEnd.test.tsx new file mode 100644 index 0000000..a0f448f --- /dev/null +++ b/packages/bindx-compiler/tests/endToEnd.test.tsx @@ -0,0 +1,143 @@ +/** + * 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, + entityDef, + setStaticSelectionValidation, +} from '@contember/bindx-react' +import { bindxCompilerPlugin } from '../src/index.js' + +interface FixtureModule { + readonly Card: unknown + readonly getRenderCalls: () => 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 } +} +const schema = defineSchema({ + entities: { Article: { fields: { id: scalar(), title: 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, + }) + 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() + }) +}) diff --git a/packages/example/main.tsx b/packages/example/main.tsx index 15acccc..f00c862 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 52c3707..04050d9 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 21f9315..0824442 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 11f02fe..20cb285 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 857f8f3..6ea5c7e 100644 --- a/packages/example/vite.config.ts +++ b/packages/example/vite.config.ts @@ -3,9 +3,23 @@ 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 { bindxCompilerPlugin } 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(), + react(compilerEnabled ? { babel: { plugins: [bindxCompilerPlugin] } } : undefined), + bindxUI({ dir: './ui-overrides' }), + ], + define: { + __BINDX_COMPILER__: JSON.stringify(compilerEnabled), + }, root: __dirname, server: { port: 15180, diff --git a/tests/react/jsx/collectorProbeKeys.test.tsx b/tests/react/jsx/collectorProbeKeys.test.tsx index 5e0342c..51dc6d7 100644 --- a/tests/react/jsx/collectorProbeKeys.test.tsx +++ b/tests/react/jsx/collectorProbeKeys.test.tsx @@ -1,37 +1,39 @@ // Regression: rendering a raw ref as a JSX child (`{article.title}` instead of -// ) must not pollute the collected selection. -// React's isValidElement probes `$$typeof` on the collector proxy; that probe -// used to upgrade the scalar to a bogus relation `{ id, $$typeof }`. +// ) 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 React from 'react' -import { createComponent, Field, COMPONENT_SELECTIONS, type SelectionMeta } from '@contember/bindx-react' -import { schema } from '../../shared' +import { isValidElement } from 'react' +import { createCollectorProxy } from '@contember/bindx-react' +import { SelectionScope } from '@contember/bindx' -function collect(component: unknown, prop: string): SelectionMeta | undefined { - void (component as Record)[`$${prop}`] - const selections = (component as Record>)[COMPONENT_SELECTIONS] - return selections?.get(prop)?.selection +interface Author { + id: string + name: string +} +interface Article { + id: string + title: string + author: Author } describe('collector proxy React probe keys', () => { - test('a raw scalar ref rendered as a child stays a scalar (no $$typeof relation)', () => { - const Comp = createComponent() - .entity('article', schema.Article) - .render(({ article }) => ( -
- - {article.status} -
- )) + 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 selection = collect(Comp, 'article')! - const status = selection.fields.get('status') - expect(status).toBeDefined() - // The probe must not have turned `status` into a relation. - expect(status!.isRelation).toBe(false) - expect(status!.nested).toBeUndefined() - // No bogus `$$typeof` field anywhere in the selection. - expect([...selection.fields.keys()]).not.toContain('$$typeof') + 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/staticSelection.test.tsx b/tests/react/jsx/staticSelection.test.tsx index 7413c09..83d60c1 100644 --- a/tests/react/jsx/staticSelection.test.tsx +++ b/tests/react/jsx/staticSelection.test.tsx @@ -176,12 +176,12 @@ describe('validate mode', () => { setStaticSelectionValidation(true) const warn = spyOn(console, 'warn').mockImplementation(() => {}) - // Runtime `.map()` collection records neither params nor many-ness; the - // compiler emits both. Same fields, so this must not warn. + // 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 }) =>
{article.tags.map(t => )}
, + ({ article }) => {t => }, { article: { tags: { fields: { name: true }, many: true, params: { limit: 5 } } } }, ) From 3a51e84b8c48016396f759b1878684a3194ac63f Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 15:31:54 +0200 Subject: [PATCH 08/34] =?UTF-8?q?docs:=20phase=202=20plan=20=E2=80=94=20ne?= =?UTF-8?q?sted-component=20composition=20holes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 101 ++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 98 insertions(+), 3 deletions(-) diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index acfb8f3..d142450 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -183,8 +183,103 @@ instead of strict equality — flag them explicitly in the fixture (e.g. exporte 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) + +Motivation: measured on the largest real bindx app (`~/projects/external/npi`, packages/admin), +phase 1 compiles 216/257 chains (84 %); 40 of 41 bails are `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 npi 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). +- 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 — npi workaround removal) + +`DataGridHasOneColumn`'s `collectSelection` (bindx-dataview `createRelationColumn.tsx`) discards +the renderer's returned JSX, so nested ``/`` inside relation-column renderers are +never collected (npi works around it with a `.map()` trick). Fix: run `analyzeJsx`/`collectSelection` +on the returned JSX in addition to the proxy capture. Independent of the compiler; benefits +uncompiled apps too. + +### 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. + ## Future (explicitly out of scope now) -Fragment-reference emit for nested components (`__compose` runtime holes — the Relay-spread -equivalent), unplugin packaging, eslint plugin reusing the analyzer (bail reasons as lint -diagnostics), oxc/SWC port if Babel cost ever matters. +Unplugin packaging, eslint plugin reusing the analyzer (bail reasons as lint diagnostics), +oxc/SWC port if Babel cost ever matters. From 696c76ee1abe03b9e65448a78072a22fb05ec1ad Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 15:47:37 +0200 Subject: [PATCH 09/34] =?UTF-8?q?feat(bindx-react):=20compiled=20selection?= =?UTF-8?q?=20v2=20=E2=80=94=20nested-component=20hole=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reshape the compiled `.render(fn, compiled?)` argument into `CompiledSelection { props, holes? }` (breaking change within the experiment) and resolve nested-component holes at collection time. - bindx: `driveSelectionScope(scope, map)` drives a caller-provided open SelectionScope; `staticSelectionToMeta` becomes a thin wrapper over it. - bindx-react: new `compiledSelection.ts` holds the `CompiledSelection`/ `CompiledHole` contract and `applyCompiledSelection` — seeds one live scope per entity prop, then resolves each hole through the target's runtime selection surface (getSelection, else staticRender + tolerant scalar-prop fallback), replaying member paths via the source prop's collector proxy. Per-hole error containment; validate-mode warn names plain-React targets as selection blind spots. Host render never runs. - Shared `createFragment`/`createScalarPropMock` extracted to `collectionHelpers.ts`; `resolveComponentName` exported from analyzer. - Export `CompiledSelection`/`CompiledHole` from the jsx layer + package index for the compiler to code against. - Migrate phase-1 tests to `{ props: {...} }` and add hole coverage (createComponent + withCollector targets, entity-derived path, TDZ thunk, multiple entityProps, plain-target blind spot, error containment, render-never-runs, validate-mode no-warn). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- packages/bindx-react/src/index.ts | 3 + packages/bindx-react/src/jsx/analyzer.ts | 12 +- .../bindx-react/src/jsx/collectionHelpers.ts | 55 +++ .../bindx-react/src/jsx/compiledSelection.ts | 258 +++++++++++++ .../bindx-react/src/jsx/componentBuilder.ts | 6 +- .../src/jsx/componentBuilder.types.ts | 11 +- .../bindx-react/src/jsx/componentFactory.ts | 112 ++---- packages/bindx-react/src/jsx/index.ts | 3 + packages/bindx/src/index.ts | 2 +- packages/bindx/src/selection/index.ts | 1 + .../src/selection/staticSelectionToMeta.ts | 13 +- tests/react/jsx/staticSelection.test.tsx | 341 +++++++++++++++++- 12 files changed, 696 insertions(+), 121 deletions(-) create mode 100644 packages/bindx-react/src/jsx/collectionHelpers.ts create mode 100644 packages/bindx-react/src/jsx/compiledSelection.ts diff --git a/packages/bindx-react/src/index.ts b/packages/bindx-react/src/index.ts index 2c94198..adad7cf 100644 --- a/packages/bindx-react/src/index.ts +++ b/packages/bindx-react/src/index.ts @@ -280,6 +280,9 @@ export type { BuildProps, BuildFragmentProps, InitialBuilderState, + // Compiled selection contract (v2) + CompiledSelection, + CompiledHole, } from './jsx/index.js' // ============================================================================ diff --git a/packages/bindx-react/src/jsx/analyzer.ts b/packages/bindx-react/src/jsx/analyzer.ts index f2d087f..def0c97 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 0000000..6aee423 --- /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/compiledSelection.ts b/packages/bindx-react/src/jsx/compiledSelection.ts new file mode 100644 index 0000000..9ed0fc1 --- /dev/null +++ b/packages/bindx-react/src/jsx/compiledSelection.ts @@ -0,0 +1,258 @@ +/** + * 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. + */ +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 { + /** Per implicit entity prop — same {@link StaticFieldMap} as phase 1. */ + props: Record + /** Nested components that received entity-derived values. */ + holes?: CompiledHole[] +} + +/** + * 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 +} + +// ============================================================================ +// 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 — seeding one live + * scope per entity prop from its static field map, resolving every hole into the + * same scopes, then snapshotting. Replaces the proxy pass entirely; the host + * render function is never executed. + */ +export function applyCompiledSelection(params: ApplyCompiledSelectionParams): void { + const { compiled, selectionsMap, componentBrand, roles } = 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. + for (const [propName, scope] of propScopes) { + if (scope.hasFields()) { + const selection = scope.toSelectionMeta() + selectionsMap.set(propName, { + selection, + fragment: createFragment(selection, componentBrand, roles), + }) + } + } +} + +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 + } + + const props = assembleHoleProps(hole, ctx) + + try { + 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 } + 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 85fb09c..a3faf09 100644 --- a/packages/bindx-react/src/jsx/componentBuilder.ts +++ b/packages/bindx-react/src/jsx/componentBuilder.ts @@ -10,7 +10,6 @@ import type { SchemaRegistry, SelectionBuilder, EntityDef, - StaticSelection, } from '@contember/bindx' import { ComponentBrand, @@ -20,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' @@ -175,7 +175,7 @@ export class ComponentBuilderImpl< render( renderFn: (props: Record) => ReactNode, - staticSelection?: StaticSelection, + compiled?: CompiledSelection, ): unknown { return buildComponent( this.entityConfigs, @@ -187,7 +187,7 @@ export class ComponentBuilderImpl< this.slotNames, this.useFns, this.mockValues, - staticSelection ?? null, + compiled ?? null, ) } } diff --git a/packages/bindx-react/src/jsx/componentBuilder.types.ts b/packages/bindx-react/src/jsx/componentBuilder.types.ts index 58e25b8..1752c3d 100644 --- a/packages/bindx-react/src/jsx/componentBuilder.types.ts +++ b/packages/bindx-react/src/jsx/componentBuilder.types.ts @@ -16,10 +16,10 @@ import type { SelectionMeta, EntityDef, ResolveEntity, - StaticSelection, } 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) @@ -562,15 +562,16 @@ export interface ComponentBuilder< * Build the component with the render function. * * @param renderFn - React render function receiving typed props - * @param staticSelection - Precompiled selection injected by the selection - * compiler. Compiler-facing only — do NOT hand-write it. When present, the - * runtime skips the proxy collection pass and uses this instead; enable + * @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, - staticSelection?: StaticSelection, + compiled?: CompiledSelection, ): BindxComponent } diff --git a/packages/bindx-react/src/jsx/componentFactory.ts b/packages/bindx-react/src/jsx/componentFactory.ts index e704d1d..2755376 100644 --- a/packages/bindx-react/src/jsx/componentFactory.ts +++ b/packages/bindx-react/src/jsx/componentFactory.ts @@ -17,7 +17,6 @@ import type { AnyBrand, EntityRef, SchemaDefinition, - StaticSelection, } from '@contember/bindx' import { SchemaRegistry, @@ -25,7 +24,6 @@ import { ComponentBrand, createSelectionBuilder, SelectionScope, - staticSelectionToMeta, } from '@contember/bindx' import type { SelectionPropMeta, @@ -34,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, type CompiledSelection } from './compiledSelection.js' import { type Condition, evaluateCondition } from './conditions.js' import { useAccessor } from '../hooks/useAccessor.js' @@ -123,7 +123,7 @@ export function buildComponent( slotNames: readonly string[], useFns: readonly ((props: TProps) => object)[], mockValues: Record, - staticSelection: StaticSelection | null, + compiled: CompiledSelection | null, ): unknown { const selectionsMap = new Map() const componentDisplayName = `BindxComponent(${[...entityConfigs.keys()].join(', ')})` @@ -167,11 +167,20 @@ export function buildComponent( collectionState = 'collecting' try { // Precompiled selection present ⇒ build entries from it, skip the proxy pass. - if (staticSelection) { - applyStaticSelections(staticSelection, selectionsMap, componentBrand, roles) + if (compiled) { + applyCompiledSelection({ + compiled, + selectionsMap, + componentBrand, + roles, + implicitConfigs, + schemaRegistry, + componentDisplayName, + validateMode: staticSelectionValidationEnabled, + }) if (staticSelectionValidationEnabled) { - validateStaticSelections( - staticSelection, selectionsMap, componentDisplayName, + validateCompiledSelection( + selectionsMap, componentDisplayName, implicitConfigs, renderFn, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn, mockValues, ) @@ -320,34 +329,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. * @@ -470,31 +451,13 @@ function collectImplicitSelections( // ============================================================================ /** - * Builds selectionsMap entries from a precompiled static selection — one per - * entity prop present in the static object. Replaces the proxy pass entirely. + * 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 applyStaticSelections( - staticSelection: StaticSelection, - selectionsMap: Map, - componentBrand: ComponentBrand, - roles: readonly string[], -): void { - for (const [propName, fieldMap] of Object.entries(staticSelection)) { - const selection = staticSelectionToMeta(fieldMap) - selectionsMap.set(propName, { - selection, - fragment: createFragment(selection, componentBrand, roles), - }) - } -} - -/** - * Validate mode: also run the proxy pass and warn (once) when the precompiled - * selection disagrees with what runtime collection would produce. - */ -function validateStaticSelections( - staticSelection: StaticSelection, - staticMap: Map, +function validateCompiledSelection( + compiledMap: Map, componentDisplayName: string, implicitConfigs: [string, EntityConfig][], renderFn: (props: TProps) => ReactNode, @@ -509,15 +472,15 @@ function validateStaticSelections( try { collectImplicitSelections(implicitConfigs, renderFn, runtimeMap, componentBrand, roles, hasInterfacesMode, schemaRegistry, conditionFn, mockValues) } catch { - // Proxy pass crashed — the static selection already stands; nothing to compare. + // Proxy pass crashed — the compiled selection already stands; nothing to compare. return } const lines: string[] = [] - for (const propName of Object.keys(staticSelection)) { - const staticSelectionMeta = staticMap.get(propName)?.selection + for (const propName of runtimeMap.keys()) { + const compiledSelectionMeta = compiledMap.get(propName)?.selection const runtimeSelectionMeta = runtimeMap.get(propName)?.selection - diffUnderfetchedFields(staticSelectionMeta, runtimeSelectionMeta, [propName], lines) + diffUnderfetchedFields(compiledSelectionMeta, runtimeSelectionMeta, [propName], lines) } if (lines.length > 0) { @@ -569,29 +532,6 @@ function indexByFieldName(meta: SelectionMeta | undefined): Map { - 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, - } -} - // ============================================================================ // Selection Provider // ============================================================================ diff --git a/packages/bindx-react/src/jsx/index.ts b/packages/bindx-react/src/jsx/index.ts index 01fc2fb..7c8c95c 100644 --- a/packages/bindx-react/src/jsx/index.ts +++ b/packages/bindx-react/src/jsx/index.ts @@ -104,6 +104,9 @@ export { createComponent } from './standaloneCreateComponent.js' // Static (precompiled) selection validate-mode toggle export { setStaticSelectionValidation } 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' diff --git a/packages/bindx/src/index.ts b/packages/bindx/src/index.ts index 7e185cc..015e70f 100644 --- a/packages/bindx/src/index.ts +++ b/packages/bindx/src/index.ts @@ -162,7 +162,7 @@ export type { } from './schema/index.js' // Selection utilities -export { createFragment, buildQueryFromSelection, SELECTION_META, createSelectionBuilder, SelectionMetaCollector, mergeSelections, createEmptySelection, SelectionScope, staticSelectionToMeta } 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 diff --git a/packages/bindx/src/selection/index.ts b/packages/bindx/src/selection/index.ts index 093da97..7f46f6e 100644 --- a/packages/bindx/src/selection/index.ts +++ b/packages/bindx/src/selection/index.ts @@ -31,6 +31,7 @@ export { SelectionMetaCollector, mergeSelections, createEmptySelection } from '. export { SelectionScope, type HasManyParams } from './SelectionScope.js' export { staticSelectionToMeta, + driveSelectionScope, type StaticSelection, type StaticFieldMap, type StaticFieldNode, diff --git a/packages/bindx/src/selection/staticSelectionToMeta.ts b/packages/bindx/src/selection/staticSelectionToMeta.ts index 546a30d..e05baad 100644 --- a/packages/bindx/src/selection/staticSelectionToMeta.ts +++ b/packages/bindx/src/selection/staticSelectionToMeta.ts @@ -31,10 +31,12 @@ export type StaticFieldNode = export type StaticSelection = Record /** - * Populates a scope from a static field map, mirroring the runtime collector's - * scope operations: nested access ⇒ relation (and `child()` seeds `id`). + * 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. */ -function populateScope(scope: SelectionScope, map: StaticFieldMap): void { +export function driveSelectionScope(scope: SelectionScope, map: StaticFieldMap): void { for (const [fieldName, node] of Object.entries(map)) { if (node === true) { scope.addScalar(fieldName) @@ -49,15 +51,16 @@ function populateScope(scope: SelectionScope, map: StaticFieldMap): void { const params: HasManyParams = node.params scope.setHasManyParams(fieldName, params) } - populateScope(childScope, node.fields) + 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() - populateScope(scope, map) + driveSelectionScope(scope, map) return scope.toSelectionMeta() } diff --git a/tests/react/jsx/staticSelection.test.tsx b/tests/react/jsx/staticSelection.test.tsx index 83d60c1..023ae19 100644 --- a/tests/react/jsx/staticSelection.test.tsx +++ b/tests/react/jsx/staticSelection.test.tsx @@ -1,11 +1,14 @@ -// Tests for precompiled static selections (deliverable A): the converter, -// the static build path (proxy pass skipped), end-to-end fetch, and validate mode. +// 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, @@ -15,9 +18,10 @@ import { setStaticSelectionValidation, type SelectionMeta, type StaticFieldMap, + type EntityRef, } from '@contember/bindx-react' import { SelectionScope } from '@contember/bindx' -import { schema, renderWithBindx, getByTestId } from '../../shared' +import { schema, renderWithBindx, getByTestId, type Article } from '../../shared' afterEach(() => { cleanup() @@ -34,6 +38,16 @@ function getComponentSelection(component: unknown, propName: string): SelectionM 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. @@ -90,8 +104,8 @@ describe('staticSelectionToMeta — converter equivalence', () => { }) }) -describe('static build path', () => { - test('static selection is used and the proxy pass (render fn) is skipped', () => { +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) @@ -100,16 +114,16 @@ describe('static build path', () => { renderCalls++ return }, - { article: { title: true } }, + { props: { article: { title: true } } }, ) const selection = getComponentSelection(Comp, 'article') - // Proxy pass would have executed the render fn; the static path must not. + // 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 static argument leaves proxy collection behavior unchanged (sanity)', () => { + test('no compiled argument leaves proxy collection behavior unchanged (sanity)', () => { const Comp = createComponent() .entity('article', schema.Article) .render(({ article }) => ) @@ -118,12 +132,12 @@ describe('static build path', () => { expect([...selection!.fields.keys()]).toContain('title') }) - test('end-to-end: a static-selection component under fetches and renders', async () => { + test('end-to-end: a compiled-selection component under fetches and renders', async () => { const Comp = createComponent() .entity('article', schema.Article) .render( ({ article }) => , - { article: { title: true } }, + { props: { article: { title: true } } }, ) const { container } = renderWithBindx( @@ -138,7 +152,7 @@ describe('static build path', () => { }) describe('validate mode', () => { - test('agreeing static and runtime selections emit no warning', () => { + test('agreeing compiled and runtime selections emit no warning', () => { setStaticSelectionValidation(true) const warn = spyOn(console, 'warn').mockImplementation(() => {}) @@ -146,7 +160,7 @@ describe('validate mode', () => { .entity('article', schema.Article) .render( ({ article }) => , - { article: { title: true } }, + { props: { article: { title: true } } }, ) getComponentSelection(Comp, 'article') @@ -154,17 +168,17 @@ describe('validate mode', () => { warn.mockRestore() }) - test('static superset (extra fields, e.g. branch union) emits no warning', () => { + test('compiled superset (extra fields, e.g. branch union) emits no warning', () => { setStaticSelectionValidation(true) const warn = spyOn(console, 'warn').mockImplementation(() => {}) - // Static declares more than the render body reads (as branch unions do). + // 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 }) => , - { article: { title: true, content: true, status: true } }, + { props: { article: { title: true, content: true, status: true } } }, ) getComponentSelection(Comp, 'article') @@ -172,7 +186,7 @@ describe('validate mode', () => { warn.mockRestore() }) - test('has-many params/many-ness only in static (divergence 1) emits no warning', () => { + test('has-many params/many-ness only in compiled (divergence 1) emits no warning', () => { setStaticSelectionValidation(true) const warn = spyOn(console, 'warn').mockImplementation(() => {}) @@ -182,7 +196,7 @@ describe('validate mode', () => { .entity('article', schema.Article) .render( ({ article }) => {t => }, - { article: { tags: { fields: { name: true }, many: true, params: { limit: 5 } } } }, + { props: { article: { tags: { fields: { name: true }, many: true, params: { limit: 5 } } } } }, ) getComponentSelection(Comp, 'article') @@ -194,7 +208,7 @@ describe('validate mode', () => { setStaticSelectionValidation(true) const warn = spyOn(console, 'warn').mockImplementation(() => {}) - // Static omits `content`, but the render body reads it → runtime finds it. + // Compiled omits `content`, but the render body reads it → runtime finds it. const Comp = createComponent() .entity('article', schema.Article) .render( @@ -204,7 +218,7 @@ describe('validate mode', () => {
), - { article: { title: true } }, + { props: { article: { title: true } } }, ) getComponentSelection(Comp, 'article') @@ -214,4 +228,293 @@ describe('validate mode', () => { 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 }) => , + { + 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') }, + { + 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') }, + { + 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') }, + { + 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') }, + { + 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') }, + { + 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') }, + { + 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, + { + 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') }, + { + 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('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 + }, + { + props: { article: { title: true } }, + holes: [{ + component: () => AuthorCard, + entityProps: { author: { source: 'article', path: ['author'] } }, + }], + }, + ) + + getComponentSelection(CompiledHost, 'article') + expect(renderCalls).toBe(0) + }) }) From e4afd95adb172297d616d18e226f524a19a1d73d Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 15:50:37 +0200 Subject: [PATCH 10/34] feat(bindx-compiler): emit compiled selection v2 with nested-component holes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2 of the selection compiler. The 2nd argument of .render() changes to the CompiledSelection shape { props, holes? }: `props` is the phase-1 per-entity-prop StaticFieldMap, `holes` are statically-emitted references to nested components that received entity-derived values (the Relay-fragment-spread equivalent), resolved at collection time through the target's runtime selection surface. ENTITY_ESCAPES_TO_COMPONENT no longer bails when the escape is a prop on a component-typed element with a module-scope identifier tag. Multiple entity props on one element merge into one hole; entity props become { source, path } (absolute from the host entity prop, preserved through HasOne/HasMany/map callback roots via new RootRef source/absPath tracking); statically-literal extra props are kept, non-literal ones dropped; children stay statically analyzed. The emit is no longer pure JSON — each hole's `component` is an arrow thunk referencing the tag (TDZ-safe, lazy). Still bails: entity in a non-JSX call arg (ENTITY_ESCAPES_TO_CALL), entity in a non-path expression prop (new ENTITY_IN_EXPRESSION_PROP), spread onto an element (ENTITY_SPREAD), member/namespaced component tags (new MEMBER_COMPONENT_TAG), and tag identifiers that don't resolve to a module binding (ENTITY_ESCAPES_TO_COMPONENT). Same-element recognized bindx components are never holes (existing handling wins). Measured on the reference app: 251/257 chains compile (98%, up from 84%), 99 holes across 35 chains; the remaining 6 bails are genuinely unprovable. Also: measure script reports per-chain hole counts and a summary (with holes / total holes), and resolves its default target against the workspace root instead of cwd. Cross-package equivalence for holes and the runtime-consumption e2e are integration- gated on the v2 runtime consumer (skipped with an INTEGRATION marker). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- packages/bindx-compiler/scripts/measure.ts | 57 +++++- packages/bindx-compiler/src/analyze.ts | 11 +- packages/bindx-compiler/src/babelPlugin.ts | 2 +- packages/bindx-compiler/src/body.ts | 28 ++- packages/bindx-compiler/src/emit.ts | 32 ++- packages/bindx-compiler/src/imports.ts | 44 ++++ packages/bindx-compiler/src/index.ts | 2 + packages/bindx-compiler/src/jsx.ts | 116 +++++++++-- packages/bindx-compiler/src/resolve.ts | 67 ++++++- packages/bindx-compiler/src/types.ts | 27 ++- .../bindx-compiler/tests/endToEnd.test.tsx | 2 + .../tests/fixtures/_targets.tsx | 22 ++ .../bindx-compiler/tests/fixtures/bails.tsx | 25 ++- .../bindx-compiler/tests/fixtures/holes.tsx | 63 ++++++ packages/bindx-compiler/tests/holes.test.ts | 189 ++++++++++++++++++ packages/bindx-compiler/tests/plugin.test.ts | 9 +- 16 files changed, 638 insertions(+), 58 deletions(-) create mode 100644 packages/bindx-compiler/tests/fixtures/_targets.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/holes.tsx create mode 100644 packages/bindx-compiler/tests/holes.test.ts diff --git a/packages/bindx-compiler/scripts/measure.ts b/packages/bindx-compiler/scripts/measure.ts index 44e1603..ec57109 100644 --- a/packages/bindx-compiler/scripts/measure.ts +++ b/packages/bindx-compiler/scripts/measure.ts @@ -5,12 +5,35 @@ * * Usage: bun run measure [dir] (default: packages/example) */ -import { readdirSync, readFileSync, statSync } from 'node:fs' -import { join, relative } from 'node:path' +import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' +import { dirname, isAbsolute, join, relative } from 'node:path' import { analyzeSource, isBailed, 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)) { @@ -28,12 +51,20 @@ function findTsxFiles(dir: string): string[] { } function main(): void { - const target = process.argv[2] ?? 'packages/example' - const root = process.cwd() + const root = findRepoRoot() + const arg = process.argv[2] ?? '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() for (const file of files) { @@ -56,7 +87,13 @@ function main(): void { console.log(` L${result.loc.line} BAIL ${result.bailout.code} — ${result.bailout.message}`) } else { compiled++ - console.log(` L${result.loc.line} OK [${result.entityProps.join(', ')}]`) + 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}`) } } } @@ -65,10 +102,12 @@ function main(): void { const pct = (n: number): string => (totalChains === 0 ? '0' : ((n / totalChains) * 100).toFixed(0)) console.log('\n=== Summary ===') - console.log(`files scanned: ${files.length}`) - console.log(`total chains: ${totalChains}`) - console.log(`compiled: ${compiled} (${pct(compiled)}%)`) - console.log(`bailed: ${bailed} (${pct(bailed)}%)`) + console.log(`files scanned: ${files.length}`) + console.log(`total chains: ${totalChains}`) + console.log(`compiled: ${compiled} (${pct(compiled)}%)`) + console.log(` with holes: ${compiledWithHoles}`) + console.log(` total holes: ${totalHoles}`) + console.log(`bailed: ${bailed} (${pct(bailed)}%)`) if (byReason.size > 0) { console.log('bailed by reason:') for (const [reason, count] of [...byReason.entries()].sort((a, b) => b[1] - a[1])) { diff --git a/packages/bindx-compiler/src/analyze.ts b/packages/bindx-compiler/src/analyze.ts index 95b7535..ebfcb05 100644 --- a/packages/bindx-compiler/src/analyze.ts +++ b/packages/bindx-compiler/src/analyze.ts @@ -7,7 +7,7 @@ */ import { parse } from '@babel/parser' import * as t from '@babel/types' -import { collectImportBindings, type ImportBindings } from './imports.js' +import { collectImportBindings, collectModuleBindings, type ImportBindings } from './imports.js' import { findChains, type Chain } from './chain.js' import { BodyAnalyzer } from './body.js' import { BailError } from './resolve.js' @@ -30,17 +30,18 @@ export function parseProgram(code: string, _filename: string): t.Program { /** Analyze an already-parsed program; retains Babel node refs for the plugin. */ export function analyzeProgram(program: t.Program): InternalChainResult[] { const bindings = collectImportBindings(program) + const moduleBindings = collectModuleBindings(program) const chains = findChains(program, bindings) - return chains.map(chain => ({ chain, result: analyzeChain(chain, bindings) })) + return chains.map(chain => ({ chain, result: analyzeChain(chain, bindings, moduleBindings) })) } -function analyzeChain(chain: Chain, bindings: ImportBindings): ChainResult { +function analyzeChain(chain: Chain, bindings: ImportBindings, moduleBindings: ReadonlySet): 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) + const analyzer = new BodyAnalyzer(bindings, moduleBindings) try { if (chain.conditionFn) { analyzer.analyzeFunction(chain.conditionFn, propRoots) @@ -61,7 +62,7 @@ function analyzeChain(chain: Chain, bindings: ImportBindings): ChainResult { selection[prop] = node.toFieldMap() } } - return { loc, entityProps: chain.entityProps, selection } + return { loc, entityProps: chain.entityProps, selection, holes: analyzer.holes } } function chainLoc(call: t.CallExpression): ChainLoc { diff --git a/packages/bindx-compiler/src/babelPlugin.ts b/packages/bindx-compiler/src/babelPlugin.ts index 157c5b1..1ea6a85 100644 --- a/packages/bindx-compiler/src/babelPlugin.ts +++ b/packages/bindx-compiler/src/babelPlugin.ts @@ -27,7 +27,7 @@ export function bindxCompilerPlugin(): PluginObj { if (chain.renderCall.arguments.length >= 2) { continue } - chain.renderCall.arguments.push(selectionToAst(result.selection)) + chain.renderCall.arguments.push(selectionToAst(result.selection, result.holes)) } path.skip() }, diff --git a/packages/bindx-compiler/src/body.ts b/packages/bindx-compiler/src/body.ts index 07c75af..d660299 100644 --- a/packages/bindx-compiler/src/body.ts +++ b/packages/bindx-compiler/src/body.ts @@ -11,12 +11,20 @@ import { referencesRoot, resolve, type RootRef, } from './resolve.js' import { JsxAnalyzer } from './jsx.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) { - this.jsx = new JsxAnalyzer(this, bindings) + constructor(private readonly bindings: ImportBindings, private readonly moduleBindings: ReadonlySet) { + this.jsx = new JsxAnalyzer(this, bindings, moduleBindings) + } + + /** 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. */ @@ -51,7 +59,7 @@ export class BodyAnalyzer { if (!propRoot) { continue // scalar prop } - this.bindPattern(prop.value, { node: propRoot, path: [] }, scope) + this.bindPattern(prop.value, { node: propRoot, path: [], source: prop.key.name, absPath: [] }, scope) } } } @@ -71,7 +79,11 @@ export class BodyAnalyzer { 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] }, scope) + this.bindPattern( + prop.value, + { node, path: [prop.key.name], source: ref.source, absPath: [...ref.absPath, prop.key.name] }, + scope, + ) } return } @@ -340,20 +352,20 @@ export class BodyAnalyzer { const item = consumeMany(ref) const cb = node.arguments[0] if (cb && (t.isArrowFunctionExpression(cb) || t.isFunctionExpression(cb))) { - this.walkCallbackWithItem(cb, item, scope) + 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. */ - walkCallbackWithItem(fn: t.ArrowFunctionExpression | t.FunctionExpression, item: SelNode, scope: Scope): void { + /** 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, { node: item, path: [] }, child) + this.bindPattern(p, itemRef, child) } if (t.isBlockStatement(fn.body)) { this.walkStatements(fn.body.body, child) diff --git a/packages/bindx-compiler/src/emit.ts b/packages/bindx-compiler/src/emit.ts index 139b5a3..0287ad0 100644 --- a/packages/bindx-compiler/src/emit.ts +++ b/packages/bindx-compiler/src/emit.ts @@ -1,9 +1,11 @@ /** - * Emits a StaticSelection as a Babel object-literal AST — the 2nd argument the - * Babel plugin injects into `.render(fn, )`. + * Emits a CompiledSelection (v2) as a Babel object-literal AST — the 2nd argument the + * Babel plugin injects into `.render(fn, )`. Shape: `{ 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 { StaticFieldMap, StaticFieldNode, StaticSelection } from './types.js' +import type { AnalyzedHole, StaticFieldMap, StaticFieldNode, StaticSelection } from './types.js' const IDENTIFIER_RE = /^[A-Za-z_$][\w$]*$/ @@ -33,8 +35,30 @@ function fieldMapToAst(map: StaticFieldMap): t.ObjectExpression { ) } -export function selectionToAst(selection: StaticSelection): t.ObjectExpression { +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))) + } + return t.objectExpression(properties) +} + +export function selectionToAst(selection: StaticSelection, holes: readonly AnalyzedHole[]): t.ObjectExpression { + const properties: t.ObjectProperty[] = [ + 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) +} diff --git a/packages/bindx-compiler/src/imports.ts b/packages/bindx-compiler/src/imports.ts index 5a1c5ff..02fa63e 100644 --- a/packages/bindx-compiler/src/imports.ts +++ b/packages/bindx-compiler/src/imports.ts @@ -50,3 +50,47 @@ export function collectImportBindings(program: t.Program): ImportBindings { return { createComponent, cond, components } } + +/** + * 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 index 0690f24..bd5903d 100644 --- a/packages/bindx-compiler/src/index.ts +++ b/packages/bindx-compiler/src/index.ts @@ -7,6 +7,8 @@ export type { StaticFieldMap, StaticFieldNode, StaticHasManyParams, + AnalyzedHole, + HoleEntityProp, ChainResult, AnalyzedChain, BailedChain, diff --git a/packages/bindx-compiler/src/jsx.ts b/packages/bindx-compiler/src/jsx.ts index cd32619..b0080e9 100644 --- a/packages/bindx-compiler/src/jsx.ts +++ b/packages/bindx-compiler/src/jsx.ts @@ -4,23 +4,28 @@ * and unknown components (children analyzed; entity-rooted non-children props bail). */ import * as t from '@babel/types' -import type { SelNode } from './selectionTree.js' import type { ComponentKind, ImportBindings } from './imports.js' import { - BailError, type Scope, consumeLeaf, consumeMany, consumeRelation, evaluateLiteral, referencesRoot, resolve, + BailError, type RootRef, type Scope, consumeLeaf, consumeMany, consumeRelation, + entityPathOf, evaluateLiteral, referencesRoot, resolve, } from './resolve.js' -import type { StaticHasManyParams } from './types.js' +import type { AnalyzedHole, HoleEntityProp, StaticHasManyParams } from './types.js' 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, item: SelNode, 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) {} + constructor( + private readonly host: JsxHost, + private readonly bindings: ImportBindings, + private readonly moduleBindings: ReadonlySet, + ) {} walk(node: t.JSXElement | t.JSXFragment, scope: Scope): void { if (t.isJSXFragment(node)) { @@ -32,25 +37,28 @@ export class JsxAnalyzer { 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.walkUnknownComponent(node, scope) + this.walkMemberTagElement(node, scope) } } private componentKind(name: t.JSXOpeningElement['name']): - { type: 'bindx'; kind: ComponentKind } | { type: 'host' } | { type: 'unknown' } { + { 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] ?? '' - return first === first.toLowerCase() && first !== first.toUpperCase() ? { type: 'host' } : { type: 'unknown' } + 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: 'unknown' } + return { type: 'memberTag' } // `` / namespaced tags — not a simple identifier } private walkHostElement(node: t.JSXElement, scope: Scope): void { @@ -67,19 +75,88 @@ export class JsxAnalyzer { this.walkChildren(node.children, scope) } - private walkUnknownComponent(node: t.JSXElement, scope: Scope): void { + /** + * 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). + */ + private walkComponentElement(tag: string, node: t.JSXElement, scope: Scope): void { + const entityProps: Record = {} + const literalProps: Record = {} + + 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 + } + this.collectComponentProp(attr.name.name, attr.value, scope, entityProps, literalProps) + } + + 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` }) + } + this.host.addHole({ + component: tag, + entityProps, + literalProps: Object.keys(literalProps).length > 0 ? literalProps : undefined, + }) + } + + this.walkChildren(node.children, scope) // the `children` slot is analyzed at runtime + } + + /** 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 } - // Non-children props are not walked by the runtime; a root here is unprovable. const expr = attrExpr(attr) if (expr && referencesRoot(expr, scope)) { - throw new BailError({ code: 'ENTITY_ESCAPES_TO_COMPONENT', message: 'entity value passed to an unrecognized component' }) + throw new BailError({ code: 'MEMBER_COMPONENT_TAG', message: 'entity value passed to a member-expression component tag' }) } } - this.walkChildren(node.children, scope) // the `children` slot is analyzed at runtime + this.walkChildren(node.children, scope) + } + + private collectComponentProp( + propName: string, + value: t.JSXAttribute['value'], + scope: Scope, + entityProps: Record, + literalProps: Record, + ): 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 + } + const ep = entityPathOf(inner, scope) // may throw COMPUTED_MEMBER + if (ep.kind === 'path') { + entityProps[propName] = { source: ep.source, path: [...ep.path] } + 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). } private walkBindxComponent(kind: ComponentKind, node: t.JSXElement, scope: Scope): void { @@ -136,7 +213,7 @@ export class JsxAnalyzer { const item = many ? consumeMany(res.ref, params) : consumeRelation(res.ref) const cb = childrenCallback(node.children) if (cb) { - this.host.walkCallbackWithItem(cb, item, scope) + 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) } @@ -254,3 +331,14 @@ function attrExpr(attr: t.JSXAttribute | t.JSXSpreadAttribute): t.Expression | n } 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/resolve.ts b/packages/bindx-compiler/src/resolve.ts index 8d6c266..754140c 100644 --- a/packages/bindx-compiler/src/resolve.ts +++ b/packages/bindx-compiler/src/resolve.ts @@ -14,10 +14,16 @@ export class BailError extends Error { } } -/** A binding that resolves to `node` reached via `path` of not-yet-materialized segments. */ +/** + * 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 { @@ -76,7 +82,7 @@ export function resolve(nodeIn: t.Node, scope: Scope): Resolution { // 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: [] } } : { kind: 'none' } + return propRoot ? { kind: 'ref', ref: { node: propRoot, path: [], source: propName, absPath: [] } } : { kind: 'none' } } const objRes = resolve(node.object, scope) @@ -91,7 +97,15 @@ export function resolve(nodeIn: t.Node, scope: Scope): Resolution { if (propName === 'id' || propName.startsWith('$') || propName.startsWith('__')) { return { kind: 'opaque' } } - return { kind: 'ref', ref: { node: objRes.ref.node, path: [...objRes.ref.path, propName] } } + 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' } @@ -164,6 +178,53 @@ export function referencesRoot(node: t.Node, scope: Scope): boolean { return found } +/** + * 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. */ diff --git a/packages/bindx-compiler/src/types.ts b/packages/bindx-compiler/src/types.ts index 943e72c..9b6e6a0 100644 --- a/packages/bindx-compiler/src/types.ts +++ b/packages/bindx-compiler/src/types.ts @@ -24,9 +24,29 @@ export type StaticFieldNode = /** Selection for one implicit entity prop. Key = field name. */ export type StaticFieldMap = Record -/** Emitted as the 2nd argument of `.render()`: key = implicit entity prop name. */ +/** 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 +} + /** * Machine-readable bail codes. A component bails as a whole when any trigger fires; * the caller falls back to runtime proxy collection (always sound). @@ -37,6 +57,8 @@ export type BailoutReason = | 'DYNAMIC_ENTITY_NAME' | 'ENTITY_ESCAPES_TO_CALL' | 'ENTITY_ESCAPES_TO_COMPONENT' + | 'ENTITY_IN_EXPRESSION_PROP' + | 'MEMBER_COMPONENT_TAG' | 'ENTITY_SPREAD' | 'COMPUTED_MEMBER' | 'NON_LITERAL_HASMANY_PARAM' @@ -57,11 +79,12 @@ export interface ChainLoc { readonly column: number } -/** A chain the compiler proved: its implicit props and their static selection. */ +/** 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. */ diff --git a/packages/bindx-compiler/tests/endToEnd.test.tsx b/packages/bindx-compiler/tests/endToEnd.test.tsx index a0f448f..e614cc4 100644 --- a/packages/bindx-compiler/tests/endToEnd.test.tsx +++ b/packages/bindx-compiler/tests/endToEnd.test.tsx @@ -98,6 +98,8 @@ describe('end-to-end: transformed module runs the static path', () => { 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') }) diff --git a/packages/bindx-compiler/tests/fixtures/_targets.tsx b/packages/bindx-compiler/tests/fixtures/_targets.tsx new file mode 100644 index 0000000..90eb6fd --- /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 index 86ffcae..94470d9 100644 --- a/packages/bindx-compiler/tests/fixtures/bails.tsx +++ b/packages/bindx-compiler/tests/fixtures/bails.tsx @@ -29,24 +29,30 @@ export const BailEntityCall = createComponent() .entity('article', schema.Article) .render(({ article }) => {formatAuthor(article.author)}) -// 5. ENTITY_ESCAPES_TO_COMPONENT — entity value to a non-children prop of an unknown component -const Unknown = (props: { data: unknown }): ReactNode => {String(props.data)} -export const BailEntityComponent = createComponent() +// 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 }) => ) + .render(({ article }) => ) -// 6. ENTITY_SPREAD — spread of an entity root +// 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 }) =>
) -// 7. COMPUTED_MEMBER — computed member access off a root +// 8. COMPUTED_MEMBER — computed member access off a root const key: string = 'title' export const BailComputedMember = createComponent() .entity('article', schema.Article) .render(({ article }) => ) -// 8. NON_LITERAL_HASMANY_PARAM — a HasMany param that is not a static literal +// 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) @@ -56,7 +62,7 @@ export const BailNonLiteralParam = createComponent() )) -// 9. ENTITY_REASSIGNMENT — entity-rooted binding is not const +// 10. ENTITY_REASSIGNMENT — entity-rooted binding is not const export const BailReassignment = createComponent() .entity('article', schema.Article) .render(({ article }) => { @@ -70,7 +76,8 @@ export const cases: FixtureCase[] = [ { expect: 'bail', code: 'EXPLICIT_RENDER_FN' }, { expect: 'bail', code: 'DYNAMIC_ENTITY_NAME' }, { expect: 'bail', code: 'ENTITY_ESCAPES_TO_CALL' }, - { expect: 'bail', code: 'ENTITY_ESCAPES_TO_COMPONENT' }, + { 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' }, diff --git a/packages/bindx-compiler/tests/fixtures/holes.tsx b/packages/bindx-compiler/tests/fixtures/holes.tsx new file mode 100644 index 0000000..935a217 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/holes.tsx @@ -0,0 +1,63 @@ +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 + non-literal extra props (literals kept, non-literals dropped) +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/holes.test.ts b/packages/bindx-compiler/tests/holes.test.ts new file mode 100644 index 0000000..1822b40 --- /dev/null +++ b/packages/bindx-compiler/tests/holes.test.ts @@ -0,0 +1,189 @@ +/** + * 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 } from 'node:fs' +import { join } from 'node:path' +import { describe, expect, test } from 'bun:test' +import { transformSync } from '@babel/core' +import { analyzeSource, bindxCompilerPlugin, isBailed, type AnalyzedChain, type BailoutReason } from '../src/index.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, non-literal props dropped', () => { + 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 } }) + }) + + 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:') + }) +}) + +// INTEGRATION: enable after runtime hole resolution lands. The runtime proxy oracle +// resolves nested getSelection/staticRender targets, so compiled (fields + resolved +// holes) must equal the oracle for these fixtures. Needs the v2 runtime consumer. +describe.skip('hole equivalence vs runtime oracle', () => { + test('createComponent / withCollector / plain targets agree with the oracle', () => { + // INTEGRATION: enable after runtime hole resolution lands. + }) +}) diff --git a/packages/bindx-compiler/tests/plugin.test.ts b/packages/bindx-compiler/tests/plugin.test.ts index 476788a..849826d 100644 --- a/packages/bindx-compiler/tests/plugin.test.ts +++ b/packages/bindx-compiler/tests/plugin.test.ts @@ -31,15 +31,18 @@ export const Card = createComponent() ` describe('babel plugin injection', () => { - test('injects the StaticSelection as the 2nd argument of .render()', () => { + 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. + // The emitted literal is the render call's 2nd argument, now wrapped in `props`. + 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)', () => { @@ -52,8 +55,8 @@ export const C = createComponent() ` 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') - expect(output).not.toContain('fields: {') }) test('re-running the plugin does not double-inject', () => { From b1b3b7f6eb2bc092d30f452f67794081df4e1371 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 16:07:49 +0200 Subject: [PATCH 11/34] fix(bindx-dataview): walk relation-column renderer JSX for nested selections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit createRelationColumn's collectSelection called the renderer with a collector proxy and discarded the returned JSX, so nested declarative / inside relation-column renderers never registered their selections (the top-level proxy access only captured the immediate relation). the reference app worked around it with a .map() trick on the proxy instead of declarative JSX. Fix: 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, exactly as collectImplicitSelections does. Errors are contained per column. Benefits uncompiled apps too. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- .../src/createRelationColumn.tsx | 28 ++++++- .../dataview/createRelationColumn.test.tsx | 80 +++++++++++++++++++ 2 files changed, 104 insertions(+), 4 deletions(-) diff --git a/packages/bindx-dataview/src/createRelationColumn.tsx b/packages/bindx-dataview/src/createRelationColumn.tsx index d841484..bc8c3bf 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/tests/react/dataview/createRelationColumn.test.tsx b/tests/react/dataview/createRelationColumn.test.tsx index 501f452..e5cb59a 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) + }) +}) From 31914b76ce4f8408ce24ebc06ad0db8de1cb9af6 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 16:08:01 +0200 Subject: [PATCH 12/34] feat(bindx-compiler): enable hole equivalence + e2e against runtime resolution; reference-app re-measure + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Flesh out the previously-skipped hole-equivalence describe in holes.test.ts: for each hole-carrying fixture, transform with the Babel plugin, load the transformed module, and compare the COMPILED selection (holes resolved by applyCompiledSelection at collection time) against the ORACLE (untransformed module, runtime proxy pass resolves nested targets). All 8 fixtures agree exactly — createComponent/withCollector targets fold in their fields, plain targets are a shared blind spot (both register only the touched leaf). No divergences: both paths replay the same collector-proxy gets, so equality holds by construction. Extend endToEnd.test.tsx with a hole case: a host passing article.author to a nested createComponent target. Asserts (a) the host render fn is not executed during collection, (b) the nested target's field renders under via MockAdapter (the hole put it in the fetch), (c) validate mode is silent. Re-measured the reference app: phase 2 with holes compiles 251/257 (98%, was 84% in phase 1); 35 chains carry 99 holes; 6 bails remain (5 ENTITY_IN_EXPRESSION_PROP, 1 ENTITY_REASSIGNMENT). Docs updated: selection-collection.md gains a holes section (incl. the plain-component shared blind spot + validate-mode warn); compiler-plan.md marks phase 2 and the dataview fix implemented with the numbers. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 33 +++++-- docs/selection-collection.md | 45 +++++++-- .../bindx-compiler/tests/endToEnd.test.tsx | 92 +++++++++++++++++-- packages/bindx-compiler/tests/holes.test.ts | 74 +++++++++++++-- 4 files changed, 214 insertions(+), 30 deletions(-) diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index d142450..20d5597 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -183,10 +183,19 @@ instead of strict equality — flag them explicitly in the fixture (e.g. exporte 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) +## Phase 2 — nested-component composition (holes) — IMPLEMENTED -Motivation: measured on the largest real bindx app (`~/projects/external/npi`, packages/admin), -phase 1 compiles 216/257 chains (84 %); 40 of 41 bails are `ENTITY_ESCAPES_TO_COMPONENT`. +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 `~/projects/external/npi`, packages/admin, 257 chains): +**251/257 compiled = 98 %** (phase 1 was 216/257 = 84 %). 35 chains carry 99 holes total. Only 6 +bails remain: 5 `ENTITY_IN_EXPRESSION_PROP`, 1 `ENTITY_REASSIGNMENT` — the `ENTITY_ESCAPES_TO_COMPONENT` +class that dominated phase 1 is gone. + +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 @@ -262,13 +271,17 @@ target (with and without sibling dummy ``s), multiple entity props on one 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 — npi workaround removal) - -`DataGridHasOneColumn`'s `collectSelection` (bindx-dataview `createRelationColumn.tsx`) discards -the renderer's returned JSX, so nested ``/`` inside relation-column renderers are -never collected (npi works around it with a `.map()` trick). Fix: run `analyzeJsx`/`collectSelection` -on the returned JSX in addition to the proxy capture. Independent of the compiler; benefits -uncompiled apps too. +### Related runtime fix (in scope — npi 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 (npi 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 (npi regression)"). ### Explicit non-goal diff --git a/docs/selection-collection.md b/docs/selection-collection.md index a1f83ea..de8e039 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -363,13 +363,42 @@ Low-level API for precise control over reported fields. Used by Field, HasOne, H 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, staticSelection)`. When present, the runtime uses it directly +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: @@ -404,13 +433,17 @@ params/many-ness (the runtime never records these in implicit collection). 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_SPREAD`, -`COMPUTED_MEMBER`, `NON_LITERAL_HASMANY_PARAM`, `INTERFACES_MODE`, `UNCLASSIFIED`). -Bailed chains are left untouched and fall back to the runtime proxy pass. +machine-readable reason (e.g. `ENTITY_ESCAPES_TO_CALL`, `ENTITY_IN_EXPRESSION_PROP`, +`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. -Measure the compiled-vs-bailed rate over a source tree with +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`). +`packages/example`). On the largest real bindx app (`npi`, `packages/admin`, 257 chains) +phase 2 with holes compiles **251/257 (98%)** — 35 chains carry 99 holes total — leaving +only 6 bails (5 `ENTITY_IN_EXPRESSION_PROP`, 1 `ENTITY_REASSIGNMENT`); phase 1 without +holes compiled 84%. See [docs/compiler-plan.md](./compiler-plan.md) for the full design. diff --git a/packages/bindx-compiler/tests/endToEnd.test.tsx b/packages/bindx-compiler/tests/endToEnd.test.tsx index e614cc4..f9046e7 100644 --- a/packages/bindx-compiler/tests/endToEnd.test.tsx +++ b/packages/bindx-compiler/tests/endToEnd.test.tsx @@ -23,6 +23,7 @@ import { Entity, defineSchema, scalar, + hasOne, entityDef, setStaticSelectionValidation, } from '@contember/bindx-react' @@ -33,6 +34,11 @@ interface FixtureModule { 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 = ` @@ -56,7 +62,7 @@ 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 { +async function loadTransformed(source: string): Promise { const out = transformSync(source, { filename: 'card.tsx', plugins: [bindxCompilerPlugin], @@ -69,14 +75,18 @@ async function loadTransformed(source: string): Promise { const path = join(TMP_DIR, `.e2e-${counter++}.tsx`) writeFileSync(path, out.code) tmpFiles.push(path) - return import(path) as Promise + return import(path) as Promise } interface Schema { - Article: { id: string; title: string } + 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() } } }, + entities: { + Article: { fields: { id: scalar(), title: scalar(), author: hasOne('Author') } }, + Author: { fields: { id: scalar(), name: scalar() } }, + }, }) const articleDef = entityDef('Article') @@ -104,7 +114,7 @@ describe('end-to-end: transformed module runs the static path', () => { }) test('collection skips the render fn, then fetches and renders the field', async () => { - const mod = await loadTransformed(SOURCE) + 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. @@ -135,7 +145,7 @@ describe('end-to-end: transformed module runs the static path', () => { setStaticSelectionValidation(true) const warn = spyOn(console, 'warn').mockImplementation(() => {}) - const mod = await loadTransformed(SOURCE) + const mod = await loadTransformed(SOURCE) // Collection now also runs the proxy pass and diffs — must agree. void (mod.Card as Record).$article @@ -143,3 +153,73 @@ describe('end-to-end: transformed module runs the static path', () => { 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/holes.test.ts b/packages/bindx-compiler/tests/holes.test.ts index 1822b40..7991bff 100644 --- a/packages/bindx-compiler/tests/holes.test.ts +++ b/packages/bindx-compiler/tests/holes.test.ts @@ -4,11 +4,13 @@ * end-to-end equivalence (compiled fields + resolved holes vs runtime oracle) is * integration-gated — see the skipped block at the bottom. */ -import { readFileSync } from 'node:fs' +import { readFileSync, rmSync, writeFileSync } from 'node:fs' import { join } from 'node:path' -import { describe, expect, test } from 'bun:test' +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` @@ -179,11 +181,67 @@ describe('hole emit (Babel plugin thunk)', () => { }) }) -// INTEGRATION: enable after runtime hole resolution lands. The runtime proxy oracle -// resolves nested getSelection/staticRender targets, so compiled (fields + resolved -// holes) must equal the oracle for these fixtures. Needs the v2 runtime consumer. -describe.skip('hole equivalence vs runtime oracle', () => { - test('createComponent / withCollector / plain targets agree with the oracle', () => { - // INTEGRATION: enable after runtime hole resolution lands. +// 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) + }) + } }) From 4cfdcaf883326d840aeaf77f63f8737cd2059152 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 16:33:09 +0200 Subject: [PATCH 13/34] fix(bindx-compiler): bail on unsafe function props on hole elements (under-fetch) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A hole element's function props / render-prop children were dropped from the emitted hole (non-literal), but a hole target's staticRender may INVOKE such a closure during collection with a collector proxy — the reference app's SelectField does `{e => props.children(e)}`. The runtime oracle thus collected fields from the closure body (e.g. `it => ` -> author.name) that the compiled path could not, so compiled ⊂ runtime -> under-fetch -> UnfetchedFieldError in compiled apps. This violated emit-or-bail (under-approximation must be impossible). Fix: a dropped inline closure on a hole element is safe to omit IFF invoking it cannot reach a selection scope — no reference to its OWN parameters (transitively) and no captured entity roots. Otherwise the chain bails with the new reason FUNCTION_PROP_ON_HOLE (default deny). Non-hole function children stay statically walked (sound). New helper isHoleClosureSafe (resolve.ts) + assertHoleClosuresSafe (jsx.ts); regression + safe fixtures. the reference app (257 chains): 242/257 = 94% compiled (was an unsound 98%), 15 bails (9 FUNCTION_PROP_ON_HOLE, 5 ENTITY_IN_EXPRESSION_PROP, 1 ENTITY_REASSIGNMENT). Closure lifting to recover the rate is a phase-2.1 candidate (not implemented). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 47 +++++++- docs/selection-collection.md | 23 +++- packages/bindx-compiler/src/jsx.ts | 31 ++++- packages/bindx-compiler/src/resolve.ts | 60 +++++++++- packages/bindx-compiler/src/types.ts | 1 + .../tests/fixtures/holeClosures.tsx | 62 ++++++++++ .../bindx-compiler/tests/holeClosures.test.ts | 106 ++++++++++++++++++ 7 files changed, 316 insertions(+), 14 deletions(-) create mode 100644 packages/bindx-compiler/tests/fixtures/holeClosures.tsx create mode 100644 packages/bindx-compiler/tests/holeClosures.test.ts diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index 20d5597..e6fbb4a 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -191,9 +191,46 @@ emission (`packages/bindx-compiler`), full hole-equivalence + end-to-end tests, relation-column fix have all landed and are green. Result (re-measured on `~/projects/external/npi`, packages/admin, 257 chains): -**251/257 compiled = 98 %** (phase 1 was 216/257 = 84 %). 35 chains carry 99 holes total. Only 6 -bails remain: 5 `ENTITY_IN_EXPRESSION_PROP`, 1 `ENTITY_REASSIGNMENT` — the `ENTITY_ESCAPES_TO_COMPONENT` -class that dominated phase 1 is gone. +**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 — npi'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 candidate (not implemented): closure lifting.** A param-only closure that captures +nothing from the render scope could be *emitted into the hole literal* (lifted verbatim) instead of +dropped, so the runtime replays it into the target's `staticRender` and collection proceeds — most of +the `FUNCTION_PROP_ON_HOLE` bails (render-prop children like npi's `SelectField`) would recover the +rate back toward 98 % while staying sound. Deferred. 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, @@ -257,7 +294,9 @@ In `ensureImplicitCollected`, when a compiled selection is present: 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). + 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)`. diff --git a/docs/selection-collection.md b/docs/selection-collection.md index de8e039..53a8c9d 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -434,16 +434,27 @@ The compiler emits a selection only when it can prove it. Over-approximation (ex 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`, -`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`, `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` closes an under-fetch class: a hole element's function props / +render-prop children are dropped from the emitted hole, but a hole target's `staticRender` +may *invoke* such a closure with a collector proxy during collection (npi's `SelectField` +does `{e => props.children(e)}`), collecting fields +the compiled path never sees. A dropped inline closure is safe to omit **only** when +invoking it cannot reach a scope — no reference to its OWN parameters (transitively) and no +captured entity roots (`onClick={() => save()}` is safe; `it => ` is +not). Otherwise the chain bails. (Lifting param-only closures into the hole literal is a +future recovery — see 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 (`npi`, `packages/admin`, 257 chains) -phase 2 with holes compiles **251/257 (98%)** — 35 chains carry 99 holes total — leaving -only 6 bails (5 `ENTITY_IN_EXPRESSION_PROP`, 1 `ENTITY_REASSIGNMENT`); phase 1 without -holes compiled 84%. +phase 2 with holes compiles **242/257 (94%)** — 26 chains carry 82 holes total — leaving +15 bails (9 `FUNCTION_PROP_ON_HOLE`, 5 `ENTITY_IN_EXPRESSION_PROP`, 1 `ENTITY_REASSIGNMENT`); +phase 1 without holes compiled 84%. (An earlier 98% reading was unsound — it counted the +now-bailed `FUNCTION_PROP_ON_HOLE` chains as compiled.) See [docs/compiler-plan.md](./compiler-plan.md) for the full design. diff --git a/packages/bindx-compiler/src/jsx.ts b/packages/bindx-compiler/src/jsx.ts index b0080e9..6c2008a 100644 --- a/packages/bindx-compiler/src/jsx.ts +++ b/packages/bindx-compiler/src/jsx.ts @@ -7,7 +7,7 @@ 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, + entityPathOf, evaluateLiteral, isHoleClosureSafe, referencesRoot, resolve, } from './resolve.js' import type { AnalyzedHole, HoleEntityProp, StaticHasManyParams } from './types.js' @@ -83,6 +83,7 @@ export class JsxAnalyzer { private walkComponentElement(tag: string, node: t.JSXElement, scope: Scope): void { const entityProps: Record = {} const literalProps: Record = {} + const functionProps: Array = [] for (const attr of node.openingElement.attributes) { if (t.isJSXSpreadAttribute(attr)) { @@ -92,10 +93,17 @@ export class JsxAnalyzer { if (!t.isJSXIdentifier(attr.name) || attr.name.name === 'children') { continue } + const inner = jsxAttrInner(attr.value) + if (inner && (t.isArrowFunctionExpression(inner) || t.isFunctionExpression(inner))) { + functionProps.push(inner) // dropped from the hole — must be proven safe if this is a hole + } this.collectComponentProp(attr.name.name, attr.value, scope, entityProps, literalProps) } if (Object.keys(entityProps).length > 0) { + // A hole drops the element's function props/children; if the target may invoke one + // with a collector proxy during collection, dropping it under-fetches → bail. + this.assertHoleClosuresSafe(tag, functionProps, node.children, scope) 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` }) @@ -110,6 +118,27 @@ export class JsxAnalyzer { this.walkChildren(node.children, scope) // the `children` slot is analyzed at runtime } + /** Bail if any dropped function prop / render-prop child of a hole element is unsafe to omit. */ + private assertHoleClosuresSafe( + tag: string, + functionProps: ReadonlyArray, + children: t.JSXElement['children'], + scope: Scope, + ): void { + for (const fn of functionProps) { + if (!isHoleClosureSafe(fn, scope)) { + throw new BailError({ code: 'FUNCTION_PROP_ON_HOLE', message: `function prop on hole element <${tag}> may be invoked with an entity during collection` }) + } + } + for (const child of children) { + if (t.isJSXExpressionContainer(child) + && (t.isArrowFunctionExpression(child.expression) || t.isFunctionExpression(child.expression)) + && !isHoleClosureSafe(child.expression, scope)) { + throw new BailError({ code: 'FUNCTION_PROP_ON_HOLE', message: `render-prop children of hole element <${tag}> may be invoked with an entity during collection` }) + } + } + } + /** 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) { diff --git a/packages/bindx-compiler/src/resolve.ts b/packages/bindx-compiler/src/resolve.ts index 754140c..0672056 100644 --- a/packages/bindx-compiler/src/resolve.ts +++ b/packages/bindx-compiler/src/resolve.ts @@ -150,14 +150,15 @@ export function consumeMany(ref: RootRef, params?: StaticHasManyParams): SelNode return item } -/** Conservative check: does the subtree textually reference any root binding? */ -export function referencesRoot(node: t.Node, scope: Scope): boolean { +/** 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 const visit = (n: t.Node): void => { if (found) { return } - if (t.isIdentifier(n) && (scope.roots.has(n.name) || scope.propsParams.has(n.name))) { + if (t.isIdentifier(n) && pred(n.name)) { found = true return } @@ -178,6 +179,59 @@ export function referencesRoot(node: t.Node, scope: Scope): boolean { 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) + } + } + } +} + +/** + * Can this closure be safely OMITTED from a phase-2 hole? The hole target's `staticRender` + * may invoke a dropped function prop / render-prop child with a collector proxy during + * collection, so the closure is safe to drop only when invoking it cannot reach a selection + * scope. The gateways are the closure's OWN parameters (a proxy would flow in there — a + * transitive reference in the body is unsafe) and captured entity roots. No gateway → safe. + * Nested inner functions' own params are theirs, not ours (see docs/compiler-plan.md, Phase 2). + */ +export function isHoleClosureSafe(fn: t.ArrowFunctionExpression | t.FunctionExpression, scope: Scope): boolean { + if (referencesRoot(fn, scope)) { + return false // captured entity root reachable when invoked + } + const ownParams = new Set() + for (const param of fn.params) { + collectParamNames(param, ownParams) + } + return !anyIdentifier(fn.body, name => ownParams.has(name)) +} + /** * 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); diff --git a/packages/bindx-compiler/src/types.ts b/packages/bindx-compiler/src/types.ts index 9b6e6a0..e32d0b9 100644 --- a/packages/bindx-compiler/src/types.ts +++ b/packages/bindx-compiler/src/types.ts @@ -58,6 +58,7 @@ export type BailoutReason = | 'ENTITY_ESCAPES_TO_CALL' | 'ENTITY_ESCAPES_TO_COMPONENT' | 'ENTITY_IN_EXPRESSION_PROP' + | 'FUNCTION_PROP_ON_HOLE' | 'MEMBER_COMPONENT_TAG' | 'ENTITY_SPREAD' | 'COMPUTED_MEMBER' diff --git a/packages/bindx-compiler/tests/fixtures/holeClosures.tsx b/packages/bindx-compiler/tests/fixtures/holeClosures.tsx new file mode 100644 index 0000000..8d6b823 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/holeClosures.tsx @@ -0,0 +1,62 @@ +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 under-fetch guard: function props / render-prop children of a HOLE element are +// dropped from the emitted hole, but the target's staticRender may INVOKE them with a +// collector proxy during collection. See docs/compiler-plan.md (FUNCTION_PROP_ON_HOLE). + +interface SelectFieldProps { + field: EntityRef + children: (entity: EntityRef) => ReactNode +} + +// Mirrors npi's SelectField: a withCollector primitive whose staticRender reaches the field +// ONLY through the render-prop child. Dropping that child under-fetches unless the chain bails. +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 => {} + +// UNSAFE — the render-prop child reads a field off its OWN param. The hole drops it, yet +// SelectField.staticRender invokes it with a collector proxy → compiled would under-fetch. +// The chain BAILS with FUNCTION_PROP_ON_HOLE. +export const UnsafeRenderProp = createComponent() + .entity('article', schema.Article) + .render(({ article }) => ( + + {it => } + + )) + +// SAFE — 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/holeClosures.test.ts b/packages/bindx-compiler/tests/holeClosures.test.ts new file mode 100644 index 0000000..0708efc --- /dev/null +++ b/packages/bindx-compiler/tests/holeClosures.test.ts @@ -0,0 +1,106 @@ +/** + * Phase-2 under-fetch guard (FUNCTION_PROP_ON_HOLE). A function prop / render-prop child of + * a hole element is dropped from the emitted hole, but the target's staticRender may invoke + * it with a collector proxy during collection. Unsafe closures (own params / captured roots) + * must bail the chain; param-less, root-free closures stay safe to omit and keep compiling. + */ +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('two host chains: unsafe render-prop bails, safe closures compile', () => { + expect(results.length).toBe(2) + }) + + test('#0 UnsafeRenderProp bails FUNCTION_PROP_ON_HOLE', () => { + const result = results[0]! + expect(isBailed(result)).toBe(true) + if (isBailed(result)) { + expect(result.bailout.code).toBe('FUNCTION_PROP_ON_HOLE') + } + }) + + test('#1 SafeHoleClosures compiles with the AuthorSummary hole (safe props omitted)', () => { + const result = results[1]! + expect(isBailed(result)).toBe(false) + if (!isBailed(result)) { + expect(result.holes).toEqual([ + { component: 'AuthorSummary', entityProps: { author: { source: 'article', path: ['author'] } }, literalProps: undefined }, + ]) + } + }) +}) + +describe('hole closure safety — 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 + } + + // Isolated unsafe chain — mirrors the SelectField render-prop shape. + const UNSAFE = ` +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('bailed unsafe chain gets no injected 2nd argument (runtime fallback)', () => { + const out = transform(UNSAFE) + 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 unsafe +// chain bails → no injection → runtime proxy fallback (trivially equal). The safe chain compiles +// with the hole, resolved through AuthorSummary's staticRender → author.name. +describe('hole closure safety — 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('UnsafeRenderProp — bailed compiled path equals the oracle (both collect author.name)', () => { + const reference = runtimePlain(oracle.UnsafeRenderProp, 'article') + expect(reference).toMatchObject({ author: { name: true } }) + expect(runtimePlain(compiled.UnsafeRenderProp, '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) + }) +}) From cceaf6b35fbe2ab8862f0febb5a5d00e3188e5bf Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 17:04:45 +0200 Subject: [PATCH 14/34] feat(bindx-react): CompiledHole.extraProps for lifted hole values MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds `extraProps?: Record unknown>` to CompiledHole — thunked (TDZ-safe) non-entity values the compiler lifts into a hole (module-scope bindings, render-scope-free closures). Hole resolution resolves each thunk and merges it into the assembled props before the entity proxies, so a target's staticRender that invokes one (`props.children(entity)`) collects the same fields the runtime oracle would — closing the identifier/closure under-fetch class by construction. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- .../bindx-react/src/jsx/compiledSelection.ts | 16 +++++++++ tests/react/jsx/staticSelection.test.tsx | 33 ++++++++++++++++++- 2 files changed, 48 insertions(+), 1 deletion(-) diff --git a/packages/bindx-react/src/jsx/compiledSelection.ts b/packages/bindx-react/src/jsx/compiledSelection.ts index 9ed0fc1..373388c 100644 --- a/packages/bindx-react/src/jsx/compiledSelection.ts +++ b/packages/bindx-react/src/jsx/compiledSelection.ts @@ -7,6 +7,10 @@ * 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' @@ -53,6 +57,14 @@ export interface CompiledHole { * 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> } // ============================================================================ @@ -180,6 +192,10 @@ function collectNested(node: ReactNode): SelectionMeta { */ 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) diff --git a/tests/react/jsx/staticSelection.test.tsx b/tests/react/jsx/staticSelection.test.tsx index 023ae19..194585b 100644 --- a/tests/react/jsx/staticSelection.test.tsx +++ b/tests/react/jsx/staticSelection.test.tsx @@ -21,7 +21,7 @@ import { type EntityRef, } from '@contember/bindx-react' import { SelectionScope } from '@contember/bindx' -import { schema, renderWithBindx, getByTestId, type Article } from '../../shared' +import { schema, renderWithBindx, getByTestId, type Article, type Author } from '../../shared' afterEach(() => { cleanup() @@ -492,6 +492,37 @@ describe('compiled selection v2 — nested-component holes', () => { 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') }, + { + 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() From 0dcf5662ececf0c0c239579aba8debb34976f4e0 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 17:05:00 +0200 Subject: [PATCH 15/34] feat(bindx-compiler): lift identifier/closure/cond/JSX hole props via extraProps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.1: instead of dropping (and bailing on) a hole element's non-entity props, lift what a target may invoke during collection into the hole's extraProps, making compiled ≡ oracle by construction. - Taint lattice (default deny) for identifier-valued hole props: module-scope binding → lift; inert scalar/.use() render param → drop; render-local / unresolvable → bail (new RENDER_LOCAL_ON_HOLE). Scope now tracks scalarParams (safe-drop) and locals (bail; checked first so a shadowing local wins). - Inline closure lifting: a function prop / render-prop child of a hole that uses its own params but captures nothing from render scope is emitted verbatim into extraProps (recovers most FUNCTION_PROP_ON_HOLE); param-less/root-free closures still drop; entity-root / render-capture closures still bail. - cond.* DSL in JSX props (``): record the FieldRef args as touched leaves, drop the prop. Verified strictly equal to the runtime Case/If getSelection oracle (a condition carries no selection beyond its args). - JSX-element/fragment prop values (`draftSlot={}`): analyzed like children (recurse), prop not emitted — an equal-or-superset union vs the oracle. the reference app (257 chains): 94% → 99% (254/257); the 3 remaining bails are all genuine (root-capturing handler, host-root-capturing closure, entity reassignment). Docs updated (compiler-plan phase 2.1, selection-collection). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 81 +++++++++++++++-- docs/selection-collection.md | 33 ++++--- packages/bindx-compiler/src/body.ts | 33 +++++-- packages/bindx-compiler/src/emit.ts | 7 ++ packages/bindx-compiler/src/holeProps.ts | 80 +++++++++++++++++ packages/bindx-compiler/src/jsx.ts | 87 ++++++++++++------- packages/bindx-compiler/src/resolve.ts | 55 ++++++++++-- packages/bindx-compiler/src/types.ts | 8 ++ .../bindx-compiler/tests/equivalence.test.ts | 4 + .../tests/fixtures/condProps.tsx | 38 ++++++++ .../tests/fixtures/holeClosures.tsx | 36 +++++--- .../bindx-compiler/tests/fixtures/holes.tsx | 3 +- .../tests/fixtures/jsxProps.tsx | 33 +++++++ .../bindx-compiler/tests/holeClosures.test.ts | 86 +++++++++++++----- packages/bindx-compiler/tests/holes.test.ts | 4 +- 15 files changed, 482 insertions(+), 106 deletions(-) create mode 100644 packages/bindx-compiler/src/holeProps.ts create mode 100644 packages/bindx-compiler/tests/fixtures/condProps.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/jsxProps.tsx diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index e6fbb4a..0bc5459 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -226,11 +226,9 @@ danger. Function **children of non-hole** elements stay statically walked (sound ignores the closure or collects a subset of the analyzed union). Implemented in `jsx.ts` (`walkComponentElement` → `assertHoleClosuresSafe`) + `resolve.ts` (`isHoleClosureSafe`). -**Phase 2.1 candidate (not implemented): closure lifting.** A param-only closure that captures -nothing from the render scope could be *emitted into the hole literal* (lifted verbatim) instead of -dropped, so the runtime replays it into the target's `staticRender` and collection proceeds — most of -the `FUNCTION_PROP_ON_HOLE` bails (render-prop children like npi's `SelectField`) would recover the -rate back toward 98 % while staying sound. Deferred. +**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, @@ -331,6 +329,79 @@ progressive-enhancement equivalence guarantee. The path for that blind spot is d (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 `~/projects/external/npi`, packages/admin, 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. + ## Future (explicitly out of scope now) Unplugin packaging, eslint plugin reusing the analyzer (bail reasons as lint diagnostics), diff --git a/docs/selection-collection.md b/docs/selection-collection.md index 53a8c9d..f0a11f0 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -434,27 +434,26 @@ The compiler emits a selection only when it can prove it. Over-approximation (ex 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`, `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` closes an under-fetch class: a hole element's function props / -render-prop children are dropped from the emitted hole, but a hole target's `staticRender` -may *invoke* such a closure with a collector proxy during collection (npi's `SelectField` -does `{e => props.children(e)}`), collecting fields -the compiled path never sees. A dropped inline closure is safe to omit **only** when -invoking it cannot reach a scope — no reference to its OWN parameters (transitively) and no -captured entity roots (`onClick={() => save()}` is safe; `it => ` is -not). Otherwise the chain bails. (Lifting param-only closures into the hole literal is a -future recovery — see compiler-plan.md, phase 2.1.) +`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 (npi'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 (`npi`, `packages/admin`, 257 chains) -phase 2 with holes compiles **242/257 (94%)** — 26 chains carry 82 holes total — leaving -15 bails (9 `FUNCTION_PROP_ON_HOLE`, 5 `ENTITY_IN_EXPRESSION_PROP`, 1 `ENTITY_REASSIGNMENT`); -phase 1 without holes compiled 84%. (An earlier 98% reading was unsound — it counted the -now-bailed `FUNCTION_PROP_ON_HOLE` chains as compiled.) +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%. See [docs/compiler-plan.md](./compiler-plan.md) for the full design. diff --git a/packages/bindx-compiler/src/body.ts b/packages/bindx-compiler/src/body.ts index d660299..a9d8e42 100644 --- a/packages/bindx-compiler/src/body.ts +++ b/packages/bindx-compiler/src/body.ts @@ -8,7 +8,7 @@ import type { SelNode } from './selectionTree.js' import type { ImportBindings } from './imports.js' import { BailError, type Scope, childScope, consumeLeaf, consumeMany, consumeRelation, - referencesRoot, resolve, type RootRef, + paramNamesOf, referencesRoot, resolve, type RootRef, } from './resolve.js' import { JsxAnalyzer } from './jsx.js' import type { AnalyzedHole } from './types.js' @@ -29,7 +29,7 @@ export class BodyAnalyzer { /** 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 } + 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) @@ -57,7 +57,12 @@ export class BodyAnalyzer { } const propRoot = scope.propRoots.get(prop.key.name) if (!propRoot) { - continue // scalar prop + // 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) } @@ -148,6 +153,10 @@ export class BodyAnalyzer { 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 } @@ -295,11 +304,14 @@ export class BodyAnalyzer { } private shadowBindings(pattern: t.Node, scope: Scope): void { - if (t.isIdentifier(pattern)) { - scope.roots.delete(pattern.name) - scope.propsParams.delete(pattern.name) + // 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) } - // Nested-pattern params of arbitrary functions never introduce entity roots. } private walkCall(node: t.CallExpression | t.OptionalCallExpression, scope: Scope): void { @@ -367,6 +379,13 @@ export class BodyAnalyzer { 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 { diff --git a/packages/bindx-compiler/src/emit.ts b/packages/bindx-compiler/src/emit.ts index 0287ad0..86a1d1f 100644 --- a/packages/bindx-compiler/src/emit.ts +++ b/packages/bindx-compiler/src/emit.ts @@ -50,6 +50,13 @@ function holeToAst(hole: AnalyzedHole): t.ObjectExpression { 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). + const entries = Object.entries(hole.extraProps).map( + ([name, expr]) => t.objectProperty(key(name), t.arrowFunctionExpression([], expr)), + ) + properties.push(t.objectProperty(t.identifier('extraProps'), t.objectExpression(entries))) + } return t.objectExpression(properties) } diff --git a/packages/bindx-compiler/src/holeProps.ts b/packages/bindx-compiler/src/holeProps.ts new file mode 100644 index 0000000..bbf9895 --- /dev/null +++ b/packages/bindx-compiler/src/holeProps.ts @@ -0,0 +1,80 @@ +/** + * Phase-2.1 lifting: classify a hole element's non-entity props (closures, identifiers) into + * `extraProps` (lifted verbatim) 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 — lift when the value is + * reproducible at the module-scope emit site, bail otherwise (default deny). See docs/compiler-plan.md. + */ +import * as t from '@babel/types' +import { BailError, type Scope, classifyHoleClosure } from './resolve.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 +} + +export interface HolePropInputs { + readonly tag: string + readonly closureProps: ReadonlyArray + readonly identifierProps: ReadonlyArray + readonly childClosure: Closure | null + readonly scope: Scope + readonly moduleBindings: ReadonlySet +} + +/** + * Builds a hole's `extraProps` (target prop → value expression, emitted as an arrow thunk), lifting + * what a target may invoke and bailing on what cannot be reproduced at the emit site. + */ +export function resolveHoleExtraProps(inputs: HolePropInputs): Record { + const { tag, closureProps, identifierProps, childClosure, scope, moduleBindings } = 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) { + liftClosure(name, fn) + } + if (childClosure) { + liftClosure('children', childClosure) + } + for (const { name, ident } of identifierProps) { + 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/jsx.ts b/packages/bindx-compiler/src/jsx.ts index 6c2008a..e263943 100644 --- a/packages/bindx-compiler/src/jsx.ts +++ b/packages/bindx-compiler/src/jsx.ts @@ -7,10 +7,15 @@ import * as t from '@babel/types' import type { ComponentKind, ImportBindings } from './imports.js' import { BailError, type RootRef, type Scope, consumeLeaf, consumeMany, consumeRelation, - entityPathOf, evaluateLiteral, isHoleClosureSafe, referencesRoot, resolve, + entityPathOf, evaluateLiteral, referencesRoot, resolve, } from './resolve.js' +import { type Closure, type HoleClosureProp, type HoleIdentifierProp, resolveHoleExtraProps } from './holeProps.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). */ @@ -78,12 +83,15 @@ export class JsxAnalyzer { /** * 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). + * 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 entityProps: Record = {} const literalProps: Record = {} - const functionProps: Array = [] + const closureProps: HoleClosureProp[] = [] + const identifierProps: HoleIdentifierProp[] = [] for (const attr of node.openingElement.attributes) { if (t.isJSXSpreadAttribute(attr)) { @@ -93,52 +101,41 @@ export class JsxAnalyzer { if (!t.isJSXIdentifier(attr.name) || attr.name.name === 'children') { continue } - const inner = jsxAttrInner(attr.value) - if (inner && (t.isArrowFunctionExpression(inner) || t.isFunctionExpression(inner))) { - functionProps.push(inner) // dropped from the hole — must be proven safe if this is a hole + 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) + this.collectComponentProp(attr.name.name, attr.value, scope, entityProps, literalProps, identifierProps) } + const childClosure = childrenCallback(node.children) + if (Object.keys(entityProps).length > 0) { - // A hole drops the element's function props/children; if the target may invoke one - // with a collector proxy during collection, dropping it under-fetches → bail. - this.assertHoleClosuresSafe(tag, functionProps, node.children, scope) 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` }) } + const extraProps = resolveHoleExtraProps({ tag, closureProps, identifierProps, childClosure, scope, moduleBindings: this.moduleBindings }) 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 } - /** Bail if any dropped function prop / render-prop child of a hole element is unsafe to omit. */ - private assertHoleClosuresSafe( - tag: string, - functionProps: ReadonlyArray, - children: t.JSXElement['children'], - scope: Scope, - ): void { - for (const fn of functionProps) { - if (!isHoleClosureSafe(fn, scope)) { - throw new BailError({ code: 'FUNCTION_PROP_ON_HOLE', message: `function prop on hole element <${tag}> may be invoked with an entity during collection` }) - } - } - for (const child of children) { - if (t.isJSXExpressionContainer(child) - && (t.isArrowFunctionExpression(child.expression) || t.isFunctionExpression(child.expression)) - && !isHoleClosureSafe(child.expression, scope)) { - throw new BailError({ code: 'FUNCTION_PROP_ON_HOLE', message: `render-prop children of hole element <${tag}> may be invoked with an entity during collection` }) - } - } - } - /** 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) { @@ -160,6 +157,7 @@ export class JsxAnalyzer { scope: Scope, entityProps: Record, literalProps: Record, + identifierProps: Array<{ name: string; ident: t.Identifier }>, ): void { if (value === null || value === undefined) { literalProps[propName] = true // boolean shorthand `` @@ -173,11 +171,28 @@ export class JsxAnalyzer { 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}\`` }) } @@ -188,6 +203,16 @@ export class JsxAnalyzer { // 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)) { diff --git a/packages/bindx-compiler/src/resolve.ts b/packages/bindx-compiler/src/resolve.ts index 0672056..d67b1fa 100644 --- a/packages/bindx-compiler/src/resolve.ts +++ b/packages/bindx-compiler/src/resolve.ts @@ -31,6 +31,18 @@ export interface Scope { 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 { @@ -38,9 +50,18 @@ export function childScope(scope: Scope): Scope { 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' } @@ -214,22 +235,38 @@ function collectParamNames(node: t.Node, out: Set): void { } /** - * Can this closure be safely OMITTED from a phase-2 hole? The hole target's `staticRender` - * may invoke a dropped function prop / render-prop child with a collector proxy during - * collection, so the closure is safe to drop only when invoking it cannot reach a selection - * scope. The gateways are the closure's OWN parameters (a proxy would flow in there — a - * transitive reference in the body is unsafe) and captured entity roots. No gateway → safe. - * Nested inner functions' own params are theirs, not ours (see docs/compiler-plan.md, Phase 2). + * 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 function isHoleClosureSafe(fn: t.ArrowFunctionExpression | t.FunctionExpression, scope: Scope): boolean { +export type HoleClosureClass = 'drop' | 'lift' | 'bail' + +export function classifyHoleClosure(fn: t.ArrowFunctionExpression | t.FunctionExpression, scope: Scope): HoleClosureClass { if (referencesRoot(fn, scope)) { - return false // captured entity root reachable when invoked + return 'bail' // captured entity root reachable when invoked } const ownParams = new Set() for (const param of fn.params) { collectParamNames(param, ownParams) } - return !anyIdentifier(fn.body, name => ownParams.has(name)) + 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' } /** diff --git a/packages/bindx-compiler/src/types.ts b/packages/bindx-compiler/src/types.ts index e32d0b9..be0901c 100644 --- a/packages/bindx-compiler/src/types.ts +++ b/packages/bindx-compiler/src/types.ts @@ -2,6 +2,7 @@ * 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 { @@ -45,6 +46,12 @@ export interface AnalyzedHole { 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 } /** @@ -59,6 +66,7 @@ export type BailoutReason = | 'ENTITY_ESCAPES_TO_COMPONENT' | 'ENTITY_IN_EXPRESSION_PROP' | 'FUNCTION_PROP_ON_HOLE' + | 'RENDER_LOCAL_ON_HOLE' | 'MEMBER_COMPONENT_TAG' | 'ENTITY_SPREAD' | 'COMPUTED_MEMBER' diff --git a/packages/bindx-compiler/tests/equivalence.test.ts b/packages/bindx-compiler/tests/equivalence.test.ts index d9ec3d1..00dd3d3 100644 --- a/packages/bindx-compiler/tests/equivalence.test.ts +++ b/packages/bindx-compiler/tests/equivalence.test.ts @@ -10,6 +10,8 @@ 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' @@ -25,6 +27,8 @@ const FIXTURES: ReadonlyArray = [ ['mapHasMany.tsx', mapHasMany], ['constAlias.tsx', constAlias], ['condition.tsx', condition], + ['condProps.tsx', condProps], + ['jsxProps.tsx', jsxProps], ['irrelevantChain.tsx', irrelevantChain], ['bails.tsx', bails], ] diff --git a/packages/bindx-compiler/tests/fixtures/condProps.tsx b/packages/bindx-compiler/tests/fixtures/condProps.tsx new file mode 100644 index 0000000..ac1910a --- /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/holeClosures.tsx b/packages/bindx-compiler/tests/fixtures/holeClosures.tsx index 8d6b823..4bbd434 100644 --- a/packages/bindx-compiler/tests/fixtures/holeClosures.tsx +++ b/packages/bindx-compiler/tests/fixtures/holeClosures.tsx @@ -2,9 +2,12 @@ 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 under-fetch guard: function props / render-prop children of a HOLE element are -// dropped from the emitted hole, but the target's staticRender may INVOKE them with a -// collector proxy during collection. See docs/compiler-plan.md (FUNCTION_PROP_ON_HOLE). +// 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 @@ -12,7 +15,7 @@ interface SelectFieldProps { } // Mirrors npi's SelectField: a withCollector primitive whose staticRender reaches the field -// ONLY through the render-prop child. Dropping that child under-fetches unless the chain bails. +// ONLY through the render-prop child (it INVOKES it with a collector proxy). export const SelectField = withCollector( (_props: SelectFieldProps): ReactNode => null, (props: SelectFieldProps): ReactNode => ( @@ -37,10 +40,10 @@ export const AuthorSummary = withCollector( const sideEffect = (): void => {} -// UNSAFE — the render-prop child reads a field off its OWN param. The hole drops it, yet -// SelectField.staticRender invokes it with a collector proxy → compiled would under-fetch. -// The chain BAILS with FUNCTION_PROP_ON_HOLE. -export const UnsafeRenderProp = createComponent() +// 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 }) => ( @@ -48,9 +51,20 @@ export const UnsafeRenderProp = createComponent() )) -// SAFE — 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. +// 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 }) => ( diff --git a/packages/bindx-compiler/tests/fixtures/holes.tsx b/packages/bindx-compiler/tests/fixtures/holes.tsx index 935a217..c0b9052 100644 --- a/packages/bindx-compiler/tests/fixtures/holes.tsx +++ b/packages/bindx-compiler/tests/fixtures/holes.tsx @@ -48,7 +48,8 @@ export const DerivedPathViaCallback = createComponent() )) -// 6. literal + non-literal extra props (literals kept, non-literals dropped) +// 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 }) => ( diff --git a/packages/bindx-compiler/tests/fixtures/jsxProps.tsx b/packages/bindx-compiler/tests/fixtures/jsxProps.tsx new file mode 100644 index 0000000..1b6f94c --- /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/holeClosures.test.ts b/packages/bindx-compiler/tests/holeClosures.test.ts index 0708efc..3661e39 100644 --- a/packages/bindx-compiler/tests/holeClosures.test.ts +++ b/packages/bindx-compiler/tests/holeClosures.test.ts @@ -1,8 +1,9 @@ /** - * Phase-2 under-fetch guard (FUNCTION_PROP_ON_HOLE). A function prop / render-prop child of - * a hole element is dropped from the emitted hole, but the target's staticRender may invoke - * it with a collector proxy during collection. Unsafe closures (own params / captured roots) - * must bail the chain; param-less, root-free closures stay safe to omit and keep compiling. + * 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' @@ -19,30 +20,42 @@ describe('hole closure safety (fixtures/holeClosures.tsx)', () => { const code = readFileSync(FIXTURE, 'utf8') const results = analyzeSource(code, 'holeClosures.tsx') - test('two host chains: unsafe render-prop bails, safe closures compile', () => { - expect(results.length).toBe(2) + test('three host chains recognized (lift, bail, drop)', () => { + expect(results.length).toBe(3) }) - test('#0 UnsafeRenderProp bails FUNCTION_PROP_ON_HOLE', () => { + 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('#1 SafeHoleClosures compiles with the AuthorSummary hole (safe props omitted)', () => { - const result = results[1]! + test('#2 SafeHoleClosures compiles with the AuthorSummary hole (safe props omitted)', () => { + const result = results[2]! expect(isBailed(result)).toBe(false) if (!isBailed(result)) { - expect(result.holes).toEqual([ - { component: 'AuthorSummary', entityProps: { author: { source: 'article', path: ['author'] } }, literalProps: undefined }, - ]) + 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 safety — plugin injection', () => { +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) { @@ -51,8 +64,8 @@ describe('hole closure safety — plugin injection', () => { return out.code } - // Isolated unsafe chain — mirrors the SelectField render-prop shape. - const UNSAFE = ` + // 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) => ( @@ -63,17 +76,36 @@ export const Host = createComponent().entity('article', ArticleDef).render(({ ar )) ` - test('bailed unsafe chain gets no injected 2nd argument (runtime fallback)', () => { - const out = transform(UNSAFE) + // 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 unsafe -// chain bails → no injection → runtime proxy fallback (trivially equal). The safe chain compiles -// with the hole, resolved through AuthorSummary's staticRender → author.name. -describe('hole closure safety — equivalence vs runtime oracle', () => { +// 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 @@ -92,10 +124,16 @@ describe('hole closure safety — equivalence vs runtime oracle', () => { rmSync(tmpPath, { force: true }) }) - test('UnsafeRenderProp — bailed compiled path equals the oracle (both collect author.name)', () => { - const reference = runtimePlain(oracle.UnsafeRenderProp, 'article') + 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.UnsafeRenderProp, 'article')).toEqual(reference) + expect(runtimePlain(compiled.CapturingRenderProp, 'article')).toEqual(reference) }) test('SafeHoleClosures — compiled hole equals the oracle (both collect author.name)', () => { diff --git a/packages/bindx-compiler/tests/holes.test.ts b/packages/bindx-compiler/tests/holes.test.ts index 7991bff..27ebfdc 100644 --- a/packages/bindx-compiler/tests/holes.test.ts +++ b/packages/bindx-compiler/tests/holes.test.ts @@ -98,10 +98,12 @@ describe('hole analyzer (fixtures/holes.tsx)', () => { expect(holesAt(5)[0]?.entityProps).toEqual({ author: { source: 'article', path: ['author'] } }) }) - test('#6 literal props kept, non-literal props dropped', () => { + 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', () => { From caba4fa3a1bb8578769e173843eee23cdb333b08 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 17:49:31 +0200 Subject: [PATCH 16/34] =?UTF-8?q?docs:=20phase=202.2=20plan=20=E2=80=94=20?= =?UTF-8?q?collector=20contracts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 68 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index 0bc5459..e94500f 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -402,7 +402,73 @@ captures the host entity root `footer.linkColumns` — not liftable), 1 `ENTITY_ (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) + +Motivation: the last real npi 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. +- npi: validated on a patched TEMP COPY (scratchpad) of `footer-editor.tsx` + `_shared.tsx` with + `InitializingRepeater` declaring `{ children: itemOf('field') }` — the npi repo itself is NOT + modified; the suggested npi patch ships in the report/docs instead. + ## 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. +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). From ab445eb38d2d87cc91245732fd331114f580795c Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 17:56:58 +0200 Subject: [PATCH 17/34] feat(bindx-react): declarative collector contracts for withCollector MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add itemOf/entityOf combinators and a CollectorContract object form to withCollector. A contract derives its staticRender automatically — one / per entry replaying the callback prop, guarded so a missing callback still registers the relation with no nested selection. The overload discriminates function (staticRender) vs object (contract) by a runtime typeof guard; the contract is also attached under the exported COLLECTOR_CONTRACT symbol for introspection. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- packages/bindx-react/src/index.ts | 5 + .../bindx-react/src/jsx/collectorContract.tsx | 66 ++++++ packages/bindx-react/src/jsx/index.ts | 9 + packages/bindx-react/src/jsx/withCollector.ts | 39 +++- tests/react/jsx/collectorContract.test.tsx | 207 ++++++++++++++++++ 5 files changed, 317 insertions(+), 9 deletions(-) create mode 100644 packages/bindx-react/src/jsx/collectorContract.tsx create mode 100644 tests/react/jsx/collectorContract.test.tsx diff --git a/packages/bindx-react/src/index.ts b/packages/bindx-react/src/index.ts index adad7cf..ba24d35 100644 --- a/packages/bindx-react/src/index.ts +++ b/packages/bindx-react/src/index.ts @@ -364,6 +364,11 @@ export { // Component (unified API) createComponent, withCollector, + COLLECTOR_CONTRACT, + itemOf, + entityOf, + type CallbackContract, + type CollectorContract, isBindxComponent, mergeFragments, COMPONENT_MARKER, diff --git a/packages/bindx-react/src/jsx/collectorContract.tsx b/packages/bindx-react/src/jsx/collectorContract.tsx new file mode 100644 index 0000000..6f6c2d4 --- /dev/null +++ b/packages/bindx-react/src/jsx/collectorContract.tsx @@ -0,0 +1,66 @@ +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. */ +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/index.ts b/packages/bindx-react/src/jsx/index.ts index 7c8c95c..25d79d6 100644 --- a/packages/bindx-react/src/jsx/index.ts +++ b/packages/bindx-react/src/jsx/index.ts @@ -110,6 +110,15 @@ 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/withCollector.ts b/packages/bindx-react/src/jsx/withCollector.ts index 6b26d41..4885d5c 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/tests/react/jsx/collectorContract.test.tsx b/tests/react/jsx/collectorContract.test.tsx new file mode 100644 index 0000000..8734930 --- /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') + }) + }) +}) From 1cd5cee8aad87ea0419e5330ef5013c33fb29592 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 18:16:54 +0200 Subject: [PATCH 18/34] feat(bindx-compiler): collector-contract discovery and contract-aware analysis MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 2.2 compiler side. A `withCollector(_, contract)` component declares how its callback props are invoked (`itemOf`/`entityOf`); the analyzer now discovers that contract and treats the callback exactly like a / child — no hole, no lift, no FUNCTION_PROP_ON_HOLE/RENDER_LOCAL_ON_HOLE for contract targets. Discovery (src/contracts.ts, ContractResolver + ContractFileCache): - local `const Tag = withCollector(_, {...})` (TS wrappers unwrapped), or - imported binding via a RELATIVE specifier (./x → x.tsx|ts|jsx|js / x/index.*, ESM ./x.js → TS source) plus an optional `alias` prefix→path map; the sibling module is parsed (no execution/type-checker) and cached by path+mtime. Contract literals: object of `itemOf('…')`/`entityOf('…')` calls (combinators imported from @contember/bindx*, string-literal args); a module-level const identifier resolving to such a literal is accepted. Any deviation → no contract → existing hole/bail rules (a fallback hole still resolves at runtime through the derived staticRender, so soundness holds). Threaded via analyzeProgram(program, { filename, alias, cache }) → BodyAnalyzer → JsxAnalyzer as a ContractLookup; jsx.ts walkContractComponent replaces hole formation for contract tags. parseProgram extracted to src/parse.ts to avoid an analyze↔contracts cycle; resolve.unwrap exported for reuse. Fixtures + oracle equivalence (strict): same-file + cross-file targets, footer-editor replica (item callback capturing a host-root field), entityOf, dropped non-contract fn prop, missing callback, and a negative unparseable-contract (spread) that falls back to a FUNCTION_PROP_ON_HOLE bail. Validated on a patched TEMP COPY of reference-app footer-editor.tsx + _shared.tsx (repo unmodified): InitializingRepeater → { children: itemOf('field') } turns the footer-editor L112 FUNCTION_PROP_ON_HOLE bail into OK (8/8). Full reference-app re-measure unchanged at 254/257 (99%) since the app has not adopted contracts. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 80 ++++- docs/selection-collection.md | 25 ++ packages/bindx-compiler/src/analyze.ts | 40 ++- packages/bindx-compiler/src/babelPlugin.ts | 15 +- packages/bindx-compiler/src/body.ts | 9 +- packages/bindx-compiler/src/contracts.ts | 317 ++++++++++++++++++ packages/bindx-compiler/src/index.ts | 11 +- packages/bindx-compiler/src/jsx.ts | 72 ++++ packages/bindx-compiler/src/parse.ts | 14 + packages/bindx-compiler/src/resolve.ts | 3 +- .../bindx-compiler/tests/contracts.test.ts | 85 +++++ .../tests/fixtures/_contractTargets.tsx | 27 ++ .../tests/fixtures/contracts.tsx | 97 ++++++ packages/bindx-compiler/tests/harness.ts | 5 +- 14 files changed, 775 insertions(+), 25 deletions(-) create mode 100644 packages/bindx-compiler/src/contracts.ts create mode 100644 packages/bindx-compiler/src/parse.ts create mode 100644 packages/bindx-compiler/tests/contracts.test.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_contractTargets.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/contracts.tsx diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index e94500f..f0f7d75 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -402,7 +402,13 @@ captures the host entity root `footer.linkColumns` — not liftable), 1 `ENTITY_ (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) +## 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 npi 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 @@ -467,6 +473,78 @@ withCollector(runtime, contract: CollectorContract) `InitializingRepeater` declaring `{ children: itemOf('field') }` — the npi repo itself is NOT modified; the suggested npi 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 }`; re-exports with a `from` source are 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 — npi 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 `~/projects/external/npi/packages/admin` re-measure is **unchanged** — 254/257 (99%), 3 bails +(1 `ENTITY_IN_EXPRESSION_PROP`, 1 `FUNCTION_PROP_ON_HOLE`, 1 `ENTITY_REASSIGNMENT`) — because npi has +not adopted contracts. Adopting the suggested patch would clear the remaining `FUNCTION_PROP_ON_HOLE`. + +Suggested npi 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 +``` + ## Future (explicitly out of scope now) Unplugin packaging, eslint plugin reusing the analyzer (bail reasons as lint diagnostics), diff --git a/docs/selection-collection.md b/docs/selection-collection.md index f0a11f0..a9990e6 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -354,6 +354,31 @@ 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** specifier (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. diff --git a/packages/bindx-compiler/src/analyze.ts b/packages/bindx-compiler/src/analyze.ts index ebfcb05..cdcc6c7 100644 --- a/packages/bindx-compiler/src/analyze.ts +++ b/packages/bindx-compiler/src/analyze.ts @@ -5,43 +5,57 @@ * machine-readable bailout. Over-approximation (extra fields) is acceptable; * under-approximation is impossible by construction (default deny on the unknown). */ -import { parse } from '@babel/parser' 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 } from './resolve.js' import { SelNode } from './selectionTree.js' +import { parseProgram } from './parse.js' +import { ContractFileCache, ContractResolver, type ContractLookup } from './contracts.js' import type { ChainLoc, ChainResult, StaticSelection } from './types.js' +export { parseProgram } + export interface InternalChainResult { readonly chain: Chain readonly result: ChainResult } -export function parseProgram(code: string, _filename: string): t.Program { - const file = parse(code, { - sourceType: 'module', - plugins: ['jsx', 'typescript'], - }) - return file.program +/** 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?: ContractFileCache } +// Shared across analyzeProgram/plugin invocations, keyed internally by path+mtime. +const defaultContractCache = new ContractFileCache() + /** Analyze an already-parsed program; retains Babel node refs for the plugin. */ -export function analyzeProgram(program: t.Program): InternalChainResult[] { +export function analyzeProgram(program: t.Program, options: AnalyzeOptions = {}): InternalChainResult[] { const bindings = collectImportBindings(program) const moduleBindings = collectModuleBindings(program) + const resolver = new ContractResolver(program, { + filename: options.filename, + alias: options.alias ?? {}, + cache: options.cache ?? defaultContractCache, + }) + const lookup: ContractLookup = tag => resolver.resolve(tag) const chains = findChains(program, bindings) - return chains.map(chain => ({ chain, result: analyzeChain(chain, bindings, moduleBindings) })) + return chains.map(chain => ({ chain, result: analyzeChain(chain, bindings, moduleBindings, lookup) })) } -function analyzeChain(chain: Chain, bindings: ImportBindings, moduleBindings: ReadonlySet): ChainResult { +function analyzeChain(chain: Chain, bindings: ImportBindings, moduleBindings: ReadonlySet, lookup: ContractLookup): 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) + const analyzer = new BodyAnalyzer(bindings, moduleBindings, lookup) try { if (chain.conditionFn) { analyzer.analyzeFunction(chain.conditionFn, propRoots) @@ -74,6 +88,6 @@ function chainLoc(call: t.CallExpression): ChainLoc { } } -export function analyzeSource(code: string, filename: string): ChainResult[] { - return analyzeProgram(parseProgram(code, filename)).map(r => r.result) +export function analyzeSource(code: string, filename: string, options: Omit = {}): ChainResult[] { + return analyzeProgram(parseProgram(code, filename), { ...options, filename }).map(r => r.result) } diff --git a/packages/bindx-compiler/src/babelPlugin.ts b/packages/bindx-compiler/src/babelPlugin.ts index 1ea6a85..3d81145 100644 --- a/packages/bindx-compiler/src/babelPlugin.ts +++ b/packages/bindx-compiler/src/babelPlugin.ts @@ -6,20 +6,27 @@ * The runtime side of `.render(fn, static)` is deliverable A — this plugin only * emits the argument and never imports anything from bindx-react. */ -import type { PluginObj } from '@babel/core' +import type { PluginObj, PluginPass } from '@babel/core' import { analyzeProgram } from './analyze.js' import { selectionToAst } from './emit.js' import { isBailed } from './types.js' -export function bindxCompilerPlugin(): PluginObj { +/** Plugin options: `alias` maps non-relative import prefixes to paths for cross-file contract discovery. */ +export interface BindxCompilerOptions { + readonly alias?: Record +} + +export function bindxCompilerPlugin(_api?: unknown, options?: BindxCompilerOptions): PluginObj { + const alias = options?.alias ?? {} return { name: 'bindx-selection-compiler', manipulateOptions(_opts, parserOpts: { plugins: unknown[] }): void { parserOpts.plugins.push('jsx', 'typescript') }, visitor: { - Program(path): void { - for (const { chain, result } of analyzeProgram(path.node)) { + Program(path, state: PluginPass): void { + const filename = state.file.opts.filename ?? undefined + for (const { chain, result } of analyzeProgram(path.node, { filename, alias })) { if (isBailed(result)) { continue } diff --git a/packages/bindx-compiler/src/body.ts b/packages/bindx-compiler/src/body.ts index a9d8e42..3e065e1 100644 --- a/packages/bindx-compiler/src/body.ts +++ b/packages/bindx-compiler/src/body.ts @@ -11,6 +11,7 @@ import { paramNamesOf, referencesRoot, resolve, type RootRef, } from './resolve.js' import { JsxAnalyzer } from './jsx.js' +import type { ContractLookup } from './contracts.js' import type { AnalyzedHole } from './types.js' export class BodyAnalyzer { @@ -18,8 +19,12 @@ export class BodyAnalyzer { /** Nested-component holes collected across the render + condition functions (phase 2). */ readonly holes: AnalyzedHole[] = [] - constructor(private readonly bindings: ImportBindings, private readonly moduleBindings: ReadonlySet) { - this.jsx = new JsxAnalyzer(this, bindings, moduleBindings) + constructor( + private readonly bindings: ImportBindings, + private readonly moduleBindings: ReadonlySet, + contracts: ContractLookup, + ) { + this.jsx = new JsxAnalyzer(this, bindings, moduleBindings, contracts) } /** Public so JsxAnalyzer can register a hole it discovered. */ diff --git a/packages/bindx-compiler/src/contracts.ts b/packages/bindx-compiler/src/contracts.ts new file mode 100644 index 0000000..0374542 --- /dev/null +++ b/packages/bindx-compiler/src/contracts.ts @@ -0,0 +1,317 @@ +/** + * 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. + * + * This is a bounded exception to the purely-local principle: to read a contract declared + * in another module we resolve RELATIVE specifiers only (plus an optional `alias` map), + * PARSE the target (no execution, no type checker), and cache per path+mtime. + */ +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' + +export interface CallbackContract { + readonly kind: 'itemOf' | 'entityOf' + readonly field: string +} + +/** Key = callback prop name (`children` included) → the relation it is invoked over. */ +export type CollectorContract = Record + +/** Resolves a component tag to its declared contract, or null (→ existing hole/bail rules). */ +export type ContractLookup = (tag: string) => CollectorContract | null + +/** Local names of the bindx symbols a module needs for contract extraction. */ +interface ModuleView { + readonly program: t.Program + readonly withCollector: ReadonlySet + readonly itemOf: ReadonlySet + readonly entityOf: ReadonlySet +} + +function isBindxSource(source: string): boolean { + return source === '@contember/bindx' || source.startsWith('@contember/bindx-') || source.startsWith('@contember/bindx/') +} + +/** Collect local binding names for `withCollector`/`itemOf`/`entityOf` imported from bindx. */ +function makeModuleView(program: t.Program): ModuleView { + const withCollector = new Set() + const itemOf = new Set() + const entityOf = 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) + } + } + } + return { program, withCollector, itemOf, entityOf } +} + +/** mtime-keyed cache of parsed sibling modules — shared across analyzeProgram / plugin runs. */ +export class ContractFileCache { + 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 contract, existing rules apply + } + this.cache.set(path, { mtime, view }) + return view + } +} + +export interface ContractResolverOptions { + readonly filename?: string + readonly alias: Record + readonly cache: ContractFileCache +} + +export class ContractResolver { + private readonly self: ModuleView + private readonly memo = new Map() + + constructor(program: t.Program, private readonly options: ContractResolverOptions) { + this.self = makeModuleView(program) + } + + resolve(tag: string): CollectorContract | null { + const cached = this.memo.get(tag) + if (cached !== undefined) { + return cached + } + const contract = this.compute(tag) + this.memo.set(tag, contract) + return contract + } + + private compute(tag: string): CollectorContract | null { + // 1. Local `const tag = withCollector(_, contract)` in this module. + const localInit = findTopLevelVarInit(this.self.program, tag) + if (localInit) { + return this.contractFromInit(localInit, this.self) + } + // 2. Imported binding — relative specifier (or alias-mapped) only. + const imp = findImport(this.self.program, tag) + if (!imp) { + return null + } + const path = this.resolveModulePath(imp.source) + if (!path) { + return null + } + const view = this.options.cache.get(path) + if (!view) { + return null + } + const init = resolveExportedInit(view.program, imp.importedName) + return init ? this.contractFromInit(init, view) : null + } + + /** Contract from a binding initializer, iff it is `withCollector(_, )` in `view`. */ + private contractFromInit(init: t.Expression, 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 + } + + /** Resolve an import specifier to an existing absolute file (relative or alias-mapped only). */ + private resolveModulePath(source: string): string | null { + const base = this.toAbsoluteBase(source) + return base ? firstExisting(base) : null + } + + /** Absolute base path (no extension resolution) for a relative or alias-mapped specifier. */ + private toAbsoluteBase(source: string): string | null { + if (source.startsWith('.')) { + return this.options.filename ? resolvePath(dirname(this.options.filename), 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 +} + +/** 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 +} + +interface ImportRef { + readonly source: string + readonly importedName: string +} + +/** Find where `local` is imported from and under which imported name (`default` for default). */ +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 +} + +/** Initializer of a module-level `const name = ` (unwrapped), searching plain + `export` decls. */ +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 +} + +/** Initializer exported under `importedName`: `export const X`, `export { local as X }`, `export default`. */ +function resolveExportedInit(program: t.Program, importedName: string): t.Expression | null { + if (importedName === 'default') { + for (const node of program.body) { + if (t.isExportDefaultDeclaration(node) && t.isExpression(node.declaration)) { + const inner = unwrap(node.declaration) + return t.isExpression(inner) ? inner : null + } + } + return null + } + for (const node of program.body) { + if (!t.isExportNamedDeclaration(node)) { + continue + } + if (node.declaration && t.isVariableDeclaration(node.declaration)) { + const init = varInit(node.declaration, importedName) + if (init) { + return init + } + } + if (!node.source) { + // `export { local as X }` — follow to the local declaration (re-exports with a source are unfollowable). + for (const spec of node.specifiers) { + if (t.isExportSpecifier(spec)) { + const exported = t.isIdentifier(spec.exported) ? spec.exported.name : spec.exported.value + if (exported === importedName) { + return findTopLevelVarInit(program, spec.local.name) + } + } + } + } + } + return null +} diff --git a/packages/bindx-compiler/src/index.ts b/packages/bindx-compiler/src/index.ts index bd5903d..3ce27d2 100644 --- a/packages/bindx-compiler/src/index.ts +++ b/packages/bindx-compiler/src/index.ts @@ -1,5 +1,12 @@ -export { analyzeSource, analyzeProgram, parseProgram, type InternalChainResult } from './analyze.js' -export { bindxCompilerPlugin, default } from './babelPlugin.js' +export { analyzeSource, analyzeProgram, parseProgram, type AnalyzeOptions, type InternalChainResult } from './analyze.js' +export { bindxCompilerPlugin, default, type BindxCompilerOptions } from './babelPlugin.js' +export { + ContractFileCache, + ContractResolver, + type CallbackContract, + type CollectorContract, + type ContractLookup, +} from './contracts.js' export { selectionToAst } from './emit.js' export { fieldMapToPlain, selectionToPlain } from './selectionTree.js' export type { diff --git a/packages/bindx-compiler/src/jsx.ts b/packages/bindx-compiler/src/jsx.ts index e263943..9188ee3 100644 --- a/packages/bindx-compiler/src/jsx.ts +++ b/packages/bindx-compiler/src/jsx.ts @@ -10,6 +10,7 @@ import { entityPathOf, evaluateLiteral, referencesRoot, resolve, } from './resolve.js' import { type Closure, type HoleClosureProp, type HoleIdentifierProp, resolveHoleExtraProps } from './holeProps.js' +import type { CollectorContract, ContractLookup } from './contracts.js' import type { AnalyzedHole, HoleEntityProp, StaticHasManyParams } from './types.js' function asClosure(node: t.Node | null): Closure | null { @@ -30,6 +31,7 @@ export class JsxAnalyzer { private readonly host: JsxHost, private readonly bindings: ImportBindings, private readonly moduleBindings: ReadonlySet, + private readonly contracts: ContractLookup, ) {} walk(node: t.JSXElement | t.JSXFragment, scope: Scope): void { @@ -88,6 +90,12 @@ export class JsxAnalyzer { * 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[] = [] @@ -136,6 +144,70 @@ export class JsxAnalyzer { 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) { diff --git a/packages/bindx-compiler/src/parse.ts b/packages/bindx-compiler/src/parse.ts new file mode 100644 index 0000000..85e7b91 --- /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 index d67b1fa..f300aeb 100644 --- a/packages/bindx-compiler/src/resolve.ts +++ b/packages/bindx-compiler/src/resolve.ts @@ -67,7 +67,8 @@ export type Resolution = | { kind: 'opaque' } | { kind: 'none' } -function unwrap(node: t.Node): t.Node { +/** 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) } diff --git a/packages/bindx-compiler/tests/contracts.test.ts b/packages/bindx-compiler/tests/contracts.test.ts new file mode 100644 index 0000000..36fcc53 --- /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/fixtures/_contractTargets.tsx b/packages/bindx-compiler/tests/fixtures/_contractTargets.tsx new file mode 100644 index 0000000..bbb42cd --- /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/contracts.tsx b/packages/bindx-compiler/tests/fixtures/contracts.tsx new file mode 100644 index 0000000..9aa3f3c --- /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/harness.ts b/packages/bindx-compiler/tests/harness.ts index 91bf086..41bb280 100644 --- a/packages/bindx-compiler/tests/harness.ts +++ b/packages/bindx-compiler/tests/harness.ts @@ -9,8 +9,9 @@ import { COMPONENT_SELECTIONS, convertToQuerySelection, type SelectionMeta } fro import { analyzeSource, isBailed, selectionToPlain, type ChainResult } from '../src/index.js' export function analyzeFixture(dir: string, file: string): ChainResult[] { - const code = readFileSync(join(dir, 'fixtures', file), 'utf8') - return analyzeSource(code, file) + 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. */ From 8f71e4ea89da7a27e0ffda2290964a0fa27e4af5 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 18:59:11 +0200 Subject: [PATCH 19/34] =?UTF-8?q?docs:=20phase=203=20plan=20=E2=80=94=20En?= =?UTF-8?q?tity=20root=20compilation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 56 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 55 insertions(+), 1 deletion(-) diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index f0f7d75..e928679 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -545,8 +545,62 @@ now-unused `HasMany` JSX import: ) as (props: InitializingRepeaterProps) => React.ReactNode ``` +## Phase 3 — Entity root compilation + +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. npi 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 npi measure re-run with root counts. + ## 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). +(phase-2.2 alternative — superseded by contracts unless a non-contract case demands it), +DataGrid/DataView root compilation (phase 3.1). From db85777d7c21afd445066d3cd0e3d606c3469846 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 19:13:01 +0200 Subject: [PATCH 20/34] feat(bindx-react): Entity accepts compiled root selection Phase 3 runtime side: (both by-mode and create-mode) gains a compiler-facing `compiledSelection?: CompiledSelection` prop. When present, the root SelectionMeta is built statically (fields under the fixed `entity` key + resolved holes) and children is never invoked with a collector. - Extract `resolveCompiledSelection` / `resolveCompiledRootSelection` from applyCompiledSelection (shared per-prop resolution, no fragments for the root). - New `useRootSelection` hook seams into where useSelectionCollection was, deriving a stable queryKey from the compiled structure via the same buildQueryFromSelection hashing. - Validate mode also runs the (contained) children walk and applies the under-fetch-only diff, warning with the entity type. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- .../bindx-react/src/hooks/useRootSelection.ts | 79 ++++++ .../bindx-react/src/jsx/compiledSelection.ts | 74 ++++- .../bindx-react/src/jsx/componentFactory.ts | 24 ++ .../bindx-react/src/jsx/components/Entity.tsx | 35 ++- .../jsx/entityCompiledSelection.test.tsx | 259 ++++++++++++++++++ 5 files changed, 453 insertions(+), 18 deletions(-) create mode 100644 packages/bindx-react/src/hooks/useRootSelection.ts create mode 100644 tests/react/jsx/entityCompiledSelection.test.tsx diff --git a/packages/bindx-react/src/hooks/useRootSelection.ts b/packages/bindx-react/src/hooks/useRootSelection.ts new file mode 100644 index 0000000..ae314c8 --- /dev/null +++ b/packages/bindx-react/src/hooks/useRootSelection.ts @@ -0,0 +1,79 @@ +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 { resolveCompiledRootSelection, type CompiledSelection } from '../jsx/compiledSelection.js' +import { 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() + + // Compiled root selection — present ⇒ skip the children(collector) walk (unless validating). + const compiledResult = useMemo((): SelectionCollectionResult | null => { + if (!compiledSelection) { + return null + } + const selection = resolveCompiledRootSelection({ + compiled: compiledSelection, + entityType, + schemaRegistry, + validateMode, + }) + return { selection, queryKey: JSON.stringify(buildQueryFromSelection(selection)) } + }, [compiledSelection, 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/jsx/compiledSelection.ts b/packages/bindx-react/src/jsx/compiledSelection.ts index 373388c..8a45237 100644 --- a/packages/bindx-react/src/jsx/compiledSelection.ts +++ b/packages/bindx-react/src/jsx/compiledSelection.ts @@ -83,13 +83,43 @@ export interface ApplyCompiledSelectionParams { } /** - * Builds `selectionsMap` entries from a compiled selection — seeding one live - * scope per entity prop from its static field map, resolving every hole into the - * same scopes, then snapshotting. Replaces the proxy pass entirely; the host - * render function is never executed. + * 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 { compiled, selectionsMap, componentBrand, roles } = params + 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 => { @@ -120,15 +150,39 @@ export function applyCompiledSelection(params: ApplyCompiledSelectionParams): vo } // 3. Snapshot every scope that captured fields. + const metas = new Map() for (const [propName, scope] of propScopes) { if (scope.hasFields()) { - const selection = scope.toSelectionMeta() - selectionsMap.set(propName, { - selection, - fragment: createFragment(selection, componentBrand, roles), - }) + 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 { diff --git a/packages/bindx-react/src/jsx/componentFactory.ts b/packages/bindx-react/src/jsx/componentFactory.ts index 2755376..17692d1 100644 --- a/packages/bindx-react/src/jsx/componentFactory.ts +++ b/packages/bindx-react/src/jsx/componentFactory.ts @@ -74,6 +74,11 @@ 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 +} + // ============================================================================ // Entity Config (Runtime) // ============================================================================ @@ -490,6 +495,25 @@ function validateCompiledSelection( } } +/** + * 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. diff --git a/packages/bindx-react/src/jsx/components/Entity.tsx b/packages/bindx-react/src/jsx/components/Entity.tsx index 19062ca..1dd74a2 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/tests/react/jsx/entityCompiledSelection.test.tsx b/tests/react/jsx/entityCompiledSelection.test.tsx new file mode 100644 index 0000000..6c65694 --- /dev/null +++ b/tests/react/jsx/entityCompiledSelection.test.tsx @@ -0,0 +1,259 @@ +// Runtime side of Phase 3 — compiled root selection. The compiler injects +// `compiledSelection={{ 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 = { 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 = { 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 = { + 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 = { + 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 = { 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 = { 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 = { 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') + }) + }) +}) From 96765107a0dfac102a68e5a40c3ee9e0e7368875 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Tue, 21 Jul 2026 19:27:56 +0200 Subject: [PATCH 21/34] feat(bindx-compiler): compile Entity root selections MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3 compiler side: statically prove root selections and inject compiledSelection={{ props: { entity: {...} }, holes: [...] }} onto the element, so the runtime never invokes children with a collector (runtime side landed in 32d2927). - New top-level scan (entityRoots.ts): find elements whose tag resolves to a @contember/bindx* import, anywhere in the file (routes, createComponent bodies, any nesting). ImportBindings gains a separate `entity` set so a nested stays opaque to the host chain's per-chain walk. - BodyAnalyzer.analyzeRootChildren: the children closure's first param IS the root, so it is analyzed like a / callback (source 'entity'). All existing machinery applies unchanged — paths, holes + extraProps, collector contracts, cond-in-props, JSX props, branch union. Non-function children bail ENTITY_NO_FUNCTION_CHILDREN; the runtime walk stays. - Emit: entitySelectionAttr + plugin injection, idempotent (skips elements already carrying compiledSelection). EntityRootResult / analyzeEntityRoots on public API; measure reports entity roots separately. - Tests: adapter-oracle equivalence (RecordingMockAdapter captures the QuerySpec; transformed vs untransformed request the same root selection, superset for branch unions) across scalars, nested has-one, compiled-component-in-closure, hole, collector contract, branch union, create mode, Entity-in-component, bail; plus children-not-invoked counter and emit/idempotence. the reference app: chains unchanged 254/257; entity roots 84/105 compiled (114 holes), 21 bailed (11 RENDER_LOCAL_ON_HOLE, 7 ENTITY_ESCAPES_TO_CALL, 2 ENTITY_NO_FUNCTION_CHILDREN, 1 FUNCTION_PROP_ON_HOLE). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 56 ++- docs/selection-collection.md | 38 ++ packages/bindx-compiler/scripts/measure.ts | 44 +- packages/bindx-compiler/src/analyze.ts | 39 +- packages/bindx-compiler/src/babelPlugin.ts | 25 +- packages/bindx-compiler/src/body.ts | 12 + packages/bindx-compiler/src/emit.ts | 14 + packages/bindx-compiler/src/entityRoots.ts | 131 ++++++ packages/bindx-compiler/src/imports.ts | 10 +- packages/bindx-compiler/src/index.ts | 16 +- packages/bindx-compiler/src/types.ts | 25 ++ .../bindx-compiler/tests/entityRoots.test.tsx | 423 ++++++++++++++++++ 12 files changed, 814 insertions(+), 19 deletions(-) create mode 100644 packages/bindx-compiler/src/entityRoots.ts create mode 100644 packages/bindx-compiler/tests/entityRoots.test.tsx diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index e928679..ce76972 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -545,7 +545,61 @@ now-unused `HasMany` JSX import: ) as (props: InitializingRepeaterProps) => React.ReactNode ``` -## Phase 3 — Entity root compilation +## 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 `~/projects/external/npi`, packages/admin) + +Chains unchanged: **254/257 (99%)**, 3 bails (host analysis untouched). Entity roots: +**84/105 compiled** (114 holes — every compiled root carries ≥1 hole: npi'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 diff --git a/docs/selection-collection.md b/docs/selection-collection.md index a9990e6..79bd5f1 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -480,6 +480,44 @@ phase 2.1 compiles **254/257 (99%)** — 38 chains carry 112 holes total — lea (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 `npi`, `packages/admin`, the plugin compiles +**84/105** `` roots (114 holes; npi'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). + See [docs/compiler-plan.md](./compiler-plan.md) for the full design. ## Provider Setup diff --git a/packages/bindx-compiler/scripts/measure.ts b/packages/bindx-compiler/scripts/measure.ts index ec57109..b448a76 100644 --- a/packages/bindx-compiler/scripts/measure.ts +++ b/packages/bindx-compiler/scripts/measure.ts @@ -7,7 +7,7 @@ */ import { existsSync, readdirSync, readFileSync, statSync } from 'node:fs' import { dirname, isAbsolute, join, relative } from 'node:path' -import { analyzeSource, isBailed, type BailoutReason } from '../src/index.js' +import { analyzeEntityRoots, analyzeSource, isBailed, isEntityRootBailed, type BailoutReason } from '../src/index.js' const SKIP_DIRS = new Set(['node_modules', 'dist', '.git']) @@ -67,16 +67,24 @@ function main(): void { 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) } catch (error) { console.log(`${relative(root, file)} PARSE ERROR: ${String(error)}`) continue } - if (results.length === 0) { + if (results.length === 0 && roots.length === 0) { continue } console.log(relative(root, file)) @@ -96,24 +104,50 @@ function main(): void { 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): string => (totalChains === 0 ? '0' : ((n / totalChains) * 100).toFixed(0)) + 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)}%)`) + console.log(`compiled: ${compiled} (${pct(compiled, totalChains)}%)`) console.log(` with holes: ${compiledWithHoles}`) console.log(` total holes: ${totalHoles}`) - console.log(`bailed: ${bailed} (${pct(bailed)}%)`) + 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 index cdcc6c7..b0d41c3 100644 --- a/packages/bindx-compiler/src/analyze.ts +++ b/packages/bindx-compiler/src/analyze.ts @@ -13,7 +13,8 @@ import { BailError } from './resolve.js' import { SelNode } from './selectionTree.js' import { parseProgram } from './parse.js' import { ContractFileCache, ContractResolver, type ContractLookup } from './contracts.js' -import type { ChainLoc, ChainResult, StaticSelection } from './types.js' +import { analyzeEntityRoot, findEntityElements, type InternalEntityRootResult } from './entityRoots.js' +import type { ChainLoc, ChainResult, EntityRootResult, StaticSelection } from './types.js' export { parseProgram } @@ -35,20 +36,41 @@ export interface AnalyzeOptions { // Shared across analyzeProgram/plugin invocations, keyed internally by path+mtime. const defaultContractCache = new ContractFileCache() -/** Analyze an already-parsed program; retains Babel node refs for the plugin. */ -export function analyzeProgram(program: t.Program, options: AnalyzeOptions = {}): InternalChainResult[] { - const bindings = collectImportBindings(program) - const moduleBindings = collectModuleBindings(program) +interface ProgramContext { + readonly bindings: ImportBindings + readonly moduleBindings: ReadonlySet + readonly lookup: ContractLookup +} + +function programContext(program: t.Program, options: AnalyzeOptions): ProgramContext { const resolver = new ContractResolver(program, { filename: options.filename, alias: options.alias ?? {}, cache: options.cache ?? defaultContractCache, }) - const lookup: ContractLookup = tag => resolver.resolve(tag) + return { + bindings: collectImportBindings(program), + moduleBindings: collectModuleBindings(program), + lookup: tag => resolver.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 } = programContext(program, options) const chains = findChains(program, bindings) return chains.map(chain => ({ chain, result: analyzeChain(chain, bindings, moduleBindings, lookup) })) } +/** Analyze every top-level `` selection root in a program (phase 3). */ +export function analyzeEntityRootsInProgram(program: t.Program, options: AnalyzeOptions = {}): InternalEntityRootResult[] { + const { bindings, moduleBindings, lookup } = programContext(program, options) + return findEntityElements(program, bindings).map(element => ({ + element, + result: analyzeEntityRoot(element, bindings, moduleBindings, lookup), + })) +} + function analyzeChain(chain: Chain, bindings: ImportBindings, moduleBindings: ReadonlySet, lookup: ContractLookup): ChainResult { const loc = chainLoc(chain.renderCall) if (chain.earlyBail) { @@ -91,3 +113,8 @@ function chainLoc(call: t.CallExpression): ChainLoc { 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/babelPlugin.ts b/packages/bindx-compiler/src/babelPlugin.ts index 3d81145..f7bbe29 100644 --- a/packages/bindx-compiler/src/babelPlugin.ts +++ b/packages/bindx-compiler/src/babelPlugin.ts @@ -7,9 +7,10 @@ * emits the argument and never imports anything from bindx-react. */ import type { PluginObj, PluginPass } from '@babel/core' -import { analyzeProgram } from './analyze.js' -import { selectionToAst } from './emit.js' -import { isBailed } from './types.js' +import { analyzeEntityRootsInProgram, analyzeProgram } from './analyze.js' +import { entitySelectionAttr, selectionToAst } from './emit.js' +import { ENTITY_ROOT_KEY, hasCompiledSelectionAttr } from './entityRoots.js' +import { isBailed, isEntityRootBailed } from './types.js' /** Plugin options: `alias` maps non-relative import prefixes to paths for cross-file contract discovery. */ export interface BindxCompilerOptions { @@ -26,7 +27,12 @@ export function bindxCompilerPlugin(_api?: unknown, options?: BindxCompilerOptio visitor: { Program(path, state: PluginPass): void { const filename = state.file.opts.filename ?? undefined - for (const { chain, result } of analyzeProgram(path.node, { filename, alias })) { + // Analyze both surfaces before mutating: chain injection and Entity-attribute + // injection are independent, but reading the whole AST first keeps them so. + const chainResults = analyzeProgram(path.node, { filename, alias }) + const entityResults = analyzeEntityRootsInProgram(path.node, { filename, alias }) + + for (const { chain, result } of chainResults) { if (isBailed(result)) { continue } @@ -36,6 +42,17 @@ export function bindxCompilerPlugin(_api?: unknown, options?: BindxCompilerOptio } chain.renderCall.arguments.push(selectionToAst(result.selection, result.holes)) } + + for (const { element, result } of entityResults) { + if (isEntityRootBailed(result)) { + continue + } + // Idempotence: skip elements already carrying a compiledSelection attribute. + if (hasCompiledSelectionAttr(element)) { + continue + } + element.openingElement.attributes.push(entitySelectionAttr(ENTITY_ROOT_KEY, result.selection, result.holes)) + } path.skip() }, }, diff --git a/packages/bindx-compiler/src/body.ts b/packages/bindx-compiler/src/body.ts index 3e065e1..311664b 100644 --- a/packages/bindx-compiler/src/body.ts +++ b/packages/bindx-compiler/src/body.ts @@ -46,6 +46,18 @@ export class BodyAnalyzer { } } + /** + * 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)) { diff --git a/packages/bindx-compiler/src/emit.ts b/packages/bindx-compiler/src/emit.ts index 86a1d1f..d7d7647 100644 --- a/packages/bindx-compiler/src/emit.ts +++ b/packages/bindx-compiler/src/emit.ts @@ -69,3 +69,17 @@ export function selectionToAst(selection: StaticSelection, holes: readonly Analy } return t.objectExpression(properties) } + +/** + * Emits the `compiledSelection={{ props: { entity: {...} }, holes: [...] }}` JSX attribute + * the Babel plugin injects onto a proven `` element (phase 3). The root field map + * lives under the fixed `rootKey`; holes are the same thunk-carrying shape as chains. + */ +export function entitySelectionAttr( + rootKey: string, + selection: StaticFieldMap, + holes: readonly AnalyzedHole[], +): t.JSXAttribute { + const obj = selectionToAst({ [rootKey]: selection }, holes) + return t.jsxAttribute(t.jsxIdentifier('compiledSelection'), t.jsxExpressionContainer(obj)) +} diff --git a/packages/bindx-compiler/src/entityRoots.ts b/packages/bindx-compiler/src/entityRoots.ts new file mode 100644 index 0000000..32489c2 --- /dev/null +++ b/packages/bindx-compiler/src/entityRoots.ts @@ -0,0 +1,131 @@ +/** + * 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 } from './resolve.js' +import { SelNode } from './selectionTree.js' +import type { ImportBindings } from './imports.js' +import type { ContractLookup } from './contracts.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. */ +export function findEntityElements(program: t.Program, bindings: ImportBindings): t.JSXElement[] { + const elements: t.JSXElement[] = [] + walkAst(program, node => { + if (t.isJSXElement(node) && isEntityTag(node.openingElement.name, bindings)) { + elements.push(node) + } + }) + return elements +} + +function isEntityTag(name: t.JSXOpeningElement['name'], bindings: ImportBindings): boolean { + return t.isJSXIdentifier(name) && bindings.entity.has(name.name) +} + +export function analyzeEntityRoot( + element: t.JSXElement, + bindings: ImportBindings, + moduleBindings: ReadonlySet, + lookup: ContractLookup, +): 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) + try { + analyzer.analyzeRootChildren(childrenFn, rootNode, ENTITY_ROOT_KEY) + } catch (error) { + if (error instanceof BailError) { + return { loc, bailout: error.bailout } + } + throw 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/imports.ts b/packages/bindx-compiler/src/imports.ts index 02fa63e..571f1ce 100644 --- a/packages/bindx-compiler/src/imports.ts +++ b/packages/bindx-compiler/src/imports.ts @@ -17,6 +17,8 @@ export interface ImportBindings { 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 { @@ -27,6 +29,7 @@ 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)) { @@ -42,13 +45,18 @@ export function collectImportBindings(program: t.Program): ImportBindings { 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 (COMPONENT_NAMES.has(imported)) { components.set(local, imported as ComponentKind) } } } - return { createComponent, cond, components } + return { createComponent, cond, components, entity } } /** diff --git a/packages/bindx-compiler/src/index.ts b/packages/bindx-compiler/src/index.ts index 3ce27d2..4fa5833 100644 --- a/packages/bindx-compiler/src/index.ts +++ b/packages/bindx-compiler/src/index.ts @@ -1,4 +1,13 @@ -export { analyzeSource, analyzeProgram, parseProgram, type AnalyzeOptions, type InternalChainResult } from './analyze.js' +export { + analyzeSource, + analyzeProgram, + analyzeEntityRoots, + analyzeEntityRootsInProgram, + parseProgram, + type AnalyzeOptions, + type InternalChainResult, +} from './analyze.js' +export { ENTITY_ROOT_KEY, type InternalEntityRootResult } from './entityRoots.js' export { bindxCompilerPlugin, default, type BindxCompilerOptions } from './babelPlugin.js' export { ContractFileCache, @@ -19,8 +28,11 @@ export type { ChainResult, AnalyzedChain, BailedChain, + EntityRootResult, + AnalyzedEntityRoot, + BailedEntityRoot, BailoutReason, Bailout, ChainLoc, } from './types.js' -export { isBailed } from './types.js' +export { isBailed, isEntityRootBailed } from './types.js' diff --git a/packages/bindx-compiler/src/types.ts b/packages/bindx-compiler/src/types.ts index be0901c..50b9573 100644 --- a/packages/bindx-compiler/src/types.ts +++ b/packages/bindx-compiler/src/types.ts @@ -72,6 +72,7 @@ export type BailoutReason = | 'COMPUTED_MEMBER' | 'NON_LITERAL_HASMANY_PARAM' | 'ENTITY_REASSIGNMENT' + | 'ENTITY_NO_FUNCTION_CHILDREN' | 'UNCLASSIFIED' /** A bail with human-readable context. */ @@ -107,3 +108,27 @@ 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/tests/entityRoots.test.tsx b/packages/bindx-compiler/tests/entityRoots.test.tsx new file mode 100644 index 0000000..1ed9a50 --- /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) + }) +}) From 6166dc32fb310650170ff1afe2cee8e9bba320e1 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 11:43:18 +0200 Subject: [PATCH 22/34] =?UTF-8?q?docs:=20phase=203.1=20plan=20=E2=80=94=20?= =?UTF-8?q?hole-target=20classification=20+=20entity-like=20roots?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 44 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index ce76972..63d7d0d 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -652,9 +652,51 @@ system; phase 3.1 candidate). (superset assertion); create-mode Entity. - Full npi measure re-run with root counts. +## Phase 3.1 — hole-target classification + entity-like roots + +Motivation (npi 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, +npi'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), 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). npi re-measure with +`--entity-like=RefreshableEntity` — expected: root bails 21 → ~9, plus ~82 newly visible roots. + ## 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 (phase 3.1). +DataGrid/DataView root compilation. From 7a1fa1d424a632f7eb2afe0697990ea2d538bc2d Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 12:09:30 +0200 Subject: [PATCH 23/34] feat(bindx-compiler): hole-target classification + entityLike roots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 3.1: classify a hole target so provably-inert props drop without bailing, and surface entity roots hidden behind forwarding wrappers. Compiler-only — the entityLike attribute rides the wrapper's {...props} spread into the real , which already consumes compiledSelection (no runtime change). Part A — target-kind classification (src/targetKind.ts). Shared binding resolution (local + relative-import + parse cache) extracted to src/moduleResolve.ts and taught to resolve plain function/class declarations; ContractResolver refactored onto it. Classify a hole tag into createComponent / plain / collectorStatic / unknown (collector contracts still handled first, separately): - createComponent → getSelection never reads scalars nor invokes function slots → render-locals + function props/children drop, no bail; slots from .slots([...]). - plain → no surface → every non-entity prop drops; hole still emitted. - collectorStatic → parse the staticRender's referenced prop-name set (destructured keys / props.x; rest/spread/aliasing → "all"); unreferenced dropped props are safe, referenced ones keep the existing bails. - unknown → conservative lattice unchanged (default deny). holePolicyFor() feeds the decision into resolveHoleExtraProps. Part B — entityLike roots. analyzeSource/analyzeProgram/entity-root scan/Babel plugin gain entityLike?: string[]; matching prefers an import's original exported name over its alias, else the local declaration name. measure gains --entity-like=Name,.... the reference app: chains unchanged 254/257; entity roots 84 -> 93/105 from classification, and --entity-like=RefreshableEntity surfaces 35 hidden roots (105 -> 140, 124 compiled). Fixtures + adapter-oracle equivalence in targetKinds.test.tsx. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 34 +- docs/selection-collection.md | 36 +++ packages/bindx-compiler/scripts/measure.ts | 19 +- packages/bindx-compiler/src/analyze.ts | 52 +++- packages/bindx-compiler/src/babelPlugin.ts | 12 +- packages/bindx-compiler/src/body.ts | 4 +- packages/bindx-compiler/src/contracts.ts | 235 +------------- packages/bindx-compiler/src/entityRoots.ts | 68 +++- packages/bindx-compiler/src/holeProps.ts | 62 +++- packages/bindx-compiler/src/index.ts | 2 + packages/bindx-compiler/src/jsx.ts | 9 +- packages/bindx-compiler/src/moduleResolve.ts | 287 +++++++++++++++++ packages/bindx-compiler/src/targetKind.ts | 214 +++++++++++++ packages/bindx-compiler/tests/rootOracle.tsx | 140 +++++++++ .../bindx-compiler/tests/targetKinds.test.tsx | 291 ++++++++++++++++++ 15 files changed, 1208 insertions(+), 257 deletions(-) create mode 100644 packages/bindx-compiler/src/moduleResolve.ts create mode 100644 packages/bindx-compiler/src/targetKind.ts create mode 100644 packages/bindx-compiler/tests/rootOracle.tsx create mode 100644 packages/bindx-compiler/tests/targetKinds.test.tsx diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index 63d7d0d..e664ff2 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -652,7 +652,39 @@ system; phase 3.1 candidate). (superset assertion); create-mode Entity. - Full npi measure re-run with root counts. -## Phase 3.1 — hole-target classification + entity-like roots +## 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 `~/projects/external/npi/packages/admin`) + +- **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 (npi 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 diff --git a/docs/selection-collection.md b/docs/selection-collection.md index 79bd5f1..e5b19ae 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -518,6 +518,42 @@ selection (superset for branch unions). On `npi`, `packages/admin`, the plugin c 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 (npi'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 `npi`, `packages/admin`: 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 diff --git a/packages/bindx-compiler/scripts/measure.ts b/packages/bindx-compiler/scripts/measure.ts index b448a76..1f04546 100644 --- a/packages/bindx-compiler/scripts/measure.ts +++ b/packages/bindx-compiler/scripts/measure.ts @@ -50,9 +50,24 @@ function findTsxFiles(dir: string): string[] { 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 arg = process.argv[2] ?? 'packages/example' + 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)) { @@ -79,7 +94,7 @@ function main(): void { let roots try { results = analyzeSource(code, file) - roots = analyzeEntityRoots(code, file) + roots = analyzeEntityRoots(code, file, { entityLike }) } catch (error) { console.log(`${relative(root, file)} PARSE ERROR: ${String(error)}`) continue diff --git a/packages/bindx-compiler/src/analyze.ts b/packages/bindx-compiler/src/analyze.ts index b0d41c3..4915678 100644 --- a/packages/bindx-compiler/src/analyze.ts +++ b/packages/bindx-compiler/src/analyze.ts @@ -12,8 +12,10 @@ import { BodyAnalyzer } from './body.js' import { BailError } from './resolve.js' import { SelNode } from './selectionTree.js' import { parseProgram } from './parse.js' -import { ContractFileCache, ContractResolver, type ContractLookup } from './contracts.js' -import { analyzeEntityRoot, findEntityElements, type InternalEntityRootResult } from './entityRoots.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 } @@ -30,54 +32,72 @@ export interface AnalyzeOptions { /** 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?: ContractFileCache + 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[] } // Shared across analyzeProgram/plugin invocations, keyed internally by path+mtime. -const defaultContractCache = new ContractFileCache() +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 resolver = new ContractResolver(program, { + const resolverOptions = { filename: options.filename, alias: options.alias ?? {}, - cache: options.cache ?? defaultContractCache, - }) + cache: options.cache ?? defaultModuleCache, + } + const contracts = new ContractResolver(program, resolverOptions) + const targets = new TargetKindResolver(program, resolverOptions) return { bindings: collectImportBindings(program), moduleBindings: collectModuleBindings(program), - lookup: tag => resolver.resolve(tag), + 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 } = programContext(program, options) + const { bindings, moduleBindings, lookup, targetKinds } = programContext(program, options) const chains = findChains(program, bindings) - return chains.map(chain => ({ chain, result: analyzeChain(chain, bindings, moduleBindings, lookup) })) + return chains.map(chain => ({ chain, result: analyzeChain(chain, bindings, moduleBindings, lookup, targetKinds) })) } -/** Analyze every top-level `` selection root in a program (phase 3). */ +/** 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 } = programContext(program, options) - return findEntityElements(program, bindings).map(element => ({ + 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), + result: analyzeEntityRoot(element, bindings, moduleBindings, lookup, targetKinds), })) } -function analyzeChain(chain: Chain, bindings: ImportBindings, moduleBindings: ReadonlySet, lookup: ContractLookup): ChainResult { +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) + const analyzer = new BodyAnalyzer(bindings, moduleBindings, lookup, targetKinds) try { if (chain.conditionFn) { analyzer.analyzeFunction(chain.conditionFn, propRoots) diff --git a/packages/bindx-compiler/src/babelPlugin.ts b/packages/bindx-compiler/src/babelPlugin.ts index f7bbe29..8b14d1e 100644 --- a/packages/bindx-compiler/src/babelPlugin.ts +++ b/packages/bindx-compiler/src/babelPlugin.ts @@ -12,13 +12,21 @@ import { entitySelectionAttr, selectionToAst } from './emit.js' import { ENTITY_ROOT_KEY, hasCompiledSelectionAttr } from './entityRoots.js' import { isBailed, isEntityRootBailed } from './types.js' -/** Plugin options: `alias` maps non-relative import prefixes to paths for cross-file contract discovery. */ +/** + * 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). + */ export interface BindxCompilerOptions { readonly alias?: Record + readonly entityLike?: readonly string[] } export function bindxCompilerPlugin(_api?: unknown, options?: BindxCompilerOptions): PluginObj { const alias = options?.alias ?? {} + const entityLike = options?.entityLike return { name: 'bindx-selection-compiler', manipulateOptions(_opts, parserOpts: { plugins: unknown[] }): void { @@ -30,7 +38,7 @@ export function bindxCompilerPlugin(_api?: unknown, options?: BindxCompilerOptio // Analyze both surfaces before mutating: chain injection and Entity-attribute // injection are independent, but reading the whole AST first keeps them so. const chainResults = analyzeProgram(path.node, { filename, alias }) - const entityResults = analyzeEntityRootsInProgram(path.node, { filename, alias }) + const entityResults = analyzeEntityRootsInProgram(path.node, { filename, alias, entityLike }) for (const { chain, result } of chainResults) { if (isBailed(result)) { diff --git a/packages/bindx-compiler/src/body.ts b/packages/bindx-compiler/src/body.ts index 311664b..1e1ecd9 100644 --- a/packages/bindx-compiler/src/body.ts +++ b/packages/bindx-compiler/src/body.ts @@ -12,6 +12,7 @@ import { } 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 { @@ -23,8 +24,9 @@ export class BodyAnalyzer { private readonly bindings: ImportBindings, private readonly moduleBindings: ReadonlySet, contracts: ContractLookup, + targetKinds: TargetKindLookup, ) { - this.jsx = new JsxAnalyzer(this, bindings, moduleBindings, contracts) + this.jsx = new JsxAnalyzer(this, bindings, moduleBindings, contracts, targetKinds) } /** Public so JsxAnalyzer can register a hole it discovered. */ diff --git a/packages/bindx-compiler/src/contracts.ts b/packages/bindx-compiler/src/contracts.ts index 0374542..1e8a502 100644 --- a/packages/bindx-compiler/src/contracts.ts +++ b/packages/bindx-compiler/src/contracts.ts @@ -4,16 +4,19 @@ * exactly like a ``/`` child (param → root, host captures → paths), * so no hole and no lift is needed. * - * This is a bounded exception to the purely-local principle: to read a contract declared - * in another module we resolve RELATIVE specifiers only (plus an optional `alias` map), - * PARSE the target (no execution, no type checker), and cache per path+mtime. + * 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 { 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 { + BindingResolver, ModuleCache, type BindingResolverOptions, type ModuleView, + findTopLevelVarInit, +} from './moduleResolve.js' import { unwrap } from './resolve.js' +export { ModuleCache as ContractFileCache } from './moduleResolve.js' + export interface CallbackContract { readonly kind: 'itemOf' | 'entityOf' readonly field: string @@ -25,82 +28,14 @@ export type CollectorContract = Record /** Resolves a component tag to its declared contract, or null (→ existing hole/bail rules). */ export type ContractLookup = (tag: string) => CollectorContract | null -/** Local names of the bindx symbols a module needs for contract extraction. */ -interface ModuleView { - readonly program: t.Program - readonly withCollector: ReadonlySet - readonly itemOf: ReadonlySet - readonly entityOf: ReadonlySet -} - -function isBindxSource(source: string): boolean { - return source === '@contember/bindx' || source.startsWith('@contember/bindx-') || source.startsWith('@contember/bindx/') -} - -/** Collect local binding names for `withCollector`/`itemOf`/`entityOf` imported from bindx. */ -function makeModuleView(program: t.Program): ModuleView { - const withCollector = new Set() - const itemOf = new Set() - const entityOf = 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) - } - } - } - return { program, withCollector, itemOf, entityOf } -} - -/** mtime-keyed cache of parsed sibling modules — shared across analyzeProgram / plugin runs. */ -export class ContractFileCache { - 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 contract, existing rules apply - } - this.cache.set(path, { mtime, view }) - return view - } -} - -export interface ContractResolverOptions { - readonly filename?: string - readonly alias: Record - readonly cache: ContractFileCache -} +export type ContractResolverOptions = BindingResolverOptions export class ContractResolver { - private readonly self: ModuleView + private readonly binding: BindingResolver private readonly memo = new Map() - constructor(program: t.Program, private readonly options: ContractResolverOptions) { - this.self = makeModuleView(program) + constructor(program: t.Program, options: ContractResolverOptions) { + this.binding = new BindingResolver(program, options) } resolve(tag: string): CollectorContract | null { @@ -108,36 +43,14 @@ export class ContractResolver { if (cached !== undefined) { return cached } - const contract = this.compute(tag) + const resolved = this.binding.resolve(tag) + const contract = resolved ? this.contractFromInit(resolved.init, resolved.view) : null this.memo.set(tag, contract) return contract } - private compute(tag: string): CollectorContract | null { - // 1. Local `const tag = withCollector(_, contract)` in this module. - const localInit = findTopLevelVarInit(this.self.program, tag) - if (localInit) { - return this.contractFromInit(localInit, this.self) - } - // 2. Imported binding — relative specifier (or alias-mapped) only. - const imp = findImport(this.self.program, tag) - if (!imp) { - return null - } - const path = this.resolveModulePath(imp.source) - if (!path) { - return null - } - const view = this.options.cache.get(path) - if (!view) { - return null - } - const init = resolveExportedInit(view.program, imp.importedName) - return init ? this.contractFromInit(init, view) : null - } - /** Contract from a binding initializer, iff it is `withCollector(_, )` in `view`. */ - private contractFromInit(init: t.Expression, view: ModuleView): CollectorContract | null { + 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 @@ -145,38 +58,6 @@ export class ContractResolver { const arg = call.arguments[1] return arg && t.isExpression(arg) ? contractFromExpr(arg, view) : null } - - /** Resolve an import specifier to an existing absolute file (relative or alias-mapped only). */ - private resolveModulePath(source: string): string | null { - const base = this.toAbsoluteBase(source) - return base ? firstExisting(base) : null - } - - /** Absolute base path (no extension resolution) for a relative or alias-mapped specifier. */ - private toAbsoluteBase(source: string): string | null { - if (source.startsWith('.')) { - return this.options.filename ? resolvePath(dirname(this.options.filename), 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 } /** Follow an identifier / object literal to a validated contract, or null. */ @@ -231,87 +112,3 @@ function propKeyName(key: t.Node): string | null { } return t.isStringLiteral(key) ? key.value : null } - -interface ImportRef { - readonly source: string - readonly importedName: string -} - -/** Find where `local` is imported from and under which imported name (`default` for default). */ -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 -} - -/** Initializer of a module-level `const name = ` (unwrapped), searching plain + `export` decls. */ -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 -} - -/** Initializer exported under `importedName`: `export const X`, `export { local as X }`, `export default`. */ -function resolveExportedInit(program: t.Program, importedName: string): t.Expression | null { - if (importedName === 'default') { - for (const node of program.body) { - if (t.isExportDefaultDeclaration(node) && t.isExpression(node.declaration)) { - const inner = unwrap(node.declaration) - return t.isExpression(inner) ? inner : null - } - } - return null - } - for (const node of program.body) { - if (!t.isExportNamedDeclaration(node)) { - continue - } - if (node.declaration && t.isVariableDeclaration(node.declaration)) { - const init = varInit(node.declaration, importedName) - if (init) { - return init - } - } - if (!node.source) { - // `export { local as X }` — follow to the local declaration (re-exports with a source are unfollowable). - for (const spec of node.specifiers) { - if (t.isExportSpecifier(spec)) { - const exported = t.isIdentifier(spec.exported) ? spec.exported.name : spec.exported.value - if (exported === importedName) { - return findTopLevelVarInit(program, spec.local.name) - } - } - } - } - } - return null -} diff --git a/packages/bindx-compiler/src/entityRoots.ts b/packages/bindx-compiler/src/entityRoots.ts index 32489c2..c41687f 100644 --- a/packages/bindx-compiler/src/entityRoots.ts +++ b/packages/bindx-compiler/src/entityRoots.ts @@ -16,6 +16,7 @@ import { BailError } 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. */ @@ -27,19 +28,73 @@ export interface InternalEntityRootResult { readonly result: EntityRootResult } -/** Collect every `` JSX element whose tag is a bindx `Entity` import binding. */ -export function findEntityElements(program: t.Program, bindings: ImportBindings): t.JSXElement[] { +/** + * 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)) { + if (t.isJSXElement(node) && isEntityTag(node.openingElement.name, bindings, entityLikeLocals)) { elements.push(node) } }) return elements } -function isEntityTag(name: t.JSXOpeningElement['name'], bindings: ImportBindings): boolean { - return t.isJSXIdentifier(name) && bindings.entity.has(name.name) +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( @@ -47,6 +102,7 @@ export function analyzeEntityRoot( bindings: ImportBindings, moduleBindings: ReadonlySet, lookup: ContractLookup, + targetKinds: TargetKindLookup, ): EntityRootResult { const loc = elementLoc(element) const childrenFn = entityChildrenFn(element) @@ -56,7 +112,7 @@ export function analyzeEntityRoot( } const rootNode = new SelNode() - const analyzer = new BodyAnalyzer(bindings, moduleBindings, lookup) + const analyzer = new BodyAnalyzer(bindings, moduleBindings, lookup, targetKinds) try { analyzer.analyzeRootChildren(childrenFn, rootNode, ENTITY_ROOT_KEY) } catch (error) { diff --git a/packages/bindx-compiler/src/holeProps.ts b/packages/bindx-compiler/src/holeProps.ts index bbf9895..b0d4e37 100644 --- a/packages/bindx-compiler/src/holeProps.ts +++ b/packages/bindx-compiler/src/holeProps.ts @@ -1,11 +1,15 @@ /** - * Phase-2.1 lifting: classify a hole element's non-entity props (closures, identifiers) into - * `extraProps` (lifted verbatim) 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 — lift when the value is - * reproducible at the module-scope emit site, bail otherwise (default deny). See docs/compiler-plan.md. + * 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 @@ -19,6 +23,40 @@ export interface HoleIdentifierProp { 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 @@ -26,14 +64,16 @@ export interface HolePropInputs { 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 and bailing on what cannot be reproduced at the emit site. + * 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 } = inputs + const { tag, closureProps, identifierProps, childClosure, scope, moduleBindings, policy } = inputs const extraProps: Record = {} const liftClosure = (name: string, fn: Closure): void => { @@ -46,12 +86,18 @@ export function resolveHoleExtraProps(inputs: HolePropInputs): Record, private readonly contracts: ContractLookup, + private readonly targetKinds: TargetKindLookup, ) {} walk(node: t.JSXElement | t.JSXFragment, scope: Scope): void { @@ -124,7 +126,10 @@ export class JsxAnalyzer { // 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` }) } - const extraProps = resolveHoleExtraProps({ tag, closureProps, identifierProps, childClosure, scope, moduleBindings: this.moduleBindings }) + // 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, diff --git a/packages/bindx-compiler/src/moduleResolve.ts b/packages/bindx-compiler/src/moduleResolve.ts new file mode 100644 index 0000000..57189c7 --- /dev/null +++ b/packages/bindx-compiler/src/moduleResolve.ts @@ -0,0 +1,287 @@ +/** + * 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). 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 +} + +/** + * 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 { + 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) + if (!path) { + return null + } + const view = this.options.cache.get(path) + if (!view) { + return null + } + const init = resolveExportedBinding(view.program, imp.importedName) + return init ? { init, view } : null + } + + /** Resolve an import specifier to an existing absolute file (relative or alias-mapped only). */ + private resolveModulePath(source: string): string | null { + const base = this.toAbsoluteBase(source) + return base ? firstExisting(base) : null + } + + private toAbsoluteBase(source: string): string | null { + if (source.startsWith('.')) { + return this.options.filename ? resolvePath(dirname(this.options.filename), 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 +} + +/** Binding exported under `importedName`, following `export const/function/class` and `export { local as X }`. */ +export function resolveExportedBinding(program: t.Program, importedName: string): t.Node | 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 d + } + if (t.isExpression(d)) { + return unwrap(d) + } + } + } + return null + } + for (const node of program.body) { + if (!t.isExportNamedDeclaration(node)) { + continue + } + if (node.declaration) { + if (t.isVariableDeclaration(node.declaration)) { + const init = varInit(node.declaration, importedName) + if (init) { + return init + } + } + if ((t.isFunctionDeclaration(node.declaration) || t.isClassDeclaration(node.declaration)) && node.declaration.id?.name === importedName) { + return node.declaration + } + } + if (!node.source) { + // `export { local as X }` — follow to the local declaration (re-exports with a source are unfollowable). + for (const spec of node.specifiers) { + if (t.isExportSpecifier(spec)) { + const exported = t.isIdentifier(spec.exported) ? spec.exported.name : spec.exported.value + if (exported === importedName) { + return findTopLevelBinding(program, spec.local.name) + } + } + } + } + } + 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/targetKind.ts b/packages/bindx-compiler/src/targetKind.ts new file mode 100644 index 0000000..73caa21 --- /dev/null +++ b/packages/bindx-compiler/src/targetKind.ts @@ -0,0 +1,214 @@ +/** + * 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 { + 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 + + const visit = (node: t.Node, asMemberObject: boolean): void => { + if (escaped) { + return + } + if (t.isIdentifier(node)) { + // A bare reference to the props identifier that is NOT the object of a `.x` access escapes. + if (node.name === paramName && !asMemberObject) { + escaped = true + } + return + } + 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 + } else { + escaped = true // `p[x]` — cannot know which prop + } + return // do not descend into the object identifier + } + for (const key of t.VISITOR_KEYS[node.type] ?? []) { + const child: unknown = (node as unknown as Record)[key] + if (Array.isArray(child)) { + for (const item of child) { + if (isNode(item)) { + visit(item, false) + } + } + } else if (isNode(child)) { + visit(child, false) + } + } + } + + visit(root, false) + return escaped ? 'all' : names +} + +function isNode(value: unknown): value is t.Node { + return typeof value === 'object' && value !== null && typeof (value as { type?: unknown }).type === 'string' +} diff --git a/packages/bindx-compiler/tests/rootOracle.tsx b/packages/bindx-compiler/tests/rootOracle.tsx new file mode 100644 index 0000000..1e962dc --- /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 0000000..92d1a0e --- /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 npi 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 }) + }) +}) From 44f0c46d21e2c2b5b17baa6860b80c175f976166 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 12:23:24 +0200 Subject: [PATCH 24/34] feat(bindx-compiler): follow re-export chains in module binding resolution MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BindingResolver now follows from-source re-exports — export { X } from, export { X as Y } from, and export * from (barrel index files) — so a createComponent/contract target hidden behind a barrel is classified instead of conservatively bailing. The chase is depth-limited (5 hops) and cycle-guarded (visited set); a star ambiguity or an exhausted budget stays unfollowable, so the conservative hole/bail rules are preserved. Applies uniformly to both target-kind classification and contract discovery, and follows aliased entry specifiers too. On the reference app this clears the genuine barrel bail (Organization360Meetings → MeetingRecordCreateBody re-exported through a MeetingRecordDetail/index.ts barrel). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 10 +- docs/selection-collection.md | 3 +- packages/bindx-compiler/src/moduleResolve.ts | 160 +++++++++++--- .../tests/fixtures/_ccBarrel.ts | 4 + .../tests/fixtures/_ccStarBarrel.ts | 2 + .../tests/fixtures/_ccTarget.tsx | 10 + .../tests/fixtures/_contractBarrel.ts | 3 + .../tests/fixtures/_cycleBarrelA.ts | 2 + .../tests/fixtures/_cycleBarrelB.ts | 1 + .../bindx-compiler/tests/fixtures/_deep1.ts | 2 + .../bindx-compiler/tests/fixtures/_deep2.ts | 2 + .../bindx-compiler/tests/fixtures/_deep3.ts | 2 + .../bindx-compiler/tests/fixtures/_deep4.ts | 2 + .../bindx-compiler/tests/fixtures/_deep5.ts | 2 + .../bindx-compiler/tests/fixtures/_deep6.ts | 2 + .../bindx-compiler/tests/reexport.test.tsx | 206 ++++++++++++++++++ 16 files changed, 377 insertions(+), 36 deletions(-) create mode 100644 packages/bindx-compiler/tests/fixtures/_ccBarrel.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_ccStarBarrel.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_ccTarget.tsx create mode 100644 packages/bindx-compiler/tests/fixtures/_contractBarrel.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_cycleBarrelA.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_cycleBarrelB.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_deep1.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_deep2.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_deep3.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_deep4.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_deep5.ts create mode 100644 packages/bindx-compiler/tests/fixtures/_deep6.ts create mode 100644 packages/bindx-compiler/tests/reexport.test.tsx diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index e664ff2..6885fdd 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -487,8 +487,10 @@ Resolution for a component tag: **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 }`; re-exports with a `from` source are unfollowable → - null), and extract. + (`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 @@ -695,7 +697,9 @@ npi's `RefreshableEntity` forwarding wrapper hides 82 Entity roots from the root ### 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), classify: +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 diff --git a/docs/selection-collection.md b/docs/selection-collection.md index e5b19ae..7e1da52 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -377,7 +377,8 @@ solves the case a hole cannot: a callback that both uses its item param **and** 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** specifier (see docs/compiler-plan.md, Phase 2.2). +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 diff --git a/packages/bindx-compiler/src/moduleResolve.ts b/packages/bindx-compiler/src/moduleResolve.ts index 57189c7..7dedea7 100644 --- a/packages/bindx-compiler/src/moduleResolve.ts +++ b/packages/bindx-compiler/src/moduleResolve.ts @@ -3,8 +3,10 @@ * 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). Parse-only, no execution, no type checker; cached per - * path+mtime so sibling modules are read at most once per run. + * (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' @@ -101,6 +103,8 @@ export interface ResolvedBinding { * 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() @@ -127,7 +131,7 @@ export class BindingResolver { if (!imp) { return null } - const path = this.resolveModulePath(imp.source) + const path = this.resolveModulePath(imp.source, this.options.filename) if (!path) { return null } @@ -135,19 +139,66 @@ export class BindingResolver { if (!view) { return null } - const init = resolveExportedBinding(view.program, imp.importedName) - return init ? { init, view } : null + return this.chaseExport(view, path, imp.importedName, new Set([path]), BindingResolver.MAX_HOPS) } - /** Resolve an import specifier to an existing absolute file (relative or alias-mapped only). */ - private resolveModulePath(source: string): string | null { - const base = this.toAbsoluteBase(source) + /** + * 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 || visited.has(path)) { + return null // unresolvable specifier or 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): string | null { + private toAbsoluteBase(source: string, fromFile: string | undefined): string | null { if (source.startsWith('.')) { - return this.options.filename ? resolvePath(dirname(this.options.filename), source) : null + return fromFile ? resolvePath(dirname(fromFile), source) : null } for (const [prefix, target] of Object.entries(this.options.alias)) { if (source === prefix || source.startsWith(`${prefix}/`)) { @@ -216,48 +267,93 @@ export function findTopLevelBinding(program: t.Program, name: string): t.Node | return null } -/** Binding exported under `importedName`, following `export const/function/class` and `export { local as X }`. */ -export function resolveExportedBinding(program: t.Program, importedName: string): t.Node | 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 d + return { kind: 'local', node: d } } if (t.isExpression(d)) { - return unwrap(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) { - if (t.isVariableDeclaration(node.declaration)) { - const init = varInit(node.declaration, importedName) - if (init) { - return init - } - } - if ((t.isFunctionDeclaration(node.declaration) || t.isClassDeclaration(node.declaration)) && node.declaration.id?.name === importedName) { - return node.declaration + const local = localFromDeclaration(node.declaration, importedName) + if (local) { + return { kind: 'local', node: local } } + continue } - if (!node.source) { - // `export { local as X }` — follow to the local declaration (re-exports with a source are unfollowable). - for (const spec of node.specifiers) { - if (t.isExportSpecifier(spec)) { - const exported = t.isIdentifier(spec.exported) ? spec.exported.name : spec.exported.value - if (exported === importedName) { - return findTopLevelBinding(program, spec.local.name) - } - } - } + 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 } diff --git a/packages/bindx-compiler/tests/fixtures/_ccBarrel.ts b/packages/bindx-compiler/tests/fixtures/_ccBarrel.ts new file mode 100644 index 0000000..52612f2 --- /dev/null +++ b/packages/bindx-compiler/tests/fixtures/_ccBarrel.ts @@ -0,0 +1,4 @@ +// Named re-export barrel (`export { X } from`) — the shape npi's index.ts files 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 0000000..7fc11fd --- /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 0000000..f069a88 --- /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 0000000..ad7f6f9 --- /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/_cycleBarrelA.ts b/packages/bindx-compiler/tests/fixtures/_cycleBarrelA.ts new file mode 100644 index 0000000..ac2a360 --- /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 0000000..75028b7 --- /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 0000000..762dead --- /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 0000000..e17727a --- /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 0000000..29a34b9 --- /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 0000000..c406c90 --- /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 0000000..3a1fa66 --- /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 0000000..d242ade --- /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/reexport.test.tsx b/packages/bindx-compiler/tests/reexport.test.tsx new file mode 100644 index 0000000..8276d26 --- /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 }) + }) +}) From 1179088ab75ccf2dc496db7b813d7b4b1dd11b1e Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 13:50:24 +0200 Subject: [PATCH 25/34] feat(bindx-compiler): vite plugin with cross-module watch invalidation Add a first-class Vite plugin (bindxCompiler) that runs the selection compiler with enforce:'pre' and registers every cross-file module the analyzer consults via addWatchFile. The analyzer now threads an onDependency(absPath) callback from AnalyzeOptions down through BindingResolver (entry import + every re-export hop, incl. parse failures) so both contract discovery and target-kind classification report their reads. This stops a stale injected literal (from an un-retransformed file A after contract/target file B changes) from flipping into an under-fetch in dev. Wire the example under the existing BINDX_COMPILER=1 gate to use the plugin before react() instead of injecting the babel plugin into @vitejs/plugin-react. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 25 +++++ packages/bindx-compiler/src/analyze.ts | 8 ++ packages/bindx-compiler/src/babelPlugin.ts | 7 +- packages/bindx-compiler/src/index.ts | 7 ++ packages/bindx-compiler/src/moduleResolve.ts | 15 ++- packages/bindx-compiler/src/vitePlugin.ts | 103 ++++++++++++++++++ .../bindx-compiler/tests/dependency.test.ts | 73 +++++++++++++ .../bindx-compiler/tests/vitePlugin.test.ts | 74 +++++++++++++ packages/example/vite.config.ts | 6 +- 9 files changed, 312 insertions(+), 6 deletions(-) create mode 100644 packages/bindx-compiler/src/vitePlugin.ts create mode 100644 packages/bindx-compiler/tests/dependency.test.ts create mode 100644 packages/bindx-compiler/tests/vitePlugin.test.ts diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index 6885fdd..605967c 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -730,6 +730,31 @@ adapter-oracle-equal; plain target; collector-static referenced vs unreferenced conservative; entityLike forwarding-wrapper root end-to-end). npi 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 ` void } // Shared across analyzeProgram/plugin invocations, keyed internally by path+mtime. @@ -57,6 +64,7 @@ function programContext(program: t.Program, options: AnalyzeOptions): ProgramCon 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) diff --git a/packages/bindx-compiler/src/babelPlugin.ts b/packages/bindx-compiler/src/babelPlugin.ts index 8b14d1e..3b45a8d 100644 --- a/packages/bindx-compiler/src/babelPlugin.ts +++ b/packages/bindx-compiler/src/babelPlugin.ts @@ -22,11 +22,14 @@ import { isBailed, isEntityRootBailed } from './types.js' 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 } export function bindxCompilerPlugin(_api?: unknown, options?: BindxCompilerOptions): PluginObj { const alias = options?.alias ?? {} const entityLike = options?.entityLike + const onDependency = options?.onDependency return { name: 'bindx-selection-compiler', manipulateOptions(_opts, parserOpts: { plugins: unknown[] }): void { @@ -37,8 +40,8 @@ export function bindxCompilerPlugin(_api?: unknown, options?: BindxCompilerOptio const filename = state.file.opts.filename ?? undefined // Analyze both surfaces before mutating: chain injection and Entity-attribute // injection are independent, but reading the whole AST first keeps them so. - const chainResults = analyzeProgram(path.node, { filename, alias }) - const entityResults = analyzeEntityRootsInProgram(path.node, { filename, alias, entityLike }) + const chainResults = analyzeProgram(path.node, { filename, alias, onDependency }) + const entityResults = analyzeEntityRootsInProgram(path.node, { filename, alias, entityLike, onDependency }) for (const { chain, result } of chainResults) { if (isBailed(result)) { diff --git a/packages/bindx-compiler/src/index.ts b/packages/bindx-compiler/src/index.ts index 382434e..8c9edaf 100644 --- a/packages/bindx-compiler/src/index.ts +++ b/packages/bindx-compiler/src/index.ts @@ -9,6 +9,13 @@ export { } from './analyze.js' export { ENTITY_ROOT_KEY, type InternalEntityRootResult } from './entityRoots.js' export { bindxCompilerPlugin, default, type BindxCompilerOptions } from './babelPlugin.js' +export { + bindxCompiler, + type BindxCompilerViteOptions, + type BindxCompilerVitePlugin, + type BindxTransformContext, + type BindxTransformResult, +} from './vitePlugin.js' export { ContractFileCache, ContractResolver, diff --git a/packages/bindx-compiler/src/moduleResolve.ts b/packages/bindx-compiler/src/moduleResolve.ts index 7dedea7..5367830 100644 --- a/packages/bindx-compiler/src/moduleResolve.ts +++ b/packages/bindx-compiler/src/moduleResolve.ts @@ -86,6 +86,12 @@ 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 } /** @@ -135,6 +141,7 @@ export class BindingResolver { 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 @@ -179,8 +186,12 @@ export class BindingResolver { /** 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 || visited.has(path)) { - return null // unresolvable specifier or a cycle → stop + 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) { diff --git a/packages/bindx-compiler/src/vitePlugin.ts b/packages/bindx-compiler/src/vitePlugin.ts new file mode 100644 index 0000000..0a1b8ac --- /dev/null +++ b/packages/bindx-compiler/src/vitePlugin.ts @@ -0,0 +1,103 @@ +/** + * 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' + +/** 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)[] +} + +/** 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 +} + +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(' { + 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, 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 } + }, + } +} diff --git a/packages/bindx-compiler/tests/dependency.test.ts b/packages/bindx-compiler/tests/dependency.test.ts new file mode 100644 index 0000000..32d9aa0 --- /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/vitePlugin.test.ts b/packages/bindx-compiler/tests/vitePlugin.test.ts new file mode 100644 index 0000000..ab9f903 --- /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/example/vite.config.ts b/packages/example/vite.config.ts index 6ea5c7e..b505af7 100644 --- a/packages/example/vite.config.ts +++ b/packages/example/vite.config.ts @@ -5,7 +5,7 @@ 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 { bindxCompilerPlugin } from '../bindx-compiler/src/index.js' +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. @@ -14,7 +14,9 @@ const compilerEnabled = process.env['BINDX_COMPILER'] === '1' export default defineConfig({ plugins: [ tailwindcss(), - react(compilerEnabled ? { babel: { plugins: [bindxCompilerPlugin] } } : undefined), + // Runs before react() (enforce: 'pre') and tracks cross-module deps via addWatchFile. + ...(compilerEnabled ? [bindxCompiler()] : []), + react(), bindxUI({ dir: './ui-overrides' }), ], define: { From 26538c747001ee1f2f1ed933264ba5880421f0f3 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 13:51:14 +0200 Subject: [PATCH 26/34] feat(bindx-compiler): version-mark emitted selections Emit `v: 2` as the first property of the CompiledSelection object literal (both the chain second-arg and the compiledSelection attribute, via selectionToAst). The runtime rejects any literal without `v === 2` and falls back to the proxy pass, so a stale/corrupt emit can never under-fetch. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- packages/bindx-compiler/src/emit.ts | 4 +++- packages/bindx-compiler/tests/plugin.test.ts | 2 ++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/bindx-compiler/src/emit.ts b/packages/bindx-compiler/src/emit.ts index d7d7647..43604ca 100644 --- a/packages/bindx-compiler/src/emit.ts +++ b/packages/bindx-compiler/src/emit.ts @@ -1,6 +1,6 @@ /** * Emits a CompiledSelection (v2) as a Babel object-literal AST — the 2nd argument the - * Babel plugin injects into `.render(fn, )`. Shape: `{ props: {...}, holes?: [...] }`. + * 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. */ @@ -62,6 +62,8 @@ function holeToAst(hole: AnalyzedHole): t.ObjectExpression { 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) { diff --git a/packages/bindx-compiler/tests/plugin.test.ts b/packages/bindx-compiler/tests/plugin.test.ts index 849826d..baffce2 100644 --- a/packages/bindx-compiler/tests/plugin.test.ts +++ b/packages/bindx-compiler/tests/plugin.test.ts @@ -34,6 +34,8 @@ 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: {') From 845ce28fe4257ff4297eb56c852319baa8811c3e Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 13:51:23 +0200 Subject: [PATCH 27/34] feat(bindx-react): compiled-selection validation, runtime fallback + killswitch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Harden the runtime consumers of compiler-emitted selections so a malformed or throwing literal degrades to the runtime proxy pass instead of crashing or silently under-fetching (soundness: over-fetch OK, under-fetch never): - CompiledSelection now requires `v: 2`; add exported guard isValidCompiledSelection (object, v===2, props a plain object of objects, holes an array if present). - componentFactory: on an invalid literal OR a top-level throw from applyCompiledSelection, warn once with attribution and fall back to collectImplicitSelections (partial compiled entries cleared first). Per-hole containment is unchanged. - useRootSelection: validate + try/catch inside the memo; on failure warn and fall back to the children-collector walk, keeping hook order stable. - compiledSelection: move assembleHoleProps inside the per-hole try so a throwing extraProps thunk (TDZ/module-init) degrades only that hole. - Killswitch setCompiledSelectionsEnabled(false) (exported) makes both consumers ignore compiled literals — incident mitigation without a rebuild. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 24 ++++ .../bindx-react/src/hooks/useRootSelection.ts | 41 +++++-- packages/bindx-react/src/index.ts | 1 + .../bindx-react/src/jsx/compiledSelection.ts | 27 +++- .../bindx-react/src/jsx/componentFactory.ts | 84 ++++++++++--- packages/bindx-react/src/jsx/index.ts | 3 + .../jsx/entityCompiledSelection.test.tsx | 42 ++++++- tests/react/jsx/staticSelection.test.tsx | 116 ++++++++++++++++-- 8 files changed, 293 insertions(+), 45 deletions(-) diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index 605967c..cb79bb7 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -755,6 +755,30 @@ dev that can flip a correct emit into an under-fetch, violating the soundness in Example wiring: `packages/example/vite.config.ts` places `bindxCompiler()` before `react()` under the existing `BINDX_COMPILER=1` gate. +### Version marker + runtime validation, fallback, and killswitch — IMPLEMENTED + +The emitted `CompiledSelection` now carries `v: 2` as its first property (`emit.ts` `selectionToAst`, +covering both the chain second-arg and the `` `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. + ## Future (explicitly out of scope now) Unplugin packaging, eslint plugin reusing the analyzer (bail reasons as lint diagnostics), diff --git a/packages/bindx-react/src/hooks/useRootSelection.ts b/packages/bindx-react/src/hooks/useRootSelection.ts index ae314c8..3052c4f 100644 --- a/packages/bindx-react/src/hooks/useRootSelection.ts +++ b/packages/bindx-react/src/hooks/useRootSelection.ts @@ -3,8 +3,8 @@ 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 { resolveCompiledRootSelection, type CompiledSelection } from '../jsx/compiledSelection.js' -import { isStaticSelectionValidationEnabled, validateCompiledRootSelection } from '../jsx/componentFactory.js' +import { isValidCompiledSelection, resolveCompiledRootSelection, type CompiledSelection } from '../jsx/compiledSelection.js' +import { isCompiledSelectionsEnabled, isStaticSelectionValidationEnabled, validateCompiledRootSelection } from '../jsx/componentFactory.js' /** * Params for {@link useRootSelection}. @@ -27,20 +27,39 @@ export function useRootSelection(params: UseRootSelectionParams): SelectionColle 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) { + if (!compiledSelection || !compiledEnabled) { return null } - const selection = resolveCompiledRootSelection({ - compiled: compiledSelection, - entityType, - schemaRegistry, - validateMode, - }) - return { selection, queryKey: JSON.stringify(buildQueryFromSelection(selection)) } - }, [compiledSelection, entityType, schemaRegistry, validateMode]) + 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; diff --git a/packages/bindx-react/src/index.ts b/packages/bindx-react/src/index.ts index ba24d35..f62d727 100644 --- a/packages/bindx-react/src/index.ts +++ b/packages/bindx-react/src/index.ts @@ -379,6 +379,7 @@ export { setBrandValidation, validateBrand, setStaticSelectionValidation, + setCompiledSelectionsEnabled, } from './jsx/index.js' // Entity Scope diff --git a/packages/bindx-react/src/jsx/compiledSelection.ts b/packages/bindx-react/src/jsx/compiledSelection.ts index 8a45237..d36df47 100644 --- a/packages/bindx-react/src/jsx/compiledSelection.ts +++ b/packages/bindx-react/src/jsx/compiledSelection.ts @@ -31,12 +31,35 @@ import type { EntityConfig } from './componentFactory.js' * 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. @@ -210,9 +233,9 @@ function resolveHole(hole: CompiledHole, ctx: HoleResolutionContext): void { return } - const props = assembleHoleProps(hole, ctx) - 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. diff --git a/packages/bindx-react/src/jsx/componentFactory.ts b/packages/bindx-react/src/jsx/componentFactory.ts index 17692d1..289afe6 100644 --- a/packages/bindx-react/src/jsx/componentFactory.ts +++ b/packages/bindx-react/src/jsx/componentFactory.ts @@ -33,7 +33,7 @@ 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, type CompiledSelection } from './compiledSelection.js' +import { applyCompiledSelection, isValidCompiledSelection, type CompiledSelection } from './compiledSelection.js' import { type Condition, evaluateCondition } from './conditions.js' import { useAccessor } from '../hooks/useAccessor.js' @@ -79,6 +79,19 @@ 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) // ============================================================================ @@ -160,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 { @@ -172,24 +234,8 @@ export function buildComponent( collectionState = 'collecting' try { // Precompiled selection present ⇒ build entries from it, skip the proxy pass. - if (compiled) { - applyCompiledSelection({ - compiled, - selectionsMap, - componentBrand, - roles, - implicitConfigs, - schemaRegistry, - componentDisplayName, - validateMode: staticSelectionValidationEnabled, - }) - if (staticSelectionValidationEnabled) { - validateCompiledSelection( - selectionsMap, componentDisplayName, - implicitConfigs, renderFn, componentBrand, roles, - hasInterfacesMode, schemaRegistry, conditionFn, mockValues, - ) - } + // 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) diff --git a/packages/bindx-react/src/jsx/index.ts b/packages/bindx-react/src/jsx/index.ts index 25d79d6..b578fab 100644 --- a/packages/bindx-react/src/jsx/index.ts +++ b/packages/bindx-react/src/jsx/index.ts @@ -104,6 +104,9 @@ 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' diff --git a/tests/react/jsx/entityCompiledSelection.test.tsx b/tests/react/jsx/entityCompiledSelection.test.tsx index 6c65694..90505e9 100644 --- a/tests/react/jsx/entityCompiledSelection.test.tsx +++ b/tests/react/jsx/entityCompiledSelection.test.tsx @@ -1,5 +1,5 @@ // Runtime side of Phase 3 — compiled root selection. The compiler injects -// `compiledSelection={{ props: { entity: {...} }, holes: [...] }}`; here those literals +// `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' @@ -39,7 +39,7 @@ function isCollector(value: unknown): boolean { describe('compiled — collection is skipped', () => { test('renders WITHOUT ever invoking children with a collector', async () => { let collectorCalls = 0 - const compiledSelection: CompiledSelection = { props: { entity: { title: true } } } + const compiledSelection: CompiledSelection = { v: 2, props: { entity: { title: true } } } const { container } = renderWithBindx( @@ -83,7 +83,7 @@ describe('compiled — collection is skipped', () => { describe('compiled — fetch + render under MockAdapter', () => { test('scalar fields', async () => { - const compiledSelection: CompiledSelection = { props: { entity: { title: true, content: true } } } + const compiledSelection: CompiledSelection = { v: 2, props: { entity: { title: true, content: true } } } const { container } = renderWithBindx( @@ -104,6 +104,7 @@ describe('compiled — fetch + render under MockAdapter', () => { test('nested has-one', async () => { const compiledSelection: CompiledSelection = { + v: 2, props: { entity: { title: true, author: { fields: { name: true } } } }, } @@ -128,6 +129,7 @@ describe('compiled — fetch + render under MockAdapter', () => { .render(({ author }) => ) const compiledSelection: CompiledSelection = { + v: 2, props: { entity: { title: true } }, holes: [{ component: () => AuthorCard, @@ -156,7 +158,7 @@ describe('compiled — fetch + render under MockAdapter', () => { test('create-mode Entity', async () => { let collectorCalls = 0 - const compiledSelection: CompiledSelection = { props: { entity: { title: true } } } + 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. @@ -183,7 +185,7 @@ describe('compiled — validate mode', () => { setStaticSelectionValidation(true) const warn = spyOn(console, 'warn').mockImplementation(() => {}) - const compiledSelection: CompiledSelection = { props: { entity: { title: true } } } + const compiledSelection: CompiledSelection = { v: 2, props: { entity: { title: true } } } const { container } = renderWithBindx( @@ -212,7 +214,7 @@ describe('compiled — validate mode', () => { (props: ContentProbeProps) => , ) - const compiledSelection: CompiledSelection = { props: { entity: { title: true } } } + const compiledSelection: CompiledSelection = { v: 2, props: { entity: { title: true } } } const { container } = renderWithBindx( @@ -257,3 +259,31 @@ describe(' — no compiledSelection is unchanged (sanity)', () => { }) }) }) + +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 index 194585b..99620f2 100644 --- a/tests/react/jsx/staticSelection.test.tsx +++ b/tests/react/jsx/staticSelection.test.tsx @@ -16,17 +16,20 @@ import { 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() - // Validate mode is a module-level flag — never leak it into other tests. + // Module-level flags — never leak them into other tests. setStaticSelectionValidation(false) + setCompiledSelectionsEnabled(true) }) // Triggers static collection via the `$` fragment getter, then reads @@ -114,7 +117,7 @@ describe('compiled build path (props only)', () => { renderCalls++ return }, - { props: { article: { title: true } } }, + { v: 2, props: { article: { title: true } } }, ) const selection = getComponentSelection(Comp, 'article') @@ -137,7 +140,7 @@ describe('compiled build path (props only)', () => { .entity('article', schema.Article) .render( ({ article }) => , - { props: { article: { title: true } } }, + { v: 2, props: { article: { title: true } } }, ) const { container } = renderWithBindx( @@ -160,7 +163,7 @@ describe('validate mode', () => { .entity('article', schema.Article) .render( ({ article }) => , - { props: { article: { title: true } } }, + { v: 2, props: { article: { title: true } } }, ) getComponentSelection(Comp, 'article') @@ -178,7 +181,7 @@ describe('validate mode', () => { .entity('article', schema.Article) .render( ({ article }) => , - { props: { article: { title: true, content: true, status: true } } }, + { v: 2, props: { article: { title: true, content: true, status: true } } }, ) getComponentSelection(Comp, 'article') @@ -196,7 +199,7 @@ describe('validate mode', () => { .entity('article', schema.Article) .render( ({ article }) => {t => }, - { props: { article: { tags: { fields: { name: true }, many: true, params: { limit: 5 } } } } }, + { v: 2, props: { article: { tags: { fields: { name: true }, many: true, params: { limit: 5 } } } } }, ) getComponentSelection(Comp, 'article') @@ -218,7 +221,7 @@ describe('validate mode', () => { ), - { props: { article: { title: true } } }, + { v: 2, props: { article: { title: true } } }, ) getComponentSelection(Comp, 'article') @@ -244,6 +247,7 @@ describe('validate mode', () => { .render( ({ article }) => , { + v: 2, props: { article: {} }, holes: [{ component: () => AuthorCard, @@ -282,6 +286,7 @@ describe('compiled selection v2 — nested-component holes', () => { .render( () => { throw new Error('render must not run on the compiled path') }, { + v: 2, props: { article: {} }, holes: [{ component: () => AuthorCard, @@ -306,6 +311,7 @@ describe('compiled selection v2 — nested-component holes', () => { .render( () => { throw new Error('render must not run') }, { + v: 2, props: { article: {} }, holes: [{ component: () => Summary, @@ -335,6 +341,7 @@ describe('compiled selection v2 — nested-component holes', () => { .render( () => { throw new Error('render must not run') }, { + v: 2, props: { article: {} }, holes: [{ component: () => TitleCollector, @@ -356,6 +363,7 @@ describe('compiled selection v2 — nested-component holes', () => { .render( () => { throw new Error('render must not run') }, { + v: 2, props: { article: { title: true } }, holes: [{ component: () => AuthorName, @@ -375,6 +383,7 @@ describe('compiled selection v2 — nested-component holes', () => { .render( () => { throw new Error('render must not run') }, { + v: 2, props: { article: {} }, holes: [{ // LateAuthorCard is declared below — the thunk defers resolution @@ -409,6 +418,7 @@ describe('compiled selection v2 — nested-component holes', () => { .render( () => { throw new Error('render must not run') }, { + v: 2, props: { article: {} }, holes: [{ component: () => Pair, @@ -432,6 +442,7 @@ describe('compiled selection v2 — nested-component holes', () => { .render( () => null, { + v: 2, props: { article: { title: true } }, holes: [{ component: () => PlainThing, @@ -470,6 +481,7 @@ describe('compiled selection v2 — nested-component holes', () => { .render( () => { throw new Error('render must not run') }, { + v: 2, props: { article: { title: true } }, holes: [ { @@ -509,6 +521,7 @@ describe('compiled selection v2 — nested-component holes', () => { .render( () => { throw new Error('render must not run') }, { + v: 2, props: { article: {} }, holes: [{ component: () => SelectField, @@ -537,6 +550,7 @@ describe('compiled selection v2 — nested-component holes', () => { return }, { + v: 2, props: { article: { title: true } }, holes: [{ component: () => AuthorCard, @@ -549,3 +563,91 @@ describe('compiled selection v2 — nested-component holes', () => { 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']) + }) +}) From 89e6c808f93c33b82605d923a6a003b30a6eb390 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 14:09:07 +0200 Subject: [PATCH 28/34] feat(bindx-compiler): crash containment, diagnostics option, emit hardening Contain unexpected (non-BailError) crashes per unit as a new INTERNAL_ERROR bail so the sound runtime proxy pass takes over instead of failing the build; the plugin also wraps discovery and each emit (one bad injection never loses the others). Add a diagnostics option ('off'|'summary'|'verbose') threaded through the babel and Vite plugins, with a single reporter deciding all output and a buildEnd grand total. Deep-clone expressions copied into hole extraProps so no AST node sits at two tree positions. Hoist each compiled literal to a module-scope const for stable identity across parent renders. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 35 +++++++ packages/bindx-compiler/src/analyze.ts | 5 +- packages/bindx-compiler/src/babelPlugin.ts | 113 ++++++++++++++++++--- packages/bindx-compiler/src/diagnostics.ts | 66 ++++++++++++ packages/bindx-compiler/src/emit.ts | 23 +++-- packages/bindx-compiler/src/entityRoots.ts | 5 +- packages/bindx-compiler/src/index.ts | 3 +- packages/bindx-compiler/src/resolve.ts | 10 ++ packages/bindx-compiler/src/types.ts | 3 + packages/bindx-compiler/src/vitePlugin.ts | 19 +++- 10 files changed, 253 insertions(+), 29 deletions(-) create mode 100644 packages/bindx-compiler/src/diagnostics.ts diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index cb79bb7..6267fe9 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -779,6 +779,41 @@ literal (e.g. a schema change that predates a re-transform, or a version skew) m 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. + ## Future (explicitly out of scope now) Unplugin packaging, eslint plugin reusing the analyzer (bail reasons as lint diagnostics), diff --git a/packages/bindx-compiler/src/analyze.ts b/packages/bindx-compiler/src/analyze.ts index 9e711bd..dbfc9f6 100644 --- a/packages/bindx-compiler/src/analyze.ts +++ b/packages/bindx-compiler/src/analyze.ts @@ -9,7 +9,7 @@ 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 } from './resolve.js' +import { BailError, internalErrorBail } from './resolve.js' import { SelNode } from './selectionTree.js' import { parseProgram } from './parse.js' import { ModuleCache } from './moduleResolve.js' @@ -117,7 +117,8 @@ function analyzeChain( if (error instanceof BailError) { return { loc, bailout: error.bailout } } - throw error + // Contain an unexpected compiler bug per chain: bail (proxy fallback is sound), never crash the build. + return { loc, bailout: internalErrorBail(error) } } const selection: StaticSelection = {} diff --git a/packages/bindx-compiler/src/babelPlugin.ts b/packages/bindx-compiler/src/babelPlugin.ts index 3b45a8d..4b18cec 100644 --- a/packages/bindx-compiler/src/babelPlugin.ts +++ b/packages/bindx-compiler/src/babelPlugin.ts @@ -1,16 +1,22 @@ /** - * Babel plugin: injects the emitted StaticSelection as the 2nd argument of each - * proven chain's `.render(...)` call. Bailed chains are left untouched, so the - * runtime proxy pass remains the fallback (progressive enhancement). + * 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. * - * The runtime side of `.render(fn, static)` is deliverable A — this plugin only - * emits the argument 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 { PluginObj, PluginPass } from '@babel/core' +import type { NodePath, PluginObj, PluginPass } from '@babel/core' +import * as t from '@babel/types' import { analyzeEntityRootsInProgram, analyzeProgram } from './analyze.js' -import { entitySelectionAttr, selectionToAst } from './emit.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: @@ -18,18 +24,26 @@ import { isBailed, isEntityRootBailed } from './types.js' * - `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 } 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 return { name: 'bindx-selection-compiler', manipulateOptions(_opts, parserOpts: { plugins: unknown[] }): void { @@ -38,36 +52,107 @@ export function bindxCompilerPlugin(_api?: unknown, options?: BindxCompilerOptio visitor: { Program(path, state: PluginPass): void { const filename = state.file.opts.filename ?? undefined - // Analyze both surfaces before mutating: chain injection and Entity-attribute - // injection are independent, but reading the whole AST first keeps them so. - const chainResults = analyzeProgram(path.node, { filename, alias, onDependency }) - const entityResults = analyzeEntityRootsInProgram(path.node, { filename, alias, entityLike, onDependency }) + 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 }), entries) + const entityResults = discover(() => analyzeEntityRootsInProgram(path.node, { filename, alias, entityLike, onDependency }), 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 } - chain.renderCall.arguments.push(selectionToAst(result.selection, result.holes)) + 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. + // Idempotence: skip elements already carrying a compiledSelection attribute + // (an identifier-valued attribute from a prior hoist also triggers this skip). if (hasCompiledSelectionAttr(element)) { continue } - element.openingElement.attributes.push(entitySelectionAttr(ENTITY_ROOT_KEY, result.selection, result.holes)) + 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/diagnostics.ts b/packages/bindx-compiler/src/diagnostics.ts new file mode 100644 index 0000000..a2a6c0d --- /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 index 43604ca..33a391a 100644 --- a/packages/bindx-compiler/src/emit.ts +++ b/packages/bindx-compiler/src/emit.ts @@ -52,8 +52,10 @@ function holeToAst(hole: AnalyzedHole): t.ObjectExpression { } 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([], expr)), + ([name, expr]) => t.objectProperty(key(name), t.arrowFunctionExpression([], t.cloneNode(expr, true))), ) properties.push(t.objectProperty(t.identifier('extraProps'), t.objectExpression(entries))) } @@ -73,15 +75,20 @@ export function selectionToAst(selection: StaticSelection, holes: readonly Analy } /** - * Emits the `compiledSelection={{ props: { entity: {...} }, holes: [...] }}` JSX attribute - * the Babel plugin injects onto a proven `` element (phase 3). The root field map - * lives under the fixed `rootKey`; holes are the same thunk-carrying shape as chains. + * 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 entitySelectionAttr( +export function entitySelectionObject( rootKey: string, selection: StaticFieldMap, holes: readonly AnalyzedHole[], -): t.JSXAttribute { - const obj = selectionToAst({ [rootKey]: selection }, holes) - return t.jsxAttribute(t.jsxIdentifier('compiledSelection'), t.jsxExpressionContainer(obj)) +): 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 index c41687f..84c1392 100644 --- a/packages/bindx-compiler/src/entityRoots.ts +++ b/packages/bindx-compiler/src/entityRoots.ts @@ -12,7 +12,7 @@ import * as t from '@babel/types' import { walkAst } from './astWalk.js' import { BodyAnalyzer } from './body.js' -import { BailError } from './resolve.js' +import { BailError, internalErrorBail } from './resolve.js' import { SelNode } from './selectionTree.js' import type { ImportBindings } from './imports.js' import type { ContractLookup } from './contracts.js' @@ -119,7 +119,8 @@ export function analyzeEntityRoot( if (error instanceof BailError) { return { loc, bailout: error.bailout } } - throw error + // 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 } diff --git a/packages/bindx-compiler/src/index.ts b/packages/bindx-compiler/src/index.ts index 8c9edaf..1c7915c 100644 --- a/packages/bindx-compiler/src/index.ts +++ b/packages/bindx-compiler/src/index.ts @@ -25,7 +25,8 @@ export { } from './contracts.js' export { ModuleCache } from './moduleResolve.js' export { TargetKindResolver, type TargetKind, type TargetKindLookup } from './targetKind.js' -export { selectionToAst } from './emit.js' +export { selectionToAst, entitySelectionObject, compiledSelectionAttr } from './emit.js' +export { reportFile, reportTotals, type DiagnosticsMode, type DiagnosticEntry, type DiagnosticTotals } from './diagnostics.js' export { fieldMapToPlain, selectionToPlain } from './selectionTree.js' export type { StaticSelection, diff --git a/packages/bindx-compiler/src/resolve.ts b/packages/bindx-compiler/src/resolve.ts index f300aeb..1f7ac2e 100644 --- a/packages/bindx-compiler/src/resolve.ts +++ b/packages/bindx-compiler/src/resolve.ts @@ -14,6 +14,16 @@ export class BailError extends Error { } } +/** 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 diff --git a/packages/bindx-compiler/src/types.ts b/packages/bindx-compiler/src/types.ts index 50b9573..df217f9 100644 --- a/packages/bindx-compiler/src/types.ts +++ b/packages/bindx-compiler/src/types.ts @@ -74,6 +74,9 @@ export type BailoutReason = | '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 { diff --git a/packages/bindx-compiler/src/vitePlugin.ts b/packages/bindx-compiler/src/vitePlugin.ts index 0a1b8ac..3538054 100644 --- a/packages/bindx-compiler/src/vitePlugin.ts +++ b/packages/bindx-compiler/src/vitePlugin.ts @@ -16,6 +16,7 @@ */ import { transformAsync, type BabelFileResult } from '@babel/core' import { bindxCompilerPlugin } from './babelPlugin.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 { @@ -27,6 +28,8 @@ export interface BindxCompilerViteOptions { 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). */ @@ -45,6 +48,7 @@ export interface BindxCompilerVitePlugin { readonly name: string readonly enforce: 'pre' transform(this: BindxTransformContext, code: string, id: string): Promise + buildEnd(): void } const JSX_FILE = /\.[jt]sx$/ @@ -63,7 +67,13 @@ function mayCompile(code: string): boolean { * `enforce: 'pre'` ordering is what guarantees it runs first regardless of array position. */ export function bindxCompiler(options: BindxCompilerViteOptions = {}): BindxCompilerVitePlugin { - const { alias, entityLike, include, exclude } = options + const { alias, entityLike, include, exclude, diagnostics = 'off' } = options + // Per-instance accumulator (no module-level state) for the buildEnd grand total. + const totals: { compiled: number; bailed: number } = { compiled: 0, bailed: 0 } + const accumulate = (t: DiagnosticTotals): void => { + totals.compiled += t.compiled + totals.bailed += t.bailed + } return { name: 'bindx-compiler', enforce: 'pre', @@ -88,7 +98,7 @@ export function bindxCompiler(options: BindxCompilerViteOptions = {}): BindxComp babelrc: false, sourceMaps: true, parserOpts: { plugins: ['typescript', 'jsx'] }, - plugins: [[bindxCompilerPlugin, { alias, entityLike, onDependency: (dep: string) => deps.add(dep) }]], + plugins: [[bindxCompilerPlugin, { alias, entityLike, diagnostics, onReport: accumulate, onDependency: (dep: string) => deps.add(dep) }]], }) if (!result?.code) { return null @@ -99,5 +109,10 @@ export function bindxCompiler(options: BindxCompilerViteOptions = {}): BindxComp } return { code: result.code, map: result.map } }, + buildEnd(): void { + if (diagnostics !== 'off') { + reportTotals(totals) + } + }, } } From 8507750828397b7d0c40a39345d570fd5c591299 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 14:09:13 +0200 Subject: [PATCH 29/34] test(bindx-compiler): crash containment, diagnostics, and emit-interop tests Cover per-chain crash containment (analysis wrapper + plugin-level via a spyOn'd BodyAnalyzer that throws only for the crashing chain), the diagnostics modes, and Entity literal hoisting/idempotency. Add a shared-AST interop test (compiler pass -> @babel/plugin-transform-react-jsx over the same nodes) plus a direct node-identity assertion proving extraProps is deep-cloned. Adds @babel/plugin-transform-react-jsx as a devDependency for the interop pass. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- bun.lock | 7 + packages/bindx-compiler/package.json | 1 + .../bindx-compiler/tests/hardening.test.ts | 156 ++++++++++++++++++ .../bindx-compiler/tests/interop.test.tsx | 136 +++++++++++++++ 4 files changed, 300 insertions(+) create mode 100644 packages/bindx-compiler/tests/hardening.test.ts create mode 100644 packages/bindx-compiler/tests/interop.test.tsx diff --git a/bun.lock b/bun.lock index ef2e008..9858e9b 100644 --- a/bun.lock +++ b/bun.lock @@ -46,6 +46,7 @@ "@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", @@ -223,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=="], @@ -243,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=="], diff --git a/packages/bindx-compiler/package.json b/packages/bindx-compiler/package.json index f866cd0..dfe3104 100644 --- a/packages/bindx-compiler/package.json +++ b/packages/bindx-compiler/package.json @@ -21,6 +21,7 @@ "@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" diff --git a/packages/bindx-compiler/tests/hardening.test.ts b/packages/bindx-compiler/tests/hardening.test.ts new file mode 100644 index 0000000..1b95634 --- /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/interop.test.tsx b/packages/bindx-compiler/tests/interop.test.tsx new file mode 100644 index 0000000..f7bc4fd --- /dev/null +++ b/packages/bindx-compiler/tests/interop.test.tsx @@ -0,0 +1,136 @@ +/** + * #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, selectionToAst, type AnalyzedHole } from '../src/index.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') + }) +}) From 848c86e36a8b6e59802c04b70c8e0f194d473dbb Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 14:24:48 +0200 Subject: [PATCH 30/34] refactor(bindx-compiler): remove casts, unify AST walkers, trim public API - imports.ts: resolve component kinds via a ReadonlyMap (no `as ComponentKind`) - astWalk.ts: add 'skip'/'stop' WalkControl, cast-free child access via Reflect.get and `'type' in value` node guard; fold the hand-rolled visitors in resolve.ts (anyIdentifier) and targetKind.ts (referencedMembersOf) onto walkAst - contracts.ts: import CallbackContract/CollectorContract from @contember/bindx-react (single source of truth) and drop the dead ContractFileCache alias; add a bindx-react project reference so tsc --build resolves the type import - index.ts: trim to bindxCompilerPlugin/bindxCompiler, analyze* + is*Bailed guards and public result/option types; tests import internals from their source modules - vite plugin: create one ModuleCache per instance and thread it through the babel plugin (BindxCompilerOptions.cache); singleton stays the bare-plugin default Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 27 +++++++++++++ packages/bindx-compiler/src/astWalk.ts | 38 +++++++++++++----- packages/bindx-compiler/src/babelPlugin.ts | 11 ++++- packages/bindx-compiler/src/contracts.ts | 15 +++---- packages/bindx-compiler/src/imports.ts | 15 +++++-- packages/bindx-compiler/src/index.ts | 15 ------- packages/bindx-compiler/src/resolve.ts | 23 ++--------- packages/bindx-compiler/src/targetKind.ts | 40 +++++-------------- packages/bindx-compiler/src/vitePlugin.ts | 6 ++- packages/bindx-compiler/tests/harness.ts | 3 +- .../bindx-compiler/tests/interop.test.tsx | 3 +- packages/bindx-compiler/tsconfig.json | 5 ++- .../bindx-react/src/jsx/collectorContract.tsx | 6 ++- 13 files changed, 112 insertions(+), 95 deletions(-) diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index 6267fe9..a960210 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -814,6 +814,33 @@ validate-mode to re-warn) each render; a hoisted const is stable. Chains keep th (`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), diff --git a/packages/bindx-compiler/src/astWalk.ts b/packages/bindx-compiler/src/astWalk.ts index eb0aacc..32b2f9b 100644 --- a/packages/bindx-compiler/src/astWalk.ts +++ b/packages/bindx-compiler/src/astWalk.ts @@ -5,10 +5,23 @@ */ import * as t from '@babel/types' -/** Depth-first pre-order walk. Return `false` from `enter` to skip a node's children. */ -export function walkAst(root: t.Node, enter: (node: t.Node) => boolean | void): void { +/** 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 => { - if (enter(node) === false) { + const control = enter(node) + if (control === 'stop') { + stopped = true + return + } + if (control === 'skip') { return } const keys = t.VISITOR_KEYS[node.type] @@ -16,22 +29,29 @@ export function walkAst(root: t.Node, enter: (node: t.Node) => boolean | void): return } for (const key of keys) { - // Index access is required to traverse arbitrary node shapes generically. - const child: unknown = (node as unknown as Record)[key] + // 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 (item && typeof item === 'object' && isNode(item)) { + if (isNode(item)) { visit(item) } + if (stopped) { + return + } } - } else if (child && typeof child === 'object' && isNode(child)) { + } else if (isNode(child)) { visit(child) } + if (stopped) { + return + } } } + visit(root) } -function isNode(value: object): value is t.Node { - return typeof (value as { type?: unknown }).type === 'string' +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 index 4b18cec..0c0f8aa 100644 --- a/packages/bindx-compiler/src/babelPlugin.ts +++ b/packages/bindx-compiler/src/babelPlugin.ts @@ -12,6 +12,7 @@ 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' @@ -36,6 +37,11 @@ export interface BindxCompilerOptions { 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 { @@ -44,6 +50,7 @@ export function bindxCompilerPlugin(_api?: unknown, options?: BindxCompilerOptio 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 { @@ -56,8 +63,8 @@ export function bindxCompilerPlugin(_api?: unknown, options?: BindxCompilerOptio // 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 }), entries) - const entityResults = discover(() => analyzeEntityRootsInProgram(path.node, { filename, alias, entityLike, onDependency }), entries) + 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)) { diff --git a/packages/bindx-compiler/src/contracts.ts b/packages/bindx-compiler/src/contracts.ts index 1e8a502..1e8b57d 100644 --- a/packages/bindx-compiler/src/contracts.ts +++ b/packages/bindx-compiler/src/contracts.ts @@ -10,20 +10,15 @@ */ import * as t from '@babel/types' import { - BindingResolver, ModuleCache, type BindingResolverOptions, type ModuleView, + BindingResolver, type BindingResolverOptions, type ModuleView, findTopLevelVarInit, } from './moduleResolve.js' import { unwrap } from './resolve.js' -export { ModuleCache as ContractFileCache } from './moduleResolve.js' - -export interface CallbackContract { - readonly kind: 'itemOf' | 'entityOf' - readonly field: string -} - -/** Key = callback prop name (`children` included) → the relation it is invoked over. */ -export type CollectorContract = Record +// 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 diff --git a/packages/bindx-compiler/src/imports.ts b/packages/bindx-compiler/src/imports.ts index 571f1ce..7c7f603 100644 --- a/packages/bindx-compiler/src/imports.ts +++ b/packages/bindx-compiler/src/imports.ts @@ -6,8 +6,14 @@ import * as t from '@babel/types' export type ComponentKind = 'Field' | 'Attribute' | 'Show' | 'HasOne' | 'HasMany' | 'If' -const COMPONENT_NAMES: ReadonlySet = new Set([ - '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 { @@ -41,6 +47,7 @@ export function collectImportBindings(program: t.Program): ImportBindings { } 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') { @@ -50,8 +57,8 @@ export function collectImportBindings(program: t.Program): ImportBindings { // 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 (COMPONENT_NAMES.has(imported)) { - components.set(local, imported as ComponentKind) + } else if (kind) { + components.set(local, kind) } } } diff --git a/packages/bindx-compiler/src/index.ts b/packages/bindx-compiler/src/index.ts index 1c7915c..225e4dc 100644 --- a/packages/bindx-compiler/src/index.ts +++ b/packages/bindx-compiler/src/index.ts @@ -3,11 +3,8 @@ export { analyzeProgram, analyzeEntityRoots, analyzeEntityRootsInProgram, - parseProgram, type AnalyzeOptions, - type InternalChainResult, } from './analyze.js' -export { ENTITY_ROOT_KEY, type InternalEntityRootResult } from './entityRoots.js' export { bindxCompilerPlugin, default, type BindxCompilerOptions } from './babelPlugin.js' export { bindxCompiler, @@ -16,18 +13,6 @@ export { type BindxTransformContext, type BindxTransformResult, } from './vitePlugin.js' -export { - ContractFileCache, - ContractResolver, - type CallbackContract, - type CollectorContract, - type ContractLookup, -} from './contracts.js' -export { ModuleCache } from './moduleResolve.js' -export { TargetKindResolver, type TargetKind, type TargetKindLookup } from './targetKind.js' -export { selectionToAst, entitySelectionObject, compiledSelectionAttr } from './emit.js' -export { reportFile, reportTotals, type DiagnosticsMode, type DiagnosticEntry, type DiagnosticTotals } from './diagnostics.js' -export { fieldMapToPlain, selectionToPlain } from './selectionTree.js' export type { StaticSelection, StaticFieldMap, diff --git a/packages/bindx-compiler/src/resolve.ts b/packages/bindx-compiler/src/resolve.ts index 1f7ac2e..e18bdcf 100644 --- a/packages/bindx-compiler/src/resolve.ts +++ b/packages/bindx-compiler/src/resolve.ts @@ -5,6 +5,7 @@ * 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' @@ -186,28 +187,12 @@ export function consumeMany(ref: RootRef, params?: StaticHasManyParams): SelNode * property names / object keys too) — sound for default-deny bail decisions. */ function anyIdentifier(node: t.Node, pred: (name: string) => boolean): boolean { let found = false - const visit = (n: t.Node): void => { - if (found) { - return - } + walkAst(node, n => { if (t.isIdentifier(n) && pred(n.name)) { found = true - return - } - for (const key of t.VISITOR_KEYS[n.type] ?? []) { - const child: unknown = (n as unknown as Record)[key] - if (Array.isArray(child)) { - for (const item of child) { - if (item && typeof item === 'object' && typeof (item as { type?: unknown }).type === 'string') { - visit(item as t.Node) - } - } - } else if (child && typeof child === 'object' && typeof (child as { type?: unknown }).type === 'string') { - visit(child as t.Node) - } + return 'stop' } - } - visit(node) + }) return found } diff --git a/packages/bindx-compiler/src/targetKind.ts b/packages/bindx-compiler/src/targetKind.ts index 73caa21..e23736b 100644 --- a/packages/bindx-compiler/src/targetKind.ts +++ b/packages/bindx-compiler/src/targetKind.ts @@ -8,6 +8,7 @@ * 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' @@ -171,44 +172,21 @@ function referencedMembersOf(root: t.Node, paramName: string): ReadonlySet() let escaped = false - const visit = (node: t.Node, asMemberObject: boolean): void => { - if (escaped) { - return - } - if (t.isIdentifier(node)) { - // A bare reference to the props identifier that is NOT the object of a `.x` access escapes. - if (node.name === paramName && !asMemberObject) { - escaped = true - } - return - } + 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 - } else { - escaped = true // `p[x]` — cannot know which prop + return 'skip' // handled `p.x` wholesale; don't descend into the `p` object identifier } - return // do not descend into the object identifier + escaped = true // `p[x]` — cannot know which prop + return 'stop' } - for (const key of t.VISITOR_KEYS[node.type] ?? []) { - const child: unknown = (node as unknown as Record)[key] - if (Array.isArray(child)) { - for (const item of child) { - if (isNode(item)) { - visit(item, false) - } - } - } else if (isNode(child)) { - visit(child, false) - } + if (t.isIdentifier(node) && node.name === paramName) { + escaped = true // a bare reference to props not used as a clean `.x` access + return 'stop' } - } + }) - visit(root, false) return escaped ? 'all' : names } - -function isNode(value: unknown): value is t.Node { - return typeof value === 'object' && value !== null && typeof (value as { type?: unknown }).type === 'string' -} diff --git a/packages/bindx-compiler/src/vitePlugin.ts b/packages/bindx-compiler/src/vitePlugin.ts index 3538054..8b70223 100644 --- a/packages/bindx-compiler/src/vitePlugin.ts +++ b/packages/bindx-compiler/src/vitePlugin.ts @@ -16,6 +16,7 @@ */ 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. */ @@ -68,6 +69,9 @@ function mayCompile(code: string): boolean { */ export function bindxCompiler(options: BindxCompilerViteOptions = {}): BindxCompilerVitePlugin { const { alias, entityLike, include, exclude, diagnostics = 'off' } = options + // One parsed-sibling-module cache per plugin instance: bounds dev-server memory per build and + // isolates test runs, while still sharing sibling parses across every file of this build. + const cache = new ModuleCache() // Per-instance accumulator (no module-level state) for the buildEnd grand total. const totals: { compiled: number; bailed: number } = { compiled: 0, bailed: 0 } const accumulate = (t: DiagnosticTotals): void => { @@ -98,7 +102,7 @@ export function bindxCompiler(options: BindxCompilerViteOptions = {}): BindxComp babelrc: false, sourceMaps: true, parserOpts: { plugins: ['typescript', 'jsx'] }, - plugins: [[bindxCompilerPlugin, { alias, entityLike, diagnostics, onReport: accumulate, onDependency: (dep: string) => deps.add(dep) }]], + plugins: [[bindxCompilerPlugin, { alias, entityLike, diagnostics, cache, onReport: accumulate, onDependency: (dep: string) => deps.add(dep) }]], }) if (!result?.code) { return null diff --git a/packages/bindx-compiler/tests/harness.ts b/packages/bindx-compiler/tests/harness.ts index 41bb280..d9b5825 100644 --- a/packages/bindx-compiler/tests/harness.ts +++ b/packages/bindx-compiler/tests/harness.ts @@ -6,7 +6,8 @@ import { readFileSync } from 'node:fs' import { join } from 'node:path' import { COMPONENT_SELECTIONS, convertToQuerySelection, type SelectionMeta } from '@contember/bindx-react' -import { analyzeSource, isBailed, selectionToPlain, type ChainResult } from '../src/index.js' +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) diff --git a/packages/bindx-compiler/tests/interop.test.tsx b/packages/bindx-compiler/tests/interop.test.tsx index f7bc4fd..05290d8 100644 --- a/packages/bindx-compiler/tests/interop.test.tsx +++ b/packages/bindx-compiler/tests/interop.test.tsx @@ -22,7 +22,8 @@ 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, selectionToAst, type AnalyzedHole } from '../src/index.js' +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. diff --git a/packages/bindx-compiler/tsconfig.json b/packages/bindx-compiler/tsconfig.json index 941e449..e128c29 100644 --- a/packages/bindx-compiler/tsconfig.json +++ b/packages/bindx-compiler/tsconfig.json @@ -9,5 +9,8 @@ "outDir": "./dist", "rootDir": "./src" }, - "include": ["./src/**/*"] + "include": ["./src/**/*"], + "references": [ + { "path": "../bindx-react" } + ] } diff --git a/packages/bindx-react/src/jsx/collectorContract.tsx b/packages/bindx-react/src/jsx/collectorContract.tsx index 6f6c2d4..910be94 100644 --- a/packages/bindx-react/src/jsx/collectorContract.tsx +++ b/packages/bindx-react/src/jsx/collectorContract.tsx @@ -3,7 +3,11 @@ 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. */ +/** + * 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 From 015a9d3b0d3b169eba1254d6014fcea8e6da7e9b Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 14:24:53 +0200 Subject: [PATCH 31/34] test(bindx-compiler): options plumbing and render-arg coverage - optionsPlumbing.test.ts: assert alias + entityLike take effect through the babel plugins-with-options tuple (emit only happens when the option is present) - plugin.test.ts: a hand-written `.render(fn, literal)` second arg is preserved and not injected over; a non-inline `.render(ref)` arg bails EXPLICIT_RENDER_FN Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- .../tests/optionsPlumbing.test.ts | 79 +++++++++++++++++++ packages/bindx-compiler/tests/plugin.test.ts | 31 +++++++- 2 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/bindx-compiler/tests/optionsPlumbing.test.ts diff --git a/packages/bindx-compiler/tests/optionsPlumbing.test.ts b/packages/bindx-compiler/tests/optionsPlumbing.test.ts new file mode 100644 index 0000000..5fb4e90 --- /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 index baffce2..49603fc 100644 --- a/packages/bindx-compiler/tests/plugin.test.ts +++ b/packages/bindx-compiler/tests/plugin.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from 'bun:test' import { transformSync } from '@babel/core' -import { bindxCompilerPlugin } from '../src/index.js' +import { analyzeSource, bindxCompilerPlugin, isBailed } from '../src/index.js' function transform(code: string): string { const out = transformSync(code, { @@ -67,4 +67,33 @@ export const C = createComponent() 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') + }) }) From 72ebd7e09e0bdf9f21309ddc4e982ed79d135b52 Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 15:10:14 +0200 Subject: [PATCH 32/34] docs: refer to the reference app generically Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- docs/compiler-plan.md | 46 +++++++++---------- docs/selection-collection.md | 12 ++--- .../tests/fixtures/_ccBarrel.ts | 2 +- .../tests/fixtures/holeClosures.tsx | 2 +- .../bindx-compiler/tests/targetKinds.test.tsx | 2 +- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/docs/compiler-plan.md b/docs/compiler-plan.md index a960210..a1e9096 100644 --- a/docs/compiler-plan.md +++ b/docs/compiler-plan.md @@ -190,7 +190,7 @@ Status: **implemented** on `experiment/selection-compiler`. Runtime resolution 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 `~/projects/external/npi`, packages/admin, 257 chains): +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. @@ -198,7 +198,7 @@ remain: 9 `FUNCTION_PROP_ON_HOLE`, 5 `ENTITY_IN_EXPRESSION_PROP`, 1 `ENTITY_REAS > **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 — npi's `SelectField` does exactly +> 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 @@ -279,7 +279,7 @@ In `ensureImplicitCollected`, when a compiled selection is present: 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 npi dummy-`` blind spot). In validate mode, emit a dev-only warn naming 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. @@ -308,17 +308,17 @@ target (with and without sibling dummy ``s), multiple entity props on one 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 — npi workaround removal) — DONE +### 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 (npi worked around it with a `.map()` trick). Fixed: `walkRendererJsx` now runs +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 (npi regression)"). +("nested declarative selection (the reference app regression)"). ### Explicit non-goal @@ -393,7 +393,7 @@ analysis yields an equal-or-superset union (under-fetch impossible; over-fetch a target renders the slot as children (`withCollector` returning `<>{props.slot}{props.children}`) the two are exactly equal. -### Result (re-measured on `~/projects/external/npi`, packages/admin, 257 chains) +### 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 @@ -410,7 +410,7 @@ 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 npi bail (footer-editor `LinksSection`) is a render-prop child that +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 @@ -469,9 +469,9 @@ withCollector(runtime, contract: CollectorContract) 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. -- npi: validated on a patched TEMP COPY (scratchpad) of `footer-editor.tsx` + `_shared.tsx` with - `InitializingRepeater` declaring `{ children: itemOf('field') }` — the npi repo itself is NOT - modified; the suggested npi patch ships in the report/docs instead. +- 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 @@ -511,7 +511,7 @@ no safety bail** — the derived staticRender provably never invokes them (this `FUNCTION_PROP_ON_HOLE`/`RENDER_LOCAL_ON_HOLE` class for contract components). A missing callback for an entry records the relation only. -### Implemented — npi temp-copy validation +### 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') }` @@ -525,11 +525,11 @@ preserved) and patched only the COPY's `InitializingRepeater` to `{ children: it `` remains a legitimate hole resolved through `FooterLinkRow`'s own staticRender. -Full `~/projects/external/npi/packages/admin` re-measure is **unchanged** — 254/257 (99%), 3 bails -(1 `ENTITY_IN_EXPRESSION_PROP`, 1 `FUNCTION_PROP_ON_HOLE`, 1 `ENTITY_REASSIGNMENT`) — because npi has +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 npi patch (`packages/admin/app/components/web-builder/forms/_shared.tsx`) — also drop the +Suggested reference-app patch (`packages/admin/app/components/web-builder/forms/_shared.tsx`) — also drop the now-unused `HasMany` JSX import: ```diff @@ -582,10 +582,10 @@ measure entity-root reporting — plus adapter-oracle equivalence tests root the plugin pushes `compiledSelection={{ props: { entity: {...} }, holes: [...] }}` onto the element (idempotent — skips elements already carrying the attribute). -### Result (measured on `~/projects/external/npi`, packages/admin) +### 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: npi's dominant pattern is +**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. @@ -606,7 +606,7 @@ no attribute). Plus children-not-invoked-during-collection (SCOPE_REF counter) a 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. npi has 147 +— 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). @@ -652,7 +652,7 @@ system; phase 3.1 candidate). 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 npi measure re-run with root counts. +- Full the reference app measure re-run with root counts. ## Phase 3.1 — hole-target classification + entity-like roots — IMPLEMENTED @@ -666,7 +666,7 @@ plain `function`/`class` declarations too); target classification lives in `src/ option on `analyzeSource`/`analyzeProgram`/`analyzeEntityRoots`/the Babel plugin, plus a `--entity-like=Name,...` measure flag. -### Result (re-measured on `~/projects/external/npi/packages/admin`) +### 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: @@ -688,12 +688,12 @@ param `p` yields the `p.x` accesses, with any other use (`p[x]`, `{...p}`, `f(p) `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 (npi entity-root bail audit): 12 of 21 root bails are render-locals / function children +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, -npi's `RefreshableEntity` forwarding wrapper hides 82 Entity roots from the root scan entirely. +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) @@ -727,7 +727,7 @@ opt-in requirement, documented (no runtime change needed). Measure gains a CLI f 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). npi re-measure with +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 diff --git a/docs/selection-collection.md b/docs/selection-collection.md index 7e1da52..cafeb55 100644 --- a/docs/selection-collection.md +++ b/docs/selection-collection.md @@ -466,7 +466,7 @@ machine-readable reason (e.g. `ENTITY_ESCAPES_TO_CALL`, `ENTITY_IN_EXPRESSION_PR `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 (npi's `SelectField` does +`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 @@ -476,7 +476,7 @@ root bails, as does a render-local const passed onward). See docs/compiler-plan. 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 (`npi`, `packages/admin`, 257 chains) +`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%. @@ -512,8 +512,8 @@ 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 `npi`, `packages/admin`, the plugin compiles -**84/105** `` roots (114 holes; npi's dominant pattern is +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. @@ -542,7 +542,7 @@ tag — reusing the contract parse cache — into `createComponent`, `plain`, `c their own resolver, before target-kind classification. **`entityLike` — roots behind forwarding wrappers.** Some apps wrap `` in a thin component -that forwards props (npi'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 @@ -551,7 +551,7 @@ forwarding is the opt-in requirement** (there is no runtime change; `` a `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 `npi`, `packages/admin`: chains stay **254/257**; entity roots go **84 → 93/105** from +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). diff --git a/packages/bindx-compiler/tests/fixtures/_ccBarrel.ts b/packages/bindx-compiler/tests/fixtures/_ccBarrel.ts index 52612f2..836b0cb 100644 --- a/packages/bindx-compiler/tests/fixtures/_ccBarrel.ts +++ b/packages/bindx-compiler/tests/fixtures/_ccBarrel.ts @@ -1,4 +1,4 @@ -// Named re-export barrel (`export { X } from`) — the shape npi's index.ts files use. +// 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/holeClosures.tsx b/packages/bindx-compiler/tests/fixtures/holeClosures.tsx index 4bbd434..68ae08f 100644 --- a/packages/bindx-compiler/tests/fixtures/holeClosures.tsx +++ b/packages/bindx-compiler/tests/fixtures/holeClosures.tsx @@ -14,7 +14,7 @@ interface SelectFieldProps { children: (entity: EntityRef) => ReactNode } -// Mirrors npi's SelectField: a withCollector primitive whose staticRender reaches the field +// 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, diff --git a/packages/bindx-compiler/tests/targetKinds.test.tsx b/packages/bindx-compiler/tests/targetKinds.test.tsx index 92d1a0e..2d1c6a1 100644 --- a/packages/bindx-compiler/tests/targetKinds.test.tsx +++ b/packages/bindx-compiler/tests/targetKinds.test.tsx @@ -2,7 +2,7 @@ * 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 npi shapes (createComponent + render-local, PublishedRevisionIdProvider + * 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' From 16b1b6f07aadf63e1bffd822204a1b09ea55c4eb Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 15:27:36 +0200 Subject: [PATCH 33/34] test(browser): wait for the filtered option before clicking select popovers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The tests waited only for ANY button in the popover, so on a slow runner the click could hit the stale pre-filter option list (or a remounting node) — picking the wrong author / losing the click, then timing out on the save-button wait. Wait until the first option shows the filtered text instead. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- tests/browser/articleEditor.test.ts | 6 ++++-- tests/browser/authorSelect.test.ts | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/tests/browser/articleEditor.test.ts b/tests/browser/articleEditor.test.ts index 85e2f48..bb25bb0 100644 --- a/tests/browser/articleEditor.test.ts +++ b/tests/browser/articleEditor.test.ts @@ -24,7 +24,8 @@ 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) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('Jane')) el('[role="dialog"] button[class]').click() waitFor(() => !el('article-save-button').isDisabled) @@ -46,7 +47,8 @@ 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) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('TypeScript')) el('[role="dialog"] button[class]').click() waitFor(() => el('tag-badge-TypeScript').exists) diff --git a/tests/browser/authorSelect.test.ts b/tests/browser/authorSelect.test.ts index 9665e7a..d394d53 100644 --- a/tests/browser/authorSelect.test.ts +++ b/tests/browser/authorSelect.test.ts @@ -21,7 +21,8 @@ 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) + // Wait for the FILTERED option — the stale pre-filter list also has buttons + waitFor(() => el('[role="dialog"] button[class]').text.includes('Bob')) el('[role="dialog"] button[class]').click() waitFor(() => !el('author-select-save-button').isDisabled) From 1e847cc5cde19c035245e87d574d8ef3504b91bd Mon Sep 17 00:00:00 2001 From: David Matejka Date: Wed, 22 Jul 2026 15:32:13 +0200 Subject: [PATCH 34/34] test(browser): retry lost popover clicks via clickUntil helper Filtered-option waits didn't fix CI: the click can land on an option node mid-remount (debounced fetch re-renders the list) and get silently lost. clickUntil re-clicks until the expected outcome materializes, checking the condition first so a registered click is never repeated (no multi-select toggle-off). Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_013zh1EN87d1Q7urtu7sDRYG --- tests/browser/articleEditor.test.ts | 16 ++++++++------- tests/browser/authorSelect.test.ts | 9 +++++---- tests/browser/browser.ts | 31 +++++++++++++++++++++++++++++ 3 files changed, 45 insertions(+), 11 deletions(-) diff --git a/tests/browser/articleEditor.test.ts b/tests/browser/articleEditor.test.ts index bb25bb0..6618f4f 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', () => { @@ -26,9 +26,10 @@ browserTest('Article Editor', () => { el('[role="dialog"] input').fill('Jane') // Wait for the FILTERED option — the stale pre-filter list also has buttons waitFor(() => el('[role="dialog"] button[class]').text.includes('Jane')) - el('[role="dialog"] button[class]').click() - - waitFor(() => !el('article-save-button').isDisabled) + clickUntil( + () => el('[role="dialog"] button[class]'), + () => !el('article-save-button').isDisabled, + ) expect(el('article-dirty-notice').exists).toBe(true) }) @@ -49,9 +50,10 @@ browserTest('Article Editor', () => { el('[role="dialog"] input').fill('TypeScript') // Wait for the FILTERED option — the stale pre-filter list also has buttons waitFor(() => el('[role="dialog"] button[class]').text.includes('TypeScript')) - el('[role="dialog"] button[class]').click() - - waitFor(() => el('tag-badge-TypeScript').exists) + 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 d394d53..fe1e8ad 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', () => { @@ -23,9 +23,10 @@ browserTest('Article with Author Select', () => { el('[role="dialog"] input').fill('Bob') // Wait for the FILTERED option — the stale pre-filter list also has buttons waitFor(() => el('[role="dialog"] button[class]').text.includes('Bob')) - el('[role="dialog"] button[class]').click() - - waitFor(() => !el('author-select-save-button').isDisabled) + 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 435743e..e375403 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\`)`