Skip to content

The transformation foundation: internal Identity/Route/Collect model, profile v2, fidelity, functions — 0.5.0 - #29

Merged
ryandmonk merged 15 commits into
mainfrom
feat/internal-model
Aug 7, 2026
Merged

The transformation foundation: internal Identity/Route/Collect model, profile v2, fidelity, functions — 0.5.0#29
ryandmonk merged 15 commits into
mainfrom
feat/internal-model

Conversation

@ryandmonk

Copy link
Copy Markdown
Contributor

The foundation milestone for the ratified three-primitive transformation model, ahead of the T1–T5 representation capabilities. Fifteen commits; 239 tests (was 166 at baseline); every gate green locally, from a fresh clone, and against downstream Studio via a packed install.

What this establishes

  • The engine consumes only the internal model. emitNode reads Identity / Route / Collect; the ten v1 directives desugar in transform/desugar.ts, the only compatibility boundary — structurally enforced by engine-boundary.test.ts (proven non-vacuous by reintroducing a directive read: two assertions fail).
  • Profile v2, the primitive language, with an explicit profileVersion dispatcher: distinct reviewable schemas, one language per document, unknown versions refuse with pathed findings, loaded v2 profiles stamped language: "v2".
  • Load-time validation closes what gate A3 cannot: destinations must be declared (A3 catches omissions, not inventions), write conflicts refuse under the declared order discipline, contradictory and dead spellings refuse, and unimplemented capability spellings (transparent, onto, joinOn, second children route) are refused, not reinterpreted.
  • Derived sub-component coverage, fatal for v2 at both entries, report + --strict-coverage for v1. Measured before enforcement: pinned shadcn / astryx / acme all zero-unresolved.
  • The transformation ledger (EmitSurfaceResult.fidelity) + --strict-surface (exit 5, configurable classes, never alters emitted bytes).
  • Declared catalog functions, fail-closed on every call: undeclared names refuse at A3, arg schemas validate, $ref refuses at load, nothing executable can be expressed.
  • scaffoldProfile emits honest v2 drafts: mechanical structure from evidence, every sub-component an explicit unresolved decision that transform() refuses until the author makes it.
  • The reproducible production-v3 evaluation fixture (eval/): the pinned v3 contract (sha256-verified copy at dspack 48643ff) plus a mechanically derived measurement profile. Honest result, superseding the lost /tmp record: 106 subs under 19 mapped compounds — 20 resolved (card 5, table 7, alert-dialog 8), 86 unresolved. The 86 are deliberate open decisions and the measure of the T1–T5 work; the fixture is labelled NOT-Studio's-profile and ships outside the npm package.

Compatibility

  • All 15 v1 emission pins byte-identical since they were pinned against the pre-rewrite engine (git diff 15216d4..HEAD on the snapshot file is empty).
  • The keystone: the entire shadcn profile re-spelled in v2 emits byte-identically — same catalog digests both A2UI versions (7c1b72dfd83dde14 / 2dde439bbf643834), same messages and warnings across all directive surfaces and worked examples.
  • The only intentional output changes are the four scaffold cases, re-pinned with in-test explanations: astryx-scaffold digests now describe a scaffold that emits; shadcn-scaffold's pinned behaviour is the per-sub refusal. No golden was re-baselined to accept drift.
  • Downstream Studio, against the packed 0.5.0 tgz (registry dep untouched, contract pin in place): committed catalogs rebuild byte-identically; 155 unit tests, typechecks, both static exports, exhibit Playwright 106, composer agent-mode + a11y 42 — all green, identical to the 0.4.1 baseline.

Hardening

An adversarial 17-agent review/verify pass over the increment confirmed 13 findings (10 distinct defects, all in the new v2/ledger surface, none in v1 paths) — gate side doors, blessed-but-unemittable spellings, ledger honesty gaps, unvalidated function args. All ten fixed with named regression tests in review-findings.test.ts.

Doctrine now documented (docs/PROFILES.md)

Ordering is explicit because object-key order and warning order are observable in deterministic artifacts — goldens are never re-baselined for incidental sequence changes. Compound consumption resolves in document order as one traversal: tree position, not declaration order, decides.

Boundaries held

No T1–T5 capability is implemented or reachable (refusal-tested). Neither contract pin moves. Studio is not migrated to v3. Version is proposed as 0.5.0 (capability boundary under 0.x; zero breaking v1 behavior); not published.

