refactor(ts-migration): Phase 11 batch 1 — the UI-node engine

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<any>(…)` 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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HBG1wnuRfJKCstGB8Fekr8
This commit is contained in:
2026-09-18 13:50:33 +05:30
parent ecf5e75d76
commit eaa677f815
10 changed files with 66 additions and 14 deletions

View File

@@ -30,8 +30,7 @@ import { makeNode } from './node';
import { applyPatch } from './patch';
import { nodeRegistry } from './registry';
/** @type {Map<string, any[]>} */
const compositions = new Map();
const compositions: Map<string, any[]> = new Map();
/**
* A container node's placement, as its own registration declared it.

View File

@@ -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<any>(entry.propSchema).map(([key, rule]) => ({
key,
label: rule.label || key,
kind: Array.isArray(rule.enum) ? 'enum' : rule.type || 'string',

View File

@@ -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';

View File

@@ -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(),

View File

@@ -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<string, any>;
constructor() {
/** @type {Map<string, any>} */
this.types = new Map();
}

View File

@@ -44,8 +44,7 @@ const KIND_REASON = {
parts: 'it is a set of parts that make up one whole',
};
/** @type {Map<string, any>} */
const providers = new Map();
const providers: Map<string, any> = 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-]*)*$/;

View File

@@ -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];
}

View File

@@ -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<any>(schema)) {
if (rule?.required && node.props?.[key] == null) {
problems.push(problem(at, `\`${entry.type}\` needs \`${key}\`.`));
}