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..afb66366 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" }, ), }); @@ -505,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({ @@ -579,6 +611,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 +757,119 @@ 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. + * + * 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 { 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 +998,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 +1088,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 +1120,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 +1267,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..b90debe4 --- /dev/null +++ b/packages/cli/tests/targets-cli.test.ts @@ -0,0 +1,677 @@ +/** + * 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, Service, useHostFiles } 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 — 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) { + 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 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", "second.md"], + ["doc.md", "--"], + ]); + }); + }); + + 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"], + ]); + }); + }); + + 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"], + ]); + }); + }); + + 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("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"); + 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; + /** Whether the host's provider installer ran. */ + serviceInstalled: boolean; + /** Whether anything asked that installed provider to start a service. */ + serviceStarted: boolean; + /** How many times the run read the document itself. */ + documentReads: number; + reads: string[]; +} + +/** + * Drive `runXmd` in this process, with one filesystem that answers differently + * from a chosen read of the document onward. + * + * 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: reads before `replaceFrom` return + * whatever is on disk, that read and every later one return the replacement, + * and every path the run touches is recorded. + * + * `replaceFrom` picks which of the run's three reads first sees the + * replacement, which is what separates a refusal before provider installation + * from one after it. The host installer is recorded separately from the + * provider it installs: installing a provider is not starting a service, and + * this suite must be able to tell the two apart. + */ +function* replacingRun( + args: string[], + documentPath: string, + replacement: string, + replaceFrom: number, + cwd: string, +): Operation { + const reads: string[] = []; + let status = 0; + let stderr = ""; + let serviceInstalled = false; + let serviceStarted = 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 >= replaceFrom) { + return replacement; + } + } + return yield* next(target); + }, + }); + + // 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, serviceStarted, documentReads, 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 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 + // 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*"'); + + 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 165c22e8..71863b41 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,30 @@ 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%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"); + + // `%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"); + + // 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/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/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..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 @@ -3055,6 +3057,93 @@ 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`. + +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: + +```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 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 +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 +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. 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 f55feffc..b7a8437b 100644 --- a/specs/root-document-props-spec.md +++ b/specs/root-document-props-spec.md @@ -328,6 +328,28 @@ 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 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 +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 +359,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.