🤖 Generated with Claude Code

The emitter is about to be rewritten onto a smaller internal representation
(Identity / Route / Collect), with the v1 surface-plan directives desugaring
into it. That refactor is only legitimate if it is invisible, and nothing in
the suite could currently prove it: out/ carries committed catalogs, but no
test compares them, so every description in a catalog could change silently.

So pin the artifacts themselves. 19 digests across the reachable matrix:

- shadcn x {TypeScript profile, JSON profile} x a2ui {0.9.1, 1.0}, with the
  two spellings asserted equal — profile-as-data is only credible if
  authoring a profile as a file changes nothing;
- astryx and shadcn scaffolded profiles x both A2UI versions;
- the hand-authored surface, every worked example in the contract, and seven
  hand-built surfaces that reach the directives the examples miss
  (textChildProp, actionProp, textProp, subFlatten transparent+asText,
  subTable, subText+subButtonText, the Column wrap, id deduplication).

Warnings are hashed alongside messages: a refactor that quietly stopped
warning about a flattened compound would otherwise pass.

Verified the gate is not vacuous by injecting a one-word change into a single
warning message — two digests moved and the suite failed.
…ing into it

Ten surface-plan directives accumulated one per collision. Measured, that
vocabulary does not generalize: only four are used by both shipped profiles,
the second emit target reuses none, and the scaffolder can reach two. They are
point-fixes for one collision — shadcn's compound composition meeting A2UI's
fixed catalog vocabulary — not a vocabulary for projecting design systems onto
protocols.

Underneath they answer three questions: does this node emit, and as what
(Identity); where does each piece of its subtree land (Routing); how does a
repeated structure become an array (Repetition). This adds that as types, and
desugars all ten directives into it. The v1 spellings keep working and keep
emitting byte-identically; past desugar.ts the engine sees only primitives.

Three refinements came from implementation contact, each a discovery rather
than an escape hatch:

- a route's source is an ordered Selector[], not one selector. The audited
  label lift ("the sub's label, else the first direct text under it") is a
  fallback chain, and ordered selectors express it without a special case;
- a selector names a SET of subs, because a table row accepts a header-cell
  wherever it accepts a cell. One selector naming both is honest; two collects
  racing for one destination is not;
- first-write-wins becomes a uniform write discipline instead of `??=`
  scattered per directive. Only textProp/textChildProp overwrote, and that
  asymmetry is now an explicit field rather than an implicit code path.

subTable — the most table-shaped directive, 10 required keys used once in the
whole corpus — becomes two nested collects. Its fixed three-level depth is
nesting, its literal `{ cells }` record key is an item field name, and its
`drops` map is ordinary drops with authored reasons.

Sub-coverage becomes derivable. `subCoverage` is authored prose the engine
never reads, enforced only by a test in another repo; deriveSubCoverage()
reproduces it from the model. Building it surfaced a case worth keeping:
alert-dialog's content/header/footer are named by no directive at all — they
are walked through by the subtree consumer on the way to a named sub. A
derivation counting only named subs would have called them missing while the
emitter handled them correctly.

Totality is asserted directly, because the failure mode of a desugaring layer
is not mistranslation but silent omission: a directive that falls through
still type-checks, still loads, and simply stops taking effect.
emitNode read v1 directives directly. Now it reads Identity / Route / Collect,
and desugar.ts is the only compatibility boundary. All 19 pinned artifacts are
byte-identical across both A2UI versions; no golden was re-baselined.

Two orderings had to become DECLARED data rather than emergent behaviour,
because both are observable in hashed artifacts and both were previously
decided by the shape of a method:

WriteOrder — when each route and collect writes, and therefore object key
order. A route-driven engine resolves in declaration order, which is not v1's
method order: it emitted `caption,columns,rows` where declaration order gives
`columns,rows,caption`.

Diagnostics — an explicit (tree path, band, phase, rule, seq) sort key, with
phases derived from where v1 actually pushed each warning rather than invented.
The band is what makes a parent's post-child diagnostics sort after its whole
subtree; a flat (node, phase) sort would hoist the wrap warning above the
children it wraps. The engine collects diagnostics in any order and sorts once.

One finding worth recording. v1 resolves all consuming directives in a SINGLE
document-order walk, so tree position — not directive order — decides which
sibling claims a destination: an alert-dialog writes triggerLabel before title
because the trigger precedes the content. Resolving each consuming route
independently reproduced the same values in a different key order.

