Skip to content

🧪 Evaluate Configliere's proposed route API against the real xmd grammar (#796) - #802

Draft
taras wants to merge 4 commits into
mainfrom
spike/issue-796-configliere-route-api
Draft

taras wants to merge 4 commits into
mainfrom
spike/issue-796-configliere-route-api

Conversation

@taras

@taras taras commented Sep 10, 2026 •

Copy link
Copy Markdown
Owner

Not for merge. This is the evaluation spike issue #796 asks for. It exists
to measure what Configliere's proposed route API can carry, and its conclusion
is a recommendation rather than an adoption. It changes no public grammar,
implements no part of #173, and leaves no compatibility layer behind.

Closes nothing. Evaluates bombshell-dev/configliere#30
and then bombshell-dev/configliere#32.

Read the addendum for the current
state.
Upstream has since closed all three of the API blockers this spike
reported. The recommendation in this first half was superseded by round two,
and round two's remaining design question was superseded by 658a271. What is
left is one distribution problem. Everything else here still describes the
port.

Why

Configliere PR #30 proposes replacing the released program()/commands()/object()/field()
parser with a statically typed entry-point router. #796 asks whether the real
xmd command definitions and dispatch boundary can be expressed through it, and
what that would cost. The only way to answer is to write it.

The artifact this evaluates

Preview https://pkg.pr.new/configliere@30
Manifest configliere@0.4.0-pr+dbd7d191ab8aff37c71cc03bc4688ad372ec7e23
Commit fb52bc6567dadd2528e684b123d013eb9b701ace
Pinned guide README at that commit
Tarball SHA-512 7542739864076c30f3fc0657d0b2678c2e90d2317cf1e9ad5bbbabb457ca8ad7365f232eef209b0e0b3a68532cc3447491d0ccf2a9c3b153db1ce7cc1df477a9

