From f8883eb9e77ce3963f648ba2237b69f20ef3c599 Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:12:41 -0400 Subject: [PATCH 1/4] =?UTF-8?q?=E2=9C=A8=20Describe=20terminal=20grids=20a?= =?UTF-8?q?s=20executable=20document=20structure=20(#729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `` and `` become reserved core structural syntax. This Story owns the authored structure alone: the grammar, the placement rules, and the row-major layout a grid derives. No terminal authority, provider, pane execution, shell, durability or replay is built here. The grid's closed props hold one required positive-integer `columns`; a pane's hold one required non-empty `title`. Titles are display labels and identify nothing — a pane's structural identity is its ordinal among the grid's direct children, and rows are derived in authored row-major order with the last row left short. `structural-rules.ts` decides what the source says, so expansion and document validation cannot disagree about it: only whitespace and direct `` panes may appear in a grid, and an empty grid, direct text, a non-pane element, a control structure that would produce panes, a nested grid, and a pane written anywhere else are refused. `terminal-grid.ts` places the panes once `columns` and each `title` are known. A grid the grammar accepts runs until a terminal provider would be asked for one. This build installs none, so it refuses there, before any pane body expands or a default shell starts, and carries the layout it derived beside the refusal. Evidence rows TG1-TG4: the new structural suite owns TG1, TG2 and TG4; the catalog, validation and `xmd syntax` suites own TG3. --- packages/cli/tests/syntax-cli.test.ts | 46 ++ packages/core/src/document-validation.ts | 24 + packages/core/src/expand.ts | 156 +++++- packages/core/src/structural-rules.ts | 244 +++++++++ packages/core/src/structural.ts | 23 + packages/core/src/terminal-grid.ts | 69 +++ .../core/tests/document-validation.test.ts | 154 ++++++ packages/core/tests/syntax-catalog.test.ts | 76 +++ .../tests/terminal-grid-structure.test.ts | 511 ++++++++++++++++++ 9 files changed, 1301 insertions(+), 2 deletions(-) create mode 100644 packages/core/src/terminal-grid.ts create mode 100644 packages/core/tests/terminal-grid-structure.test.ts diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index 24a997908..39da9b20e 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -301,6 +301,36 @@ describe("Tier SX — the run profile the command describes", () => { ]); }); + it("TG3: describes both terminal-grid constructs without probing for a terminal", function* () { + // Whatever this runtime can or cannot open, the language is the same, so + // the one boundary a capability probe would cross is a trap here. + const catalog = yield* scoped(function* () { + yield* API.Process.around({ + // deno-lint-ignore require-yield + *exec([options]): Operation { + throw new Error(`describing the syntax ran ${JSON.stringify(options.command)}`); + }, + }); + return yield* syntaxCatalog([]); + }); + const [structural, builtIn] = catalog.categories; + + const grid = structural.entries.find((entry) => entry.name === "Terminal.Grid"); + const pane = structural.entries.find((entry) => entry.name === "Terminal"); + expect(grid?.origin).toEqual({ kind: "structural", construct: "Terminal.Grid" }); + expect(pane?.origin).toEqual({ kind: "structural", construct: "Terminal" }); + expect(grid?.syntax).toEqual([""]); + expect(pane?.syntax).toEqual([ + '', + '', + ]); + expect(grid?.description ?? "").not.toBe(""); + expect(pane?.description ?? "").not.toBe(""); + // Reserved syntax, so neither name is a component this profile offers. + expect(names(builtIn.entries)).not.toContain("Terminal.Grid"); + expect(names(builtIn.entries)).not.toContain("Terminal"); + }); + it("SX3: describes without minting an execution claimant", function* () { const catalog = yield* syntaxSymbols([]); const session = catalog.categories[1].entries.find((entry) => entry.name === "Session"); @@ -539,6 +569,22 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources }); }); + it("TG3: prints both terminal-grid constructs, in markdown and in JSON", function* () { + yield* useWorkspace(WORKSPACE, function* (cwd) { + const markdown = yield* runCli(["syntax"], { cwd }).expect(); + expect(markdown.stdout).toContain("### ``"); + expect(markdown.stdout).toContain("### ``"); + expect(markdown.stdout).toContain(""); + expect(markdown.stdout).toContain(''); + expect(markdown.stdout).toContain(''); + + const json = yield* runCli(["syntax", "--json"], { cwd }).expect(); + const structural = parseCatalog(json.stdout).categories[0].entries; + expect(names(structural)).toContain("Terminal.Grid"); + expect(names(structural)).toContain("Terminal"); + }); + }); + it("SX12: succeeds with the defaults in a package tree full of directory links", function* () { yield* useWorkspace( { diff --git a/packages/core/src/document-validation.ts b/packages/core/src/document-validation.ts index 15350db98..9605a39ad 100644 --- a/packages/core/src/document-validation.ts +++ b/packages/core/src/document-validation.ts @@ -74,7 +74,9 @@ import { strayCaseMessage, strayElseMessage, strayStructuralMessage, + strayTerminalMessage, switchStructure, + terminalGridStructure, } from "./structural-rules.ts"; import type { StructuralViolation } from "./structural-rules.ts"; import type { @@ -314,6 +316,8 @@ interface LexicalContext { readonly insideIf: boolean; /** Whether a `` in this source lexically encloses this point. */ readonly insideSwitch: boolean; + /** Whether a `` in this source lexically encloses this point. */ + readonly insideTerminalGrid: boolean; /** Whether the immediate parent is an ``. */ readonly underAnswers: boolean; } @@ -492,6 +496,7 @@ class ValidationState { insideLoop: false, insideIf: false, insideSwitch: false, + insideTerminalGrid: false, underAnswers: false, }); } @@ -1102,6 +1107,24 @@ class ValidationState { return context.insideSwitch ? [] : [{ code: "structural-usage-invalid", source: "Case", message: strayCaseMessage() }]; + case "Terminal.Grid": + // The whole layout is decided from source, so every pane's own mistake + // is reported where it was written — and so is a construct written + // below the grid that the grid does not lay out. + return terminalGridStructure(segment).violations; + case "Terminal": + // A well-placed `` is its grid's, and one placed wrongly + // under a grid is already reported by that grid's own structure. What + // is left is a pane with no grid above it at all. + return context.insideTerminalGrid + ? [] + : [ + { + code: "structural-usage-invalid", + source: "Terminal", + message: strayTerminalMessage(), + }, + ]; case "Else": // A well-placed `` is its ``'s, and one placed wrongly under // an `` is already reported by that ``'s own structure. What is @@ -1272,6 +1295,7 @@ function childContext(segment: ComponentElement, context: LexicalContext): Lexic insideLoop: context.insideLoop || segment.name === "Loop", insideIf: context.insideIf || segment.name === "If", insideSwitch: context.insideSwitch || segment.name === "Switch", + insideTerminalGrid: context.insideTerminalGrid || segment.name === "Terminal.Grid", underAnswers: segment.name === "Answers", }; } diff --git a/packages/core/src/expand.ts b/packages/core/src/expand.ts index 7e35cf22c..933560f28 100644 --- a/packages/core/src/expand.ts +++ b/packages/core/src/expand.ts @@ -13,7 +13,7 @@ * middleware installation) execute before children's code blocks. */ -import { ensure, Err, scoped, useScope, withResolvers } from "effection"; +import { ensure, Err, Ok, scoped, useScope, withResolvers } from "effection"; import type { Operation, Result } from "effection"; import type { FunctionComponent, @@ -57,9 +57,17 @@ import { strayCaseMessage, strayElseMessage, strayStructuralMessage, + strayTerminalMessage, switchStructure, + terminalColumns, + terminalColumnsMissingMessage, + terminalGridStructure, + terminalTitle, + terminalTitleMissingMessage, } from "./structural-rules.ts"; -import type { StructuralViolation, SwitchCase } from "./structural-rules.ts"; +import type { StructuralViolation, SwitchCase, TerminalPane } from "./structural-rules.ts"; +import { terminalGridLayout } from "./terminal-grid.ts"; +import type { PlacedPane } from "./terminal-grid.ts"; import { asBindingViolation, asExpressionViolation, @@ -1174,6 +1182,28 @@ function* expandListSegments( break; } + if (segment.name === "Terminal.Grid") { + // No raise() here, like the branches above: expandTerminalGrid + // reports every error it creates. + yield* expandTerminalGrid(segment, result); + break; + } + + if (segment.name === "Terminal") { + // A well-placed is consumed by its and + // never expanded on its own. Reaching this branch means the pane sits + // outside every grid, so it names no component and is diagnosed + // rather than resolved from the filesystem. + result.push( + yield* raise({ + type: "error", + message: positioned(strayTerminalMessage(), segment), + source: "Terminal", + }), + ); + break; + } + if (segment.name === "Break") { result.push(...(yield* expandBreak(segment, loop))); break; @@ -2029,6 +2059,128 @@ function* expandSwitch( ); } +function terminalGridError(segment: ComponentElement, message: string): ErrorSegment { + return { type: "error", message: positioned(message, segment), source: "Terminal.Grid" }; +} + +function terminalPaneError(segment: ComponentElement, message: string): ErrorSegment { + return { type: "error", message: positioned(message, segment), source: "Terminal" }; +} + +/** + * The value one prop of a terminal-grid construct produced, or why evaluating + * it failed. A missing prop is `undefined`, which is also what an expression + * evaluating to `undefined` leaves behind (§6.5) — absence either way, and the + * caller says what its construct requires instead. + */ +function* resolveStructuralProp( + segment: ComponentElement, + construct: string, + prop: string, +): Operation> { + const expression = segment.expressions[prop]; + if (expression === undefined) { + return Ok(segment.props[prop]); + } + try { + const resolved = yield* resolveExpressionProps( + {}, + { [prop]: expression }, + construct, + segment.projectedEnv, + ); + return Ok(resolved[prop]); + } catch (error) { + return Err(error instanceof Error ? error : new Error(String(error))); + } +} + +/** + * Open the grid the author wrote (spec §6.21). + * + * The whole layout is decided before anything opens: the panes and their forms + * from source, then `columns` and each pane's `title` from the values the + * document computes. Only once the concrete grid is complete is a terminal + * provider anything's business — and this build has none, so the grid refuses + * there. Nothing beneath a pane has expanded and no shell has started when it + * does, which is what makes the refusal a closed one rather than a partial grid + * left behind. + */ +function* expandTerminalGrid(segment: ComponentElement, owner: Segment[]): Operation { + const structure = terminalGridStructure(segment); + if (structure.violations.length > 0) { + for (const violation of structure.violations) { + owner.push(yield* raise(structuralErrorSegment(violation, segment))); + } + return; + } + + const columnsValue = yield* resolveStructuralProp(segment, "Terminal.Grid", "columns"); + if (!columnsValue.ok) { + owner.push(yield* raise(terminalGridError(segment, columnsValue.error.message))); + return; + } + if (columnsValue.value === undefined) { + owner.push(yield* raise(terminalGridError(segment, terminalColumnsMissingMessage()))); + return; + } + const columns = terminalColumns(columnsValue.value); + if (!columns.ok) { + owner.push(yield* raise(terminalGridError(segment, columns.error.message))); + return; + } + + const placed: PlacedPane[] = []; + for (const pane of structure.panes) { + const title = yield* resolvePaneTitle(pane); + if (!title.ok) { + owner.push(yield* raise(terminalPaneError(pane.element, title.error.message))); + return; + } + placed.push({ title: title.value, form: pane.form }); + } + + const layout = terminalGridLayout(columns.value, placed); + owner.push( + yield* raise({ + type: "error", + message: positioned(noTerminalProviderMessage(), segment), + source: "Terminal.Grid", + // The grid the author asked for, carried beside the sentence so an + // assertion is about the layout that was derived rather than about the + // wording of a refusal. + cause: { + layout: { + columns: layout.columns, + rows: layout.rows, + cells: layout.cells.map((cell) => ({ ...cell })), + }, + }, + }), + ); +} + +/** The label one pane displays, from the value its own `title` prop produced. */ +function* resolvePaneTitle(pane: TerminalPane): Operation> { + const value = yield* resolveStructuralProp(pane.element, "Terminal", "title"); + if (!value.ok) { + return value; + } + if (value.value === undefined) { + return Err(new Error(terminalTitleMissingMessage())); + } + return terminalTitle(value.value); +} + +/** What a complete grid says on a host where nothing can open one. */ +function noTerminalProviderMessage(): string { + return ( + "no terminal provider opened this grid. A host installs the terminal-grid capability " + + "explicitly, and this one installs none, so no pane expanded its content and no default " + + "shell started." + ); +} + function loopError(segment: ComponentElement, message: string): ErrorSegment { return { type: "error", message: positioned(message, segment), source: "Loop" }; } diff --git a/packages/core/src/structural-rules.ts b/packages/core/src/structural-rules.ts index e343ad872..24aa676bb 100644 --- a/packages/core/src/structural-rules.ts +++ b/packages/core/src/structural-rules.ts @@ -1034,3 +1034,247 @@ export function answerViolations(segment: ComponentElement): StructuralViolation } return found; } + +const TERMINAL_GRID_PROPS = new Set(["columns"]); +const TERMINAL_PROPS = new Set(["title"]); + +/** What a `` written outside the grid that lays it out says. */ +export function strayTerminalMessage(): string { + return ( + " must be a direct child of . is reserved: it never " + + "resolves a component, and only the grid it belongs to can place it." + ); +} + +/** What a `` written inside another grid says. */ +export function nestedTerminalGridMessage(): string { + return ( + " cannot be written inside another . A grid lays out the " + + "panes it is written with, so one pane cannot become a grid of its own." + ); +} + +/** + * How many columns a grid lays its panes across, or why `columns` rejects it. + * + * The same rule wherever the value came from: a literal is checked while the + * document is only being read, and an expression's answer is checked here too + * once expansion has evaluated it. + */ +export function terminalColumns(columns: Json): Result { + if (typeof columns !== "number") { + return Err( + new Error( + `Prop "columns" on must be a positive integer, not ${jsonKind(columns)}.`, + ), + ); + } + if (!Number.isInteger(columns) || columns < 1) { + return Err( + new Error( + `Prop "columns" on must be a positive integer. Got: ` + + `${JSON.stringify(columns)}.`, + ), + ); + } + return Ok(columns); +} + +/** What a `` naming no column count at all says. */ +export function terminalColumnsMissingMessage(): string { + return ' requires a "columns" prop (a positive integer).'; +} + +/** The label one pane displays, or why `title` rejects it. */ +export function terminalTitle(title: Json): Result { + if (typeof title !== "string") { + return Err( + new Error(`Prop "title" on must be a non-empty string, not ${jsonKind(title)}.`), + ); + } + if (title.length === 0) { + return Err(new Error('Prop "title" on must be a non-empty string. Got: "".')); + } + return Ok(title); +} + +/** What a `` naming no title at all says. */ +export function terminalTitleMissingMessage(): string { + return ' requires a "title" prop (the label the pane displays).'; +} + +/** One pane a grid lays out, and where it sat among its siblings. */ +export interface TerminalPane { + readonly element: ComponentElement; + /** The child index the pane was written at. */ + readonly index: number; + /** + * The pane's structural identity: its position among the grid's panes, + * counting from zero. A title is a display label and identifies nothing. + */ + readonly ordinal: number; + /** Whether the pane runs the markdown it holds or the host's default shell. */ + readonly form: "paired" | "self-closing"; +} + +/** How a `` body divides into panes, and what the division got wrong. */ +export interface TerminalGridStructure { + readonly violations: StructuralViolation[]; + /** The direct panes, in authored order. */ + readonly panes: TerminalPane[]; +} + +/** Which of a pane's two forms was written: its own markdown, or a shell. */ +function paneForm(segment: ComponentElement): TerminalPane["form"] { + return segment.selfClosing ? "self-closing" : "paired"; +} + +/** Everything one `` pane decides from what the author wrote (spec §6.21). */ +function terminalPaneViolations(segment: ComponentElement): StructuralViolation[] { + const found: StructuralViolation[] = []; + const unknownProp = authoredPropNames(segment).find((name) => !TERMINAL_PROPS.has(name)); + if (unknownProp !== undefined) { + found.push( + violation( + "structural-usage-invalid", + "Terminal", + ` only accepts a "title" prop. Got: "${unknownProp}".`, + segment, + ), + ); + } + + if ("title" in segment.props) { + const title = terminalTitle(segment.props.title); + if (!title.ok) { + found.push(violation("structural-usage-invalid", "Terminal", title.error.message, segment)); + } + } else if (!("title" in segment.expressions)) { + found.push( + violation("structural-usage-invalid", "Terminal", terminalTitleMissingMessage(), segment), + ); + } + return found; +} + +/** + * Every `` and `` below a grid that the grid does not + * lay out. The walk stops at a nested grid, which is reported where it sits and + * owns whatever is written beneath it. + */ +function misplacedTerminalViolations(children: Segment[]): StructuralViolation[] { + const found: StructuralViolation[] = []; + + const walk = (segments: Segment[], depth: number): void => { + for (const segment of segments) { + if (segment.type !== "component") { + continue; + } + if (segment.name === "Terminal.Grid") { + if (depth > 0) { + found.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + nestedTerminalGridMessage(), + segment, + ), + ); + } + continue; + } + if (segment.name === "Terminal" && depth > 0) { + found.push( + violation("structural-usage-invalid", "Terminal", strayTerminalMessage(), segment), + ); + } + walk(segment.children, depth + 1); + } + }; + + walk(children, 0); + return found; +} + +/** + * Divide a `` body into its panes and validate the division + * (spec §6.21). Everything here is read from source, so a grid whose layout the + * author got wrong is refused before `columns` is evaluated, before a pane's + * content expands, and before any terminal provider is asked for anything. + * + * The panes are the grid's direct children and only they: a control structure + * that would produce panes as it ran cannot be one, because which panes exist + * is what the grid must know before it opens anything. + */ +export function terminalGridStructure(segment: ComponentElement): TerminalGridStructure { + const violations: StructuralViolation[] = []; + const panes: TerminalPane[] = []; + + const unknownProp = authoredPropNames(segment).find((name) => !TERMINAL_GRID_PROPS.has(name)); + if (unknownProp !== undefined) { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + ` only accepts a "columns" prop. Got: "${unknownProp}".`, + ), + ); + } + if ("columns" in segment.props) { + const columns = terminalColumns(segment.props.columns); + if (!columns.ok) { + violations.push( + violation("structural-usage-invalid", "Terminal.Grid", columns.error.message), + ); + } + } else if (!("columns" in segment.expressions)) { + violations.push( + violation("structural-usage-invalid", "Terminal.Grid", terminalColumnsMissingMessage()), + ); + } + if (segment.selfClosing) { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + " holds the panes it lays out, so it is written paired: " + + '.', + ), + ); + } + + let substantive = 0; + for (const [index, child] of segment.children.entries()) { + if (isBlankText(child)) { + continue; + } + substantive++; + if (child.type !== "component" || child.name !== "Terminal") { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + ` holds only panes. Found ${describeSegment(child)} ` + + "directly inside it. Write control flow inside a pane instead.", + child.type === "component" ? child : undefined, + ), + ); + continue; + } + violations.push(...terminalPaneViolations(child)); + panes.push({ element: child, index, ordinal: panes.length, form: paneForm(child) }); + } + + if (!segment.selfClosing && substantive === 0) { + violations.push( + violation( + "structural-usage-invalid", + "Terminal.Grid", + " requires at least one pane.", + ), + ); + } + + violations.push(...misplacedTerminalViolations(segment.children)); + return { violations, panes }; +} diff --git a/packages/core/src/structural.ts b/packages/core/src/structural.ts index ad36a80ae..0740a8e62 100644 --- a/packages/core/src/structural.ts +++ b/packages/core/src/structural.ts @@ -169,6 +169,29 @@ export const STRUCTURAL_DECLARATIONS: readonly StructuralDeclaration[] = [ as: null, context: "A multiline template, in place of the single-line `template` prop.", }, + { + name: "Terminal.Grid", + syntax: [""], + description: + "Show several interactive terminals at once. " + + '`' + + '` fills `columns` columns with its panes ' + + "in the order they are written, leaving the last row short when the count does not " + + "divide. Only `` panes may be written directly inside it.", + as: null, + context: "The `` panes the grid lays out.", + }, + { + name: "Terminal", + syntax: ['', ''], + description: + "Give one pane of a `` its work. " + + '`` runs that markdown in the pane; ' + + '`` runs the host\'s default interactive shell. `title` ' + + "labels the pane on screen, so two panes may share one.", + as: null, + context: "Markdown the pane runs, in the paired form.", + }, ]; /** diff --git a/packages/core/src/terminal-grid.ts b/packages/core/src/terminal-grid.ts new file mode 100644 index 000000000..59a08a920 --- /dev/null +++ b/packages/core/src/terminal-grid.ts @@ -0,0 +1,69 @@ +/** + * The concrete grid an authored `` derives (spec §6.21). + * + * `structural-rules.ts` decides what the source says: which panes were written, + * in what order, and what is wrong with the way they were written. What it + * cannot decide is where each pane sits, because that also depends on `columns` + * — a value the document may compute. This module is where the two meet, once + * both are known and before anything is opened. + * + * A layout is provider-neutral data. It names no terminal, multiplexer, socket, + * process or window: it says how many columns the author asked for, how many + * rows that many panes fill, and which cell each pane occupies. + */ + +import type { TerminalPane } from "./structural-rules.ts"; + +/** One pane, placed. */ +export interface TerminalGridCell { + /** The pane's structural identity: its position among the panes, from zero. */ + readonly ordinal: number; + /** The row it occupies, from zero. */ + readonly row: number; + /** The column it occupies, from zero. */ + readonly column: number; + /** The label it displays. Two cells may carry the same one. */ + readonly title: string; + /** Whether it runs the markdown the pane holds or the host's default shell. */ + readonly form: TerminalPane["form"]; +} + +/** The complete grid one `` asked for. */ +export interface TerminalGridLayout { + readonly columns: number; + /** How many rows those columns take to hold every pane. */ + readonly rows: number; + /** Every pane, in authored order, which is also row-major order. */ + readonly cells: readonly TerminalGridCell[]; +} + +/** One pane's placeable facts, once its title has been resolved. */ +export interface PlacedPane { + readonly title: string; + readonly form: TerminalPane["form"]; +} + +/** + * Place the panes across `columns` columns in the order they were authored. + * + * Row-major: the first `columns` panes fill the first row, the next fill the + * second, and a count that does not divide leaves the positions at the end of + * the last row unused. Nothing is reordered, padded, or balanced — the author's + * order is the layout, and a pane's ordinal is its identity wherever it lands. + */ +export function terminalGridLayout( + columns: number, + panes: readonly PlacedPane[], +): TerminalGridLayout { + return { + columns, + rows: Math.ceil(panes.length / columns), + cells: panes.map((pane, ordinal) => ({ + ordinal, + row: Math.floor(ordinal / columns), + column: ordinal % columns, + title: pane.title, + form: pane.form, + })), + }; +} diff --git a/packages/core/tests/document-validation.test.ts b/packages/core/tests/document-validation.test.ts index 6c5f6fd91..c37f8c92b 100644 --- a/packages/core/tests/document-validation.test.ts +++ b/packages/core/tests/document-validation.test.ts @@ -681,6 +681,160 @@ describe("Tier DV: branch selection", () => { }); }); +describe("Tier DV: terminal grids", () => { + const GRID_DOC = [ + "", + '', + '', + "", + '', + "", + "", + ].join("\n"); + + it("TG3: a well-formed grid is valid, and nothing beneath it runs", function* () { + const { result, seen } = yield* validateText(GRID_DOC, { + tree: { "components/Widget.md": WIDGET }, + }); + + expect(result.outcome).toBe("valid"); + expect(result.diagnostics).toEqual([]); + expect(names(result)).toEqual(["Terminal.Grid", "Terminal", "Widget", "Terminal"]); + expect(named(result, "Terminal.Grid").origin).toEqual({ + kind: "structural", + construct: "Terminal.Grid", + }); + expect(named(result, "Terminal").origin).toEqual({ + kind: "structural", + construct: "Terminal", + }); + // A pane's body is walked like any other region, and none of it — no + // shell, no command, no agent, no terminal — was reached to walk it. + expect(seen.effects).toEqual([]); + // Reserved means selection never looked for a file that could supply + // either construct. + expect(seen.reads.some((read) => read.includes("Terminal"))).toBe(false); + }); + + it("TG3: reports each invalid authored form, with no execution", function* () { + const invalid: [string, string, string][] = [ + [ + "an unknown prop on the grid", + '\n', + ' only accepts a "columns" prop. Got: "layout".', + ], + [ + "a capture on the grid", + '\n', + ' only accepts a "columns" prop. Got: "as".', + ], + [ + "no column count", + '\n', + ' requires a "columns" prop (a positive integer).', + ], + [ + "a column count that is not a positive integer", + '\n', + 'Prop "columns" on must be a positive integer. Got: 0.', + ], + [ + "an unknown prop on a pane", + '\n', + ' only accepts a "title" prop. Got: "shell".', + ], + [ + "no title on a pane", + "\n", + ' requires a "title" prop (the label the pane displays).', + ], + [ + "an empty title", + '\n', + 'Prop "title" on must be a non-empty string. Got: "".', + ], + [ + "a self-closing grid", + "\n", + " holds the panes it lays out", + ], + [ + "a grid with no pane", + "\n", + " requires at least one pane.", + ], + [ + "text written directly in a grid", + 'a note\n', + ' holds only panes. Found text "a note" directly inside it.', + ], + [ + "a direct element that is not a pane", + '\n', + " holds only panes. Found directly inside it.", + ], + [ + "a pane produced by control flow", + '\n', + " holds only panes. Found directly inside it.", + ], + [ + "a nested grid", + '' + + '\n', + " cannot be written inside another .", + ], + [ + "a pane outside every grid", + 'alone\n', + " must be a direct child of .", + ], + [ + "a pane below a grid that is not one of its panes", + '' + + "\n", + " must be a direct child of .", + ], + ]; + + for (const [form, source, message] of invalid) { + const { result, seen } = yield* validateText(source, { + tree: { "components/Widget.md": WIDGET }, + }); + + expect(`${form}: ${result.outcome}`).toBe(`${form}: invalid`); + expect(`${form}: ${codes(result).includes("structural-usage-invalid")}`).toBe( + `${form}: true`, + ); + const said = result.diagnostics.some((diagnostic) => diagnostic.message.includes(message)); + expect(`${form}: ${said}`).toBe(`${form}: true`); + expect(`${form}: ${JSON.stringify(seen.effects)}`).toBe(`${form}: []`); + } + }); + + it("TG3: answers the same way twice", function* () { + const first = yield* validateText("\n"); + const second = yield* validateText("\n"); + + expect(JSON.stringify(second.result)).toBe(JSON.stringify(first.result)); + }); + + it("TG3: a dynamic column count and title are decided by expansion, not here", function* () { + const { result, seen } = yield* validateText( + ["", "", "", ""].join( + "\n", + ), + ); + + // Whether those expressions produce a positive integer and a non-empty + // string is a value the document computes, and evaluating one is + // expansion's alone. + expect(result.outcome).toBe("valid"); + expect(result.diagnostics).toEqual([]); + expect(seen.effects).toEqual([]); + }); +}); + describe("Tier DV: source, target and declaration failures", () => { const ROWS: { readonly id: string; diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 8339704bb..20a8932cb 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -348,6 +348,82 @@ describe("Tier SY: structural vocabulary", () => { expect(find(entries, "Case").as).toBeUndefined(); }); + it("TG3: freezes the and entries the catalog publishes", function* () { + const catalog = yield* catalogFor({}, []); + const entries = structural(catalog); + + expect(catalog.version).toBe(1); + expect(find(entries, "Terminal.Grid")).toEqual({ + kind: "structural", + name: "Terminal.Grid", + origin: { kind: "structural", construct: "Terminal.Grid" }, + syntax: [""], + description: + "Show several interactive terminals at once. " + + '`' + + '` fills `columns` columns with its panes ' + + "in the order they are written, leaving the last row short when the count does not " + + "divide. Only `` panes may be written directly inside it.", + context: "The `` panes the grid lays out.", + }); + expect(find(entries, "Terminal")).toEqual({ + kind: "structural", + name: "Terminal", + origin: { kind: "structural", construct: "Terminal" }, + syntax: ['', ''], + description: + "Give one pane of a `` its work. " + + '`` runs that markdown in the pane; ' + + '`` runs the host\'s default interactive shell. `title` ' + + "labels the pane on screen, so two panes may share one.", + context: "Markdown the pane runs, in the paired form.", + }); + // Neither construct binds, so neither carries an `as` sentence at all. + expect(find(entries, "Terminal.Grid").as).toBeUndefined(); + expect(find(entries, "Terminal").as).toBeUndefined(); + }); + + it("TG3: a repository file cannot supply the grid or a pane, and neither can a registration", function* () { + const catalog = yield* catalogFor( + { + components: { kind: "directory" }, + "components/Terminal.md": markdown("a repository terminal\n"), + "components/Terminal": { kind: "directory" }, + "components/Terminal/Grid.md": markdown("a repository grid\n"), + }, + ["components"], + ); + + for (const name of ["Terminal.Grid", "Terminal"]) { + expect(names(structural(catalog))).toContain(name); + expect(names(userProvided(catalog))).not.toContain(name); + expect(names(builtIn(catalog))).not.toContain(name); + } + + for (const name of ["Terminal.Grid", "Terminal"]) { + let refused: unknown; + yield* scoped(function* () { + try { + yield* registerComponents([ + { + name, + origin: "tier-tg", + props: {}, + *fn() { + return ""; + }, + }, + ]); + } catch (error) { + refused = error; + } + }); + expect(refused instanceof Error ? refused.message : "").toContain( + `cannot register "${name}": it is structural syntax the engine owns`, + ); + } + }); + it("SY5b: a repository file cannot supply or , and neither can a registration", function* () { const catalog = yield* catalogFor( { diff --git a/packages/core/tests/terminal-grid-structure.test.ts b/packages/core/tests/terminal-grid-structure.test.ts new file mode 100644 index 000000000..76c440cba --- /dev/null +++ b/packages/core/tests/terminal-grid-structure.test.ts @@ -0,0 +1,511 @@ +/** + * Tier TG — the authored structure of a terminal grid (spec §6.21). + * + * What an author may write, and where each pane lands, decided before anything + * opens. These rows drive the real expansion path: a grid the grammar accepts + * runs until the point a terminal provider would be asked for one, and this + * build installs none, so it refuses there and carries the layout it derived + * beside the refusal. + * + * Provider non-observation is asserted rather than assumed. Every run traps the + * two boundaries a pane's body would cross — resolving a component and running + * a code block — and a row is evidence only when both stayed empty. That the + * machine running these tests has no tmux is not evidence of anything: nothing + * here would look for one. + */ + +import { describe, it } from "@executablemd/test-support/bdd"; +import { expect } from "@executablemd/test-support/expect"; +import { scoped } from "effection"; +import type { Operation } from "effection"; + +import { Component } from "../src/component-api.ts"; +import { expandSegments } from "../src/expand.ts"; +import { renderSegments } from "../src/render.ts"; +import { scanSegments } from "../src/scanner.ts"; +import { terminalGridLayout } from "../src/terminal-grid.ts"; +import type { Json, Segment } from "../src/types.ts"; + +interface GridRun { + segments: Segment[]; + output: string; + /** Every component the run tried to resolve, in order. */ + imports: string[]; + /** The source of every code block the run ran, in order. */ + blocks: string[]; + /** Every expression the document evaluated, by label, in order. */ + calls: string[]; +} + +/** + * Expand one document with every effect a pane could have trapped. + * + * A component this run resolves, a code block it runs, or an expression it + * evaluates is recorded rather than performed, so "nothing beneath the grid + * happened" is something the row reads back instead of assuming. + */ +function runGrid(source: string, values: Record = {}): Operation { + return scoped(function* () { + const imports: string[] = []; + const blocks: string[] = []; + const calls: string[] = []; + yield* Component.around( + { + // deno-lint-ignore require-yield + *importComponent([name], _next) { + imports.push(name); + throw new Error(`Component not found: ${name}`); + }, + // deno-lint-ignore require-yield + *applyModifiers([_modifiers, context], _next) { + blocks.push(context.content); + return { output: "", exitCode: 0, stderr: "" }; + }, + }, + { at: "min" }, + ); + const testEnv = { + values: { + ...values, + seen: (label: string, value: unknown) => { + calls.push(label); + return value; + }, + }, + }; + yield* Component.around({ env: () => testEnv }, { at: "min" }); + const segments = yield* expandSegments(scanSegments(source), {}, {}, new Set()); + return { segments, output: renderSegments(segments), imports, blocks, calls }; + }); +} + +function errorMessages(segments: Segment[]): string[] { + return segments.filter((segment) => segment.type === "error").map((segment) => segment.message); +} + +/** The one message a run that refused for a single reason reports. */ +function soleError(run: GridRun): string { + const messages = errorMessages(run.segments); + expect(messages).toHaveLength(1); + return messages[0]!; +} + +/** + * The grid a run derived, read from the refusal that carries it. + * + * A run that refused for a grammar or placement reason never derived one, so + * asking for it is also how a row states that the grid was complete. + */ +function derivedLayout(run: GridRun): Json { + const refusal = run.segments.find( + (segment) => segment.type === "error" && segment.source === "Terminal.Grid", + ); + if (refusal === undefined || refusal.type !== "error" || refusal.cause === undefined) { + throw new Error(`no terminal-grid refusal carrying a layout: ${errorMessages(run.segments)}`); + } + return refusal.cause; +} + +/** Every boundary a pane's body would have crossed, and none of them did. */ +function reachedNothing(run: GridRun): void { + expect(run.imports).toEqual([]); + expect(run.blocks).toEqual([]); + expect(run.calls).toEqual([]); +} + +/** + * Work a pane's body would do, so a body that expanded would be recorded. + * + * One of each boundary `reachedNothing()` reads: a component to resolve, an + * expression to evaluate, and a command to run. + */ +const PANE_BODY = [ + "", + "", + 'reached', + "", + "```bash exec", + "echo ran", + "```", +].join("\n"); + +describe("Tier TG — the grid grammar", () => { + it("TG1: accepts a paired grid with positive integer columns and both pane forms", function* () { + const run = yield* runGrid( + [ + "", + 'Instructions.', + '', + "", + ].join("\n"), + ); + + // The grammar accepted it, so the run reached the one thing this build + // cannot do — and stopped there. + expect(soleError(run)).toContain("no terminal provider opened this grid"); + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 1, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, + { ordinal: 1, row: 0, column: 1, title: "Shell", form: "self-closing" }, + ], + }, + }); + }); + + it("TG1: refuses an unknown prop and `as` on the grid", function* () { + const unknown = yield* runGrid( + '', + ); + expect(soleError(unknown)).toContain( + ' only accepts a "columns" prop. Got: "layout".', + ); + + const captured = yield* runGrid( + '', + ); + expect(soleError(captured)).toContain( + ' only accepts a "columns" prop. Got: "as".', + ); + reachedNothing(unknown); + reachedNothing(captured); + }); + + it("TG1: refuses an unknown prop and `as` on a pane", function* () { + const unknown = yield* runGrid( + '', + ); + expect(soleError(unknown)).toContain(' only accepts a "title" prop. Got: "shell".'); + + const captured = yield* runGrid( + '', + ); + expect(soleError(captured)).toContain(' only accepts a "title" prop. Got: "as".'); + reachedNothing(unknown); + reachedNothing(captured); + }); + + it("TG1: requires columns to be a positive integer, however it was written", function* () { + const missing = yield* runGrid(''); + expect(soleError(missing)).toContain( + ' requires a "columns" prop (a positive integer).', + ); + + for (const literal of ["{0}", "{-1}", "{2.5}", '"2"', "{null}"]) { + const run = yield* runGrid( + ``, + ); + expect(soleError(run)).toContain('Prop "columns" on must be a positive'); + reachedNothing(run); + } + + // The same rule reaches a value the document computes, which the source + // could not have decided about. + const computed = yield* runGrid( + '', + { size: 0 }, + ); + expect(soleError(computed)).toContain( + 'Prop "columns" on must be a positive integer. Got: 0.', + ); + reachedNothing(computed); + }); + + it("TG1: requires a non-empty title on every pane, however it was written", function* () { + const missing = yield* runGrid(""); + expect(soleError(missing)).toContain( + ' requires a "title" prop (the label the pane displays).', + ); + + for (const literal of ['""', "{3}", "{null}"]) { + const run = yield* runGrid( + ``, + ); + expect(soleError(run)).toContain('Prop "title" on must be a non-empty string'); + reachedNothing(run); + } + + const computed = yield* runGrid( + "", + { label: "" }, + ); + expect(soleError(computed)).toContain( + 'Prop "title" on must be a non-empty string. Got: "".', + ); + reachedNothing(computed); + }); + + it("TG1: refuses a self-closing grid", function* () { + const run = yield* runGrid(""); + expect(soleError(run)).toContain(" holds the panes it lays out"); + reachedNothing(run); + }); +}); + +describe("Tier TG — structural placement", () => { + it("TG2: refuses a grid with no pane", function* () { + const run = yield* runGrid(""); + expect(soleError(run)).toContain(" requires at least one pane."); + reachedNothing(run); + }); + + it("TG2: refuses ordinary text written directly in a grid", function* () { + const run = yield* runGrid( + 'a note', + ); + expect(soleError(run)).toContain( + ' holds only panes. Found text "a note" directly inside it.', + ); + reachedNothing(run); + }); + + it("TG2: refuses a direct element that is not a pane", function* () { + const run = yield* runGrid( + '', + ); + expect(soleError(run)).toContain( + " holds only panes. Found directly inside it.", + ); + // The element was refused as authored structure, so it was never resolved. + reachedNothing(run); + }); + + it("TG2: refuses a control structure that would produce the panes", function* () { + const run = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + + const messages = errorMessages(run.segments); + expect(messages).toHaveLength(2); + expect(messages[0]).toContain( + " holds only panes. Found directly inside it.", + ); + expect(messages[0]).toContain("Write control flow inside a pane instead."); + expect(messages[1]).toContain(" must be a direct child of ."); + // The condition decides which panes would exist, and the grid must know + // that from the source, so it is never evaluated. + reachedNothing(run); + }); + + it("TG2: refuses a grid nested inside a pane", function* () { + const run = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + expect(soleError(run)).toContain( + " cannot be written inside another .", + ); + reachedNothing(run); + }); + + it("TG2: refuses a pane written outside every grid", function* () { + const alone = yield* runGrid('Instructions.'); + expect(soleError(alone)).toContain(" must be a direct child of ."); + + // Below a grid but not one of its panes is the same mistake, reported where + // the pane was written. + const buried = yield* runGrid( + [ + "", + '', + '', + "", + "", + ].join("\n"), + ); + expect(soleError(buried)).toContain(" must be a direct child of ."); + reachedNothing(alone); + reachedNothing(buried); + }); + + it("TG2: treats whitespace between panes as nothing at all", function* () { + const run = yield* runGrid( + [ + "", + "", + ' ', + "", + ' ', + "", + "", + ].join("\n"), + ); + + expect(soleError(run)).toContain("no terminal provider opened this grid"); + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 1, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "A", form: "self-closing" }, + { ordinal: 1, row: 0, column: 1, title: "B", form: "self-closing" }, + ], + }, + }); + }); + + it("TG2: a complete grid refuses before any pane body or default shell", function* () { + const run = yield* runGrid( + [ + "", + '', + "", + PANE_BODY, + "", + '', + "", + ].join("\n"), + ); + + expect(soleError(run)).toContain("no pane expanded its content and no default shell started."); + // The pane held a component and a command; neither was reached, and the + // grid rendered nothing of its own. + reachedNothing(run); + expect(run.output).toContain("no terminal provider opened this grid"); + }); +}); + +describe("Tier TG — row-major layout", () => { + const positions = (columns: number, panes: number) => + terminalGridLayout( + columns, + Array.from({ length: panes }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })), + ).cells.map((cell) => [cell.row, cell.column]); + + it("TG4: places one through five panes row-major across two columns", function* () { + expect(positions(2, 1)).toEqual([[0, 0]]); + expect(positions(2, 2)).toEqual([ + [0, 0], + [0, 1], + ]); + expect(positions(2, 3)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + ]); + expect(positions(2, 4)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + ]); + expect(positions(2, 5)).toEqual([ + [0, 0], + [0, 1], + [1, 0], + [1, 1], + [2, 0], + ]); + // The last row is left short rather than balanced or padded. + expect([1, 2, 3, 4, 5].map((panes) => terminalGridLayout(2, filler(panes)).rows)).toEqual([ + 1, 1, 2, 2, 3, + ]); + }); + + it("TG4: places one through five panes row-major across three columns", function* () { + expect(positions(3, 1)).toEqual([[0, 0]]); + expect(positions(3, 2)).toEqual([ + [0, 0], + [0, 1], + ]); + expect(positions(3, 3)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + ]); + expect(positions(3, 4)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + [1, 0], + ]); + expect(positions(3, 5)).toEqual([ + [0, 0], + [0, 1], + [0, 2], + [1, 0], + [1, 1], + ]); + expect([1, 2, 3, 4, 5].map((panes) => terminalGridLayout(3, filler(panes)).rows)).toEqual([ + 1, 1, 1, 2, 2, + ]); + }); + + it("TG4: an executed grid derives those same positions", function* () { + const run = yield* runGrid( + [ + "", + '', + '', + '', + '', + '', + "", + ].join("\n"), + ); + + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 3, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "One", form: "self-closing" }, + { ordinal: 1, row: 0, column: 1, title: "Two", form: "self-closing" }, + { ordinal: 2, row: 1, column: 0, title: "Three", form: "self-closing" }, + { ordinal: 3, row: 1, column: 1, title: "Four", form: "self-closing" }, + { ordinal: 4, row: 2, column: 0, title: "Five", form: "self-closing" }, + ], + }, + }); + }); + + it("TG4: duplicate titles stay valid, and identity is the ordinal", function* () { + const run = yield* runGrid( + [ + "", + 'first', + '', + 'third', + "", + ].join("\n"), + ); + + // Three panes sharing one label are three panes: the ordinal separates + // them, and the form each one was written in travels with it. + expect(derivedLayout(run)).toEqual({ + layout: { + columns: 2, + rows: 2, + cells: [ + { ordinal: 0, row: 0, column: 0, title: "Agent", form: "paired" }, + { ordinal: 1, row: 0, column: 1, title: "Agent", form: "self-closing" }, + { ordinal: 2, row: 1, column: 0, title: "Agent", form: "paired" }, + ], + }, + }); + }); +}); + +/** Panes that differ only in count, for a row about rows. */ +function filler(panes: number): { title: string; form: "self-closing" }[] { + return Array.from({ length: panes }, (_unused, index) => ({ + title: `pane ${index}`, + form: "self-closing" as const, + })); +} From 106028e65df958ae0be40fadfd58bf6e4df70eea Mon Sep 17 00:00:00 2001 From: Taras Mankovski <74687+taras@users.noreply.github.com> Date: Wed, 2 Sep 2026 12:45:27 -0400 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=9D=20Shorten=20the=20terminal-gri?= =?UTF-8?q?d=20component=20descriptions=20(#729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both descriptions lead with what the author can do and show the invocation to copy. The placement rule, the row-major detail and the title's display role are the catalog's structured fields and §6.21's to state, not a second reference entry beside the forms. The frozen catalog entries in `syntax-catalog.test.ts` mirror the two declarations, so they move with them. --- packages/core/src/structural.ts | 14 +++++--------- packages/core/tests/syntax-catalog.test.ts | 14 +++++--------- 2 files changed, 10 insertions(+), 18 deletions(-) diff --git a/packages/core/src/structural.ts b/packages/core/src/structural.ts index 0740a8e62..4823aca3d 100644 --- a/packages/core/src/structural.ts +++ b/packages/core/src/structural.ts @@ -173,11 +173,8 @@ export const STRUCTURAL_DECLARATIONS: readonly StructuralDeclaration[] = [ name: "Terminal.Grid", syntax: [""], description: - "Show several interactive terminals at once. " + - '`' + - '` fills `columns` columns with its panes ' + - "in the order they are written, leaving the last row short when the count does not " + - "divide. Only `` panes may be written directly inside it.", + "Open several terminals in one view. " + + '``', as: null, context: "The `` panes the grid lays out.", }, @@ -185,10 +182,9 @@ export const STRUCTURAL_DECLARATIONS: readonly StructuralDeclaration[] = [ name: "Terminal", syntax: ['', ''], description: - "Give one pane of a `` its work. " + - '`` runs that markdown in the pane; ' + - '`` runs the host\'s default interactive shell. `title` ' + - "labels the pane on screen, so two panes may share one.", + "Expand Markdown or open a shell in a pane. " + + '`` runs content; ' + + '`` opens a shell.', as: null, context: "Markdown the pane runs, in the paired form.", }, diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 20a8932cb..83baec843 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -359,11 +359,8 @@ describe("Tier SY: structural vocabulary", () => { origin: { kind: "structural", construct: "Terminal.Grid" }, syntax: [""], description: - "Show several interactive terminals at once. " + - '`' + - '` fills `columns` columns with its panes ' + - "in the order they are written, leaving the last row short when the count does not " + - "divide. Only `` panes may be written directly inside it.", + "Open several terminals in one view. " + + '``', context: "The `` panes the grid lays out.", }); expect(find(entries, "Terminal")).toEqual({ @@ -372,10 +369,9 @@ describe("Tier SY: structural vocabulary", () => { origin: { kind: "structural", construct: "Terminal" }, syntax: ['', ''], description: - "Give one pane of a `` its work. " + - '`` runs that markdown in the pane; ' + - '`` runs the host\'s default interactive shell. `title` ' + - "labels the pane on screen, so two panes may share one.", + "Expand Markdown or open a shell in a pane. " + + '`` runs content; ' + + '`` opens a shell.', context: "Markdown the pane runs, in the paired form.", }); // Neither construct binds, so neither carries an `as` sentence at all. From 9fe517d1a8bb8f7905e5866016c280d85eb9c453 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 10 Sep 2026 21:42:29 -0400 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=90=9B=20Call=20the=20catalog=20helpe?= =?UTF-8?q?rs=20by=20the=20names=20they=20have=20(#729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two call sites still named `syntaxCatalog` and `parseCatalog`, which nothing declares. The file imports `syntaxSymbols` and declares `parseSymbols`, and uses them correctly everywhere else; these two were missed when the pair was renamed. `deno task check` is the first step of every test job, so the whole matrix stopped there rather than running anything: five Deno shards and four Node shards reported a failure none of them had reached a test to find. The two implicit-`any` reports beside them were the same defect seen twice — with `syntaxCatalog` unresolved the catalog degraded to `any`, so the callbacks reading its entries had nothing to infer from. Naming the helper settles both. --- packages/cli/tests/syntax-cli.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/cli/tests/syntax-cli.test.ts b/packages/cli/tests/syntax-cli.test.ts index 39da9b20e..5b437dd3a 100644 --- a/packages/cli/tests/syntax-cli.test.ts +++ b/packages/cli/tests/syntax-cli.test.ts @@ -311,7 +311,7 @@ describe("Tier SX — the run profile the command describes", () => { throw new Error(`describing the syntax ran ${JSON.stringify(options.command)}`); }, }); - return yield* syntaxCatalog([]); + return yield* syntaxSymbols([]); }); const [structural, builtIn] = catalog.categories; @@ -579,7 +579,7 @@ describe("Tier SX — the command line", { sanitizeOps: false, sanitizeResources expect(markdown.stdout).toContain(''); const json = yield* runCli(["syntax", "--json"], { cwd }).expect(); - const structural = parseCatalog(json.stdout).categories[0].entries; + const structural = parseSymbols(json.stdout).categories[0].entries; expect(names(structural)).toContain("Terminal.Grid"); expect(names(structural)).toContain("Terminal"); }); From 1a3c09a4c02d3f06fdf66e0a01973fa056e8a538 Mon Sep 17 00:00:00 2001 From: Taras Mankovski Date: Thu, 10 Sep 2026 22:01:41 -0400 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=90=9B=20Read=20the=20catalog=20versi?= =?UTF-8?q?on=20this=20branch=20publishes=20(#729)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TG3 still froze the catalog at version 1. Its two neighbours in the same file already read 2, and so does `inspect.ts` here, on main, and on every branch above this one: the constant moved and this one assertion did not follow. Both runtimes reported it, because the number is the catalog's rather than either runtime's. The rest of the row is untouched. What TG3 exists to freeze is the pair of structural entries the catalog publishes, and that is what it still checks. --- packages/core/tests/syntax-catalog.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/core/tests/syntax-catalog.test.ts b/packages/core/tests/syntax-catalog.test.ts index 83baec843..f840b6356 100644 --- a/packages/core/tests/syntax-catalog.test.ts +++ b/packages/core/tests/syntax-catalog.test.ts @@ -352,7 +352,7 @@ describe("Tier SY: structural vocabulary", () => { const catalog = yield* catalogFor({}, []); const entries = structural(catalog); - expect(catalog.version).toBe(1); + expect(catalog.version).toBe(2); expect(find(entries, "Terminal.Grid")).toEqual({ kind: "structural", name: "Terminal.Grid",