That looked at first like a v1 implementation accident to be preserved
grudgingly. It is not. Walking a compound once and distributing what you find
is what consuming a compound MEANS, and the alternative — letting the order
two directives happen to be authored in decide which of two siblings wins — is
the accident. So consuming routes are resolved as one group, by position.

Also fixes a warning the first cut of the engine dropped entirely: the
subContent composition-flattened casualty had no home once applySubContent was
gone. The byte gate caught it.

Structural boundary test: the engine source may not name any of the ten legacy
directives, nor reach into `surfacePlan` at all. Verified non-vacuous by
reintroducing a `plan.surfacePlan?.subTable` read — two assertions failed.

166 tests green (was 140); build, check:sync, test:pack and test:json-render
all pass.
…ble profile

`subCoverage` is authored prose the engine never reads, enforced only by a test
in another repo. The internal model can derive the same facts — but a gate that
looks correct and quietly breaks valid legacy behaviour is worse than the prose
it replaces, so measure before refusing anything.

Measured across all four reachable contract/profile pairs:

  pinned shadcn v2.3.0    20 subs under 3 compounds   0 unresolved
  astryx v0.1.4            0 subs (props-based)       0 unresolved
  acme v0.1.0              4 subs under 1 compound    0 unresolved
  production shadcn v3.0.0 29 subs under 6 compounds  1 unresolved

Zero false positives on every profile that ships. The single finding is
`radio-group -> radio-group-item` in the production profile — which is exactly
the measured T2 failure (a sibling family A2UI models as data on the parent,
not as children) that motivated the collect primitive. The derivation found
the known gap on its own, without being told it existed.

So making unresolved coverage fatal at load would be safe for every v1 profile
today, and would correctly refuse the production profile until T2 lands. That
decision stays separate and unmade here; this is the evidence for it.

Reports only, exits 0. Astryx is included precisely to show a zero: it declares
no sub-components at all, so the entire sub-* directive family is dead by
construction for props-based contracts.
…sion dispatcher

v2 is the first profile language that authors the internal model directly:
Identity / Route / Collect in a closed string syntax, instead of the ten v1
directives named after the collisions that produced them.

  routes:   { from: ["sub(alert-dialog-title).text"], to: "prop:title" }
  collects: { of: ["table-body"], into: "prop:rows", shape: "records",
              field: "cells", row: ["table-row"], cells: ["table-cell"] }
  subs:     { "card-header": "transparent", "card-title": { "asText": "h3" } }

The dispatcher is explicit: loadProfile reads profileVersion, validates "1"
against profile.v1.schema.json exactly as before (frozen), validates "2"
against the new profile.v2.schema.json and then PARSES every plan's surface
block — a v2 profile that loads is a profile the engine can emit, with grammar
errors surfacing at load as pathed findings rather than mid-emission. Loaded
v2 profiles are stamped language:"v2"; unknown versions refuse naming the
supported set; no document is ever silently interpreted under the wrong
version. One language per document: each schema refuses the other's syntax.

The grammar is deliberately closed — no expression language, no JSON paths,
no predicates, no computed destination names, no cross-instance routing, and
the unimplemented capability spellings (plan-level transparent, onto, joinOn,
a second children route) are refused, not reinterpreted; each arrives with
its capability class T1-T5 as a grammar addition.

Two rules the parser derives that v1 made authors imply:

- Write order is a function of what a route reads and writes, never authored
  and never declaration position. A same-phase double-claim on one
  destination refuses; distinct-phase layering is legal because the outcome
  is fully determined (v1's Table relies on exactly this: a passthrough prop
  wins over the collect harvest because Verbatim runs first).
- consumesSubtree is derived from the presence of consuming routes/collects,
  and children-routing plus subtree consumption is refused as contradictory.

Also threads Collect.flatten as declared data, replacing the origin-string
sniffing (`origin.endsWith(".header")`) that could never have worked for
authored v2 collects, and drops the dead dropReason helper.

The keystone proof: the ENTIRE shadcn profile re-spelled in v2 loads through
the dispatcher and emits byte-identically to the v1 profile — same catalog
digests as the pinned byte gate (both A2UI versions), same messages and
warnings for all seven directive surfaces and both worked examples. v2 is a
new spelling, not a new emitter.

