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:
@@ -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.
|
||||
@@ -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',
|
||||
@@ -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';
|
||||
@@ -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(),
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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-]*)*$/;
|
||||
@@ -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];
|
||||
}
|
||||
@@ -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}\`.`));
|
||||
}
|
||||
Reference in New Issue
Block a user