pnpm-lock.yaml records resolution: {tarball: https://pkg.pr.new/configliere@30}
with version: 0.4.0-pr and no integrity hash — pnpm records none for an
HTTPS tarball, and the recorded version drops the +dbd7d19… build metadata.
The URL is the only identity in the lock and it is mutable upstream, so the
SHA-512 above is this evaluation's own record of the bytes it consumed.

Recommendation

Request upstream changes and repeat. Three blockers stand between this
preview and an adoption decision. None of them is an XMD defect, and none is
fixable on this side.

  1. The preview cannot be consumed by Deno. An HTTPS tarball dependency is
    Not implemented scheme 'https'. The only configuration that gives Deno both
    the runtime and the types is a links override onto an unpacked mirror, and
    that is refused by every --node-modules-dir=none task the repository runs:
    build:web, build, verify, verify:clean. Publishing the preview
    somewhere Deno resolves — JSR, or a real npm prerelease tag — removes this
    entirely.
  2. A repeatable option cannot be expressed at all. Not by the ordinary
    reader and not by an XMD-written one.
  3. The general dynamic element is not exported. checkpoint() adds values
    and nothing else, so a document's generated --props-* options still have to
    be lifted out of argv before parsing — which is the phase the dynamic API
    exists to remove.

Everything else the port met, and the static shape is genuinely better than what
it replaces.

What changes

Before: one program() with a flat commands() map; the parser ignores
every option it does not define; xmd.parse() is called four times in different
places; the command is a string on a config object; props.ts reads per-source
provenance out of the parser's inspect().

After: a route tree whose entry points are addresses. parse() returns an
intent, dispatch narrows on intent.method and intent.route, and each handler
reads a model that belongs to its own route. Observable CLI behaviour is
unchanged except where this description says otherwise.

How it works

argv → retired-command refusal → eval lift → first-token classification
     → props phase (document inspected, --props-* lifted)
     → parse(definition, {argv, values}) → intent
     → dispatch on intent.route → the existing run/plan/test/syntax/upgrade/workflow operations

packages/cli/src/cli-route.ts owns the immutable definitions, the synchronous
parse driver and the presentation. cli.ts owns I/O, Effection lifetime and
every refusal that has an accepted wording. No Configliere callback performs
I/O; no generator uses async/await.

Two definitions, not one. Configliere selects a child route from any
unclaimed word in the segment; xmd selects a command only from the first
token, and a word anywhere else is a document reference. commandToken()
chooses between the full tree and a children-less shorthand root, which is what
keeps xmd --raw run -e '# Probe' naming a document called run.

Review guide

Start with: packages/cli/src/cli-route.ts

Then review:

  1. The route tree and commandToken()/definitionFor — the first-token rule.
  2. dispatch() in packages/cli/src/cli.ts — the narrowing and the order of
    refusals before a parse failure is reported.
  3. liftArgs()/readRepeatedOption()/readDocumentArguments() — everything
    the grammar cannot bind, and why.
  4. resolveProps() in packages/cli/src/props.ts — the precedence walk that
    replaced the released parser's inspection.

Look carefully at: the six places where an accepted message had to be
restored ahead of the stock diagnostic. Each is a case where the proposed API
refuses something the released one ignored.

What must stay true

  • A command is named only by the first token. Enforced by commandToken()
    and by parsing a children-less definition otherwise; checked by CFE1 and by
    inline-cli.test.ts IE31.
  • Only the root answers --version, and it writes the bare version.
    Enforced by declaring version() on the root alone; checked by CFE5,
    cli-help.test.ts CH4 and upgrade-cli.test.ts UC6.
  • -- still protects a positional. Enforced by reading intent.literals;
    checked by CFE6, stdin-cli.test.ts SI15 and workflow-cli.test.ts WFC13.
  • Every accepted refusal keeps its wording and its order. Enforced by
    running each command's own scan before the parse failure is reported; checked
    by UC6/UC7/UC11, SD12, CA7, DT11 and WFC8.
  • The document is inspected before its generated options are read. Enforced
    by beforeProperties() truncation plus extractPropsArgs; checked by
    props-cli.test.ts and props-sources.test.ts.

How to verify it

  • packages/cli/tests/configliere-route-api.test.ts proves the definitions and
    the intent typing (CFE1–CFE8, CFE11, CFE13). It is a production-boundary
    test: it exercises the real tree, not one written for the occasion. Mutating
    commandToken() to accept a command in any position fails CFE1 and nothing
    else, which is the discrimination it was written for.
  • cli-help.test.ts (15 steps) and plan-cli.test.ts PS4 prove help output is
    byte-identical, including --agent-provider <AGENTPROVIDER> and the trailing
    -h, --help show help row.
  • deno task test packages/cli/tests/ — 93 files pass (712 steps), 2 fail.
    Both failures are Could not resolve 'npm:configliere@^0.4.0-pr' in a child
    process the suite sandboxes with its own HOME; agent-cli passes once
    DENO_DIR is inherited, workflow-crash WFX3 does not. Neither is a parsing
    difference — see Risks.
  • Node: 68 of 69. The one failure is cli-help CH7, which requires empty
    stderr while the tsx child writes [DEP0205] module.register() is deprecated
    before any CLI code runs. Bun runs the same four files 69/69.
  • deno task lint, deno task check, deno task check:jsr and
    git diff --check all exit 0.

Scope

Included

  • The real command tree, dispatch boundary and presentation, expressed through
    the proposed API.
  • The measurement of what the API cannot express, kept visible in the code.

Intentionally unchanged

  • Every black-box test. None was edited to pass.
  • packages/cli/src/workflow.ts keeps lifecycle request semantics and its
    Effection Result<WorkflowCommand>; only its legacy definition moved.
  • packages/cli/src/props.ts keeps schema inspection, lossless decoding and
    source diagnostics; only its resolver changed.
  • Let root documents declare ordered positional arguments #173. No document-declared positional is defined by any route, and CFE13
    enumerates every positional to prove it.

New abstractions

  • cli-route.ts exists because the definitions, the parse driver and the
    presentation are one subject and were previously spread across cli.ts and
    workflow.ts. Consumers: cli.ts and the acceptance test.
  • withDefault() exists because schema() accepts StandardSchemaV1<T, T>,
    so every Zod .default() is rejected — the default widens the input type
    while the output stays T. It also records whether help should describe the
    parameter as required, which is the one thing the released field.default()
    expressed and this API does not.
  • routeValues() exists because a lifted repeatable option has to reach the
    model addressed to the route that owns it.

New dependencies

  • Package: configliere at https://pkg.pr.new/configliere@30
    (0.4.0-pr+dbd7d191ab8aff37c71cc03bc4688ad372ec7e23), replacing ^0.4.0.
  • Used for: the evaluation itself.
  • Why existing dependencies are insufficient: the released version does not
    contain the API under evaluation.

Generated or mechanical changes

  • packages/cli/vendor/configliere-pr30/ is the exact preview tarball, unpacked
    and unmodified — 247 files, 8 559 lines, 1.2 MB. Verified byte-identical to
    the pnpm store copy with diff -r. It exists only because Deno cannot resolve
    an HTTPS tarball dependency. Skim it; nothing in it was written here. It is
    excluded from deno.json's workspace exclude, from the lint ignore list and
    from .oxfmtrc.json, exactly as the other vendored packages are. The tarball
    ships no LICENSE file; its manifest declares MIT.
  • The semantic diff, excluding that mirror, is +2 082 / −672:
    cli-route.ts +966, cli.ts +648/−514, the new test +400, props.ts
    +44/−79, workflow.ts −69, and 24 lines of manifest and lock changes.

What the port had to work around

Each of these is a finding, not a shim. Each one is named in the code.

Limit Consequence
A repeatable option cannot be read. bindPhase truncates every reader's view at the first unclaimed word, so an occurrence written after a value is invisible; a reader settles its parameter on first success; and CLIRead types the value string | boolean, so a list cannot leave a reader anyway. --include and --pattern are lifted out of argv by readRepeatedOption() and handed back as route value sources. --include had no scanner before — it was field.array().
checkpoint() adds values, never parameters or routes, and lib/dynamic is present in the tarball but exported from neither esm/mod.js nor esm/mod.d.ts. The document is still inspected before parsing and its --props-* options lifted. Two parses remain, named in the code as the checkpoint gap. This is not a checkpoint migration.
A Help or Version intent carries no model. takeHelpFlag was kept rather than retired. The control parse chooses the method and the route; a second parse with the control removed supplies the document that xmd run doc.md --help and xmd workflow start flow.md --help describe.
No per-source inspection exists. The API reports a model and a flat issue list. props.ts owns the precedence walk: which source supplied a value, and whether a higher one failed, cannot be recovered from a parse.
The stock printers do not preserve the contract. printVersion() renders xmd 0.12.0; printHelp() renders a different shape from Usage: xmd run [OPTIONS] [path]. Presentation is XMD-owned, derived from the definitions — optionality by validating undefined, defaults by what that validation returns.
A route with children declares no parameters of its own. xmd workflow --help unions its actions' parameters so the released page survives. Parsing still binds each parameter on the action that owns it.
The tokenizer changed what a dash-leading positional is. - is now a word an argument can claim; -#Section is a flag no argument will see. Both are lifted before the parse for the run form, and dropped for every other command — the released parser refused every dash-leading positional, which is why xmd test - searches for documents.

Where the two dependency layouts disagree

Recorded because it is the finding, not a step to repeat.

  1. packages/cli/package.json → the tarball URL. deno install --frozen=false
    exits 0 with Warning Not implemented scheme 'https' twice, and drops
    npm:configliere@0.4 from deno.lock. Deno simply ignores the dependency.
  2. pnpm installs it into its virtual store. Every Deno invocation then prunes
    packages/cli/node_modules/configliere
    , because with "nodeModulesDir": "auto" Deno re-synchronises node_modules before user code runs and the
    package is not in its graph. Node and Bun need pnpm install --filter @executablemd/cli after any Deno command.
  3. Mapping the Deno import at the mirror's esm/mod.js loses every type —
    Deno infers from the JavaScript. // @deno-types pointing at the sibling
    .d.ts does not help: inside a file-URL declaration file the re-exports
    from "./lib/command.js" resolve to the JavaScript, so deno check reports
    42 errors like has no exported member 'CommandZero'. That .js → .d.ts
    sibling rule applies only to npm-resolved graphs.
  4. "links": ["./packages/cli/vendor/configliere-pr30"] with
    "configliere": "npm:configliere@^0.4.0-pr" works for check, test,
    lint and check:jsr — and fails every --node-modules-dir=none task with
    Linking npm packages requires using a node_modules directory. ^0.4.0
    does not match, because 0.4.0-pr+… is a prerelease.

Risks and limitations

  • deno task build does not complete (CFE10). It fails at build:web with
    the linking error above, so no dist/xmd exists and the compiled smoke could
    not be run. Recorded rather than worked around.
  • deno task setup fails in its last phase for the same reason (CFE11), and
    the two dependency layouts do not stay consistent across a Deno command.
  • Two CLI suites fail on dependency resolution, not behaviour: agent-cli
    CA5 and workflow-crash WFX3, both Could not resolve 'npm:configliere@^0.4.0-pr' in a child sandboxed with its own HOME. A
    linked package is on no registry, so a child that re-resolves cannot find it.
    Inheriting DENO_DIR fixes the first; the workflow executor child still
    fails.
  • Unknown options now fail the parse. The released parser ignored them
    entirely. Every accepted message is preserved — the missing-root refusal, the
    upgrade scan's enumeration, each per-command refusal — but a caller who
    mistypes a flag on xmd run <doc> now sees unrecognized argument: … where
    they previously saw the document run. This is a behaviour change and needs
    separate approval before any adoption.
  • A workflow action written after -- no longer selects the action, because
    a literal cannot select a route. No test covers it.
  • props-sources.test.ts PR16 is now misnamed — "structured properties
    resolve through Configliere too" describes a resolver that is XMD's after this
    change. No existing test was edited.
  • Three as casts are added, none of which conceals dispatch narrowing:
    two introspect phase.params in cli-route.ts (Object.values(...) as Param<string, unknown>[]), and one is test setup. The handler models narrow
    without any cast, which is what CFE3 proves.
  • Recovery: the whole spike is one commit on a branch that merges nowhere.

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.

Upstream feedback, as it stood after round one

These were the round-one asks. Round two resolved two of them outright — see
what is still live below for the current list.

  1. Publish the preview where Deno can resolve it. Still live.
  2. Export the general dynamic element. Resolved in PR Clean up lint issues and fix core typecheck #32:
    dynamic is exported. What it revealed is a new and different finding.
  3. Give a repeatable option a first-class expression. Resolved in
    PR Clean up lint issues and fix core typecheck #32: multiple() does exactly this.
  4. Let a Help intent carry the model bound so far. Now subsumed by the
    typing finding: with a dynamic phase the increment does carry the model, so
    the request is really that a runtime-derived phase keep its type.
  5. Reconsider whether route selection should accept a command name from any
    word in the segment.
    Still live, unchanged.
  6. Let schema() accept a Standard Schema whose input and output differ, so
    a Zod .default() is usable, and give help a way to distinguish a defaulted
    parameter from an optional one. Still true; not being sent — XMD's
    withDefault covers it locally and it blocks nothing.
  7. Add a types condition to the package exports. Same: true, local
    workaround exists, not being sent.
  8. The README imports @frontside/configliere; the tarball's package name
    and sole export are configliere. Cosmetic; not being sent.

What to send upstream

Two findings, and only these two. Items 2 and 3 above are resolved, the typing
finding was resolved by 658a271 (see the addendum), and 6–8 are observations a
maintainer can take or leave rather than things this evaluation needs.

  1. A Deno-resolvable artifact. Every packaging symptom in this PR follows
    from the preview being an HTTPS tarball: Not implemented scheme 'https', a
    local links override as the only way in, and that override breaking every
    --node-modules-dir=none task. This is the one adoption prerequisite, and the
    only blocker left.
  2. Route selection accepts a command name from any word in the segment. For
    xmd the first token is the only position a command may occupy — every other
    word is a document reference — which is why this port carries two definitions
    and a first-token classifier.

A runtime-derived dynamic phase loses its type. Resolved in
658a271. The diagnosis and the verification are in the addendum.


Round two — against PR #32

Configliere PR #32 answered two of the three blockers this spike reported. This
section replaces the recommendation above; everything else in this description
still describes the port, except where it says otherwise here.

The artifact re-measured

Preview https://pkg.pr.new/configliere@32
Manifest configliere@0.4.0-pr+08b3a6835a0c8be9c7b946e26f0e4f0706155964
Tarball SHA-512 042e0ec03669b9ee1256dfb922649428b9044c2d72ab625f2d94dfdd9647c635acc42de87a13ea680602c6bd73b4bbdb63c98f5fed66813008c24fd841a2623d
Source commit b98cc038 — PR #32's head, "export dynamic", 2026-09-13T14:45:28Z
Built as 08b3a683 = merge of b98cc038 into 36cf35a5
Merged into 36cf35a5 — tip of model-schema-transforming, PR #32's base branch
Pinned guide README at b98cc038

Two things that record needs to be read carefully for.

The manifest's build metadata is not the source commit. 08b3a683… is a merge
commit pkg.pr.new synthesises at build time — Merge b98cc038… into 36cf35a5… —
so the published bytes are the PR head merged into its base, and neither SHA
alone identifies them. Round one had the same shape: manifest +dbd7d191…
against source fb52bc65….

PR #32 is stacked, not based on main. Its base is
model-schema-transforming, so what this round evaluated is #32 plus that
branch. The newly exported transform, ModelSchema and ModelParams come
from the base rather than from #32 itself.

And the URL served different bytes earlier the same day: 0.4.0-pr+af793fd5…,
SHA-512 b145ad86…, which was Merge 87a42d9e… into 36cf35a5… — the same base,
an earlier head, before dynamic was exported. Same URL, two artifacts, hours
apart. The lock records only the URL, so this is the mutable-identity finding
above happening rather than being predicted.

multiple() closes the repeatable-option blocker

Measured against the exact cases that defeated the previous reader:

--include a doc.md --include b   =>  include: ["a","b"]   ← occurrence after the positional
doc.md --include=a --include b   =>  include: ["a","b"]   ← mixed spellings
doc.md                           =>  include: ["components","."]
doc.md --include                 =>  FAIL --include requires a value
doc.md --include --raw           =>  FAIL --include requires a value

All three places that made it impossible were fixed: bind.ts stops truncating
a multiple parameter's view at the binding horizon, read.ts grew a reader that
claims every occurrence, and CLIRead's value type widened with a matching
decodeMany. The missing-value and dash-leading-value refusals land exactly
where readPatternFlags used to put them.

What it deleted (commit 5f3ae102): readRepeatedOption and its
interfaces, the lifting inside liftArgs, the liftedValues/routeValues
value-source channel, and the parameter test took because a model could not
report what a scanner had read. --include is now an ordinary
option(name("include"), multiple(), schema(…)) on the four routes that declare
it.

--pattern keeps no schema default, because xmd test needs to know whether the
caller wrote one — a pattern against a single document is refused, and a default
is not a refusal. The glob help displays is carried beside the schema, and the
command applies it.

dynamic works, and its type does not

dynamic is exported now, and at runtime it does everything the properties
phase needs. Measured:

doc.md --props-name Ada --raw          =>  raw:true, props-name:"Ada"     ← generated option binds
doc.md --props-name Ada -j trace.jsonl =>  journal:"trace.jsonl", props-name:"Ada"
child                                  =>  EXECUTE /child                 ← route added by the phase
child deeper                           =>  EXECUTE /child/deeper          ← and one below it
doc.md --props-name Ada --help         =>  HELP /, increment model {"path":"doc.md"}
resume({ok:false, issues})             =>  unprocessable-content carrying the loader's own issue

So the capability is there. What is not there is its type. When the
resolver's element list is derived from the run — which a document's declared
properties always are — the parse type collapses to a fully resolved union:

Help<"/"> | Version<"/"> | Execute<"/", { "/": { raw: boolean | undefined } }>

The increment is absent from it, "resume" in step narrows to
… & Record<"resume", unknown> so resume types as unknown, and the model
describes the phase after the boundary while dropping the argument bound
before it. An explicit return annotation on the resolver does not restore it.
The same definition with a statically known element list types exactly, so the
limitation is specific to a list only the run knows.

Driving that would take a cast. This repository forbids one (Parse to infer type; Do not type cast with as), and a cast would hide precisely the finding,
so the properties phase still prepares argv before parsing. The two parses
stay — but the comment on them now names the real reason, which is typing rather
than a missing capability.

Commit ac88de75 records this. CFE4 keeps the checkpoint's own limit;
CFE4b is new and drives a dynamic phase by parsing every step back out of a
value whose type stopped describing it — the finding made executable. It also
holds the ordering that defeated my first reading: an argument() in the earlier
phase claims the word before the route it names exists, so a dynamic route is
unreachable behind a positional and reachable without one. Depth itself is not
the limit.

What the third blocker now costs

Nothing about distribution changed, and round two made its cost sharper, because
this worktree was created from scratch with the dependency already wired in:

  • A fresh worktree cannot be prepared. deno task setup fails at
    build:web before the browser bundle exists. Round one only observed setup's
    last phase failing after a bundle already existed.
  • Neither position works. With links, build:web fails with Linking npm packages requires using a node_modules directory. Without it, the same task
    fails with Could not find version '0.4.0-pr+08b3a683…' for npm package 'configliere', because the lock records a version that exists nowhere but the
    local mirror. So the bundle could not be produced at all, and the suites that
    need it are unrunnable here.
  • A dangling link stops the toolchain. Deleting the old mirror before
    updating deno.json made every deno invocation refuse — including scripts
    with nothing to do with the package — with Failed loading link './packages/cli/vendor/configliere-pr30'.
  • Updating the pin needs the lock edited by hand. deno install --frozen=false refused with Could not find version '0.4.0-pr+dbd7d191…',
    because the recorded version can no longer be resolved from anywhere. The
    stale entries had to be removed from deno.lock before it would re-resolve.
  • Two runtimes cannot share the worktree. Every Deno invocation rebuilds the
    materialized copy under node_modules/.deno, so a Bun run beside a Deno run
    reads a tree being rewritten: six spurious failures with Cannot find module './lib/command.js' and ENOENT … esm/lib/dasherize.js, against files that
    are present before and after. That is the links mechanism, not Bun.

Revised recommendation

Adopt once there is a Deno-resolvable artifact. Both blockers that were
about API design are closed. What remains is one distribution problem and two
design questions this round turned from blockers into decisions:

  1. Publish the preview where Deno can resolve it — JSR, or a real npm
    prerelease tag. Every packaging symptom above follows from its absence, and
    none of them is an XMD defect.
  2. Decide how a runtime-derived dynamic phase should type. Answered by
    658a271
    , which took the first of the three options this round proposed —
    keep the phase, widen only the added model. See the addendum.
  3. Decide whether route selection should accept a command name from any word
    in the segment.
    Unchanged from round one: for xmd the first token is the
    only position a command may occupy, which is why two definitions exist.

Unchanged and still XMD's own accommodations: the stock help and version
printers, the Help intent carrying no model, and the unknown-option behaviour
change flagged above as needing separate approval.

If this becomes adoption work

Not in this PR, and recorded here so the next branch does not repeat the spike's
setup. Start it from current main, not from this base. 18b117a5 was held
deliberately so the two rounds compare cleanly, and CLI and packaging changes
have merged since — a rebase of this branch would mix them into the measurement
it exists to be.

The architecture decision that closed this round also belongs here: do not
build a custom --props-* reader.
It would be new XMD parser machinery, it
crosses this spike's complexity boundary, and it would obscure the upstream
type-system finding. The property preparation and the two parses stay. The
typing gap does not block adopting the static route API later; it blocks
claiming that document-derived properties migrated into dynamic().

Round-two evidence

Command Result
deno task lint exit 0
deno check packages/cli/src/cli.ts exit 0
deno task test packages/cli/tests/ 94 files pass (714 steps), 1 fails — workflow-crash WFX3, Could not resolve 'npm:configliere@^0.4.0-pr' in the executor child. Round one was 93 pass / 2 fail.
bun test × 4 files 70 pass, 0 fail
tsx --test × 4 files 69 of 70 — cli-help CH7 requires empty stderr while the tsx child writes [DEP0205] module.register() is deprecated, as in round one
deno task setup exit 1 at build:web, on a worktree that never had a bundle
deno task build / verify / verify:clean unrunnable for the same reason

The two slices are separable for review: 5f3ae102 is the multiple() migration
and is the one that deletes code; ac88de75 records the dynamic-phase result and
changes no production behaviour.


Addendum — 658a271 closes the typing gap

Round two reported that a dynamic phase works at run time and loses its type
when the resolver's element list is derived from the run. Upstream fixed that
four days later. No code in this PR changed; head is still 646d56a8, the
commit architecture review passed. This section records the measurement and what
it does to the recommendation.

Artifact configliere@0.4.0-pr+0e4abd9e0b32f6058bdb516fae981361f8d134ea
Source https://pkg.pr.new/configliere@658a271
Tarball SHA-512 f6e8863596e2d75376c22754bf160b90e36168e0204cdb35fd14d2df3f0c40f42c19d554fdaa56d095fd4fb3570c0fd46b48a0fd987352474262723f76b28b9c
Source commit 658a271a — "refine when fold and materialize", 2026-09-16T03:59:09Z
Built as 0e4abd9e = merge of 658a271a into 239d5be1

What changed

The length guard stays — an unknown-length pipeline still cannot be folded — but
the fallback no longer discards the route. Conservative is renamed Widened
and is no longer what a dynamic phase falls back to; a new DynamicAfter
notices when folding the extension has widened to AnyRoute and substitutes
OpenDynamic, which keeps the route's name, methods, model and children and
gives it one open phase:

type DynamicAfter<S extends AnyRoute, E extends AnyElement> =
  ApplyElement<Seed<S>, E> extends infer After extends AnyRoute
    ? AnyRoute extends After ? OpenDynamic<Seed<S>> : After
    : never;

type OpenDynamic<S extends AnyRoute> = Route<
  S["name"], MethodsOf<S>, ModelOf<S>,
  readonly [...ChildrenOf<S>, ...AnyRoute[]],
  readonly [Done<Record<string, unknown>, readonly AnyRoute[]>]
>;

Because the continuation stays a real route, ConjoinPhases has a phase list to
stitch, RequirementOf<R> finds the resolver again, and ParseAt takes the
increment branch. The widening is confined to the one thing genuinely unknown:
which parameters the resolver adds. That is the first of the three options round
two put forward.

Verified

Round two's reproduction, re-run unchanged in an isolated probe project so
nothing in this branch moved:

deno check probe.ts          → zero errors

increment model.path: doc.md          // the phase before the boundary, typed
raw: true                             // a parameter declared after it, typed
generated: Ada                        // a parameter only the run named
model: {"path":"doc.md","props-name":"Ada","raw":true}
dynamic route: /child                 // a route the resolver introduced

One nuance, and it reads as a design choice rather than a leftover: the model's
static type stays closed — { path, raw } — so a runtime-named key cannot be
read by literal index (model["props-name"] is TS7053). It reads without a
cast through Object.entries(model), which is what a document-driven CLI needs.
A statically sized resolver keeps full precision and types its added key by name.
Both measured.

What it changes here

  • The upstream list drops to two: a Deno-resolvable artifact, and
    first-token route selection. Of the eight asks round one raised, upstream has
    now closed five.
  • The recommendation is unchanged in substance — adopt once there is a
    Deno-resolvable artifact — but the reason narrows. Distribution is the only
    blocker left; nothing about the API's design is.
  • The properties migration is now viable, and has deliberately not been done.
    Round two's architecture decision — keep the property preparation and the two
    parses, do not build a custom --props-* reader — rested on the premise that
    driving a dynamic phase required a cast. 658a271 removes that premise, so the
    decision is open again. Reversing it is a call for the Architect and the
    maintainer, not something this spike takes on its own, and it is not a small
    change: the properties phase would move inside the driver, beforeProperties()
    and the argv truncation would go, extractPropsArgs would come off the parse
    path, and props.ts would read CLI values from the model.

Until that decision is taken, everything above this addendum still describes the
branch exactly.

An evaluation spike, not adoption. The released `xmd` command definitions and
dispatch boundary are re-expressed through the route API proposed in
bombshell-dev/configliere#30, so the diff, the focused regressions and the
packaging results can say what that API can and cannot carry.

`packages/cli/src/cli-route.ts` owns the immutable definitions, the synchronous
parse driver and the presentation that keeps help and version output identical.
`cli.ts` dispatches on `intent.method` and `intent.route` and reads each
handler's model off the matching route. `workflow.ts` gives up its legacy
definition; `props.ts` gives up the released parser's inspection and owns the
source precedence walk the proposed API has no equivalent for.

Three limits shaped the port and stay visible in it:

- A repeatable option cannot be read from argv at all: the binding loop
  truncates a reader's view at the first unclaimed word, and a reader settles
  its parameter once. `--include` and `--pattern` are lifted out of argv and
  handed back as route value sources.
- `checkpoint()` adds values, never parameters or routes, so a document's
  generated `--props-*` options are still lifted before parsing. The two parses
  that remain are named in the code as the checkpoint gap.
- The preview is an HTTPS tarball. Deno reports `Not implemented scheme
  'https'`, so the exact tarball is mirrored unchanged under
  `packages/cli/vendor/configliere-pr30/` and Deno resolves it through a local
  `links` override. Node and Bun consume the pnpm tarball.

Every scanner that survives is a scanner the route API cannot replace, and each
one now carries the reason.
Configliere PR #32 adds `multiple()`, which is the facility the previous preview
could not express. The binding loop now shows a multiple parameter the whole
phase instead of truncating its view at the first unclaimed word, and the reader
returns every occurrence in the order it was written — so `--include` and
`--pattern` bind from argv on the route that declares them.

That retires the whole workaround this spike needed for them:
`readRepeatedOption`, its interfaces, the lifting inside `liftArgs`, the
`liftedValues`/`routeValues` value-source channel, and the extra parameter
`test` took because a model could not say what a scanner had read.

`--pattern` keeps no schema default, because `xmd test` needs to know whether
the caller wrote one: a pattern written against a single document is refused,
and a default is not a refusal. The glob help displays is carried beside the
schema instead, and the command applies `DEFAULT_PATTERN` itself.

The preview mirror moves to the exact PR #32 bytes,
`0.4.0-pr+08b3a6835a0c8be9c7b946e26f0e4f0706155964`. CFE7 changes with its
subject: it asserted that a repeatable option had to arrive as a value source,
and now asserts that argv binds it, including the occurrence written after the
document that no reader could previously see.
PR #32 exports `dynamic`, so the phase `checkpoint()` is built from is reachable
at last. Measured against the published bytes, it does everything the props
migration needs — at runtime. It adds a document's generated options after
inspection, binds one written before the phase that declares it, introduces
nested routes to arbitrary depth, and reports a failed load through the ordinary
issue path.

What it cannot do is describe any of that. When the resolver's element list is
derived from the run — which a document's declared properties always are — the
parse type collapses to a fully resolved union: the increment is absent from it,
`resume` types as `unknown`, and the model describes the phase after the
boundary rather than the one before it. An explicit return annotation does not
restore it, and the same definition with a statically known element list types
exactly, so the limit is specific to a list only the run knows.

Driving it would therefore take a cast, which this repository does not allow and
which would hide the finding. So the properties phase keeps preparing argv
before parsing, and the two parses stay — but the comment on them now names the
real reason, which is typing rather than a missing capability.

CFE4 keeps the checkpoint's own limit. CFE4b is new and holds the rest: it
drives a dynamic phase by parsing every step back out of a value whose type
stopped describing it, which is the finding made executable. It also records the
ordering that defeated my first reading of it — an argument in the earlier phase
claims the word before the route it names exists, so a dynamic route is
unreachable behind a positional, and reachable without one.
pnpm resolves the preview for Node and Bun, and its lock still named the PR
#30 URL. The entry carries no integrity hash either way: pnpm records none for
an HTTPS tarball dependency, which is why this spike keeps its own SHA-512 of
the bytes it consumed.

This branch has not been deployed

No deployments
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.

1 participant