198 tests green (was 166): +29 profile-v2 (byte-identity, dispatcher,
refusal matrix), +1 schema-mirror gate for the v2 pair, +2 browser-boundary
picking up the new modules. All 19 pinned digests untouched.
…rcement

parse-v2 checks what a plan can be wrong about on its own; this adds what it
can only be wrong about AGAINST A CONTRACT, at both entries (transform,
emitSurface), before anything emits — v2 profiles only, because v1 behaviour
is frozen:

- every sub(x) a route, collect, or disposition names must be a declared
  sub-component of the plan's contract component;
- every self.props.X must be declared — by the contract as an ordinary prop,
  or by the plan's own structural block as a SAME-NAMED passthrough. The gate
  initially demanded contract declaration outright and correctly refused the
  v2 Table re-spelling, which is how the v1 semantic surfaced: passthrough
  props (caption/columns/rows) are A2UI structural slots authored directly on
  surface nodes, declared by the plan, deliberately absent from the contract.
  Reading an undeclared prop into a RENAMED destination declares nothing
  anywhere, and still refuses;
- derived sub-component coverage must be complete: every declared sub routed,
  collected, absorbed, transparent, re-identified, or dropped with a reason —
  anything else refuses per sub, naming the decision to make.

v1 gets the report path instead: computeCoverage now derives sub-coverage
rows from each mapped compound's transformation model (the authored
subCoverage prose stays what it always was — documentation the engine never
reads). A resolved sub is a mapped row carrying its classification; an
unresolved sub is an unclassified row plus a warning plus a cannot-represent
fidelity entry — the exact triple the existing --strict-coverage exit already
keys on, so the flag now covers subs with no new plumbing.

The measured gates behind making this fatal (scripts/sub-coverage-report.mjs,
the 2aac076 record): pinned shadcn v2.3.0, astryx and acme all derive zero
unresolved subs — re-proven here as a regression pin — so enforcement refuses
no shipped artifact, only future profiles with genuine gaps. Which is the
point.

206 tests green (+7): fully-resolved passes; unresolved sub refuses naming
the sub; undeclared sub refuses; renamed-undeclared-prop refuses while the
passthrough shape passes; emitSurface runs the same gate; v1 with a stripped
subFlatten still transforms with five card.* unclassified rows; shipped
profiles derive zero unresolved. All 19 pinned digests untouched.
…ver it

The surface path could refuse and it could warn, but it could not ACCOUNT.
All thirteen fidelity sites lived in mapping.ts — catalog-level, what a
profile can carry in principle. What one emission actually did to one surface
was scattered across advisory warnings with no classes and no strict mode, so
a lossy projection could ride to production as a note.

Now every transformation reports itself: source (the surface path plus
selector), destination, originating profile rule (route/collect origin,
propMap key, or the synthesis), kind (projected, moved, lifted, flattened,
synthesized, wrapped, dropped, deduplicated), and fidelity class reusing the
existing taxonomy. The ledger rides EmitSurfaceResult.fidelity — additive
alongside the byte-frozen messages and warnings, ordered by the same declared
(path, band, phase, rule) keys as the diagnostics, so its sequence is contract
rather than accident.

Classifications worth naming: the audited label lift is maps-cleanly (content
preserved, position changed — relocation is not loss); a valueMap HIT is
maps-cleanly while a fall-through to the declared default is
synthesis-defaults (the distinction the old code could not see because it
collapsed both into one ?? expression); compound consumption records each
harvest as a clean move AND one lossy flatten for the structure that did not
survive; id deduplication is maps-cleanly.

--strict-surface makes configured classes FATAL (exit 5): bare means
lossy,cannot-represent; --strict-surface=lossy,synthesis-defaults tightens it;
an unknown class is a usage error. Without the flag the ledger still prints —
loss is never invisible, the flag only decides whether it is also fatal.

Also restores out/ artifacts a test run had regenerated: the committed
out/catalog.v0_9_1.json is STALE relative to the shipped profile (its
Table.rows description still names a status column the profile no longer
emits) — a pre-existing housekeeping gap deliberately NOT fixed here, flagged
for its own commit. Fidelity tests write to a scratch dir, not tracked out/.

