Skip to content

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

Draft
taras wants to merge 1 commit into
mainfrom
spike/issue-796-configliere-route-api
Draft

🧪 Evaluate Configliere's proposed route API against the real xmd grammar (#796)#802
taras wants to merge 1 commit 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 thefrontside/configliere#30.

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

Actionable items for thefrontside/configliere#30, in the order they blocked this
evaluation:

  1. Publish the preview where Deno can resolve it. An HTTPS tarball is
    Not implemented scheme 'https', and the local-link workaround is
    incompatible with --node-modules-dir=none.
  2. Export the general dynamic element. lib/dynamic ships in the tarball
    and is exported from neither entry point, so checkpoint() is the only
    reachable phase and it cannot add a parameter or a route.
  3. Give a repeatable option a first-class expression. A reader cannot see
    past the binding horizon, cannot claim twice, and cannot return a list.
  4. Let a Help intent carry the model bound so far, or say why it cannot.
    Document-aware help has no other way to name the document.
  5. Reconsider whether route selection should accept 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.
  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.
  7. Add a types condition to the package exports. Declarations are
    currently found only by the .js.d.ts sibling rule.
  8. The README imports @frontside/configliere; the tarball's package name
    and sole export are configliere.

An evaluation spike, not adoption. The released `xmd` command definitions and
dispatch boundary are re-expressed through the route API proposed in
thefrontside/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.
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