From eaa677f815dea2cc9759b992d64730f9489b9aee Mon Sep 17 00:00:00 2001 From: Aravind Date: Fri, 18 Sep 2026 13:50:33 +0530 Subject: [PATCH] =?UTF-8?q?refactor(ts-migration):=20Phase=2011=20batch=20?= =?UTF-8?q?1=20=E2=80=94=20the=20UI-node=20engine?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Renames the ten modules under `src/lib/ui/` to TypeScript and annotates them. No logic is touched: no reordered statements, no changed defaults, no altered branches, no renamed locals, no edited strings. The proof is mechanical rather than argued. esbuild's output for each of the ten files, minified, is byte-for-byte what the `.js` file produced at `ecf5e75`, and the production bundle hashes to `74d17e2d…` before and after. `operations` and `patch` are pure renames — R100, not one character changed. What the annotations actually are: - Four `/** @type */` and `@param` JSDoc hatches the author had already written, restated as real annotations. These stop applying at the extension boundary, which is where most of the errors came from. `makeNode` keeps its declared `@returns {any}`; dropping it in favour of an inferred shape would have quietly narrowed a contract the author had deliberately left open. - `NodeTypeRegistry.types` as `declare`, not a field declaration. A plain one would emit a `defineProperty` under `useDefineForClassFields`, i.e. a change to the shipped JS. `declare` emits nothing, which the per-file comparison above confirms. - Two accumulators (`wants`, `params`) given the shape their own following lines build. - `Object.entries(…)` at two sites. Inferring `any` into the union parameter of `entries` yields `unknown`, not `any`, so the rule objects arrived unreadable; an explicit type argument restores what JavaScript had, without a cast. - `UiEditMatch`, an open interface, as `matchUiEdit`'s return. `UiEditMatch` is the one judgement call and it is deliberately weak. `kind` is optional and the rest is an index signature, because the nineteen return shapes share field names carrying different meanings and the suite reads these objects in around forty places. Writing the real discriminated union is a schema this phase has no mandate to invent, and `kind` stays `string` rather than a literal union partly so that no node-type name is ever written into a type — the engine check forbids exactly that. `kind` is optional for a reason worth recording: at run time the planners guard with `if (subject.kind) return subject`, so a `kind`-less object never escapes. TypeScript cannot see it, because every `kind` widens to `string` and a property that is `string` in every member is not a discriminant, so truthiness narrowing leaves a shape the function cannot produce. Marking it required would have been true of the runtime and rejected at five return sites, and the only fixes are edits to agent logic. The weaker claim is the honest one. Renaming these files also surfaced twenty errors in `uiEdit.js`, which is still JavaScript: `.js` and `.ts` infer this union differently. Verified as a property of the rename and not of any edit, by compiling the verbatim `ecf5e75` contents under a `.ts` extension — same twenty. The return annotation clears them. Measured against `ecf5e75`, all unchanged: typecheck 20 errors, same files (no new error anywhere) lint exit 0, 269 files, 0 errors, 289 warnings npm test 1684/1691, the same 7 failures verbatim build exit 0, identical bundle hash emitted JS 10/10 byte-identical The 7 failures and the `owliver-baseline.mjs` drift both pre-date this commit — they are the parallel feature session's, present at `ecf5e75` and measured there before this batch was applied. No baseline artifact is touched; recapture waits until the agent migration is complete. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8 --- src/lib/ui/{composition.js => composition.ts} | 3 +- src/lib/ui/{inspect.js => inspect.ts} | 2 +- src/lib/ui/{intent.js => intent.ts} | 53 ++++++++++++++++++- src/lib/ui/{node.js => node.ts} | 6 +-- src/lib/ui/{operations.js => operations.ts} | 0 src/lib/ui/{patch.js => patch.ts} | 0 src/lib/ui/{registry.js => registry.ts} | 9 +++- src/lib/ui/{series.js => series.ts} | 3 +- src/lib/ui/{skillNodes.js => skillNodes.ts} | 2 +- src/lib/ui/{validate.js => validate.ts} | 2 +- 10 files changed, 66 insertions(+), 14 deletions(-) rename src/lib/ui/{composition.js => composition.ts} (98%) rename src/lib/ui/{inspect.js => inspect.ts} (99%) rename src/lib/ui/{intent.js => intent.ts} (93%) rename src/lib/ui/{node.js => node.ts} (98%) rename src/lib/ui/{operations.js => operations.ts} (100%) rename src/lib/ui/{patch.js => patch.ts} (100%) rename src/lib/ui/{registry.js => registry.ts} (98%) rename src/lib/ui/{series.js => series.ts} (99%) rename src/lib/ui/{skillNodes.js => skillNodes.ts} (98%) rename src/lib/ui/{validate.js => validate.ts} (99%) 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}\`.`)); }