215 tests green (+9). Byte gates untouched: same messages, same warnings,
same 19 digests — the ledger is a new channel, not a change to the old ones.
The emitted catalog always carried A2UI's check vocabulary — Checkable,
CheckRule, FunctionCall — but our inlined FunctionCall had dropped the
upstream anyFunction constraint (inline-defs.ts records the drop), and no
catalog ever emitted a functions section. Net effect, measured: an instance
could `call` any name at all and gate A3 passed it silently. Declared checks
resolved to nothing, invisibly.

A correction to this milestone's own premise, from measurement: the dspack
CONTRACT declares no `checks` anywhere — its `constraints` are prose usage
guidance ({context, rule, severity}), correctly recorded as knowledge-layer
casualties. The hole was never contract-side; it was the unconstrained
FunctionCall plus the inexpressible functions section, both emit-side. Fixed
where it actually is.

A v2 profile now declares its function vocabulary as data:

  "functions": {
    "matchesRegexp": { "description": "...", "returns": "boolean",
                       "args": { "type": "object", "properties": { ... },
                                 "required": ["pattern"] } } }

Emission: the catalog carries the functions section (both A2UI versions —
including through the v1.0 literal rebuild, which first dropped it);
FunctionCall.call is constrained to the declared names; and the whole call
object must match one declared branch, so a call with missing required args
refuses too (the branch requires `args` exactly when the declared schema
carries required params — the first cut emitted the arg schemas as decoration
that nothing referenced, and the test caught both that and the 1.0 drop).

Fail-closed both ways: an undeclared function call fails A3; declaring is the
only way to permit. Definitions are declarative schemas — implementations
live in renderers, exactly as A2UI intends; nothing executable can enter the
profile or the catalog. v1 documents cannot declare functions (the field is
v2 language; the v1 schema refuses it), and profiles that declare none emit
byte-identically to before — all 19 pinned digests and the v2 re-spelling
byte-equivalence stand untouched.

222 tests green (+7).
…undecided subs

The old scaffold had two measured failures, and they were opposites. For
props-based contracts it emitted no surface plan at all — catalog-valid,
surface-unusable, a hard throw on the first child, for exactly the idiom the
docs call typical. For compounds it invented the judgment it should have
surfaced: every sub auto-classified transparent-or-asText from
acceptsChildren, which is precisely the guess that is wrong for a repeated
item like radio-group-item (the measured T2 gap). Too little in one place,
too much in the other.

The rewrite derives instead of guessing, and emits the v2 primitive language:

- children route + ChildList slot where the contract OBSERVABLY supports
  children: the component appears with child nodes in a worked example (the
  example corpus is the contract's own definition of expressible — that is
  measurement, not judgment), or declares sub-components. Provenance lands in
  the synthNote, naming the example.
- text route + slot where a worked example shows the component carrying text.
- sub-components are NOT decided. Each is an explicit unresolved decision:
  in notes, under x-scaffold.unresolved, and absent from the surface block —
  which the v2 contract gate refuses to transform, per sub, until the author
  decides. The refusal IS the deliverable: a work checklist with pathed
  findings, not a pretense that judgment was exercised.

The authoring loop closes and is tested: scaffold shadcn -> loads -> transform
refuses naming card/table/alert-dialog families -> resolve ONE compound's subs
-> its findings disappear while the others still refuse.

Two scaffold digests moved INTENTIONALLY and two became refusal assertions,
each explained at the pin: astryx-scaffold digests now describe a scaffold
that emits (children slots the old one omitted); shadcn-scaffold's pinned
behaviour is now the refusal itself, because the old digests pinned invented
sub-classifications. The 15 v1-profile pins are untouched.

223 tests green (+1 authoring-loop). Build, pack, json-render all pass.
…the v2 surface

A 17-agent review/verify pass (three lenses, every finding independently
refuted-or-confirmed by execution) confirmed 13 findings collapsing to ten
distinct defects — all in the new v2/ledger surface, none in the byte-frozen
v1 paths. All ten fixed, each with a regression test named for its finding:

