diff --git a/src/lib/ui/composition.js b/src/lib/ui/composition.ts similarity index 98% rename from src/lib/ui/composition.js rename to src/lib/ui/composition.ts index 367bd18..fa81163 100644 --- a/src/lib/ui/composition.js +++ b/src/lib/ui/composition.ts @@ -30,8 +30,7 @@ import { makeNode } from './node'; import { applyPatch } from './patch'; import { nodeRegistry } from './registry'; -/** @type {Map} */ -const compositions = new Map(); +const compositions: Map = new Map(); /** * A container node's placement, as its own registration declared it. diff --git a/src/lib/ui/inspect.js b/src/lib/ui/inspect.ts similarity index 99% rename from src/lib/ui/inspect.js rename to src/lib/ui/inspect.ts index 34d8e65..ff57d75 100644 --- a/src/lib/ui/inspect.js +++ b/src/lib/ui/inspect.ts @@ -99,7 +99,7 @@ export function describeNode(node, { registry = nodeRegistry, parent = null, ind densities: entry ? [...entry.densities] : [], capabilities: entry ? [...entry.capabilities] : [], editable: entry - ? Object.entries(entry.propSchema).map(([key, rule]) => ({ + ? Object.entries(entry.propSchema).map(([key, rule]) => ({ key, label: rule.label || key, kind: Array.isArray(rule.enum) ? 'enum' : rule.type || 'string', diff --git a/src/lib/ui/intent.js b/src/lib/ui/intent.ts similarity index 93% rename from src/lib/ui/intent.js rename to src/lib/ui/intent.ts index 956c423..4c723de 100644 --- a/src/lib/ui/intent.js +++ b/src/lib/ui/intent.ts @@ -140,6 +140,55 @@ const refersBack = (text) => text.split(' ').some((word) => ANAPHORA.has(word)); const NUMBER_WORDS = { one: 1, two: 2, three: 3, four: 4, six: 6, twelve: 12 }; +/** + * What `matchUiEdit` hands back, when it claims the question at all. + * + * Open on purpose. The matcher has nineteen return shapes — a plan carries + * `op`/`summary`/`node`, an ambiguity carries `candidates`, a refusal carries + * `message`, and so on — and `kind` is the word every caller switches on. + * + * Writing those nineteen out as a discriminated union is a schema this phase + * has no mandate to invent, and it would be the kind of guess that looks right + * and is subtly wrong: the branches share field NAMES carrying different + * meanings, and `skill-check.mjs` reads these objects in about forty places + * that would all have to agree with it. So the discriminant is typed, because + * that much is true of every branch, and the rest stays exactly as open as it + * was while this file was JavaScript. + * + * `kind` is `string` rather than a literal union for the same reason, and for + * one more: no file in the engine may branch on a node-type name, and a + * literal union is the first step towards writing such names into a type. + * + * `null` is a real part of the contract, not an oversight — eight paths return + * it, and it is how the matcher declines a question so the agent can answer it + * instead. `uiEdit.js` tests for it before reading anything else. + * + * Narrowing this is a later, deliberate change with the suite as its evidence, + * not a side effect of a rename. + */ +export interface UiEditMatch { + /** + * Optional, and the reason is a limit of inference rather than a branch that + * really omits it. + * + * The planners guard with `if (subject.kind) return subject;` — at run time + * only the `kind`-carrying shape ever leaves them. TypeScript cannot see + * that: every `kind` here is written as a plain string literal in a return + * position, so it widens to `string`, and a property that is `string` in + * every member is not a discriminant. Truthiness narrowing therefore does not + * drop the `kind`-less member from the union, and the inferred return type + * keeps a shape the function cannot actually produce. + * + * The honest response is the weaker claim. Marking it required would state + * something true of the runtime that the compiler would then reject at five + * `return` sites, and the only ways to satisfy it — `as const` on the + * planners' literals, or narrowing helpers — are edits to agent logic, which + * this phase does not make. + */ + kind?: string; + [key: string]: any; +} + /** * Read a request. * @@ -168,7 +217,7 @@ export function matchUiEdit(question, { * does not resolve. */ focus = null, -} = {}) { +} = {}): UiEditMatch | null { const text = canon(question); if (!text) return null; @@ -820,7 +869,7 @@ function namedSource(text, named, answerable = null) { * something, and it has to be sayable. */ function planPresent(text, tree, registry, focus = null) { - const wants = {}; + const wants: { density?: string; variant?: string } = {}; if (has(text, 'compact', 'denser', 'tighter')) wants.density = 'compact'; if (has(text, 'comfortable', 'spacious', 'roomier')) wants.density = 'comfortable'; if (has(text, 'emphasis', 'emphasise', 'emphasize')) wants.variant = 'emphasis'; diff --git a/src/lib/ui/node.js b/src/lib/ui/node.ts similarity index 98% rename from src/lib/ui/node.js rename to src/lib/ui/node.ts index 69313fa..abb6a03 100644 --- a/src/lib/ui/node.js +++ b/src/lib/ui/node.ts @@ -138,10 +138,8 @@ export const SERIES_KINDS = ['periodic', 'cumulative', 'parts']; * extra field would eventually have that field read by something, and then the * closed vocabulary would be closed only by convention. */ -/** @param {any} raw @returns {any} */ -export function makeNode(raw = {}) { - /** @type {any} */ - const source = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; +export function makeNode(raw: any = {}): any { + const source: any = raw && typeof raw === 'object' && !Array.isArray(raw) ? raw : {}; return { id: String(source.id ?? '').trim(), diff --git a/src/lib/ui/operations.js b/src/lib/ui/operations.ts similarity index 100% rename from src/lib/ui/operations.js rename to src/lib/ui/operations.ts diff --git a/src/lib/ui/patch.js b/src/lib/ui/patch.ts similarity index 100% rename from src/lib/ui/patch.js rename to src/lib/ui/patch.ts diff --git a/src/lib/ui/registry.js b/src/lib/ui/registry.ts similarity index 98% rename from src/lib/ui/registry.js rename to src/lib/ui/registry.ts index 71b5022..2ca1c86 100644 --- a/src/lib/ui/registry.js +++ b/src/lib/ui/registry.ts @@ -37,8 +37,15 @@ const DEFAULT_CAPABILITIES = ['update', 'remove', 'move', 'replace', 'hide']; const CONTAINER_CAPABILITIES = [...DEFAULT_CAPABILITIES, 'add', 'reorder']; export class NodeTypeRegistry { + /** + * Declared, not defined: `declare` emits nothing, so the constructor's + * assignment below stays the only thing that touches `types` at run time. + * A plain field declaration would emit a `defineProperty` under + * `useDefineForClassFields`, which is a change to the shipped JS. + */ + declare types: Map; + constructor() { - /** @type {Map} */ this.types = new Map(); } diff --git a/src/lib/ui/series.js b/src/lib/ui/series.ts similarity index 99% rename from src/lib/ui/series.js rename to src/lib/ui/series.ts index 2305209..80697f5 100644 --- a/src/lib/ui/series.js +++ b/src/lib/ui/series.ts @@ -44,8 +44,7 @@ const KIND_REASON = { parts: 'it is a set of parts that make up one whole', }; -/** @type {Map} */ -const providers = new Map(); +const providers: Map = new Map(); /** A series id looks like a node id: lower-case, dashes, and a dotted namespace. */ const SERIES_ID_PATTERN = /^[a-z0-9][a-z0-9-]*(\.[a-z0-9][a-z0-9-]*)*$/; diff --git a/src/lib/ui/skillNodes.js b/src/lib/ui/skillNodes.ts similarity index 98% rename from src/lib/ui/skillNodes.js rename to src/lib/ui/skillNodes.ts index 32c2506..ae76f44 100644 --- a/src/lib/ui/skillNodes.js +++ b/src/lib/ui/skillNodes.ts @@ -37,7 +37,7 @@ const slug = (value) => String(value || '') * that was doing nothing anyway. */ export function skillSectionNode(skill, section) { - const params = {}; + const params: { periods?: unknown[]; limit?: unknown } = {}; if (section.periods?.length && sourceSupportsOption(section.source, 'periods')) { params.periods = [...section.periods]; } diff --git a/src/lib/ui/validate.js b/src/lib/ui/validate.ts similarity index 99% rename from src/lib/ui/validate.js rename to src/lib/ui/validate.ts index 16d5f99..c7e9476 100644 --- a/src/lib/ui/validate.js +++ b/src/lib/ui/validate.ts @@ -141,7 +141,7 @@ function validateProps(node, entry) { problems.push(...checkValue(at, entry.type, key, value, rule)); } - for (const [key, rule] of Object.entries(schema)) { + for (const [key, rule] of Object.entries(schema)) { if (rule?.required && node.props?.[key] == null) { problems.push(problem(at, `\`${entry.type}\` needs \`${key}\`.`)); }