From 541fc03f128d8dffaba60f97752aedfaba43da5b Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 10 Aug 2026 10:57:43 -0400 Subject: [PATCH 1/2] =?UTF-8?q?=E2=9C=A8=20Address=20a=20document's=20sect?= =?UTF-8?q?ions=20from=20the=20command=20line?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `xmd targets ` prints one full document reference per addressable section, by inspection alone. A file argument to `xmd run` becomes a document reference, so `xmd run README.md#Release/Publish` and `xmd README.md#Release/*` run exactly one section of a document. The selector is replaced by its answer before anything executes. Run preparation already inspects the root to discover its props; it now retains the exact canonical target that inspection resolved and asks execution for that, never for the glob. A file replaced between the two reads therefore fails on the target the run chose rather than silently running whatever the glob would name now, and `readsValue()` raises a recognized target failure so that refusal lands before the service installer and before `execute()`. Diagnostics keep the core's own first line and render its target lists as full references, because a reference is what a caller can act on. A filename holding `#` or a literal `%HH` sequence is now written `%23` and `%25HH` for `xmd run`. `xmd test` keeps its own path grammar and gains no target selection. --- .github/workflows/ci.yml | 16 + architecture.md | 9 +- packages/cli/src/cli.ts | 274 +++++++++++- packages/cli/tests/cli-help.test.ts | 29 ++ packages/cli/tests/inline-cli.test.ts | 20 + packages/cli/tests/props-cli.test.ts | 59 +++ packages/cli/tests/targets-cli.test.ts | 568 +++++++++++++++++++++++++ packages/cli/tests/test-target.test.ts | 26 +- packages/cli/tests/value-root.test.ts | 70 +++ smoke-test/document-targets.md | 12 + specs/executable-mdx-spec.md | 81 +++- specs/root-document-props-spec.md | 24 +- 12 files changed, 1161 insertions(+), 27 deletions(-) create mode 100644 packages/cli/tests/targets-cli.test.ts create mode 100644 smoke-test/document-targets.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d79df1b2..9356042f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -190,6 +190,22 @@ jobs: test "$(./dist/xmd run smoke-test/value-root.md)" = \ '{"passed":true,"summary":"no findings"}' + # Reading a document's own outline and projecting it happen inside the + # engine, so only the compiled binary proves the catalog and the + # projection survived `deno compile`. + - name: Smoke test document targets + run: | + set -eu + printf '%s\n%s\n' \ + 'smoke-test/document-targets.md#Alpha' \ + 'smoke-test/document-targets.md#Beta' > /tmp/targets.expected + ./dist/xmd targets smoke-test/document-targets.md > /tmp/targets.out + diff /tmp/targets.expected /tmp/targets.out + + ./dist/xmd run 'smoke-test/document-targets.md#Alpha' --raw > /tmp/targeted.out + grep -q 'ALPHA_RAN' /tmp/targeted.out + ! grep -q 'BETA_RAN' /tmp/targeted.out + # `` is registered by the CLI, so the compiled binary must know it. # The document fails in preflight, before a listener or a browser, which is # what makes this runnable on a headless runner: a binary missing the diff --git a/architecture.md b/architecture.md index 0aa7174b..38c9cd71 100644 --- a/architecture.md +++ b/architecture.md @@ -641,6 +641,11 @@ re-resolved against a newer checkout to decide what a resumed run means. A glob is retained in exactly one place, a failed selection's structural record, and only so that an ordinary failed execution can be reproduced. +The command line is the first consumer of that split. `xmd run` resolves a +selector while inspecting the document, then asks execution for the exact target +that resolved — so a file replaced between the two reads fails on the target the +run chose, rather than silently running whatever the glob would name now. + A resumed run re-resolves the current selector against the *recorded* content and refuses to continue unless the outcome is the one recorded. A failed selection is an outcome too, and is recorded and compared as one — otherwise a @@ -1081,7 +1086,9 @@ Status is measured against main. | `` / `printErrors(fn)` | prints failures | built on main | | `` region `output` mode | an undecided error fails the document execution | built on main | | `Expansion` / `getExpansion()` | describes the current logical element expansion | built on main | -| document targets | catalogs a root document's addressable static headings, resolves one selector to one exact target, and projects the document to it before expansion | built on the #412 stack; `xmd targets`, targeted `xmd run`, and the targeted workflow definition are unbuilt | +| document targets | catalogs a root document's addressable static headings, resolves one selector to one exact target, and projects the document to it before expansion | built on the #412 stack | +| `xmd targets` | prints one document's catalog as full document references, by inspection alone | built on the #412 stack | +| targeted `xmd run` | reads a file argument as a document reference and executes the one exact target its selector resolved to, replacing the selector before execution rereads the file | built on the #412 stack; the targeted workflow definition is unbuilt | | `useWorkflow()` / `getWorkflowRun()` | associates one document execution with a workflow run | built on main | | `Git.revParse()` | verifies and resolves one Git revision expression contextually | built on main | | workflow run storage | creates or compatibly finds one run by public run ID, retains its identity, state, document executions and filtered journal, and validates immutable Workspace roots through one provider-owned connection entry | built on the #365 stack; public workflow execution is unbuilt | diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index e3617a4f..1a11e4a6 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -2,13 +2,19 @@ * CLI — run an executable markdown document. * * Usage: - * xmd run [options] - * xmd [options] (run is the default command) + * xmd run [options] + * xmd [options] (run is the default command) + * xmd targets + * + * A document reference is a path, optionally followed by `#` and one target + * selector naming a section of the document (spec §5.4). * * Examples: * xmd run packages/core/examples/hello-world.md * xmd packages/core/examples/hello-world.md --verbose * xmd run packages/core/examples/hello-world.md --journal events.jsonl + * xmd targets README.md + * xmd run README.md#Release/Publish */ import { @@ -40,7 +46,10 @@ import { z } from "zod"; import { AgentProviders, Config, + asDocumentTargetError, execute, + fileSource, + formatDocumentReference, inlineSource, inspectDocument, installAgentComponents, @@ -50,7 +59,7 @@ import { useNormalizedOutput, useTerminalOutput, } from "@executablemd/core"; -import type { RootDocumentSource } from "@executablemd/core"; +import type { DocumentInfo, FileRootDocument, RootDocumentSource } from "@executablemd/core"; import { env as readEnv } from "@executablemd/runtime"; import { createAcpxProvider, DEFAULT_AGENT_NAME } from "@executablemd/acp"; import { installTestingComponents, TestFailureError, useTesting } from "@executablemd/testing"; @@ -103,7 +112,7 @@ const SECRET_DETECTION_FIELD = { const runConfig = object({ path: { - description: "markdown document to execute", + description: "markdown document to execute, optionally `#` and one target selector", ...field(z.string().optional(), cli.argument()), }, // Declared so `xmd run --help` lists it with every other option. The value is @@ -189,6 +198,20 @@ const testConfig = object({ secretDetection: SECRET_DETECTION_FIELD, }); +/** + * `xmd targets` takes one document and nothing else. + * + * The argument is optional so `xmd targets --help` renders the command rather + * than failing the parse; a missing reference is reported by the command with + * its own diagnostic. + */ +const targetsConfig = object({ + path: { + description: "markdown document whose targets to list", + ...field(z.string().optional(), cli.argument()), + }, +}); + const testAgentConfig = object({ connect: { description: "opaque controller route (controller-launched workers only)", @@ -200,7 +223,12 @@ const xmd = program({ name: "xmd", version: denoJson.version, config: commands( - { run: runConfig, test: testConfig, "test-agent": testAgentConfig }, + { + run: runConfig, + test: testConfig, + targets: targetsConfig, + "test-agent": testAgentConfig, + }, { default: "run" }, ), }); @@ -579,6 +607,35 @@ function* runScopedDocument( } } +/** + * A document-target failure as the command line reports it, or `undefined` + * when this failure is not one. + * + * The core states the outcome and lists canonical target fragments; a caller + * holds a command line, so every fragment is rendered as the full document + * reference that selects it. The core's own first line is kept exactly as it + * derived it, so the wording lives in one place. + * + * `formatDocumentReference` cannot refuse this path: it round-trips what + * `fileSource` decoded, and a reference that does not decode never reaches a + * selection at all. + */ +function targetFailureReport(root: RootDocumentSource, error: unknown): string | undefined { + const failure = asDocumentTargetError(error); + if (failure === undefined) { + return undefined; + } + const [outcome = failure.message] = failure.message.split("\n"); + const ambiguous = failure.data.kind === "multiple-matches"; + const listed = ambiguous ? failure.data.matches : failure.data.available; + if (listed.length === 0) { + return `${outcome}\nThe document has no targets.`; + } + const heading = ambiguous ? "Matched targets:" : "Available targets:"; + const references = listed.map((target) => ` ${formatDocumentReference(root.path, target)}`); + return [outcome, heading, ...references].join("\n"); +} + /** Print a completed document's failure the way `xmd` has always printed it. */ function reportFailure(error: Error, prefix?: string): void { const label = prefix === undefined ? "" : `${prefix}: `; @@ -696,15 +753,114 @@ function* test( } } +/** A file document reference read as one, or why it cannot be read. */ +function readReference(reference: string): Result { + try { + return Ok(fileSource(reference)); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} + +const TARGETS_MISSING_REFERENCE = + "xmd targets requires a document reference — `xmd targets `"; + +const TARGETS_FRAGMENT = "xmd targets accepts a document reference without a target selector"; + +/** + * Refuse anything `xmd targets` does not take. + * + * The argument parser ignores options it does not define rather than rejecting + * them, so a run, test, journal, or service option written here would otherwise + * be read as silence. Listing a catalog takes one reference and nothing else, + * which makes the rule the simple one: no option, and no second argument. + */ +function targetsGrammarError(args: string[]): string | undefined { + let seen = 0; + for (const arg of args) { + if (arg === "targets" && seen === 0) { + seen = 1; + continue; + } + if (arg === "--" || arg.startsWith("-")) { + return `unrecognized option for xmd targets: ${arg} — xmd targets takes a document reference and nothing else`; + } + seen += 1; + if (seen > 2) { + return `xmd targets accepts one document reference — remove ${arg}`; + } + } + return undefined; +} + +/** + * `xmd targets` — print every document reference this document addresses. + * + * Inspection only. Nothing here installs a service, creates a journal, imports + * a component, expands the document, or performs an authored effect, so + * discovering what a document offers is always free of what it does. + * + * Written with `process.stdout.write` so a document that addresses nothing + * writes no bytes at all rather than a bare newline. + */ +function* listTargets(reference: string | undefined): Operation { + if (reference === undefined) { + console.error(TARGETS_MISSING_REFERENCE); + yield* exit(1); + return; + } + const parsed = readReference(reference); + if (!parsed.ok) { + console.error(describeError(parsed.error)); + yield* exit(1); + return; + } + if (parsed.value.target !== undefined) { + console.error(TARGETS_FRAGMENT); + yield* exit(1); + return; + } + + const inspected = yield* inspectCatalog(parsed.value); + if (!inspected.ok) { + console.error(describeError(inspected.error)); + yield* exit(1); + return; + } + + for (const target of inspected.value.targets) { + process.stdout.write(`${formatDocumentReference(inspected.value.path, target)}\n`); + } +} + +/** Inspect a document for its catalog, reporting a failure rather than raising. */ +function* inspectCatalog(root: FileRootDocument): Operation> { + try { + return Ok(yield* inspectDocument(root)); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} + /** * A document that cannot be inspected — missing, malformed, or unreadable — * reports text, so execution produces the printed error rather than inspection. + * + * A target failure is the exception, and it is raised rather than deferred. By + * the time a run reaches here the requested selector has already been replaced + * by the exact target it resolved to, so a failure means the document no longer + * offers the section this run decided on. That refusal belongs before the + * service installer and before `execute()`, not after them. */ function* readsValue(root: RootDocumentSource): Operation { try { const description = yield* inspectDocument(root); return description.returnMode === "value"; - } catch { + } catch (error) { + const failure = asDocumentTargetError(error); + if (failure !== undefined) { + throw failure; + } return false; } } @@ -833,12 +989,18 @@ function* preparePropsPhase(args: string[], evalFlags: EvalFlags): Operation` lists", + "them. In a filename, write `#` as `%23` and a literal `%` as `%25`.", +].join("\n"); + /** * Where the root document comes from, and how to write it. Neither fits an * option description, and the help renderer has no epilogue, so it is composed @@ -893,8 +1079,28 @@ const RUN_SOURCE_HELP = [ `Exactly one root document is required: a path, or one ${EVAL_OPTION} value.`, "Quote the document so the shell passes it as a single argument:", ` xmd ${EVAL_ALIAS} '# Hello'`, + "", + "A path is a document reference, and everything after its first `#` selects", + "one section of the document to run:", + " xmd run README.md#Release/Publish", + " xmd README.md#Release/*", + "", + REFERENCE_GRAMMAR_HELP, +].join("\n"); + +const TARGETS_HELP = [ + "Lists every section the document addresses, one full document reference per", + "line, in document order. The reference takes no target selector of its own.", + "", + REFERENCE_GRAMMAR_HELP, ].join("\n"); +/** + * Help for whichever command the arguments name. A command renders its + * own help when `--help` is its first argument, so the flag removed + * during the props phase is reinstated there rather than falling back to + * program help. + */ function renderHelp(phase: PropsPhase): string { const [first] = phase.args; const command = COMMAND_NAMES.includes(first) ? first : phase.root ? "run" : undefined; @@ -905,7 +1111,8 @@ function renderHelp(phase: PropsPhase): string { const help = xmd.parse({ args: [command, "--help"] }); const base = help.ok && help.value.config.help ? help.value.config.text : xmd.help({ args: [] }); - const withSource = command === "run" ? `${base}\n\n${RUN_SOURCE_HELP}` : base; + const epilogue = command === "run" ? RUN_SOURCE_HELP : command === "targets" ? TARGETS_HELP : ""; + const withSource = epilogue === "" ? base : `${base}\n\n${epilogue}`; // A document declaring only structured properties generates no // individual binding, but it still accepts the aggregate ones. @@ -1051,11 +1258,36 @@ export function* runXmd(args: string[], installService: HostServiceInstaller): O installService, ); if (!result.ok) { - reportFailure(result.error); + // The document is reread between preparation and execution, so the + // exact target this run decided on can be gone by the time it runs. + const report = targetFailureReport(propsPhase.root, result.error); + if (report === undefined) { + reportFailure(result.error); + } else { + console.error(report); + } yield* exit(1); } break; } + case "targets": { + const agentFlag = findAgentOnlyFlag(evalFlags.rest); + if (agentFlag) { + console.error( + `unrecognized option for xmd targets: ${agentFlag} — agent options are exclusive to xmd run`, + ); + yield* exit(1); + break; + } + const syntaxError = targetsGrammarError(propsPhase.args); + if (syntaxError) { + console.error(syntaxError); + yield* exit(1); + break; + } + yield* listTargets(command.config.path); + break; + } case "test": { const agentFlag = findAgentOnlyFlag(evalFlags.rest); if (agentFlag) { diff --git a/packages/cli/tests/cli-help.test.ts b/packages/cli/tests/cli-help.test.ts index d4c75559..a7ac3c05 100644 --- a/packages/cli/tests/cli-help.test.ts +++ b/packages/cli/tests/cli-help.test.ts @@ -49,4 +49,33 @@ describe("Tier CH — xmd help", { sanitizeOps: false, sanitizeResources: false // is the CLI's own and names both ways to supply a root document. expect(stderr).toContain("requires a document path or an inline document"); }); + + it("CH6: program help lists targets beside the other commands", function* () { + const { stdout } = yield* runCli(["--help"]).expect(); + expect(stdout).toContain("targets"); + }); + + it("CH7: xmd targets --help describes the command rather than failing", function* () { + const { stdout, stderr } = yield* runCli(["targets", "--help"]).expect(); + expect(stdout).toContain("Usage: xmd targets [OPTIONS] [path]"); + expect(stdout).toContain("markdown document whose targets to list"); + expect(stdout).toContain("one full document reference per"); + expect(stdout).toContain("write `#` as `%23` and a literal `%` as `%25`"); + expect(stderr).not.toContain("requires a document reference"); + }); + + it("CH8: run help teaches the document-reference grammar", function* () { + const { stdout } = yield* runCli(["run", "--help"]).expect(); + expect(stdout).toContain("xmd run README.md#Release/Publish"); + expect(stdout).toContain("xmd README.md#Release/*"); + expect(stdout).toContain("`xmd targets ` lists"); + expect(stdout).toContain("write `#` as `%23` and a literal `%` as `%25`"); + }); + + it("CH9: test help is unchanged by the reference grammar", function* () { + const { stdout } = yield* runCli(["test", "--help"]).expect(); + expect(stdout).toContain("Usage: xmd test [OPTIONS] [path]"); + expect(stdout).not.toContain("%23"); + expect(stdout).not.toContain("document reference"); + }); }); diff --git a/packages/cli/tests/inline-cli.test.ts b/packages/cli/tests/inline-cli.test.ts index f0409d9f..a11eaeb6 100644 --- a/packages/cli/tests/inline-cli.test.ts +++ b/packages/cli/tests/inline-cli.test.ts @@ -268,5 +268,25 @@ describe( expect(code).toBe(1); expect(stderr).toContain("exclusive to xmd run"); }); + + it("IE22: xmd targets takes no inline document either", function* () { + const { code, stderr } = yield* runCli(["targets", "-e", "# Hello"]).join(); + expect(code).toBe(1); + expect(stderr).toContain("exclusive to xmd run"); + }); + + it("IE23: an inline document addresses no target, so a `#` in it is text", function* () { + // Document references are file paths; the inline text is never split at + // a `#`, and a heading it happens to contain stays a heading. + const { code, stdout } = yield* runCli([ + "-e", + "# Title\n\n## Alpha\n\nALPHA_MARKER\n\n## Beta\n\nBETA_MARKER\n", + "--raw", + ]).join(); + + expect(code).toBe(0); + expect(stdout).toContain("ALPHA_MARKER"); + expect(stdout).toContain("BETA_MARKER"); + }); }, ); diff --git a/packages/cli/tests/props-cli.test.ts b/packages/cli/tests/props-cli.test.ts index c99295b4..cf712340 100644 --- a/packages/cli/tests/props-cli.test.ts +++ b/packages/cli/tests/props-cli.test.ts @@ -39,6 +39,29 @@ const HELLO = [ "", ].join("\n"); +/** One document, two sections, and props the whole document declares. */ +const SECTIONED = [ + "---", + "props:", + " type: object", + " properties:", + " name: { type: string }", + " required: [name]", + " additionalProperties: false", + "---", + "", + "# Sectioned", + "", + "## Greeting", + "", + "Hello, {props.name}!", + "", + "## Farewell", + "", + "FAREWELL_MARKER", + "", +].join("\n"); + const NESTED = [ "---", "props:", @@ -443,5 +466,41 @@ describe( }); expect(stdout).toContain("Hello, Ada!"); }); + + it("PC21: a projected root keeps the properties the whole document declares", function* () { + const { stdout } = yield* useFixture({ "sectioned.md": SECTIONED }, function* (fixture) { + return yield* runCli(["run", "sectioned.md#Greeting", "--raw", "--props-name", "Ada"], { + cwd: fixture.dir, + }).expect(); + }); + expect(stdout).toContain("Hello, Ada!"); + // Frontmatter belongs to the document, so its props reach the projection + // while the sibling section stays out of the run. + expect(stdout).not.toContain("FAREWELL_MARKER"); + }); + + it("PC22: property help describes a targeted document by its reference", function* () { + const { stdout } = yield* useFixture({ "sectioned.md": SECTIONED }, function* (fixture) { + return yield* runCli(["run", "sectioned.md#Greeting", "--help"], { + cwd: fixture.dir, + }).expect(); + }); + expect(stdout).toContain("Properties declared by sectioned.md"); + expect(stdout).toContain("--props-name "); + expect(stdout).toContain("Environment: XMD_PROPS_NAME"); + }); + + it("PC23: a required property is still required in a projected root", function* () { + const { code, stderr } = yield* useFixture( + { "sectioned.md": SECTIONED }, + function* (fixture) { + return yield* runCli(["run", "sectioned.md#Greeting", "--raw"], { + cwd: fixture.dir, + }).join(); + }, + ); + expect(code).toBe(1); + expect(stderr).toContain("name"); + }); }, ); diff --git a/packages/cli/tests/targets-cli.test.ts b/packages/cli/tests/targets-cli.test.ts new file mode 100644 index 00000000..85926ca8 --- /dev/null +++ b/packages/cli/tests/targets-cli.test.ts @@ -0,0 +1,568 @@ +/** + * Tier CT — CLI document targets (spec §5.4, §9.6). + * + * `xmd targets` lists what a document addresses, and both `xmd run` forms + * select one section of it. Suites shell out with captured stdio so exit + * status, stdout bytes, and diagnostics are asserted the way a caller observes + * them; the one exception is the inspection/execution replacement seam, which + * needs a stateful filesystem the command line cannot express. + */ +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { createContext, ensure, scoped } from "effection"; +import type { Operation } from "effection"; +import { ensureDir, exists, rm, writeTextFile } from "@effectionx/fs"; +import { randomUUID } from "node:crypto"; +import * as os from "node:os"; +import * as path from "node:path"; +import { API } from "@executablemd/runtime"; +import { runCli } from "@executablemd/test-support/launch"; +import { runXmd } from "../src/cli.ts"; + +function* useFixture( + files: Record, + body: (dir: string) => Operation, +): Operation { + const dir = path.join(os.tmpdir(), `xmd-ct-${randomUUID()}`); + yield* ensureDir(dir); + return yield* scoped(function* () { + yield* ensure(() => rm(dir, { recursive: true, force: true })); + for (const [name, content] of Object.entries(files)) { + yield* writeTextFile(path.join(dir, name), content); + } + return yield* body(dir); + }); +} + +/** + * The catalog every run-selection test addresses. + * + * The sole outermost heading is the document title, so it takes no level in a + * path: `Beta/Nested` is addressed by its own two labels. Alpha precedes Beta + * and Gamma follows it, which is what makes "excludes its siblings" mean both + * directions. + */ +const REPORT = [ + "# Report", + "", + "PREAMBLE_MARKER", + "", + "## Alpha", + "", + "ALPHA_MARKER", + "", + "## Beta", + "", + "BETA_MARKER", + "", + "### Nested", + "", + "NESTED_MARKER", + "", + "## Gamma", + "", + "GAMMA_MARKER", + "", +].join("\n"); + +const DUPLICATE = [ + "# Duplicate", + "", + "## Same", + "", + "FIRST_MARKER", + "", + "## Same", + "", + "SECOND_MARKER", + "", +].join("\n"); + +/** One heading per character class the canonical encoder has to escape. */ +const EXOTIC = [ + "# Exotic", + "", + "## a/b", + "", + "SLASH_MARKER", + "", + "## star*", + "", + "STAR_MARKER", + "", + "## hash#tag", + "", + "HASH_MARKER", + "", + "## pct%value", + "", + "PCT_MARKER", + "", + "## two words", + "", + "SPACE_MARKER", + "", + "## Ünïcødé", + "", + "UNICODE_MARKER", + "", +].join("\n"); + +/** + * Everything discovery must not do: an unresolvable component, an executable + * block, and an authored write. Expanding any one of them is observable — the + * first fails the run, and the other two leave a file behind. + */ +const EFFECTFUL = [ + "# Effects", + "", + "## Work", + "", + "", + "", + 'side effect', + "", + "```ts eval", + 'output("EVAL_RAN");', + "```", + "", +].join("\n"); + +const BROKEN_SCHEMA = [ + "---", + "props:", + " type: object", + " properties:", + " who:", + " type: not-a-json-schema-type", + "---", + "", + "# Broken", + "", + "## Kept", + "", + 'effect', + "", +].join("\n"); + +/** Each exotic heading's canonical reference, and the body it retains. */ +const EXOTIC_REFERENCES: readonly [string, string][] = [ + ["doc.md#a%2Fb", "SLASH_MARKER"], + ["doc.md#star%2A", "STAR_MARKER"], + ["doc.md#hash%23tag", "HASH_MARKER"], + ["doc.md#pct%25value", "PCT_MARKER"], + ["doc.md#two%20words", "SPACE_MARKER"], + ["doc.md#%C3%9Cn%C3%AFc%C3%B8d%C3%A9", "UNICODE_MARKER"], +]; + +function* eachRuns(dir: string, expected: readonly [string, string][]): Operation { + for (const [reference, marker] of expected) { + const ran = yield* runCli(["run", reference, "--raw"], { cwd: dir }).join(); + expect({ reference, code: ran.code }).toEqual({ reference, code: 0 }); + expect(ran.stdout).toContain(marker); + } +} + +/** + * Every invocation here fails with a diagnostic and no catalog. + * + * Split across several cases rather than one table because each row is a + * subprocess, and one case has to stay inside the shortest per-test budget of + * the three runtimes. + */ +function* eachRejected(dir: string, invocations: readonly string[][]): Operation { + for (const args of invocations) { + const { code, stdout, stderr } = yield* runCli(["targets", ...args], { cwd: dir }).join(); + expect({ args, code, stdout }).toEqual({ args, code: 1, stdout: "" }); + expect(stderr.length).toBeGreaterThan(0); + } +} + +describe("Tier CT — CLI document targets", { sanitizeOps: false, sanitizeResources: false }, () => { + it("CT1: xmd targets prints full canonical references in source order", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + const { code, stdout } = yield* runCli(["targets", "doc.md"], { cwd: dir }).join(); + expect(code).toBe(0); + expect(stdout).toBe( + ["doc.md#Alpha", "doc.md#Beta", "doc.md#Beta/Nested", "doc.md#Gamma", ""].join("\n"), + ); + }); + }); + + it("CT2: two sections with one canonical path print two lines", function* () { + yield* useFixture({ "doc.md": DUPLICATE }, function* (dir) { + const { code, stdout } = yield* runCli(["targets", "doc.md"], { cwd: dir }).join(); + expect(code).toBe(0); + expect(stdout).toBe(["doc.md#Same", "doc.md#Same", ""].join("\n")); + }); + }); + + it("CT3: a document with no targets succeeds and writes no bytes", function* () { + yield* useFixture({ "doc.md": "just a paragraph\n" }, function* (dir) { + const { code, stdout } = yield* runCli(["targets", "doc.md"], { cwd: dir }).join(); + expect(code).toBe(0); + expect(stdout).toBe(""); + }); + }); + + it("CT4: discovery runs no component, block, authored write, or service", function* () { + yield* useFixture({ "doc.md": EFFECTFUL }, function* (dir) { + const { code, stdout, stderr } = yield* runCli(["targets", "doc.md"], { cwd: dir }).join(); + expect(code).toBe(0); + // Exactly the catalog: an expanded `` renders a + // positioned diagnostic, which this byte comparison would catch. + expect(stdout).toBe("doc.md#Work\n"); + expect(stderr).toBe(""); + expect(yield* exists(path.join(dir, "written.txt"))).toBe(false); + expect(yield* exists(path.join(dir, ".xmd-eval"))).toBe(false); + }); + }); + + it("CT5: xmd targets refuses a reference it cannot take on its own terms", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + yield* eachRejected(dir, [ + [], + ["doc.md#Alpha"], + ["doc.md#"], + ["doc.md", "second.md"], + ["doc.md", "--"], + ]); + }); + }); + + it("CT5c: xmd targets refuses run and test options", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + yield* eachRejected(dir, [ + ["doc.md", "--journal", "trace.jsonl"], + ["doc.md", "--verbose"], + ["doc.md", "--raw"], + ["doc.md", "--component-dir", "components"], + ["doc.md", "--no-secret-detection"], + ["doc.md", "--pattern", "**/*.test.md"], + ]); + // Rejected before inspection, so the trace it named was never created. + expect(yield* exists(path.join(dir, "trace.jsonl"))).toBe(false); + }); + }); + + it("CT5d: xmd targets refuses inline documents, properties, and agent options", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + yield* eachRejected(dir, [ + ["-e", "# Inline"], + ["doc.md", "--props-who", "ada"], + ["doc.md", "--props", '{"who":"ada"}'], + ["doc.md", "--approve-all"], + ["doc.md", "--timeout", "5"], + ["doc.md", "--frobnicate"], + ]); + }); + }); + + it("CT5a: the missing reference and fragment diagnostics say what to write", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + const missing = yield* runCli(["targets"], { cwd: dir }).join(); + expect(missing.stderr).toContain( + "xmd targets requires a document reference — `xmd targets `", + ); + const fragment = yield* runCli(["targets", "doc.md#Alpha"], { cwd: dir }).join(); + expect(fragment.stderr).toContain( + "xmd targets accepts a document reference without a target selector", + ); + const empty = yield* runCli(["targets", "doc.md#"], { cwd: dir }).join(); + expect(empty.stderr).toContain( + "xmd targets accepts a document reference without a target selector", + ); + }); + }); + + it("CT5b: an unreadable reference and a missing file both fail", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + const malformed = yield* runCli(["targets", "%zz.md"], { cwd: dir }).join(); + expect(malformed.code).toBe(1); + expect(malformed.stderr).toContain("Invalid document reference"); + expect(malformed.stdout).toBe(""); + + const absent = yield* runCli(["targets", "absent.md"], { cwd: dir }).join(); + expect(absent.code).toBe(1); + expect(absent.stdout).toBe(""); + }); + }); + + it("CT6: a filename holding # or % is read and reprinted canonically", function* () { + const hashed = ["# Hashed", "", "## Sec", "", "HASH_FILE_MARKER", ""].join("\n"); + const percent = ["# Percent", "", "## Sec", "", "PCT_FILE_MARKER", ""].join("\n"); + yield* useFixture({ "we#ird.md": hashed, "pct%25.md": percent }, function* (dir) { + const hash = yield* runCli(["targets", "we%23ird.md"], { cwd: dir }).join(); + expect(hash.code).toBe(0); + expect(hash.stdout).toBe("we%23ird.md#Sec\n"); + + const pct = yield* runCli(["targets", "pct%2525.md"], { cwd: dir }).join(); + expect(pct.code).toBe(0); + expect(pct.stdout).toBe("pct%2525.md#Sec\n"); + + const ran = yield* runCli(["run", "we%23ird.md#Sec", "--raw"], { cwd: dir }).join(); + expect(ran.code).toBe(0); + expect(ran.stdout).toContain("HASH_FILE_MARKER"); + }); + }); + + it("CT7: an explicit run executes one target and excludes both siblings", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + const { code, stdout } = yield* runCli(["run", "doc.md#Beta", "--raw"], { cwd: dir }).join(); + expect(code).toBe(0); + expect(stdout).toContain("PREAMBLE_MARKER"); + expect(stdout).toContain("BETA_MARKER"); + expect(stdout).toContain("NESTED_MARKER"); + expect(stdout).not.toContain("ALPHA_MARKER"); + expect(stdout).not.toContain("GAMMA_MARKER"); + }); + }); + + it("CT8: the default command selects the same target as the explicit one", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + const explicit = yield* runCli(["run", "doc.md#Beta/Nested", "--raw"], { cwd: dir }).join(); + const implicit = yield* runCli(["doc.md#Beta/Nested", "--raw"], { cwd: dir }).join(); + expect(implicit.code).toBe(explicit.code); + expect(implicit.stdout).toBe(explicit.stdout); + expect(implicit.stdout).toContain("NESTED_MARKER"); + expect(implicit.stdout).not.toContain("ALPHA_MARKER"); + }); + }); + + it("CT9: a wildcard resolving to one target executes that target", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + const trailing = yield* runCli(["run", "doc.md#Al*", "--raw"], { cwd: dir }).join(); + expect(trailing.code).toBe(0); + expect(trailing.stdout).toContain("ALPHA_MARKER"); + expect(trailing.stdout).not.toContain("BETA_MARKER"); + + const embedded = yield* runCli(["run", "doc.md#G*a", "--raw"], { cwd: dir }).join(); + expect(embedded.code).toBe(0); + expect(embedded.stdout).toContain("GAMMA_MARKER"); + + const recursive = yield* runCli(["run", "doc.md#**/Nested", "--raw"], { cwd: dir }).join(); + expect(recursive.code).toBe(0); + expect(recursive.stdout).toContain("NESTED_MARKER"); + expect(recursive.stdout).not.toContain("ALPHA_MARKER"); + }); + }); + + it("CT10: no match fails before expansion and lists every available reference", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + const { code, stdout, stderr } = yield* runCli(["run", "doc.md#Delta", "--raw"], { + cwd: dir, + }).join(); + expect(code).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain('"Delta" matches no document target.'); + expect(stderr).toContain("Available targets:"); + expect(stderr).toContain(" doc.md#Alpha"); + expect(stderr).toContain(" doc.md#Beta"); + expect(stderr).toContain(" doc.md#Beta/Nested"); + expect(stderr).toContain(" doc.md#Gamma"); + // A bare canonical fragment would mean the CLI printed the core's list. + expect(stderr).not.toContain(" Alpha\n"); + }); + }); + + it("CT10a: an invalid selector reports the whole catalog too", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + const { code, stderr } = yield* runCli(["run", "doc.md#%zz", "--raw"], { cwd: dir }).join(); + expect(code).toBe(1); + expect(stderr).toContain('"%zz" is not a valid document target selector.'); + expect(stderr).toContain(" doc.md#Alpha"); + }); + }); + + it("CT10b: a document with no targets says so instead of listing none", function* () { + yield* useFixture({ "doc.md": "just a paragraph\n" }, function* (dir) { + const { code, stderr } = yield* runCli(["run", "doc.md#Any", "--raw"], { cwd: dir }).join(); + expect(code).toBe(1); + expect(stderr).toContain('"Any" matches no document target.'); + expect(stderr).toContain("The document has no targets."); + expect(stderr).not.toContain("Available targets:"); + }); + }); + + it("CT11: several matches fail and list every matching reference", function* () { + yield* useFixture({ "doc.md": DUPLICATE }, function* (dir) { + const { code, stdout, stderr } = yield* runCli(["run", "doc.md#Same", "--raw"], { + cwd: dir, + }).join(); + expect(code).toBe(1); + expect(stdout).toBe(""); + expect(stderr).toContain('"Same" matches more than one document target.'); + expect(stderr).toContain("Matched targets:"); + // The ambiguity is two entries, so it is reported as two lines. + expect(stderr).toContain(["Matched targets:", " doc.md#Same", " doc.md#Same"].join("\n")); + expect(stderr).not.toContain("Available targets:"); + }); + }); + + it("CT13: exotic headings are listed as canonical references", function* () { + yield* useFixture({ "doc.md": EXOTIC }, function* (dir) { + const listed = yield* runCli(["targets", "doc.md"], { cwd: dir }).join(); + expect(listed.code).toBe(0); + expect(listed.stdout).toBe( + [...EXOTIC_REFERENCES.map(([reference]) => reference), ""].join("\n"), + ); + }); + }); + + it("CT13a: a heading holding syntax runs through its reference", function* () { + yield* useFixture({ "doc.md": EXOTIC }, function* (dir) { + yield* eachRuns(dir, EXOTIC_REFERENCES.slice(0, 3)); + }); + }); + + it("CT13b: a heading holding whitespace or Unicode runs through its reference", function* () { + yield* useFixture({ "doc.md": EXOTIC }, function* (dir) { + yield* eachRuns(dir, EXOTIC_REFERENCES.slice(3)); + }); + }); + + it("CT16: a target failure outranks an invalid props schema and runs nothing", function* () { + yield* useFixture({ "doc.md": BROKEN_SCHEMA }, function* (dir) { + const missing = yield* runCli(["run", "doc.md#Absent", "--raw"], { cwd: dir }).join(); + expect(missing.code).toBe(1); + expect(missing.stderr).toContain('"Absent" matches no document target.'); + expect(missing.stderr).toContain(" doc.md#Kept"); + expect(yield* exists(path.join(dir, "schema-effect.txt"))).toBe(false); + + // A target the document does offer lets the schema failure be reported. + const resolvable = yield* runCli(["run", "doc.md#Kept", "--raw"], { cwd: dir }).join(); + expect(resolvable.code).toBe(1); + expect(resolvable.stderr).not.toContain("matches no document target"); + expect(resolvable.stderr).toContain("invalid props schema"); + expect(yield* exists(path.join(dir, "schema-effect.txt"))).toBe(false); + }); + }); + + it("CT-text: a targeted text root keeps its stdout and printed-error contract", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + const { code, stdout, stderr } = yield* runCli(["run", "doc.md#Alpha", "--raw"], { + cwd: dir, + }).join(); + expect(code).toBe(0); + expect(stdout).toContain("ALPHA_MARKER"); + expect(stderr).toBe(""); + }); + }); +}); + +/** + * The exit continuation `exit()` reaches for. `main()` installs one under this + * name; a suite that drives `runXmd` directly installs its own so a command's + * status is a value rather than a process exit. + */ +const ExitContext = createContext<(result: { status: number }) => Operation>("exit"); + +interface InProcessRun { + status: number; + stderr: string; + serviceInstalled: boolean; + reads: string[]; +} + +/** + * Drive `runXmd` in this process, with one filesystem that answers differently + * after the first read of the document. + * + * The command line cannot express a document that changes between the run's own + * reads, and reproducing it with a real file would be a race. The seam is + * therefore installed around the operation: read one returns whatever is on + * disk, every later read returns the replacement, and every path the run + * touches is recorded. + */ +function* replacingRun( + args: string[], + documentPath: string, + replacement: string, +): Operation { + const reads: string[] = []; + let status = 0; + let stderr = ""; + let serviceInstalled = false; + let documentReads = 0; + + const written = console.error; + return yield* scoped(function* () { + yield* ensure(() => { + console.error = written; + }); + console.error = (...parts: unknown[]) => { + stderr += `${parts.map((part) => String(part)).join(" ")}\n`; + }; + + yield* ExitContext.set(function* (result) { + status = result.status; + }); + + yield* API.Fs.around({ + *readTextFile([target], next) { + reads.push(target); + if (target === documentPath) { + documentReads += 1; + if (documentReads > 1) { + return replacement; + } + } + return yield* next(target); + }, + }); + + yield* runXmd(args, function* () { + serviceInstalled = true; + }); + + return { status, stderr, serviceInstalled, reads }; + }); +} + +const FIRST_BODY = ["# Doc", "", "## Alpha", "", "ALPHA_MARKER", ""].join("\n"); + +/** + * The same `A*` selector, a different single answer: `Alpha` is gone and `Aeta` + * is what the wildcard would now name. Its body reads a file, so executing it + * would be visible even though nothing it renders is. + */ +const REPLACEMENT_BODY = [ + "# Doc", + "", + "## Aeta", + "", + '', + "", +].join("\n"); + +describe( + "Tier CT — exact target before execution", + { sanitizeOps: false, sanitizeResources: false }, + () => { + it("CT12: a wildcard cannot resolve to one target and execute another", function* () { + yield* useFixture({ "doc.md": FIRST_BODY, "beta-input.txt": "BETA_INPUT" }, function* (dir) { + const documentPath = path.join(dir, "doc.md"); + const run = yield* replacingRun( + ["run", `${documentPath}#A*`, "--raw"], + documentPath, + REPLACEMENT_BODY, + ); + + // The wildcard resolved to Alpha, so execution asked for exactly + // Alpha — which the replaced document does not offer. + expect(run.status).toBe(1); + expect(run.stderr).toContain('"Alpha" matches no document target.'); + expect(run.stderr).toContain("Available targets:"); + expect(run.stderr).toContain(` ${documentPath}#Aeta`); + // Never the caller's own glob: the run stopped on the target it chose. + expect(run.stderr).not.toContain('"A*"'); + + // Refused before the host could gain service authority, and before + // anything Aeta's body would have read. + expect(run.serviceInstalled).toBe(false); + expect(run.reads.some((read) => read.includes("beta-input.txt"))).toBe(false); + expect(yield* exists(path.join(dir, "beta-input.txt"))).toBe(true); + }); + }); + }, +); diff --git a/packages/cli/tests/test-target.test.ts b/packages/cli/tests/test-target.test.ts index 165c22e8..207b9aeb 100644 --- a/packages/cli/tests/test-target.test.ts +++ b/packages/cli/tests/test-target.test.ts @@ -8,7 +8,7 @@ import { describe, it } from "@executablemd/test-support/bdd"; import { expect } from "@executablemd/test-support/expect"; import { ensure } from "effection"; -import { rm } from "@effectionx/fs"; +import { ensureDir, rm, writeTextFile } from "@effectionx/fs"; import { randomUUID } from "node:crypto"; import * as os from "node:os"; import * as path from "node:path"; @@ -305,4 +305,28 @@ describe("Tier DT — xmd test targets", { sanitizeOps: false, sanitizeResources "passing.test.md", ]); }); + + it("DT30: a test path keeps its literal # and %, and no fragment is read", function* () { + // `xmd run` reads a path as a document reference; `xmd test` does not, so + // these two filenames still mean themselves. + const root = path.join(os.tmpdir(), `xmd-dt-${randomUUID()}`); + yield* ensure(() => rm(root, { recursive: true, force: true })); + yield* ensureDir(root); + const body = '\n\nLITERAL_MARKER\n\n\n'; + yield* writeTextFile(path.join(root, "we#ird.test.md"), body); + yield* writeTextFile(path.join(root, "pct%25.test.md"), body); + + const hashed = yield* runCli(["test", path.join(root, "we#ird.test.md")]).join(); + expect(hashed.code).toBe(0); + expect(hashed.stdout).toContain("LITERAL_MARKER"); + + const percent = yield* runCli(["test", path.join(root, "pct%25.test.md")]).join(); + expect(percent.code).toBe(0); + expect(percent.stdout).toContain("LITERAL_MARKER"); + + // The percent-encoded spelling names no file, so it is a missing path + // rather than another way to write the same one. + const encoded = yield* runCli(["test", path.join(root, "we%23ird.test.md")]).join(); + expect(encoded.code).toBe(1); + }); }); diff --git a/packages/cli/tests/value-root.test.ts b/packages/cli/tests/value-root.test.ts index a5ba8f4c..893919b5 100644 --- a/packages/cli/tests/value-root.test.ts +++ b/packages/cli/tests/value-root.test.ts @@ -77,6 +77,46 @@ const FAILS_AFTER_RETURN_ROOT = [ const TEXT_ROOT = "TEXT_MARKER\n"; +/** One ``, in one section — so the other section declares none. */ +const SECTIONED_VALUE_ROOT = [ + "---", + "returns:", + " passed: { type: boolean }", + "---", + "", + "# Sectioned", + "", + "## Ready", + "", + "READY_MARKER", + "", + "", + "", + "## Other", + "", + "OTHER_MARKER", + "", +].join("\n"); + +const SECTIONED_OUTPUT_ROOT = [ + "# Sectioned", + "", + "## Selected", + "", + "DOCUMENTATION_MARKER", + "", + "", + "", + "SELECTED_MARKER", + "", + "", + "", + "## Sibling", + "", + "SIBLING_MARKER", + "", +].join("\n"); + describe("Tier VR — xmd run value roots", { sanitizeOps: false, sanitizeResources: false }, () => { it("VR1: stdout carries only the JSON result", function* () { const result = yield* useFixture({ "doc.md": OBJECT_ROOT }, function* (dir) { @@ -129,4 +169,34 @@ describe("Tier VR — xmd run value roots", { sanitizeOps: false, sanitizeResour expect(result.code).toBe(0); expect(result.stdout).toContain("TEXT_MARKER"); }); + + it("VR7: a targeted value root reserves stdout for its result", function* () { + const result = yield* useFixture({ "doc.md": SECTIONED_VALUE_ROOT }, function* (dir) { + return yield* runCli(["run", "doc.md#Ready", "--verbose"], { cwd: dir }).join(); + }); + expect(result.code).toBe(0); + expect(result.stdout).toBe('{"passed":true}\n'); + // The projection's own body is observability, and the sibling never ran. + expect(result.stderr).toContain("READY_MARKER"); + expect(result.stderr).not.toContain("OTHER_MARKER"); + }); + + it("VR8: a projection without is the same structural failure", function* () { + const result = yield* useFixture({ "doc.md": SECTIONED_VALUE_ROOT }, function* (dir) { + return yield* runCli(["run", "doc.md#Other"], { cwd: dir }).join(); + }); + expect(result.code).toBe(1); + expect(result.stdout).toBe(""); + expect(result.stderr).toContain("no direct top-level "); + }); + + it("VR9: in a projected text root still selects what is emitted", function* () { + const result = yield* useFixture({ "doc.md": SECTIONED_OUTPUT_ROOT }, function* (dir) { + return yield* runCli(["run", "doc.md#Selected", "--raw"], { cwd: dir }).expect(); + }); + expect(result.code).toBe(0); + expect(result.stdout).toContain("SELECTED_MARKER"); + expect(result.stdout).not.toContain("DOCUMENTATION_MARKER"); + expect(result.stdout).not.toContain("SIBLING_MARKER"); + }); }); diff --git a/smoke-test/document-targets.md b/smoke-test/document-targets.md new file mode 100644 index 00000000..ea7c6461 --- /dev/null +++ b/smoke-test/document-targets.md @@ -0,0 +1,12 @@ +# Document targets + +This document addresses two sections. `xmd targets` lists them, and `xmd run` +executes exactly one of them — its sibling does not run at all. + +## Alpha + +ALPHA_RAN + +## Beta + +BETA_RAN diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index be902cee..49efdac9 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -3055,6 +3055,78 @@ and never returns the retained terminal result. A journal with no retained terminal result is unaffected: it replays what it has and continues live, so a root import it does not contain is one this run performs. +##### The command line addresses a document + +Two commands read the document-reference grammar, and both consume the model +above rather than restating it. Neither scans headings, matches a selector, +projects a body, or defines an error of its own. + +`xmd targets ` prints the catalog: + +```text +$ xmd targets README.md +README.md#Test +README.md#Test/Node +README.md#Test/Bun +``` + +It takes exactly one file reference and nothing else — no target selector of its +own, including the empty one after a bare `#`; no inline document, document +property, agent flag, service option, run or test option; and no second +argument. Each entry is `formatDocumentReference(path, target)` followed by a +newline, in source order, duplicates retained, so a duplicate canonical path +prints twice. A document that addresses nothing writes no bytes at all and exits +zero. An unreadable reference, a missing or unreadable file, a parse or schema +failure, and an unsupported invocation each exit nonzero. + +`xmd run` takes the same grammar in both of its forms: + +```text +xmd run README.md#Release/Publish +xmd README.md#Release/* +``` + +Only a file-backed run argument is read as a reference. An inline `-e` document +is untargeted, and `xmd test` keeps its own path grammar: a test path containing +a literal `#` or `%` still names that file. + +**The selector is replaced by its answer before anything executes.** The command +inspects the document to discover its properties, and the run then reads the +file again. What execution is asked for is the exact canonical target that +inspection resolved, never the selector that resolved it. So if a wildcard names +`Alpha` during inspection and the file is replaced such that the same wildcard +would name `Beta`, the run fails on the absent `Alpha`; it never silently runs +`Beta`. That refusal is reported before the host installs a service and before +`execute()`. + +Diagnostics keep the core's own first line and render every target as a full +document reference, because a reference is what a caller can act on: + +```text +"Release/*" matches more than one document target. +Matched targets: + README.md#Release/Publish + README.md#Release/Announce +``` + +`multiple-matches` lists the matches; an invalid selector and a no-match list +the whole catalog under `Available targets:`, or say `The document has no +targets.` when the catalog is empty. Every other failure keeps the printed-error +behavior it already had. + +A filename containing `#` is written `%23` and one containing a literal `%HH` +sequence is written `%25HH`. This is a deliberate change to `xmd run` path +grammar for those two filenames, and the reason target selection can be written +at all. + +Tier CT — CLI document targets covers the catalog, its ordering and duplicates, +the empty catalog's byte-empty output, discovery running nothing, every rejected +invocation, encoded filenames, both run forms, wildcards, the failure +diagnostics, the exact-before-execute replacement, exotic headings, and target +failure outranking a schema failure. Tier CH covers the help surfaces, Tier PC +targeted properties, Tier VR targeted value and `` roots, Tier IE inline +exclusivity, and Tier DT the unchanged `xmd test` path grammar. + ### 5.5 The Component Api Expansion's context-dependent operations are exposed through one public @@ -6372,9 +6444,12 @@ yield* runXmd(args, useDenoService); The installer is invoked only for `xmd run` and `xmd test`, immediately before `execute()`. Help, inspection and agent-worker paths never install or attach a -service. Each adapter supplies host randomness, inherited environment and -stdout/stderr writers to the shared service host; production adapters reject a -non-loopback requested host before spawning. +service. `xmd targets` is one of those inspection paths: it never invokes the +installer, and neither does a run that refuses its target — the exact-target +check happens before the installer is reached, so a document the run will not +execute never gains service authority. Each adapter supplies host randomness, +inherited environment and stdout/stderr writers to the shared service host; +production adapters reject a non-loopback requested host before spawning. Each entrypoint owns its own argument order; there is no shared builder for them to forward to. `cli.ts` still reaches the host directly for terminal and diff --git a/specs/root-document-props-spec.md b/specs/root-document-props-spec.md index f55feffc..5ae679a1 100644 --- a/specs/root-document-props-spec.md +++ b/specs/root-document-props-spec.md @@ -328,6 +328,27 @@ Root document props belong to `xmd run`, and so does the inline root document. `xmd test` accepts neither `--props`, `--props-*`, `XMD_PROPS`, `XMD_PROPS_*`, nor `--eval`/`-e`. +## Targeted roots + +A file path given to `xmd run` is a document reference (§5.4): everything after +its first raw `#` selects one section of the document to run. A filename that +really contains `#` is written `%23`, and one containing a literal `%HH` +sequence is written `%25HH`. + +Props are unaffected by the selection, because they are the document's. The +frontmatter that declares them is retained by every projection, so the same +options, environment variables, defaults, requirements, and `--help` section +apply — the declaring document is named by its path, and interpolation resolves +against the projected body. A required property is still required when only one +section runs. + +Selection happens before props are extracted, so a selector that names no single +section is reported instead of a property complaint about a section that does +not exist. + +`xmd test` does not adopt this grammar. A test path containing a literal `#` or +`%` continues to name that file, and `xmd test` gains no target selection. + ## Essential Acceptance Tests Core acceptance tests prove that inspection has no body effects, programmatic @@ -337,4 +358,5 @@ interpolation and eval bindings, and invalid props prevent body effects. CLI acceptance tests prove the individual and aggregate sources, precedence, boolean and array forms, invalid-source failure, document-specific help without execution, the document-first ordering rule, and unchanged behavior for a -document without props. +document without props. They also prove that a projected root keeps the whole +document's declared properties, its property help, and its requirements. From 31a8ac997ebd7a4f6ec13be865803863e0817606 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Mon, 10 Aug 2026 11:43:41 -0400 Subject: [PATCH 2/2] =?UTF-8?q?=F0=9F=93=9D=20Say=20what=20a=20refused=20t?= =?UTF-8?q?arget=20actually=20guarantees?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two documented claims were stronger than the code. The run reads its document three times — preparation, the value-mode inspection, then execution — so a replacement that first appears on the third read is refused by `execute()`, after the host's provider installer has run. The specification said every such refusal precedes the installer. It does not, and the guarantee worth stating is the one that holds either way: installing a provider is not using one, so a refused run starts no service and expands nothing, whichever read caught it. CT12 keeps the early boundary and CT12a pins the later one, asserting that the installed provider's start operation is never called. `fileSource()` reads a raw `%` as the start of escape syntax wherever it appears, so every literal percent in a reference must be written `%25` — not only one that begins a valid `%HH` sequence. `pct%zz.md` is refused as a malformed reference rather than read as that filename. The help text already said this correctly; the specifications and the shared `fileSource()` docstring did not. The rejection cases are split three invocations to a case. Each row is a subprocess, and Bun's fixed 5s per-test budget leaves no room for six of them on a loaded runner. --- packages/cli/src/cli.ts | 13 ++- packages/cli/tests/targets-cli.test.ts | 149 +++++++++++++++++++++---- packages/cli/tests/test-target.test.ts | 6 +- packages/core/src/root-source.ts | 5 +- specs/executable-mdx-spec.md | 52 ++++++--- specs/root-document-props-spec.md | 5 +- 6 files changed, 188 insertions(+), 42 deletions(-) diff --git a/packages/cli/src/cli.ts b/packages/cli/src/cli.ts index 1a11e4a6..afb66366 100755 --- a/packages/cli/src/cli.ts +++ b/packages/cli/src/cli.ts @@ -533,6 +533,10 @@ function* runDocument( // Native service authority belongs only to document execution. Help, // document inspection, and the agent worker never enter this scope. + // + // This wires a provider into scope; it starts nothing. A run refused by the + // reread inside `execute()` below has passed this line and still never asks + // the provider for a service. yield* installService(); const execution = yield* execute({ @@ -849,8 +853,13 @@ function* inspectCatalog(root: FileRootDocument): Operation * A target failure is the exception, and it is raised rather than deferred. By * the time a run reaches here the requested selector has already been replaced * by the exact target it resolved to, so a failure means the document no longer - * offers the section this run decided on. That refusal belongs before the - * service installer and before `execute()`, not after them. + * offers the section this run decided on. + * + * Raising it here refuses the run at the earliest read that can see it, which is + * before the host's provider installer. A document replaced later still cannot + * be caught here — `execute()` reads it once more and raises the same failure + * after the installer has run — so this is the earlier of two refusals, not the + * only one. Neither starts a service or expands anything. */ function* readsValue(root: RootDocumentSource): Operation { try { diff --git a/packages/cli/tests/targets-cli.test.ts b/packages/cli/tests/targets-cli.test.ts index 85926ca8..b90debe4 100644 --- a/packages/cli/tests/targets-cli.test.ts +++ b/packages/cli/tests/targets-cli.test.ts @@ -15,7 +15,7 @@ import { ensureDir, exists, rm, writeTextFile } from "@effectionx/fs"; import { randomUUID } from "node:crypto"; import * as os from "node:os"; import * as path from "node:path"; -import { API } from "@executablemd/runtime"; +import { API, Service, useHostFiles } from "@executablemd/runtime"; import { runCli } from "@executablemd/test-support/launch"; import { runXmd } from "../src/cli.ts"; @@ -167,8 +167,9 @@ function* eachRuns(dir: string, expected: readonly [string, string][]): Operatio * Every invocation here fails with a diagnostic and no catalog. * * Split across several cases rather than one table because each row is a - * subprocess, and one case has to stay inside the shortest per-test budget of - * the three runtimes. + * subprocess and one case has to stay inside the shortest per-test budget of + * the three runtimes — Bun's fixed 5s. Three invocations per case leaves the + * margin a loaded runner needs. */ function* eachRejected(dir: string, invocations: readonly string[][]): Operation { for (const args of invocations) { @@ -218,39 +219,56 @@ describe("Tier CT — CLI document targets", { sanitizeOps: false, sanitizeResou }); }); - it("CT5: xmd targets refuses a reference it cannot take on its own terms", function* () { + it("CT5: xmd targets refuses a fragment of its own", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + yield* eachRejected(dir, [[], ["doc.md#Alpha"], ["doc.md#"]]); + }); + }); + + it("CT5b2: xmd targets refuses a second argument and a separator", function* () { yield* useFixture({ "doc.md": REPORT }, function* (dir) { yield* eachRejected(dir, [ - [], - ["doc.md#Alpha"], - ["doc.md#"], ["doc.md", "second.md"], ["doc.md", "--"], ]); }); }); - it("CT5c: xmd targets refuses run and test options", function* () { + it("CT5c: xmd targets refuses journal and output options", function* () { yield* useFixture({ "doc.md": REPORT }, function* (dir) { yield* eachRejected(dir, [ ["doc.md", "--journal", "trace.jsonl"], ["doc.md", "--verbose"], ["doc.md", "--raw"], + ]); + // Rejected before inspection, so the trace it named was never created. + expect(yield* exists(path.join(dir, "trace.jsonl"))).toBe(false); + }); + }); + + it("CT5c2: xmd targets refuses resolution and test options", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + yield* eachRejected(dir, [ ["doc.md", "--component-dir", "components"], ["doc.md", "--no-secret-detection"], ["doc.md", "--pattern", "**/*.test.md"], ]); - // Rejected before inspection, so the trace it named was never created. - expect(yield* exists(path.join(dir, "trace.jsonl"))).toBe(false); }); }); - it("CT5d: xmd targets refuses inline documents, properties, and agent options", function* () { + it("CT5d: xmd targets refuses inline documents and properties", function* () { yield* useFixture({ "doc.md": REPORT }, function* (dir) { yield* eachRejected(dir, [ ["-e", "# Inline"], ["doc.md", "--props-who", "ada"], ["doc.md", "--props", '{"who":"ada"}'], + ]); + }); + }); + + it("CT5d2: xmd targets refuses agent options and unknown options", function* () { + yield* useFixture({ "doc.md": REPORT }, function* (dir) { + yield* eachRejected(dir, [ ["doc.md", "--approve-all"], ["doc.md", "--timeout", "5"], ["doc.md", "--frobnicate"], @@ -288,6 +306,30 @@ describe("Tier CT — CLI document targets", { sanitizeOps: false, sanitizeResou }); }); + it("CT6a: a literal % is escape syntax, so its raw spelling is not a path", function* () { + // `%zz` is not a valid escape, and a reference is not repaired: the whole + // reference is refused rather than read as the literal filename. + const doc = ["# Percent", "", "## Alpha", "", "RAW_PCT_MARKER", ""].join("\n"); + yield* useFixture({ "pct%zz.md": doc }, function* (dir) { + const encoded = yield* runCli(["targets", "pct%25zz.md"], { cwd: dir }).join(); + expect(encoded.code).toBe(0); + expect(encoded.stdout).toBe("pct%25zz.md#Alpha\n"); + + const ran = yield* runCli(["run", "pct%25zz.md#Alpha", "--raw"], { cwd: dir }).join(); + expect(ran.code).toBe(0); + expect(ran.stdout).toContain("RAW_PCT_MARKER"); + + const raw = yield* runCli(["targets", "pct%zz.md"], { cwd: dir }).join(); + expect(raw.code).toBe(1); + expect(raw.stderr).toContain("Invalid document reference"); + expect(raw.stdout).toBe(""); + + const rawRun = yield* runCli(["run", "pct%zz.md", "--raw"], { cwd: dir }).join(); + expect(rawRun.code).toBe(1); + expect(rawRun.stderr).toContain("Invalid document reference"); + }); + }); + it("CT6: a filename holding # or % is read and reprinted canonically", function* () { const hashed = ["# Hashed", "", "## Sec", "", "HASH_FILE_MARKER", ""].join("\n"); const percent = ["# Percent", "", "## Sec", "", "PCT_FILE_MARKER", ""].join("\n"); @@ -460,29 +502,43 @@ const ExitContext = createContext<(result: { status: number }) => Operation { const reads: string[] = []; let status = 0; let stderr = ""; let serviceInstalled = false; + let serviceStarted = false; let documentReads = 0; const written = console.error; @@ -503,7 +559,7 @@ function* replacingRun( reads.push(target); if (target === documentPath) { documentReads += 1; - if (documentReads > 1) { + if (documentReads >= replaceFrom) { return replacement; } } @@ -511,11 +567,31 @@ function* replacingRun( }, }); + // The fixture is the working directory, so an authored relative path in + // the replacement resolves to a file that really exists — which is what + // makes "the replacement performed no authored read" a live assertion + // rather than one a missing file would satisfy anyway. + yield* API.Env.around({ + *cwd() { + return cwd; + }, + }); + + // What a runtime entrypoint installs beside `runXmd`, so an authored + // `` read in the replacement really does reach the recorder above. + yield* useHostFiles(); + yield* runXmd(args, function* () { serviceInstalled = true; + yield* Service.around({ + *start() { + serviceStarted = true; + throw new Error("the run started a service"); + }, + }); }); - return { status, stderr, serviceInstalled, reads }; + return { status, stderr, serviceInstalled, serviceStarted, documentReads, reads }; }); } @@ -539,13 +615,17 @@ describe( "Tier CT — exact target before execution", { sanitizeOps: false, sanitizeResources: false }, () => { - it("CT12: a wildcard cannot resolve to one target and execute another", function* () { + it("CT12: a replacement seen by preparation is refused before the installer", function* () { yield* useFixture({ "doc.md": FIRST_BODY, "beta-input.txt": "BETA_INPUT" }, function* (dir) { const documentPath = path.join(dir, "doc.md"); + // The value-mode inspection already sees the replacement, so the run + // never reaches the point where a provider would be installed. const run = yield* replacingRun( ["run", `${documentPath}#A*`, "--raw"], documentPath, REPLACEMENT_BODY, + 2, + dir, ); // The wildcard resolved to Alpha, so execution asked for exactly @@ -557,12 +637,41 @@ describe( // Never the caller's own glob: the run stopped on the target it chose. expect(run.stderr).not.toContain('"A*"'); - // Refused before the host could gain service authority, and before - // anything Aeta's body would have read. expect(run.serviceInstalled).toBe(false); + expect(run.serviceStarted).toBe(false); expect(run.reads.some((read) => read.includes("beta-input.txt"))).toBe(false); expect(yield* exists(path.join(dir, "beta-input.txt"))).toBe(true); }); }); + + it("CT12a: a replacement seen only by execution is refused without starting a service", function* () { + yield* useFixture({ "doc.md": FIRST_BODY, "beta-input.txt": "BETA_INPUT" }, function* (dir) { + const documentPath = path.join(dir, "doc.md"); + // Preparation and the value-mode inspection both see the original, so + // the replacement first appears on the read execution performs — after + // the host provider is installed. This is the interval the contract + // permits, and the case exists to hold what it still guarantees. + const run = yield* replacingRun( + ["run", `${documentPath}#A*`, "--raw"], + documentPath, + REPLACEMENT_BODY, + 3, + dir, + ); + + expect(run.documentReads).toBe(3); + expect(run.status).toBe(1); + // Execution asked for the exact target preparation chose, not the glob. + expect(run.stderr).toContain('"Alpha" matches no document target.'); + expect(run.stderr).toContain(` ${documentPath}#Aeta`); + expect(run.stderr).not.toContain('"A*"'); + + // Provider installation happened; using it did not. `Aeta` never + // expanded, so nothing its body would have read was read. + expect(run.serviceInstalled).toBe(true); + expect(run.serviceStarted).toBe(false); + expect(run.reads.some((read) => read.includes("beta-input.txt"))).toBe(false); + }); + }); }, ); diff --git a/packages/cli/tests/test-target.test.ts b/packages/cli/tests/test-target.test.ts index 207b9aeb..71863b41 100644 --- a/packages/cli/tests/test-target.test.ts +++ b/packages/cli/tests/test-target.test.ts @@ -314,13 +314,15 @@ describe("Tier DT — xmd test targets", { sanitizeOps: false, sanitizeResources yield* ensureDir(root); const body = '\n\nLITERAL_MARKER\n\n\n'; yield* writeTextFile(path.join(root, "we#ird.test.md"), body); - yield* writeTextFile(path.join(root, "pct%25.test.md"), body); + yield* writeTextFile(path.join(root, "pct%zz.test.md"), body); const hashed = yield* runCli(["test", path.join(root, "we#ird.test.md")]).join(); expect(hashed.code).toBe(0); expect(hashed.stdout).toContain("LITERAL_MARKER"); - const percent = yield* runCli(["test", path.join(root, "pct%25.test.md")]).join(); + // `%zz` is not a valid escape, so `xmd run` would refuse this reference + // outright. `xmd test` reads it as the filename it is. + const percent = yield* runCli(["test", path.join(root, "pct%zz.test.md")]).join(); expect(percent.code).toBe(0); expect(percent.stdout).toContain("LITERAL_MARKER"); diff --git a/packages/core/src/root-source.ts b/packages/core/src/root-source.ts index 51503a12..b651f448 100644 --- a/packages/core/src/root-source.ts +++ b/packages/core/src/root-source.ts @@ -57,8 +57,9 @@ export function inlineSource( * fixed wording: the input is a command-line argument, and echoing it back * would put arbitrary bytes into a diagnostic. * - * A filename containing `#` is written `%23`, and one containing a literal - * `%HH` sequence is written `%25HH`. + * A filename containing `#` is written `%23`, and every literal `%` is written + * `%25`: escape syntax begins at a `%` wherever one appears, so a filename + * holding one that is not a valid escape is refused rather than read literally. */ export function fileSource(reference: string): FileRootDocument { const fragment = reference.indexOf("#"); diff --git a/specs/executable-mdx-spec.md b/specs/executable-mdx-spec.md index 49efdac9..e23c2f42 100644 --- a/specs/executable-mdx-spec.md +++ b/specs/executable-mdx-spec.md @@ -2868,8 +2868,10 @@ from a level separator. An empty path, a malformed escape, a byte sequence that is not UTF-8, and NUL each fail with a cause-free `TypeError` whose message is exactly `Invalid document reference`; the input is a command-line argument, and echoing it back would put arbitrary bytes into a diagnostic. A filename -containing `#` is written `%23`, and one containing a literal `%HH` sequence is -written `%25HH`. +containing `#` is written `%23`, and **every** literal `%` is written `%25`. +Escape syntax begins at a `%` wherever one appears, so a filename holding a `%` +that is not a valid escape — `pct%zz.md` — is refused as a malformed reference +rather than read as that literal name; `pct%25zz.md` is how it is written. `formatDocumentReference()` takes a decoded path and, optionally, an already-canonical exact target. It encodes the path, validates the target rather @@ -3096,8 +3098,21 @@ file again. What execution is asked for is the exact canonical target that inspection resolved, never the selector that resolved it. So if a wildcard names `Alpha` during inspection and the file is replaced such that the same wildcard would name `Beta`, the run fails on the absent `Alpha`; it never silently runs -`Beta`. That refusal is reported before the host installs a service and before -`execute()`. +`Beta`. + +Where that refusal lands depends on which read discovered it, and **installing a +provider is not using one**: + +- `xmd targets` never invokes the host's service installer at all. +- A failure the preparation inspection or the value-mode inspection discovers + stops the run before the installer is invoked. +- A failure only the last read discovers — the document changed after both + inspections — is raised by `execute()`, which runs after the installer. The + provider is installed by then; nothing has asked it for anything. + +Every one of those refusals precedes authored work. A run that cannot decide +what to execute expands no component, starts or attaches no service, and +performs no authored effect, whichever read discovered the failure. Diagnostics keep the core's own first line and render every target as a full document reference, because a reference is what a caller can act on: @@ -3114,10 +3129,12 @@ the whole catalog under `Available targets:`, or say `The document has no targets.` when the catalog is empty. Every other failure keeps the printed-error behavior it already had. -A filename containing `#` is written `%23` and one containing a literal `%HH` -sequence is written `%25HH`. This is a deliberate change to `xmd run` path -grammar for those two filenames, and the reason target selection can be written -at all. +A filename containing `#` is written `%23`, and every literal `%` is written +`%25` — including one that is not part of a valid escape, because a raw `%` +starts escape syntax wherever it appears. This is a deliberate change to +`xmd run` path grammar for any filename holding either character, and the reason +target selection can be written at all. `xmd test` is exempt and still reads its +path literally. Tier CT — CLI document targets covers the catalog, its ordering and duplicates, the empty catalog's byte-empty output, discovery running nothing, every rejected @@ -6444,12 +6461,19 @@ yield* runXmd(args, useDenoService); The installer is invoked only for `xmd run` and `xmd test`, immediately before `execute()`. Help, inspection and agent-worker paths never install or attach a -service. `xmd targets` is one of those inspection paths: it never invokes the -installer, and neither does a run that refuses its target — the exact-target -check happens before the installer is reached, so a document the run will not -execute never gains service authority. Each adapter supplies host randomness, -inherited environment and stdout/stderr writers to the shared service host; -production adapters reject a non-loopback requested host before spawning. +service. `xmd targets` is one of those inspection paths and never invokes the +installer. + +A `xmd run` that refuses its document target (§5.4) may or may not have reached +the installer: the refusal comes before it when an inspection discovered the +failure, and after it when only execution's own read did. Either way the run +starts no service, because **installing a provider is not using one** — the +installer wires a provider into scope, and starting a service is a separate +operation a refused run never performs. + +Each adapter supplies host randomness, inherited environment and stdout/stderr +writers to the shared service host; production adapters reject a non-loopback +requested host before spawning. Each entrypoint owns its own argument order; there is no shared builder for them to forward to. `cli.ts` still reaches the host directly for terminal and diff --git a/specs/root-document-props-spec.md b/specs/root-document-props-spec.md index 5ae679a1..b7a8437b 100644 --- a/specs/root-document-props-spec.md +++ b/specs/root-document-props-spec.md @@ -332,8 +332,9 @@ nor `--eval`/`-e`. A file path given to `xmd run` is a document reference (§5.4): everything after its first raw `#` selects one section of the document to run. A filename that -really contains `#` is written `%23`, and one containing a literal `%HH` -sequence is written `%25HH`. +really contains `#` is written `%23`, and every literal `%` is written `%25` — +a raw `%` begins escape syntax wherever it appears, so `pct%zz.md` is refused as +a malformed reference and written `pct%25zz.md` instead. Props are unaffected by the selection, because they are the document's. The frontmatter that declares them is retained by every projection, so the same