GATE SIDE DOORS. validateProfileAgainstContract iterated only
profile.components and skipped surface-less plans, so a mapped compound in
`synthesized` escaped every check (coverage even fabricated "transparent
grouping" rows for its subs), and omitting the surface block transformed
fail-open while `surface: {}` refused — the gate flipped on the presence of
an empty key. Now both collections validate and a missing block is exactly
as unresolved as an empty one.

BLESSED-BUT-UNEMITTABLE. {drop: reason} on a non-collecting plan passed the
gate then crashed emission with a false unknown-component message; drops are
now warn-and-discard in the child rewrite, same semantics as the collect
path, authored reason on record. sub(x).firstText as a route's FIRST
selector parsed, derived Consume, and then resolved nothing — consuming the
subtree while writing nothing; it now refuses at load as what it is, a
fallback selector. A children route sharing its destination with a value
route had the later phase clobber the earlier one through emitChildren's
direct assignment, orphaning the synthesized Text in the hashed artifact;
the children destination is now exclusive in both directions at load. The
one-sub-one-disposition check read scalar cells at the wrong nesting level
(dead disjunct) — cells+disposition contradictions now refuse.

SILENT SUPPRESSION. Any route reading self.props.X disabled the propMap
projection of X even when writing a differently-named destination —
routedVerbatim now requires the same-named passthrough shape, so a renamed
read and the projection both land.

LEDGER HONESTY. write() reported nothing, so first-wins-skipped harvests
ledgered as clean moves — it now returns whether the write landed, and a
discarded harvest ledgers dropped/lossy. Unrouted node text was the one
unledgered loss channel (v1 discards it silently; warnings stay byte-frozen)
— it now gets a dropped/lossy entry. asText that destroys nested component
structure recorded only synthesis-defaults — it now records flattened/lossy
naming the destroyed components, mirroring the cell path.

FUNCTIONS. Declared arg schemas were emitted unvalidated: a dangling $ref
slid past gate A1 (ajv defers ref resolution) and threw raw out of gate A3 —
pass-or-crash, never a finding. Function args must now be self-contained;
any $ref refuses at load with its path.

One refuted finding, recorded for honesty: the committed out/ reports are
stale (true) but nothing consumes them and the npm package ships dist+src
only — flagged as housekeeping, not fixed here.

234 tests green (+11). All 19 byte pins, the v2 keystone byte-equivalence,
build, pack and json-render gates unchanged.
…the lost /tmp measurement

The prior production measurement (2aac076: 29 subs under 6 compounds, 1
unresolved radio-group-item) was generated from a hand-built 15-plan profile
that lived only in /tmp and did not survive a reboot. Per the release
requirement, that number is no longer a current gate; this replaces it with
evidence that cannot be lost and cannot quietly drift.

eval/ carries three durable pieces, none of which ship in the npm package
(files: dist,src only) and none of which is Studio's profile:

- shadcn-v3.dspack.json — the production contract at dspack 48643ff, a PINNED
  copy (sha256 ea87346f…, verified on every rebuild) so the fixture measures
  one corpus, stably;
- build-eval-profile.mjs — the mechanical derivation: scaffoldProfile(v3)
  plus the six shipped plans transplanted in the v2 re-spelling that
  profile-v2.test.ts byte-proves equivalent (now extracted to
  src/transform/shadcn-v2-respelling.ts so test and builder share one
  source). Zero casualties declared — the representation analysis showed some
  old casualty reasons were wrong (dialog is representable, T4), and
  classifying families as casualties here would undercount the very
  unresolved surface the fixture exists to measure;
- shadcn-v3.eval.profile.json — the committed fixture, with an x-eval label
  stating purpose, provenance, and that downstream Studio still consumes the
  pinned v2.3.0 contract with the shipped v1 profile.

The honest result SUPERSEDES the old number rather than reproducing it:

  106 sub-components under 19 mapped compounds
   20 resolved  — exactly the transplanted families (card 5, table 7,
                  alert-dialog 8; same class distribution as the pinned
                  v2.3.0 measurement, since v3 kept those families intact)
   86 unresolved — the lost profile's nine extra compound plans carried
                  judgment that is gone; every decision they had made, and
                  every one they had not, now shows as open

Reported, not shaped: the 86 are deliberate, labelled as such in the fixture,
and are the measure of the T1–T5 work ahead. The fatal v2 coverage gate
refusing this fixture against the v3 contract — 86 pathed findings — is
pinned as a test: the gate doing on the production corpus exactly what it
was built to do.

The reproducibility gate is also a test: rebuilding from committed inputs
must equal the committed fixture byte-for-byte; the coverage report script
now reads eval/ instead of the dead /tmp paths.

239 tests green (+4 eval, +1 browser-boundary picking up the respelling module).
Not a breaking v1 change — every v1 profile loads and emits byte-identically,
proven by the pinned byte gate. 0.5.0 marks a capability boundary:

- profile language v2 (Identity / Route / Collect authored directly), with an
  explicit profileVersion dispatcher and one-language-per-document schemas;
- the internal transformation architecture: the engine consumes only the
  primitive model; v1 desugars at the only compatibility boundary;
- load-time validation of selectors, destinations, write conflicts, and
  derived sub-component coverage (fatal for v2; report + --strict-coverage
  for v1);
- new public fidelity output (EmitSurfaceResult.fidelity) and --strict-surface;
- declared catalog functions, fail-closed on every call;
- scaffoldProfile emitting honest v2 drafts with explicit unresolved decisions;
- the reproducible production-v3 coverage evaluation fixture under eval/.

README now presents v2 as the authoring language and v1 as compatibility
syntax, matching docs/PROFILES.md.
Copilot AI lite review requested due to automatic review settings August 6, 2026 22:35

Copilot AI 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.

Pull request overview

Establishes the “transformation foundation” by introducing an internal surface transformation model (Identity/Route/Collect), adding profile v2 as a new authoring language with explicit version dispatch, and layering in contract-facing validation plus an emission fidelity ledger (including a --strict-surface CLI gate).

Changes:

  • Add profile v2 (schema + runtime parsing) and explicit profileVersion dispatch in loadProfile, including v2-only features like declared catalog functions.
  • Refactor the surface emitter to consume only the internal model (v1 directives desugar into it), and add ordered diagnostics + a per-emission fidelity ledger.
  • Add contract-facing validation for v2 profiles (fail-closed) and derived sub-component coverage reporting (fatal for v2, reporting for v1), with extensive regression/compatibility tests.

Reviewed changes

Copilot reviewed 35 out of 36 changed files in this pull request and generated 2 comments.

Show a summary per file
File Description
src/validate-v2.test.ts New tests covering v2 contract validation behavior and ensuring v1 remains report-only.
src/types.ts Adds SurfaceFidelityEntry to represent per-surface transformation ledger entries.
src/transform/validate-v2.ts Introduces v2-only contract-facing validation gate (ProfileContractError).
src/transform/shadcn-v2-respelling.ts Provides v2 respelling for shipped shadcn surfaces as mapping evidence.
src/transform/scaffold.ts Updates scaffoldProfile to emit v2 drafts based on observed examples and leave subs unresolved.
src/transform/profiles.ts Extends Profile with v2 concepts (surface, functions, language).
src/transform/profile.v2.schema.json Adds the v2 JSON schema (primitive language, functions, surface grammar).
src/transform/profile-schema-v2.ts Runtime mirror of the v2 JSON schema for browser-friendly loading.
src/transform/profile-load.ts Implements explicit profile version dispatch, v2 surface parsing at load, and function-args $ref refusal.
src/transform/parse-v2.ts Parses v2 surface syntax into the internal model with static validation.
src/transform/model.ts Introduces the internal Identity/Route/Collect types and derived coverage utilities.
src/transform/mapping.ts Derives sub-component coverage from the internal model and emits coverage+warnings for gaps.
src/transform/index.ts Runs v2 contract validation before mapping/emission.
src/transform/emit.ts Emits declared catalog functions and restores FunctionCall constraints based on declared vocabulary.
src/transform/desugar.ts Translates v1 directives into the internal model; adds surfaceModelOf dispatcher + caching.
src/targets/a2ui/surface.ts Refactors surface emission to read only the internal model; adds fidelity ledger integration.
src/targets/a2ui/diagnostics.ts Adds deterministic ordering for warnings/fidelity via explicit sort keys.
src/surface-fidelity.test.ts New tests validating fidelity completeness, honesty, determinism, and --strict-surface.
src/schema-mirrors.test.ts Extends schema mirror tests to cover v2 schema parity.
src/review-findings.test.ts Regression tests for previously identified v2/ledger issues.
src/profile-v2.test.ts Proves v2 respelling is byte-identical to v1 for messages/warnings and validates dispatcher/refusals.
src/profile-load.test.ts Updates scaffold tests to v2 and adds “resolve one compound and it emits” authoring loop test.
src/functions.test.ts Adds tests proving declared functions reach the catalog and constrain FunctionCall validation.
src/eval-fixture.test.ts Adds reproducible evaluation fixture tests for production v3 contract measurement.
src/engine-boundary.test.ts Ensures the surface engine never mentions v1 directives; adds ordering/meaning tests.
src/desugar.test.ts Ensures v1 desugaring is total and preserves directive semantics.
src/cli.ts Adds --strict-surface support and fidelity gating/printing.
src/byte-neutral.test.ts New byte-neutrality gates/pins across catalogs and surfaces (including scaffold intentional moves).
src/snapshots/byte-neutral.test.ts.snap Snapshot pins for byte-neutral surface digests.
scripts/sub-coverage-report.mjs Adds measurement script for derived sub-component coverage (report-only).
README.md Updates docs for v1/v2 profile-as-data, schemas, and authoring guidance.
package.json Bumps version to 0.5.0 and normalizes description punctuation.
eval/build-eval-profile.mjs Adds reproducible eval-profile builder with pinned contract hashing.
docs/PROFILES.md Documents profile v2 language, ledger/strict-surface, and ordering doctrine.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +116 to +120
issues.push({
path: at,
message: `self.props.${prop} reads a prop '${plan.dspackId}' does not declare in this contract, and it is not a same-named structural passthrough`,
});
}
Comment thread src/transform/scaffold.ts
Comment on lines +194 to +202
if (childrenBecause) {
structural.children = {
schema: CHILD_LIST,
description: "Child component IDs, in order.",
synthNote: `Scaffolded: ${childrenBecause}.`,
};
required.push("children");
routes.push({ from: ["children"], to: "slots:children" });
}
… documented guarantee

