Skip to content

✨ Leave out a prop whose expression produced nothing (#537) - #541

Merged
taras merged 1 commit into
mainfrom
agent/issue-537-omit-undefined-props
Aug 22, 2026
Merged

✨ Leave out a prop whose expression produced nothing (#537)#541
taras merged 1 commit into
mainfrom
agent/issue-537-omit-undefined-props

Conversation

@taras

@taras taras commented Aug 22, 2026

Copy link
Copy Markdown
Owner

Why

An author cannot write one component invocation whose optional identity prop is
absent until a preceding result supplies it. number={pullRequest.number} fails
the whole document on the first iteration, because a successful undefined is
rejected at the prop JSON boundary — so the same element has to be written
twice, once for each case. Closes #537.

What changes

An ordinary component's expression prop that evaluates successfully to
undefined is omitted.

Before:

<PullRequest number={pullRequest.number} title="Prepare 1.4" as="pullRequest">

Expression prop "number" on <PullRequest /> evaluated to a non-serializable
value (undefined). Props must be JSON-serializable.

After: number is simply not there. The schema answers the absence — an
optional prop stays unset, a declared default applies, and a required one
fails as a missing property with no body run — so the first iteration asks for a
pull request to exist and the next names the one it created.

How it works

scanner keeps `{undefined}` as an expression → resolveExpressionProps() evaluates
→ a successful `undefined` is skipped → validateProps() sees an absent prop

Both component kinds already pass through resolveExpressionProps() before
validation, so the omission is decided once and neither kind gets a rule of its
own. The scanner change is what makes a literal {undefined} reach that
resolver at all: it used to be read as JSON at scan time, which answered the
question with null before the boundary existed. Only a non-structural
component's prop is affected — a construct evaluates its own operand under its
own contract and keeps its scan-time reading.

Review guide

Start with: specs/executable-mdx-spec.md §6.5, "A successful undefined
is an absence"

Then review:

  1. architecture.md## The ordinary component prop boundary
  2. resolveExpressionProps() in packages/core/src/expand.ts
  3. parseAttributes() in packages/core/src/scanner.ts
  4. Tier EU in packages/core/tests/expression-props.test.ts, and the
    workflow PullRequest across a loop suite

Look carefully at:

  • The omission is before validateProps(), not after: deleting the member later
    would prevent defaults and make a required member look satisfied.
  • Only typeof result === "undefined" is omission. A function, a symbol and a
    serialization that threw are still the failure they were.

What must stay true

  • null is a value, not an absence — enforced by leaving null on the JSON
    round trip; checked by EU5 and J7b.
  • Nested values keep native normalization — enforced by not adding a
    recursive walker; checked by EU7 and J2.
  • Captures and <Let value> still receive the exact operand — enforced by
    capture separation running before the resolver and by bindsByReference();
    checked by CP1, CP8, LET2LET3, J7.
  • A construct's arbitrary-value operand is untouched — enforced by the
    isStructural() guard in the scanner; checked by A-REF2 and IF14.
  • Nothing durable holds undefined — enforced by omitting before anything
    is written and by adding no record for prop resolution; checked by EU8.

How to verify it

  • EU1/EU2 prove a runtime member and a literal take the same path, and fail
    if the scanner's old null projection bypasses the resolver.
  • EU3/EU4 prove ordering: a default is supplied and a required prop fails as
    missing with zero body runs. They fail if omission happens after validation.
  • EU8 proves the journal holds no undefined and no record of prop
    resolution, that a partial replay reconstructs the same props through ordinary
    expansion, and that a completed replay reuses its terminal result and imports
    nothing again.
  • CR22c proves an overriding repository component crosses the ordinary
    boundary while core's capturing <Json> does not.
  • creates without a number, then updates the number it created drives the real
    component, run database, DOFS Workspace, local Git remote and fake GitHub:
    exactly one creation from a normalized number: null and a create natural
    key, then one patch under the created number with an update natural key.

Focused commands, all green:

deno task test packages/core/tests/{scanner,expression-props,capture-props,let,json-component,if,component-registration}.test.ts
pnpm exec tsx --tsconfig tsconfig.node.json --test <the same seven files>
bun test --timeout=300000 <the same seven files>
deno task test packages/workflow/tests/pull-request.test.ts
deno task test packages/core     # 275 passed (2218 steps)

Two mutation runs confirm the coverage discriminates: restoring the old
rejection fails EU1EU5, EU8, CP8, CR22c and the PullRequest loop
test; disabling only the scanner branch fails A-REF4, EU2 and CP8.

Scope

Included

  • A successful root undefined omits an ordinary expression prop, before
    validation and before the JSON boundary.
  • The scanner keeps a non-structural component's literal {undefined} as an
    expression.
  • The architecture.md invariant, the §6.5 contract, the scanner/EU/CR test
    matrix rows and decision 102.
  • One <PullRequest> consumer integration through the shipped provider.

Intentionally unchanged

  • packages/workflow/src/composition/components/PullRequest.ts. Its schema
    already makes number optional and it already normalizes absence to the
    durable null the create request uses. Teaching the provider about
    undefined would hide the language defect behind a second contract.
  • null, evaluation failures, unbound names, non-serializable roots, nested
    JSON.stringify normalization, captures, <Let value>, <Json value>, and
    every structural operand contract.
  • No new journal entry, replay protocol, durable state, scope, resource,
    middleware or public API, and no dependency or lockfile change.

Risks and limitations

  • <Loop max={…}> and <Each in={…}> resolve their one prop through the same
    shared resolver. A runtime expression producing undefined there used to fail
    as "non-serializable" and now fails with the construct's own message (an
    invalid bound, "must resolve to an array"). Both are failures and no test
    asserted the old wording.
  • as={undefined} on an ordinary component is now refused as "must be a string
    literal" rather than through the binding-name check on null — the reserved
    as rule governs it either way.
  • authoredExpressions may no longer have an observable discriminator:
    {undefined} was the only JSON-readable text whose reading and evaluation
    differed, and it is now an expression. Left in place; noted for the Planner.

Scope confirmation

  • Every changed file supports the purpose described above.
  • Unrelated cleanup and formatting changes are excluded.
  • Generated or mechanical changes are clearly identified.
  • The description matches the final diff and test results.

@github-actions

github-actions Bot commented Aug 22, 2026

Copy link
Copy Markdown

PR #541: ✨ Leave out a prop whose expression produced nothing (#537)

10 files, +477 / -45

Scope

🟡 522 lines changed. PRs under 400 receive more thorough review.

Structural

Oxlint structural signals:

  • no-unnecessary-type-assertion ×18: packages/core/src/scanner.ts, packages/core/src/expand.ts
  • no-unused-vars ×7: packages/core/src/expand.ts

Slop

  • packages/core/src/scanner.ts:632// handed at scan time is unchanged.

Oxlint slop signals:

  • no-inferrable-types ×6: packages/core/src/expand.ts

Static Analysis

Oxlint: 43 diagnostics across 2 files (9 rules)
Density: 0.090 violations/added-line

no-unnecessary-type-assertion (18): packages/core/src/scanner.ts, packages/core/src/expand.ts
no-unused-vars (7): packages/core/src/expand.ts
no-inferrable-types (6): packages/core/src/expand.ts
no-unsafe-type-assertion (4): packages/core/src/scanner.ts, packages/core/src/expand.ts
no-base-to-string (4): packages/core/src/expand.ts
no-shadow (1): packages/core/src/expand.ts
restrict-template-expressions (1): packages/core/src/expand.ts
unbound-method (1): packages/core/src/expand.ts
no-implied-eval (1): packages/core/src/expand.ts

Correctness

No extraneous code patterns detected.

@taras
taras marked this pull request as ready for review August 22, 2026 17:38
@taras
taras enabled auto-merge (squash) August 22, 2026 17:38
An ordinary component's expression prop that evaluates successfully to
`undefined` is now absent rather than rejected: the omission happens in the
one resolver both Markdown and function components pass through, before
validation, so an optional prop stays unset, a declared default applies, and a
required one fails as missing with no body run. `null`, evaluation failures,
non-serializable roots, nested normalization, captures and `<Let value>` are
unchanged, and nothing durable holds `undefined`.

The scanner keeps an ordinary component's literal `{undefined}` as an
expression so it reaches that same resolver; a construct's arbitrary-value
operand keeps its scan-time reading.

One authored `<PullRequest number={pullRequest.number}>` inside a bounded loop
now creates without a number and then updates the number it created.
@taras
taras force-pushed the agent/issue-537-omit-undefined-props branch from 6681492 to 7d2e1c5 Compare August 22, 2026 17:51
@taras
taras disabled auto-merge August 22, 2026 17:51
@taras
taras merged commit 0fa1294 into main Aug 22, 2026
14 of 16 checks passed
@taras
taras deleted the agent/issue-537-omit-undefined-props branch August 22, 2026 17:51

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Found 1 redundant comment. Inline suggestions to remove them below.

// expansion is where that is decided (§6.5). Reading the literal here
// would answer with `null` before the prop ever reaches it. A construct
// evaluates its own operand under its own contract, so what it is
// handed at scan time is unchanged.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Redundant comment — restates what the code does.

Suggested change
// handed at scan time is unchanged.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Omit expression props that evaluate to undefined

1 participant