The release blocker. Gate 1's doc claimed "compilation fails on any
unresolved $ref, proving the catalog is a valid, fully self-contained
schema" — false, because ajv defers ref resolution past compile(). A
structural slot schema carrying {$ref: "#/$defs/DoesNotExist"} produced
either all-gates-PASS (validateCatalog with no surface — a public export) or
a raw MissingRefError thrown out of gate 3's un-guarded getSchema on the
main transform path. Pass-or-crash, never a finding. The functions channel
had just been closed at load; structural schemas shared the class.

Fail-first, captured before the fix: three tests escaped as uncaught
"can't resolve reference #/$defs/DoesNotExist" errors — including with the
DEFAULT surface — and the hand-built broken catalog returned pass=true from
validateCatalog.

The fix is one coherent boundary, not a per-channel patch: gate 1 now walks
the finished catalog and resolves every internal $ref itself (JSON Pointer
resolution incl. ~0/~1 escapes and array indices), failing with the ref AND
the JSON path of the offending $ref keyword. That covers structural slot
schemas, function args if refs ever reappear, and any future authored-schema
channel, at the one place the complete catalog exists — version-exact, so a
ref to a def only one A2UI version carries is judged against the catalog
actually being emitted. Gate 3 skips-as-failed when gate 1 found dangling
refs, and is belt-and-braces try/caught so no raw ajv error can ever escape
validateCatalog again.

Deliberately NOT restricted: legitimate internal refs (the shipped profiles'
ComponentId / Action / DynamicString / ChildList / CheckRule structural refs,
and nested pointers like #/components/<Name>) validate exactly as before —
byte gates prove zero output change. Load-time stays unrestricted for
structural schemas because their valid targets are emit-time- and
version-dependent; the catalog gate is the honest place.

Also surfaced by the new packed-install smoke, and fixed: profileSchemaV2
was not exported from the package index — a real public-surface gap for a
release advertising v2. Now exported alongside ProfileContractError,
ProfileParseError, SurfaceV2 and SurfaceFidelityEntry.

Pack-test now proves the whole 0.5.0 surface SURVIVES PUBLICATION, from an
external install of the tarball: v2 keystone byte-identity, unknown-version
refusal, malformed selector/destination refusals, functions $ref refusal,
structural dangling-$ref as a pathed gate finding, the fidelity ledger,
--strict-surface (exit 5 on lossy; emitted bytes identical with and without
the flag), and the browser-safe loader chain in dist.

244 tests green (+5). All byte pins and the keystone untouched.
@ryandmonk
ryandmonk force-pushed the feat/internal-model branch from 1d7154f to 1cbaf8f Compare August 7, 2026 00:01
@ryandmonk
ryandmonk merged commit 84031a5 into main Aug 7, 2026
1 check passed
@ryandmonk
ryandmonk deleted the feat/internal-model branch August 7, 2026 14:20
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.

2 participants