diff --git a/AGENTS.md b/AGENTS.md index d7e591d..b78e12c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,6 +8,46 @@ leave it staged or unstaged in the working tree, and report what changed. When a commit is requested, inspect `git status` and `git diff` first, stage only the intended files, and write the message in the established repo style. +## PR files + +When you believe the issue is 100 percent completed, write `tmp/pr-.md` +(where `` is the issue number or branch slug). Put `Fixes #` +on its own line right under the title. +Format: pedagogical +pre-text first (pedagogical: bring everyone on the same page by teaching +the construct, why it exists, and what broke, under a `## Primer` heading), then the normal description +of everything fixed, +with file references. The pre-text is for the future reader who lands on +the PR without the session context; the description is the changelog. +`tmp/` is git-ignored, so the file never leaks into commits. +- The description earns its keep with mechanism, not adjectives: every claim + names the file, the shape before and after, and the proof (which fixture + broke, which mutation fails, which counts agree). A sentence that stays + true when the file names are blanked out is a boring changelog entry: + delete it and write what actually happened instead. Never restate CI: + gate status, lint status and test counts re-verify on every push and rot + the moment the next commit lands; the PR file records what CI cannot see + (why the shape changed, which hole was tolerated and on what analysis). +- Record latent holes the work exposed as their own section: gaps the old + code silently tolerated (a pass-through ladder, an erasing guard, a + tolerated write) that the new structure surfaced. Each hole states the + mechanism that hid it, the mechanism that exposed it, and the verdict + (fixed here, tolerated deliberately with the parity analysis, or deferred + with the reason). A hole section is a decision record: without it, the + next reader re-litigates what this session already settled. +- Record dead code the work killed with the proof of death: the exact + condition that can no longer hold (key counts, tag shapes, branch + reachability) and the output-by-output check that the surviving path + already handles every live shape. "Unreachable" is a claim, not an + observation: show the shape that proves it. +- If it reads as a list, write it as a list: a paragraph that is really + N rules joined by periods is N list items. The reader scans headings + and bullets, not sentences; a buried third rule is a rule nobody + follows. Number the items so a later comment can cite them by number. +- Omit sections with nothing to say: no "rules added" section when no rule + changed, no placeholder, no `(none)`. An empty section is noise that + trains the reader to skip sections. + ## Scratch files Always use the repo-local `./tmp/` directory for scratch, repro and @@ -106,6 +146,12 @@ thumb is one decision per line: if a line picks between three shapes or merges three flag sources, split it until each line does one thing a comment can name. `npm run lint` does not enforce this; review does. +Work with local scripts, never by shuttling file contents through the model: when a job is mechanical (rename every occurrence, reformat JSON, count matches, list files), compute the change with `node`/`python3` in `bash` and read back only the summary (counts, leftover matches, diff stat) — never the full files. Reading a file just to echo it back with a trivial transformation (parse JSON and rewrite indented, rename a token, sort keys) is token burning: the transformation belongs in a script, the model only writes the script and verifies its output. Reach for `read` only when a decision needs human eyes (an exact `oldString` for `edit`, an error message, a shape to name). + +Written JSON is always `JSON.stringify(value, null, 2)` plus a trailing newline: two-space indent, no compact one-liners, no missing final newline. New expectation files come out of the generator this way; when one slips through compact, reformat it with a one-line `node` rewrite (`JSON.parse` then `JSON.stringify(..., null, 2)`), not by retyping it. Lock files and vendored JSON are npm-owned: never reformat them. + +Test helpers carry meaningful names, never numbered placeholders: a parity fixture case is named after what it checks (`checkUppercase`, `addFrozenAnchor`), a unit test after the behavior it pins (`testUtilityNameCollisionBailsOut`). Numbers in names are ordering without meaning: they force the next reader to open every definition to learn what runs, they collide silently on reorder, and they make failure lines useless (`FAIL case07` tells nobody what broke). The parity harness keys counts by function name, so a rename there must move the definition, every call line, and the runner's name pattern together — verify with the runner itself (`65/65`), not by eyeballing. + Never assert exact human-readable prose (diagnosis sentences, detail messages, log hints, one-line descriptions). Prose is presentation: pinning it turns every reword into a failing suite without any behavioral @@ -140,6 +186,21 @@ return JSON.stringify(unwrapped) === never `atomUnwrap(expandType('{a?: K}').properties.a)` inline. The names double as documentation: `parsed` is the producer output, `input` is the helper argument, and a debugger can stop between them. +Write JavaScript sources in specs as readable code in template literals, never as `\n`-joined fragments or `+`-concatenated lines. A snippet is production code the reader must review, so format it like production code: real newlines, indentation following the surrounding spec, and JSDoc continuation stars aligned under the opening `/**`. The indentation becomes part of the source, which every feeder (`runChecks`, `shapeFor`, plain `parse`) accepts without complaint. Bind the snippet to its own `const src` before passing it on, so the construction and the call are separate steps a debugger can stop between. Only escape when the snippet itself needs a backtick or `${`. Short one-liners (`'class A { x; }'`) stay single-quoted; anything spanning lines gets the template treatment: + +```js +// Inline Object.assign literals contribute their keys. +function testObjectAssignInline() { + const src = `class A { + constructor() { + Object.assign(this, {w: 1}); + } + }`; + const {out} = shapeFor(src); + return out.includes('"w": "number"'); +} +``` + Import each shared typedef once per file with `@import` at the top and use the bare name everywhere else, so `@type` casts stay short and diffs stay local: ```js diff --git a/src-runtime/TypePanel.js b/src-runtime/TypePanel.js index 06de163..38dde33 100644 --- a/src-runtime/TypePanel.js +++ b/src-runtime/TypePanel.js @@ -2064,6 +2064,9 @@ class TypePanel { const btnCopy = Button({textContent: 'Copy', title: 'Copy comparison to clipboard', onclick: () => this.copyCompare(warnObj, btnCopy)}); const {expectPretty, actualPretty} = formatCompare(warnObj.expect, warnObj.value); const Grid = genJsx('div'); + // Lazy on-click diagnosis: diffed here, in the rendering realm, only + // when the comparator opens. Twins loading late can resolve more than + // the checker knew at report time, and nothing is spent per error. let diagnosis; try { diagnosis = explainMismatch(warnObj.value, warnObj.expect, warnObj.name); diff --git a/src-runtime/compareText.js b/src-runtime/compareText.js index 4762a45..99eaec0 100644 --- a/src-runtime/compareText.js +++ b/src-runtime/compareText.js @@ -3,6 +3,9 @@ import {explainMismatch} from './explainMismatch.js'; /** * Renders a warning as plain text for the comparator Copy button: message, * diagnosis findings, the missing-keys stub, then the Expected/Actual panes. + * The diagnosis is diffed lazily here, on demand, so it reflects the + * rendering realm's current registry and options (twins loading late can + * show more than the checker knew at report time). * @param {object} warnObj - The warning row (`msg`, `expect`, `value`, `name`, `hits`). * @returns {string} The copyable comparison text. * @example compareText({msg: 'bad', expect: 'number', value: 's', name: 'x', hits: 1}) diff --git a/src-runtime/componentOptions.spec.js b/src-runtime/componentOptions.spec.js index 478b2bb..68a245b 100644 --- a/src-runtime/componentOptions.spec.js +++ b/src-runtime/componentOptions.spec.js @@ -411,6 +411,33 @@ function testMergedTowerEndToEnd() { } return true; } +// Intersection excess is judged whole, not per member: the Omit side drops +// `clearColor` exactly because the override side provides it (the engine's +// `Omit & Overrides` idiom). Tested against the raw merged +// intersection (not the `CamOptions` mapping, which flattens members +// during materialization and never reaches per-member validation). +// Override keys pass through the Omit member; keys no member declares +// still fail; wrong types fail both. +function testMergedIntersectionWholeExcess() { + prepareCameraTower(); + const expect = expandType('CamMerged<"camera">'); + const passing = [ + {clearColor: [0.5, 0.6, 0.9, 1]}, + {clearColor: new Color(), calculateProjection: () => {}}, + ]; + for (const value of passing) { + if (!validateType(value, expect, 'loc', 'data', true, warn, 0)) { + return false; + } + } + const failing = [{nope: 1}, {clearColor: 'x'}]; + for (const value of failing) { + if (validateType(value, expect, 'loc', 'data', true, warn, 0)) { + return false; + } + } + return true; +} export const tests = [ testNonNullable, testKeyofIntersection, @@ -429,4 +456,5 @@ export const tests = [ testComponentMapDropsComplexMembers, testNestedPartialPickValidates, testMergedTowerEndToEnd, + testMergedIntersectionWholeExcess, ]; diff --git a/src-runtime/componentTowerStress.spec.js b/src-runtime/componentTowerStress.spec.js index 7271f11..968918b 100644 --- a/src-runtime/componentTowerStress.spec.js +++ b/src-runtime/componentTowerStress.spec.js @@ -1,6 +1,6 @@ import {validateType} from './validateType.js'; import {registerTypedef, typedefs} from './registerTypedef.js'; -import {registerClass} from './registerClass.js'; +import {registerClass, classes} from './registerClass.js'; import {expandType} from '../src-transpiler/expandType.js'; import {getTypeKeys, resolveObject} from './getTypeKeys.js'; import {createType} from './createType.js'; @@ -378,6 +378,33 @@ function testNominalChainIntact() { } return true; } +// Nominal identity survives dual packaging: a transpiled twin that imports +// the untranspiled parent holds a distinct object under the same class +// name, so `instanceof` fails across the copy boundary. The name-chain +// fallback still decides the relation; strangers still fail. +function testNominalChainDualPackage() { + prepareStress(); + class BaseCopy {} + Object.defineProperty(BaseCopy, 'name', {value: 'TwComponent'}); + class TwinCam extends BaseCopy {} + Object.defineProperty(TwinCam, 'name', {value: 'TwCam'}); + const SavedCam = classes.TwCam; + classes.TwCam = TwinCam; + try { + if (TwinCam.prototype instanceof TwComponent) { + return false; + } + if (extendsCheck('TwCam', 'TwComponent') !== true) { + return false; + } + if (extendsCheck('TwOther', 'TwComponent') !== false) { + return false; + } + return true; + } finally { + classes.TwCam = SavedCam; + } +} // A noisy entity (tuple, promise, map, readonly array, function, record, // nested shapes, methods, primitives) maps to its slots with zero warnings. function testBigMapSilent() { @@ -425,6 +452,58 @@ function testStaticRuntimeAgreement() { } return true; } +// Explicit null passes optional slots without `| null` (engine compat: +// the engine never adopted strictNullChecks, so optionality stays +// lenient); only a missing `?` rejects null. +function testOptionalTakesExplicitNull() { + prepareStress(); + const optional = {type: 'number', optional: true}; + if (!validateType(null, optional, 'loc', 'name', true, warn, 0)) { + return false; + } + if (!validateType(undefined, optional, 'loc', 'name', true, warn, 0)) { + return false; + } + return validateType(null, 'number', 'loc', 'name', true, warn, 0) === false; +} +// Nullable unions take every member including null; the bare member +// rejects it. Both directions pin the `batchGroupId?: number | null` +// production shape. +function testNullableUnionMembers() { + prepareStress(); + const nullable = {type: 'union', members: ['number', 'null']}; + if (!validateType(null, nullable, 'loc', 'name', true, warn, 0)) { + return false; + } + if (!validateType(3, nullable, 'loc', 'name', true, warn, 0)) { + return false; + } + return validateType(null, 'number', 'loc', 'name', true, warn, 0) === false; +} +// A union whose every member fails still reports (never silently +// passes): the hostile check is the verdict the Diagnosis explains. +function testUnionAllFailStillFails() { + prepareStress(); + const union = {type: 'union', members: ['TwColor', {type: 'array', elementType: 'number'}]}; + if (validateType([0, 0], union, 'loc', 'name', true, warn, 0) === false) { + return false; + } + return validateType([0, 'x'], union, 'loc', 'name', true, warn, 0) === false; +} +// Subclass instances satisfy base-typed slots; snapshots of subclasses +// satisfy them nominally; wrong tags fail. +function testSubclassSatisfiesBase() { + prepareStress(); + class TwSubCam extends TwCam {} + registerClass(TwSubCam); + if (!validateType(new TwSubCam(), 'TwCam', 'loc', 'name', true, warn, 0)) { + return false; + } + if (!validateType({fov: 1, $type: 'TwSubCam'}, 'TwCam', 'loc', 'name', true, warn, 0)) { + return false; + } + return validateType({fov: 1, $type: 'TwOther'}, 'TwCam', 'loc', 'name', true, warn, 0) === false; +} export const tests = [ testStripKeepsAliasSpelling, testStripQuestionStillStrips, @@ -445,6 +524,11 @@ export const tests = [ testDeferredStaysUndecidable, testUnionMixShortCircuits, testNominalChainIntact, + testNominalChainDualPackage, testBigMapSilent, testStaticRuntimeAgreement, + testOptionalTakesExplicitNull, + testNullableUnionMembers, + testUnionAllFailStillFails, + testSubclassSatisfiesBase, ]; diff --git a/src-runtime/createTypeFromIndexedAccess.js b/src-runtime/createTypeFromIndexedAccess.js index 1251800..4fbc1c8 100644 --- a/src-runtime/createTypeFromIndexedAccess.js +++ b/src-runtime/createTypeFromIndexedAccess.js @@ -1,144 +1,14 @@ -import {getTypeKeys, resolveObject} from "./getTypeKeys.js"; -import {instantiateReference} from "./instantiateReference.js"; +import {getTypeKeys} from "./getTypeKeys.js"; import {stripKey} from "./stripKey.js"; -import {classes} from "./registerClass.js"; -import {typedefs} from "./registerTypedef.js"; -import {validators} from "./validators.js"; -import {mergedClassShape} from "./classShape.js"; -import {createTypeFromMapping} from "./createTypeFromMapping.js"; -/** - * Materializes Pick/Omit/Partial to object shapes for indexed access. - * Unresolvable bases or key lists yield undefined (callers fail closed). - * @param {*} target - Utility reference struct. - * @param {console["warn"]} warn - Function to warn with. - * @param {number} depth - Recursion depth. - * @returns {import('./validateType.js').TypeObject|undefined} Object shape or undefined. - */ -function materializeUtility(target, warn, depth) { - const [base, keys] = target.args ?? []; - if (target.name === 'Partial') { - const resolved = base === undefined ? undefined : resolveObjectSide(base, warn, depth + 1); - if (!resolved || !resolved.properties) { - return; - } - const properties = {}; - for (const key of Object.keys(resolved.properties)) { - const prop = resolved.properties[key]; - properties[key] = prop && typeof prop === 'object' ? {...prop, optional: true} : {type: prop, optional: true}; - } - return {type: 'object', properties}; - } - if (base === undefined || keys === undefined) { - return; - } - const resolved = resolveObjectSide(base, warn, depth + 1); - if (!resolved || !resolved.properties) { - return; - } - const names = getTypeKeys(keys, warn); - if (!Array.isArray(names)) { - return; - } - const wanted = new Set(names.map((name) => stripKey(name))); - const properties = {}; - for (const key of Object.keys(resolved.properties)) { - if (wanted.has(key) === (target.name === 'Pick')) { - properties[key] = resolved.properties[key]; - } - } - return {type: 'object', properties}; -} -/** - * Resolves the object side of an indexed access to a property map. - * Chases typedef names, instantiates generic references, materializes - * mappings and utilities, and merges intersections/unions member-wise. - * @param {*} object - Object side of the indexed access. - * @param {console["warn"]} warn - Function to warn with. - * @param {number} depth - Recursion depth. - * @returns {import('./validateType.js').TypeObject|undefined} Object shape or undefined. - */ -function resolveObjectSide(object, warn, depth = 0) { - if (depth > 10) { - return; - } - let target = object; - for (let i = 0; i < 10; i++) { - if (typeof target === 'string') { - // Class names merge harvested shapes up the constructor chain; plain - // typedef names resolve straight to their shape. - if (classes[target]) { - target = mergedClassShape(target); - continue; - } - if (typedefs[target]) { - target = typedefs[target]; - continue; - } - break; - } - if (target && target.type === 'reference') { - if ((target.name === 'NonNullable' || target.name === 'Readonly' || target.name === 'NoInfer') && target.args?.length) { - return resolveObjectSide(target.args[0], warn, depth + 1); - } - if (target.name === 'Pick' || target.name === 'Omit' || target.name === 'Partial') { - return materializeUtility(target, warn, depth); - } - if (typedefs[target.name]) { - const instance = instantiateReference(target, warn); - if (!instance || instance === target) { - break; - } - target = instance; - continue; - } - } - break; - } - if (!target || typeof target !== 'object') { - return; - } - if (target.type === 'object') { - return target.properties ? target : {type: 'object', properties: {}}; - } - if (target.type === undefined) { - // Bare `{}` (e.g. a conditional false-branch) is the empty object type. - return {type: 'object', properties: target.properties ?? {}}; - } - if (target.type === 'mapping') { - return createTypeFromMapping(target, warn) ?? undefined; - } - if ((target.type === 'intersection' || target.type === 'union') && Array.isArray(target.members)) { - const properties = {}; - for (const member of target.members) { - const resolved = resolveObjectSide(member, warn, depth + 1); - if (!resolved || !resolved.properties) { - return; - } - Object.assign(properties, resolved.properties); - } - return {type: 'object', properties}; - } - if (target.type === 'indexedAccess') { - return resolveObject(target, warn); - } - if (target.type === 'condition') { - const evaluate = validators.evaluateCondition; - if (!evaluate) { - return; - } - const decision = evaluate(target.checkType, target.extendsType, warn); - if (decision === true) { - return resolveObjectSide(target.trueType, warn, depth + 1); - } - if (decision === false) { - return resolveObjectSide(target.falseType, warn, depth + 1); - } - } -} +import {resolveObjectSide} from "./resolveObjectSide.js"; /** * @param {import('./validateIndexedAccess.js').IndexedAccess} expect - The supposed type information of said value. * @param {console["warn"]} warn - Function to warn with. * @returns {import('./validateType.js').TypeObject|undefined} - New type that can be used for validation. + * @example + * // input: {type: 'indexedAccess', object: {type: 'object', properties: {a: 'K'}}, index: '"a"'} + * createTypeFromIndexedAccess(expandType('Obj["a"]'), console.warn); + * // 'K' */ function createTypeFromIndexedAccess(expect, warn) { const {object, index} = expect; diff --git a/src-runtime/createTypeFromIndexedAccess.spec.js b/src-runtime/createTypeFromIndexedAccess.spec.js new file mode 100644 index 0000000..bbb1b8c --- /dev/null +++ b/src-runtime/createTypeFromIndexedAccess.spec.js @@ -0,0 +1,39 @@ +import {createTypeFromIndexedAccess} from './createTypeFromIndexedAccess.js'; +import {deepEqualType} from './deepEqualType.js'; +import {expandType} from '../src-transpiler/expandType.js'; +const silent = () => undefined; +// We want: a single selected prop to come back directly with its flags, +// because wrapping it would hide source optionality behind a required node. +function testSinglePropDirect() { + const parsed = expandType('{a?: K}["a"]'); + const created = createTypeFromIndexedAccess(parsed, silent); + const expected = {type: 'atom', atomType: 'K', optional: true}; + return deepEqualType(created, expected); +} +// We want: several selected props to form a union, because `T[K1 | K2]` +// is the union of the selected members. +function testMultiPropUnion() { + const parsed = expandType('{a: K, b: number}["a" | "b"]'); + const created = createTypeFromIndexedAccess(parsed, silent); + const expected = {type: 'union', members: ['K', 'number'], optional: false}; + return deepEqualType(created, expected); +} +// We want: a missing prop to yield undefined, because an unresolvable +// selection fails closed instead of validating against nothing. +function testMissingPropYieldsUndefined() { + const parsed = expandType('{a: K}["nope"]'); + return createTypeFromIndexedAccess(parsed, silent) === undefined; +} +// We want: an unresolvable object side to yield undefined, for the same +// fail-closed reason. +function testUnresolvableObjectYieldsUndefined() { + const parsed = expandType('Missing["a"]'); + return createTypeFromIndexedAccess(parsed, silent) === undefined; +} +const tests = [ + testSinglePropDirect, + testMultiPropUnion, + testMissingPropYieldsUndefined, + testUnresolvableObjectYieldsUndefined, +]; +export {tests}; diff --git a/src-runtime/evaluateCondition.js b/src-runtime/evaluateCondition.js index 65e67de..5b34115 100644 --- a/src-runtime/evaluateCondition.js +++ b/src-runtime/evaluateCondition.js @@ -422,10 +422,32 @@ function extendsCheck(check, target) { return true; } try { - return classes[check].prototype instanceof classes[target]; + if (classes[check].prototype instanceof classes[target]) { + return true; + } } catch { return undefined; } + // Dual-package tolerance: transpiled and original modules can hold + // distinct objects for one class name (the `.rti.mjs` twin imports + // the untranspiled parent while the registry holds the transpiled + // copy), so `instanceof` fails across the copy boundary. Walk the + // real prototype chain by constructor name instead: names are + // already RTI's nominal currency (`registeredNameOf`, + // `snapshotTag`/`classMatches` below). Only upgrades false to + // true, never the reverse, so verdicts stay fail-closed. + try { + let ctor = classes[check]; + for (let i = 0; i < 32 && typeof ctor === 'function' && ctor !== Object; i++) { + if (ctor.name === target) { + return true; + } + ctor = Object.getPrototypeOf(ctor); + } + } catch { + // Unreadable chain: keep the instanceof verdict below. + } + return false; } if (check !== null && (typeof check === 'object' || baseOf(check) !== undefined)) { return baseOf(check) !== undefined ? false : undefined; diff --git a/src-runtime/explainMismatch.js b/src-runtime/explainMismatch.js index 3c7eff7..517901f 100644 --- a/src-runtime/explainMismatch.js +++ b/src-runtime/explainMismatch.js @@ -17,6 +17,7 @@ import {previewValue} from './previewValue.js'; import {describeValueType, prettyValue} from './describeValue.js'; import {formatMapKey} from './formatMapKey.js'; import {snapshotTag} from './snapshotTag.js'; +import {snapshotMatches} from './snapshotMatches.js'; const MAX_DEPTH = 6; const MAX_UNION_MEMBERS = 12; const MAX_MAP_ENTRIES = 20; @@ -180,28 +181,10 @@ function passes(value, expect) { * @returns {boolean} True on a nominal match. */ function classMatches(tag, className) { - try { - if (typeof tag !== 'string' || typeof className !== 'string') { - return false; - } - const target = classes[className]; - if (typeof target !== 'function') { - return false; - } - if (tag === className) { - return true; - } - let ctor = classes[tag]; - for (let i = 0; i < 32 && typeof ctor === 'function' && ctor !== Object; i++) { - if (ctor === target) { - return true; - } - ctor = Object.getPrototypeOf(ctor); - } - } catch { - // Unreadable registry or exotic prototype: stay strict. + if (typeof tag !== 'string') { + return false; } - return false; + return snapshotMatches({$type: tag}, className); } /** * Whether an expected prop is function-typed: such props can never diff --git a/src-runtime/firstBadEntry.js b/src-runtime/firstBadEntry.js index 89bcf4b..e41a0ee 100644 --- a/src-runtime/firstBadEntry.js +++ b/src-runtime/firstBadEntry.js @@ -13,26 +13,42 @@ import {isFailingNumberAllowingInf} from "./isFailingNumberAllowingInf.js"; * Names sharing space between utility types and registered classes: the * reference validator resolves them through its utility `case` branches * before the registered-class branch can ever run, so `instanceof` alone - * would report a verdict the validator does not agree with. + * would report a verdict the validator does not agree with. Keep this set + * in sync with the utility `case` branches of `validateReference` + * (`ArrayLike` family, `Readonly*`, `ConcatArray`, `Partial` family, + * `Awaited`, `ReturnType`, `NoInfer`, intrinsics, iterables): a missing + * name lets the scan trust `instanceof` for a class that shadows it, and + * the windowed map path returns clean without ever running the loop. * @type {Set} */ const utilityNames = new Set([ + 'ArrayLike', + 'AsyncIterable', + 'AsyncIterableIterator', 'Awaited', + 'ConcatArray', 'ConstructorParameters', 'Exclude', 'Extract', + 'HTMLCollection', + 'HTMLCollectionOf', 'IfEquals', 'InstanceType', 'Iterable', 'IterableIterator', 'Lowercase', 'NoInfer', + 'NodeList', + 'NodeListOf', 'NonNullable', 'Omit', 'Parameters', 'Partial', 'Pick', 'Readonly', + 'ReadonlyArray', + 'ReadonlyMap', + 'ReadonlySet', 'Required', 'ReturnType', 'Uncapitalize', diff --git a/src-runtime/firstBadEntry.spec.js b/src-runtime/firstBadEntry.spec.js index 4a73a7a..8786595 100644 --- a/src-runtime/firstBadEntry.spec.js +++ b/src-runtime/firstBadEntry.spec.js @@ -103,6 +103,28 @@ function testUtilityNameBailsOut() { firstBadEntry(new Map([[1, new ScanBox(1)]]), 'number', {type: 'reference', name: 'Iterable'}) === -2 ); } +function testUtilityNameCollisionBailsOut() { + // Hostile registration: classes named exactly like utilities the + // validator resolves first (ReadonlyMap, ArrayLike, Awaited). The scan + // must still bail out instead of trusting `instanceof`, which would + // report a verdict the validator does not agree with. + class ReadonlyMap {} + class ArrayLike {} + class Awaited {} + registerClass(ReadonlyMap); + registerClass(ArrayLike); + registerClass(Awaited); + try { + const ref = (name, args) => ({type: 'reference', name, args}); + return firstBadEntry(new Map([['a', new ReadonlyMap()]]), 'string', ref('ReadonlyMap', ['string', 'number'])) === -2 && + firstBadEntry(new Map([[1, new ArrayLike()]]), 'number', ref('ArrayLike', ['number'])) === -2 && + firstBadEntry(new Map([[1, new Awaited()]]), 'number', ref('Awaited', [{type: 'promise', elementType: 'number'}])) === -2; + } finally { + delete classes.ReadonlyMap; + delete classes.ArrayLike; + delete classes.Awaited; + } +} function testComplexValBailsOut() { // Object nodes, unions and unknown names are full-loop territory. return firstBadEntry(new Map([[1, {x: 1}]]), 'number', {type: 'object'}) === -2 && @@ -161,6 +183,7 @@ const tests = [ testInfinityOptOut, testReferenceNodeVal, testUtilityNameBailsOut, + testUtilityNameCollisionBailsOut, testComplexValBailsOut, testComplexKeyBailsOut, testUserlandOverrideComposition, diff --git a/src-runtime/getTypeKeys.js b/src-runtime/getTypeKeys.js index 849aac4..a150a7a 100644 --- a/src-runtime/getTypeKeys.js +++ b/src-runtime/getTypeKeys.js @@ -363,6 +363,24 @@ function resolveObject(type, warn, depth) { } return resolveObject(instance, warn, depth + 1); } + if (type.type === 'condition') { + // Decided conditions resolve to a branch, mirroring validation: + // `ComponentOptionsOverridesOf<"element">` must yield its true + // branch for the intersection merge to see the override keys. + // A static import would cycle back here, so go through the table. + const evaluate = validators.evaluateCondition; + if (!evaluate) { + return; + } + const decision = evaluate(type.checkType, type.extendsType, warn); + if (decision === true) { + return resolveObject(type.trueType, warn, depth + 1); + } + if (decision === false) { + return resolveObject(type.falseType, warn, depth + 1); + } + return; + } if (typeof type.type === 'string' && (classes[type.type] || typedefs[type.type])) { // Named-type wrappers `{type: Name, optional?, readonly?}` (from // `Partial`, harvest, etc.): resolve the name for shape reading; the diff --git a/src-runtime/index.js b/src-runtime/index.js index 7b94d43..9fa6f63 100644 --- a/src-runtime/index.js +++ b/src-runtime/index.js @@ -3,6 +3,8 @@ export * from './encodeBase64.js'; export * from './decodeBase64.js'; export * from './createType.js'; export * from './createTypeFromIndexedAccess.js'; +export * from './materializeUtility.js'; +export * from './resolveObjectSide.js'; export * from './createTypeFromKeyof.js'; export * from './createTypeFromMapping.js'; export * from './applyQuestionModifier.js'; @@ -35,6 +37,7 @@ export * from './createTableHead.js'; export * from './validateDivisionAddWarning.js'; export * from './validateNumberInObject.js'; export * from './snapshotTag.js'; +export * from './snapshotMatches.js'; export * from './DisplayAnything.js'; export * from './evaluateCondition.js'; export * from './firstBadIndex.js'; diff --git a/src-runtime/materializeUtility.js b/src-runtime/materializeUtility.js new file mode 100644 index 0000000..7941cbd --- /dev/null +++ b/src-runtime/materializeUtility.js @@ -0,0 +1,51 @@ +import {getTypeKeys} from './getTypeKeys.js'; +import {instantiateReference} from './instantiateReference.js'; +import {stripKey} from './stripKey.js'; +import {resolveObjectSide} from './resolveObjectSide.js'; +/** + * Materializes Pick/Omit/Partial to object shapes for indexed access. + * Unresolvable bases or key lists yield undefined (callers fail closed). + * @param {*} target - Utility reference struct. + * @param {console["warn"]} warn - Function to warn with. + * @param {number} depth - Recursion depth. + * @returns {import('./validateType.js').TypeObject|undefined} Object shape or undefined. + * @example + * // input: {type: 'reference', name: 'Pick', args: [{type: 'object', properties: {a: 'K', b: 'number'}}, '"a"']} + * materializeUtility(expandType('Pick<{a: K, b: number}, "a">'), console.warn, 0); + * // {type: 'object', properties: {a: 'K'}} + */ +function materializeUtility(target, warn, depth) { + const [base, keys] = target.args ?? []; + if (target.name === 'Partial') { + const resolved = base === undefined ? undefined : resolveObjectSide(base, warn, depth + 1); + if (!resolved || !resolved.properties) { + return; + } + const properties = {}; + for (const key of Object.keys(resolved.properties)) { + const prop = resolved.properties[key]; + properties[key] = prop && typeof prop === 'object' ? {...prop, optional: true} : {type: prop, optional: true}; + } + return {type: 'object', properties}; + } + if (base === undefined || keys === undefined) { + return; + } + const resolved = resolveObjectSide(base, warn, depth + 1); + if (!resolved || !resolved.properties) { + return; + } + const names = getTypeKeys(keys, warn); + if (!Array.isArray(names)) { + return; + } + const wanted = new Set(names.map((name) => stripKey(name))); + const properties = {}; + for (const key of Object.keys(resolved.properties)) { + if (wanted.has(key) === (target.name === 'Pick')) { + properties[key] = resolved.properties[key]; + } + } + return {type: 'object', properties}; +} +export {materializeUtility}; diff --git a/src-runtime/materializeUtility.spec.js b/src-runtime/materializeUtility.spec.js new file mode 100644 index 0000000..38a7794 --- /dev/null +++ b/src-runtime/materializeUtility.spec.js @@ -0,0 +1,47 @@ +import {materializeUtility} from './materializeUtility.js'; +import {deepEqualType} from './deepEqualType.js'; +import {expandType} from '../src-transpiler/expandType.js'; +const silent = () => undefined; +// We want: Pick to keep only the named props, because indexed access over +// `Pick` reads the picked shape. +function testPickKeepsNamed() { + const parsed = expandType('Pick<{a: K, b: number}, "a">'); + const materialized = materializeUtility(parsed, silent, 0); + const expected = {type: 'object', properties: {a: 'K'}}; + return deepEqualType(materialized, expected); +} +// We want: Omit to drop the named props, because indexed access over +// `Omit` reads everything except the dropped keys. +function testOmitDropsNamed() { + const parsed = expandType('Omit<{a: K, b: number}, "a">'); + const materialized = materializeUtility(parsed, silent, 0); + const expected = {type: 'object', properties: {b: 'number'}}; + return deepEqualType(materialized, expected); +} +// We want: Partial to mark every prop optional, because indexed access over +// `Partial` reads the optionalized shape. +function testPartialMarksOptional() { + const parsed = expandType('Partial<{a: K}>'); + const materialized = materializeUtility(parsed, silent, 0); + const expected = {type: 'object', properties: {a: {type: 'K', optional: true}}}; + return deepEqualType(materialized, expected); +} +// We want: unresolvable bases to yield undefined, because callers fail +// closed instead of validating against a guessed shape. +function testUnresolvableYieldsUndefined() { + const parsed = expandType('Pick'); + return materializeUtility(parsed, silent, 0) === undefined; +} +// We want: missing args to yield undefined, because a malformed utility +// reference carries no base or key list to materialize from. +function testMissingArgsYieldsUndefined() { + return materializeUtility({type: 'reference', name: 'Pick', args: []}, silent, 0) === undefined; +} +const tests = [ + testPickKeepsNamed, + testOmitDropsNamed, + testPartialMarksOptional, + testUnresolvableYieldsUndefined, + testMissingArgsYieldsUndefined, +]; +export {tests}; diff --git a/src-runtime/reportedDiagnosis.spec.js b/src-runtime/reportedDiagnosis.spec.js new file mode 100644 index 0000000..d24049b --- /dev/null +++ b/src-runtime/reportedDiagnosis.spec.js @@ -0,0 +1,249 @@ +import {inspectType} from './inspectType.js'; +import {explainMismatch} from './explainMismatch.js'; +import {compareText} from './compareText.js'; +import {registerClass} from './registerClass.js'; +import {registerTypedef} from './registerTypedef.js'; +import {classes} from './registerClass.js'; +import {typedefs, bumpTypedefVersion} from './registerTypedef.js'; +import {options, setOptions} from './options.js'; +import {validators} from './validators.js'; +import {validateArray} from './validateArray.js'; +class ReportWidget { + constructor() { + this.r = 1; + this.g = 1; + this.b = 1; + this.a = 1; + } + spin() { + return this.r; + } +} +class ReportVec2 { + constructor() { + this.x = 0; + this.y = 0; + } + set() { + return this; + } +} +function prepare() { + // Registry-wiping specs run in the same process, so every test + // re-registers its own entries instead of trusting module scope. + // Runtime harvesting sees only prototype methods, never constructor + // assignments: like the engine twins, register the harvested data shapes + // next to the classes so snapshots diff against data, not just methods. + registerClass(ReportWidget); + registerClass(ReportVec2); + registerTypedef('ReportWidget', {type: 'object', properties: {r: 'number', g: 'number', b: 'number', a: 'number', spin: 'Function'}}); + registerTypedef('ReportVec2', {type: 'object', properties: {x: 'number', y: 'number', set: 'Function'}}); +} +/** + * Captures the posted report of one failing check. Cross-realm mode pushes + * the report through `structuredClone`, which is what the browser does at + * the iframe boundary: values the checker passed by reference arrive as + * copies. Loc differs per mode so the second transport is a full report, + * never a key-only repeat. Reports carry no diagnosis: the panel diffs + * lazily on open, in its own realm. + * @param {*} value - The failing value to check. + * @param {*} expect - The expected type. + * @param {string} loc - The check location. + * @param {string} name - The argument name. + * @param {boolean} crossRealm - Clone the report like a realm crossing. + * @returns {object | null} The posted report, or null when nothing posted. + */ +function postReport(value, expect, loc, name, crossRealm) { + prepare(); + const prevParent = globalThis.parent; + let posted = null; + globalThis.parent = {postMessage: (msg) => { + posted = msg; + }}; + try { + inspectType(value, expect, `${loc}-${crossRealm ? 'cross' : 'same'}`, name); + } finally { + globalThis.parent = prevParent; + } + if (posted === null) { + return null; + } + return crossRealm ? structuredClone(posted) : posted; +} +function testReportCarriesNoDiagnosis() { + // Per-error cost stays at validation: the report carries value, expect + // and messages, never a precomputed diagnosis. Both transports agree. + for (const crossRealm of [false, true]) { + const posted = postReport({r: 'x'}, 'ReportWidget', 'reportLocOne', 'reportNameOne', crossRealm); + if (!posted || posted.findings !== undefined || posted.stub !== undefined) { + return false; + } + } + return true; +} +function testLazyDiffFindsRowInForeignRegistry() { + // The panel diffs on open against whatever registry it has: with the + // types registered the lazy diff finds the true row. Rendered from the + // transported value, not a hand-built one, so clone damage would show. + for (const crossRealm of [false, true]) { + const posted = postReport({r: 'x'}, 'ReportWidget', 'reportLocTwo', 'reportNameTwo', crossRealm); + const rendered = compareText({msg: 'm', expect: posted.expect, value: posted.value, name: 'reportNameTwo', hits: 1}); + if (!rendered.includes('reportNameTwo') || !rendered.includes('Diagnosis:')) { + return false; + } + } + return true; +} +function testLazyDiffDegradesWithoutRegistry() { + // Honest degradation, pinned: a panel without the engine twins cannot + // resolve the reference, so the on-click diff reports one opaque row + // instead of inventing member rows. Late-loading twins can only add + // information from here, never silently change a verdict-shaped row. + prepare(); + const posted = postReport({r: 'x'}, 'ReportWidget', 'reportLocThree', 'reportNameThree', false); + const savedTypedefs = {...typedefs}; + const savedClasses = {...classes}; + Object.keys(typedefs).forEach((key) => delete typedefs[key]); + Object.keys(classes).forEach((key) => delete classes[key]); + bumpTypedefVersion(); + let rendered; + try { + rendered = compareText({msg: 'm', expect: posted.expect, value: posted.value, name: 'reportNameThree', hits: 1}); + } finally { + Object.assign(typedefs, savedTypedefs); + Object.assign(classes, savedClasses); + bumpTypedefVersion(); + } + const localRows = (rendered.match(/^- \[/gm) || []).length; + return localRows === 1 && rendered.includes('reportNameThree'); +} +function testAnchorsPayloadKeepsSingleRow() { + // Production anchors shape: dense arrays satisfy their number[] members + // and the live Color-like instance matches nominally after tagging, so + // the only failing property is the genuinely mistyped alignment. The + // on-click diff must say exactly that in both realms, never the + // phantom union rows. Tints are live instances, not hand-tagged + // literals, so the snapshot envelope path runs for real. + const widget = new ReportWidget(); + for (const crossRealm of [false, true]) { + const posted = postReport( + {anchor: [0, 0, 1, 1], tint: widget, align: [0, 0]}, + {type: 'object', properties: { + anchor: {type: 'union', members: ['ReportVec2', {type: 'array', elementType: 'number'}], optional: true}, + tint: {type: 'union', members: ['ReportWidget', {type: 'array', elementType: 'number'}], optional: true}, + align: {type: 'ReportVec2', optional: true}, + }}, + 'reportLocFour', + 'reportNameFour', + crossRealm, + ); + const {findings} = explainMismatch(posted.value, posted.expect, 'reportNameFour'); + if (!(Array.isArray(findings) && findings.length === 1 && findings[0].path === 'reportNameFour.align')) { + return false; + } + if (crossRealm && (!posted.value || posted.value.tint.$type !== 'ReportWidget' || !Array.isArray(posted.value.anchor))) { + return false; + } + } + return true; +} +function testOptionDriftShowsInLocalDiff() { + // Documented drift, pinned openly: a panel stricter than the checker + // (infinities rejected at render time, allowed at check time) shows an + // extra `a[1]` row the verdict never saw. The lazy diff reflects the + // rendering realm; it does not promise the checking verdict. + prepare(); + const prev = {strictNullChecks: options.strictNullChecks, checkInfinity: options.checkInfinity, exactObjects: options.exactObjects}; + setOptions({strictNullChecks: true, checkInfinity: false, exactObjects: false}); + let posted; + try { + posted = postReport({a: [0, Infinity], b: 'x'}, {type: 'object', properties: {a: {type: 'array', elementType: 'number'}, b: 'number'}}, 'reportLocFive', 'reportNameFive', false); + } finally { + setOptions(prev); + } + if (!posted) { + return false; + } + setOptions({strictNullChecks: true, checkInfinity: true, exactObjects: true}); + let rendered; + try { + rendered = compareText({msg: 'm', expect: posted.expect, value: posted.value, name: 'reportNameFive', hits: 1}); + } finally { + setOptions(prev); + } + const rows = rendered.match(/^- \[/gm) || []; + return rows.length === 2 && rendered.includes('a[1]') && rendered.includes('reportNameFive.b'); +} +function testValidatorDriftShowsInLocalDiff() { + // Documented drift, pinned openly: a panel realm whose validator table + // disagrees with the checker's (a second runtime copy with a userland + // override, a stale bundle) invents phantom union rows for dense clean + // arrays: per-element probes use the intact number check while the + // whole-array probe hits the hostile array validator, tripping the + // later-element fallback. This is the exact shape of the production + // phantom rows; the lazy diff reproduces them, it does not suppress + // them. + prepare(); + const posted = postReport( + {anchor: [0, 0, 1, 1], align: [0, 0]}, + {type: 'object', properties: { + anchor: {type: 'union', members: ['ReportVec2', {type: 'array', elementType: 'number'}], optional: true}, + align: {type: 'ReportVec2', optional: true}, + }}, + 'reportLocSix', + 'reportNameSix', + false, + ); + if (!posted) { + return false; + } + validators.validateArray = () => false; + let rendered; + try { + rendered = compareText({msg: 'm', expect: posted.expect, value: posted.value, name: 'reportNameSix', hits: 1}); + } finally { + validators.validateArray = validateArray; + } + return rendered.includes('closest is') && rendered.includes('reportNameSix.anchor'); +} +function testTypedArrayDiagnosisSurvivesTransit() { + // Views cross realms by buffer, never by reference: no `$type` expando + // may appear in the diagnosis (structuredClone drops it, and untagged + // views no longer invent it), and the same-vs-cross renders agree + // exactly. Diffed bare against the class shape, a view reports its + // missing and excess keys: x and y absent, indices excess, and never a + // `$type` row. + prepare(); + for (const crossRealm of [false, true]) { + const posted = postReport( + new Float32Array([0, 0]), + 'ReportVec2', + 'reportLocSeven', + 'reportNameSeven', + crossRealm, + ); + const {findings} = explainMismatch(posted.value, posted.expect, 'reportNameSeven'); + const paths = Array.isArray(findings) ? findings.map((row) => row.path) : []; + if (!(paths.length === 4 && paths.every((path) => path === 'reportNameSeven' || path.startsWith('reportNameSeven.')))) { + return false; + } + if (findings.some((row) => row.path.endsWith('$type') || JSON.stringify(row).includes('$type'))) { + return false; + } + const rendered = compareText({msg: 'm', expect: posted.expect, value: posted.value, name: 'reportNameSeven', hits: 1}); + const localRows = rendered.match(/^- \[/gm) || []; + if (localRows.length !== 4 || rendered.includes('$type')) { + return false; + } + } + return true; +} +export const tests = [ + testReportCarriesNoDiagnosis, + testLazyDiffFindsRowInForeignRegistry, + testLazyDiffDegradesWithoutRegistry, + testAnchorsPayloadKeepsSingleRow, + testOptionDriftShowsInLocalDiff, + testValidatorDriftShowsInLocalDiff, + testTypedArrayDiagnosisSurvivesTransit, +]; diff --git a/src-runtime/resolveObjectSide.js b/src-runtime/resolveObjectSide.js new file mode 100644 index 0000000..4f3dee8 --- /dev/null +++ b/src-runtime/resolveObjectSide.js @@ -0,0 +1,101 @@ +import {getTypeKeys} from './getTypeKeys.js'; +import {instantiateReference} from './instantiateReference.js'; +import {classes} from './registerClass.js'; +import {typedefs} from './registerTypedef.js'; +import {validators} from './validators.js'; +import {mergedClassShape} from './classShape.js'; +import {createTypeFromMapping} from './createTypeFromMapping.js'; +import {materializeUtility} from './materializeUtility.js'; +import {resolveObject} from './getTypeKeys.js'; +/** + * Resolves the object side of an indexed access to a property map. + * Chases typedef names, instantiates generic references, materializes + * mappings and utilities, and merges intersections/unions member-wise. + * @param {*} object - Object side of the indexed access. + * @param {console["warn"]} warn - Function to warn with. + * @param {number} depth - Recursion depth. + * @returns {import('./validateType.js').TypeObject|undefined} Object shape or undefined. + * @example + * // input: {type: 'object', properties: {a: 'K'}} + * resolveObjectSide(expandType('{a: K}'), console.warn); + * // {type: 'object', properties: {a: 'K'}} + */ +function resolveObjectSide(object, warn, depth = 0) { + if (depth > 10) { + return; + } + let target = object; + for (let i = 0; i < 10; i++) { + if (typeof target === 'string') { + // Class names merge harvested shapes up the constructor chain; plain + // typedef names resolve straight to their shape. + if (classes[target]) { + target = mergedClassShape(target); + continue; + } + if (typedefs[target]) { + target = typedefs[target]; + continue; + } + break; + } + if (target && target.type === 'reference') { + if ((target.name === 'NonNullable' || target.name === 'Readonly' || target.name === 'NoInfer') && target.args?.length) { + return resolveObjectSide(target.args[0], warn, depth + 1); + } + if (target.name === 'Pick' || target.name === 'Omit' || target.name === 'Partial') { + return materializeUtility(target, warn, depth); + } + if (typedefs[target.name]) { + const instance = instantiateReference(target, warn); + if (!instance || instance === target) { + break; + } + target = instance; + continue; + } + } + break; + } + if (!target || typeof target !== 'object') { + return; + } + if (target.type === 'object') { + return target.properties ? target : {type: 'object', properties: {}}; + } + if (target.type === undefined) { + // Bare `{}` (e.g. a conditional false-branch) is the empty object type. + return {type: 'object', properties: target.properties ?? {}}; + } + if (target.type === 'mapping') { + return createTypeFromMapping(target, warn) ?? undefined; + } + if ((target.type === 'intersection' || target.type === 'union') && Array.isArray(target.members)) { + const properties = {}; + for (const member of target.members) { + const resolved = resolveObjectSide(member, warn, depth + 1); + if (!resolved || !resolved.properties) { + return; + } + Object.assign(properties, resolved.properties); + } + return {type: 'object', properties}; + } + if (target.type === 'indexedAccess') { + return resolveObject(target, warn); + } + if (target.type === 'condition') { + const evaluate = validators.evaluateCondition; + if (!evaluate) { + return; + } + const decision = evaluate(target.checkType, target.extendsType, warn); + if (decision === true) { + return resolveObjectSide(target.trueType, warn, depth + 1); + } + if (decision === false) { + return resolveObjectSide(target.falseType, warn, depth + 1); + } + } +} +export {resolveObjectSide}; diff --git a/src-runtime/resolveObjectSide.spec.js b/src-runtime/resolveObjectSide.spec.js new file mode 100644 index 0000000..b8e00c7 --- /dev/null +++ b/src-runtime/resolveObjectSide.spec.js @@ -0,0 +1,47 @@ +import {resolveObjectSide} from './resolveObjectSide.js'; +import {deepEqualType} from './deepEqualType.js'; +import {expandType} from '../src-transpiler/expandType.js'; +const silent = () => undefined; +// We want: plain object shapes to pass through, because indexed access +// reads their properties directly. +function testObjectPassthrough() { + const parsed = expandType('{a: K}'); + const input = parsed; + const resolved = resolveObjectSide(input, silent, 0); + return deepEqualType(resolved, {type: 'object', properties: {a: 'K'}}); +} +// We want: utility references to materialize, because `T[K]` over +// `Pick` reads the picked shape, not the reference. +function testUtilityMaterializes() { + const parsed = expandType('Pick<{a: K, b: number}, "a">'); + const resolved = resolveObjectSide(parsed, silent, 0); + const expected = {type: 'object', properties: {a: 'K'}}; + return deepEqualType(resolved, expected); +} +// We want: intersections to merge member shapes, because `T[K]` over +// `(A & B)` reads from both sides. +function testIntersectionMerges() { + const parsed = expandType('{a: K} & {b: number}'); + const resolved = resolveObjectSide(parsed, silent, 0); + const expected = {type: 'object', properties: {a: 'K', b: 'number'}}; + return deepEqualType(resolved, expected); +} +// We want: an unresolvable member to fail the whole merge, because a +// partial property map would silently drop keys instead of failing closed. +function testUnresolvableMemberFailsMerge() { + const parsed = expandType('{a: K} & Missing'); + return resolveObjectSide(parsed, silent, 0) === undefined; +} +// We want: depth overflow to stop, because cyclic typedefs must not hang +// the resolver. +function testDepthOverflowStops() { + return resolveObjectSide(expandType('{a: K}'), silent, 11) === undefined; +} +const tests = [ + testObjectPassthrough, + testUtilityMaterializes, + testIntersectionMerges, + testUnresolvableMemberFailsMerge, + testDepthOverflowStops, +]; +export {tests}; diff --git a/src-runtime/snapshotMatches.js b/src-runtime/snapshotMatches.js new file mode 100644 index 0000000..6cad0f7 --- /dev/null +++ b/src-runtime/snapshotMatches.js @@ -0,0 +1,42 @@ +import {classes} from "./registerClass.js"; +import {snapshotTag} from "./snapshotTag.js"; +/** + * Whether a value's snapshot tag names the expected class: exact match or + * a registered subclass, walked by identity so aliases stay correct. The + * tag proves instance-hood across messaging (prototypes don't survive), + * which lets validation accept transport-stripped instances the same way + * the differ tells their missing methods from genuine defects. + * @param {*} value - The value to check. + * @param {string} className - Expected class name. + * @returns {boolean} True on a nominal tag match. + * @example + * // input: {r: 1, $type: 'Color'} + * snapshotMatches({r: 1, $type: 'Color'}, 'Color'); + * // true + */ +function snapshotMatches(value, className) { + try { + const tag = snapshotTag(value); + if (typeof tag !== 'string' || typeof className !== 'string') { + return false; + } + const target = classes[className]; + if (typeof target !== 'function') { + return false; + } + if (tag === className) { + return true; + } + let ctor = classes[tag]; + for (let i = 0; i < 32 && typeof ctor === 'function' && ctor !== Object; i++) { + if (ctor === target) { + return true; + } + ctor = Object.getPrototypeOf(ctor); + } + } catch { + // Unreadable registry or exotic prototype: stay strict. + } + return false; +} +export {snapshotMatches}; diff --git a/src-runtime/snapshotMatches.spec.js b/src-runtime/snapshotMatches.spec.js new file mode 100644 index 0000000..cf28cac --- /dev/null +++ b/src-runtime/snapshotMatches.spec.js @@ -0,0 +1,82 @@ +import {snapshotMatches} from './snapshotMatches.js'; +import {validateType} from './validateType.js'; +import {registerClass} from './registerClass.js'; +class Widget {} +class SpecialWidget extends Widget {} +class Gadget {} +const silent = () => undefined; +function prepare() { + registerClass(Widget); + registerClass(SpecialWidget); + registerClass(Gadget); +} +// We want: a live instance to fail here, because instances carry no tag: +// the caller tries `instanceof` first and only consults the tag fallback +// for transported plain data. +function testLiveInstanceSkipped() { + prepare(); + return snapshotMatches(new SpecialWidget(), 'SpecialWidget') === false; +} +// We want: a tagged snapshot to pass nominally, because prototypes do not +// survive messaging and the tag records the constructor that did. +function testTaggedSnapshotPasses() { + prepare(); + return snapshotMatches({w: 1, $type: 'SpecialWidget'}, 'SpecialWidget') === true; +} +// We want: a subclass snapshot to satisfy its parent, because the walk +// follows the registered chain by identity. +function testSubclassSnapshotSatisfiesParent() { + prepare(); + return snapshotMatches({w: 1, $type: 'SpecialWidget'}, 'Widget') === true; +} +// We want: a stranger snapshot to fail, because the tag names a class +// outside the expected hierarchy. +function testStrangerSnapshotFails() { + prepare(); + return snapshotMatches({w: 1, $type: 'Gadget'}, 'Widget') === false; +} +// We want: untagged values to fail, because without a recorded constructor +// there is no nominal claim to check. +function testUntaggedFails() { + prepare(); + return snapshotMatches({w: 1}, 'Widget') === false && + snapshotMatches(null, 'Widget') === false && + snapshotMatches('Widget', 'Widget') === false; +} +// We want: an unknown expected class to fail closed, because a missing +// registry entry decides nothing. +function testUnknownClassFails() { + prepare(); + return snapshotMatches({w: 1, $type: 'Widget'}, 'Missing') === false; +} +// We want: a tagged snapshot to validate against the bare class name, +// because union members arrive as strings and take the `validateType` +// class branch, not the `validateReference` one. +function testTaggedSnapshotValidatesBareName() { + prepare(); + return validateType({w: 1, $type: 'SpecialWidget'}, 'SpecialWidget', 'loc', 'name', true, silent, 0) === true; +} +// We want: a stranger snapshot to fail the bare class name, because the +// tag names a class outside the expected hierarchy. +function testStrangerSnapshotFailsBareName() { + prepare(); + return validateType({w: 1, $type: 'Gadget'}, 'Widget', 'loc', 'name', true, silent, 0) === false; +} +// We want: a live instance to keep passing the bare class name by +// `instanceof`, because the tag fallback must not disturb the primary path. +function testLiveInstancePassesBareName() { + prepare(); + return validateType(new SpecialWidget(), 'SpecialWidget', 'loc', 'name', true, silent, 0) === true; +} +const tests = [ + testLiveInstanceSkipped, + testTaggedSnapshotPasses, + testSubclassSnapshotSatisfiesParent, + testStrangerSnapshotFails, + testUntaggedFails, + testUnknownClassFails, + testTaggedSnapshotValidatesBareName, + testStrangerSnapshotFailsBareName, + testLiveInstancePassesBareName, +]; +export {tests}; diff --git a/src-runtime/stringifyType.js b/src-runtime/stringifyType.js index 2d34556..b36337d 100644 --- a/src-runtime/stringifyType.js +++ b/src-runtime/stringifyType.js @@ -163,7 +163,8 @@ function stringifyType(type, space = 0, depth = 0) { } case 'function': { const params = (type.parameters || []).map((_) => stringifyType(_, space, depth + 1)).join(', '); - out = `(${params})=>any`; + const ret = type.ret ? stringifyType(type.ret, space, depth + 1) : 'any'; + out = `(${params})=>${ret}`; break; } case 'new': { diff --git a/src-runtime/stringifyType.spec.js b/src-runtime/stringifyType.spec.js index 76e7774..eb7d4a2 100644 --- a/src-runtime/stringifyType.spec.js +++ b/src-runtime/stringifyType.spec.js @@ -185,6 +185,22 @@ function testIndexSignatureFallback() { } return true; } +// Function types print their declared return like constructor types do: +// the expansion carries `ret` since the return-type fix, so the display +// must not keep claiming `any`. +function testFunctionPrintsReturn() { + const str = stringifyType(expandType('(a: number) => string')); + if (str !== '(a: number)=>string') { + console.warn('stringifyType function return mismatch', {str}); + return false; + } + const bare = stringifyType({type: 'function', parameters: []}); + if (bare !== '()=>any') { + console.warn('stringifyType bare function mismatch', {bare}); + return false; + } + return true; +} export const tests = [ testIssue154, testIssue154PrettyNonNormalized, @@ -197,4 +213,5 @@ export const tests = [ testReadonlyOptionalComposes, testIndexSignatureParams, testIndexSignatureFallback, + testFunctionPrintsReturn, ]; diff --git a/src-runtime/tagSnapshot.js b/src-runtime/tagSnapshot.js index 80ae9e3..68808b3 100644 --- a/src-runtime/tagSnapshot.js +++ b/src-runtime/tagSnapshot.js @@ -1,12 +1,15 @@ import {classes} from "./registerClass.js"; import {registeredNameOf} from "./registeredNameOf.js"; /** - * Whether a node needs a `$type` tag: a registered class instance - * (nominal identity messaging strips) or a typed array / `DataView` - * (self-identifying for array-branch findings). Plain objects, arrays - * and natively handled containers (`Map`/`Set`/...) never need one. - * Constructor-style names only, mirroring what `snapshotTag` filters - * downstream. + * Whether a node needs a `$type` tag: a registered class instance, whose + * nominal identity messaging would otherwise strip. Typed arrays and + * `DataView`s deliberately stay untagged like the other natively handled + * containers (`Map`/`Set`/...) below: an expando tag on a view does not + * survive `structuredClone` (typed-array serialization is buffer-only), + * so tagging them prints a phantom `$type` row on the checking side and + * loses the tag at the realm boundary. Plain objects and arrays never + * need one. Constructor-style names only, mirroring what `snapshotTag` + * filters downstream. * @param {*} val - Candidate value. * @returns {string|undefined} Tag name or undefined. */ @@ -36,8 +39,7 @@ function tagName(val) { return undefined; } if (typeof ArrayBuffer !== 'undefined' && ArrayBuffer.isView(val)) { - const viewName = typeof ctor === 'function' ? ctor.name : val.constructor?.name; - return typeof viewName === 'string' && /^[A-Z]/.test(viewName) ? viewName : undefined; + return undefined; } } catch { return undefined; diff --git a/src-runtime/tagSnapshot.spec.js b/src-runtime/tagSnapshot.spec.js index 0718b2e..2944867 100644 --- a/src-runtime/tagSnapshot.spec.js +++ b/src-runtime/tagSnapshot.spec.js @@ -42,10 +42,14 @@ function testUnregisteredUntagged() { const out = tagSnapshot(new Ghost()); return out !== null && typeof out === 'object' && out.$type === undefined; } -function testTypedArrayTagged() { - // Typed arrays keep layout but gain a self-identifying tag. - const out = tagSnapshot(new Float32Array([1, 2])); - return out instanceof Float32Array && out.$type === 'Float32Array'; +function testTypedArrayUntagged() { + // Views post untouched like the other natively handled containers: an + // expando tag would not survive structuredClone (buffer-only + // serialization), so tagging prints a phantom row checking-side and + // loses the tag in transit. + const view = new Float32Array([1, 2]); + const out = tagSnapshot(view); + return out === view && view.$type === undefined; } function testMapUntagged() { // Natively handled containers post untouched. @@ -87,7 +91,7 @@ const tests = [ testInstanceTagged, testNestedInstancesTagged, testUnregisteredUntagged, - testTypedArrayTagged, + testTypedArrayUntagged, testMapUntagged, testCyclesSafe, testNeverThrows, diff --git a/src-runtime/validateArray.spec.js b/src-runtime/validateArray.spec.js index 50b3f29..376d240 100644 --- a/src-runtime/validateArray.spec.js +++ b/src-runtime/validateArray.spec.js @@ -27,11 +27,31 @@ function testExplicitUndefinedFails() { return validateType([undefined], numbers(), 'loc', 'name', true, warn, 0) === false && validateType([1, undefined, 2], numbers(), 'loc', 'name', true, warn, 0) === false; } +function testFrozenPasses() { + // Immutability is not shape: frozen arrays validate like dense ones. + return validateType(Object.freeze([0, 0, 1, 1]), numbers(), 'loc', 'name', true, warn, 0) === true; +} +function testTypedArraysFail() { + // Typed arrays are no `instanceof Array`, so they never satisfy + // `number[]`, however numeric their contents read. + return validateType(new Float32Array([0, 0, 1, 1]), numbers(), 'loc', 'name', true, warn, 0) === false && + validateType(new Uint8Array([1, 2]), numbers(), 'loc', 'name', true, warn, 0) === false; +} +function testExtraPropsPass() { + // Named extras on an array are not elements: validation reads indices + // only, so expando props never fail the check. + const extra = [0, 0, 1, 1]; + extra.note = 'x'; + return validateType(extra, numbers(), 'loc', 'name', true, warn, 0) === true; +} const tests = [ testDensePasses, testWrongElementFails, testLengthExtendedHolesFail, testEmptySlotsFail, testExplicitUndefinedFails, + testFrozenPasses, + testTypedArraysFail, + testExtraPropsPass, ]; export {tests}; diff --git a/src-runtime/validateIntersection.js b/src-runtime/validateIntersection.js index f39e625..6a3b5a5 100644 --- a/src-runtime/validateIntersection.js +++ b/src-runtime/validateIntersection.js @@ -1,4 +1,7 @@ import {recurse} from "./validators.js"; +import {options} from "./options.js"; +import {classes} from "./registerClass.js"; +import {resolveObject} from "./getTypeKeys.js"; /** * @param {*} value - The actual value that we need to validate. * @param {*} expect - The supposed type information of said value. @@ -12,7 +15,41 @@ import {recurse} from "./validators.js"; function validateIntersection(value, expect, loc, name, critical, warn, depth) { const {members} = expect; // console.log('validateIntersection', {value, expect, loc, name, critical, warn}); - for (const member of members) { + // Per-member excess would reject keys sibling members declare (the + // `Omit & Overrides` idiom: the Omit side omits `anchor` + // exactly because the override side provides it). Validate each member + // against its resolved shape augmented with siblings' props as + // optional, so `exactObjects` judges excess against the whole + // intersection like tsc does. Only when every member resolves to a + // plain object shape and no raw member is a class name (nominal + // instanceof verdicts must not degrade to structural ones); + // otherwise keep today's per-member behavior instead of guessing. + let checking = members; + if (options.exactObjects === true && value !== null && typeof value === 'object' && !Array.isArray(value) && + members.every((member) => !(typeof member === 'string' && classes[member]))) { + const shapes = members.map((member) => resolveObject(member, warn, depth + 1)); + if (shapes.every((shape) => shape && shape.type === 'object' && shape.properties)) { + checking = shapes.map((shape, i) => { + const extra = {}; + for (const [j, other] of shapes.entries()) { + if (j === i) { + continue; + } + for (const key of Object.keys(other.properties)) { + if (!(key in shape.properties)) { + const prop = other.properties[key]; + extra[key] = prop && typeof prop === 'object' ? {...prop, optional: true} : {type: prop, optional: true}; + } + } + } + if (!Object.keys(extra).length) { + return shape; + } + return {...shape, properties: {...shape.properties, ...extra}}; + }); + } + } + for (const member of checking) { const good = recurse(value, member, loc, name, critical, warn, depth + 1); if (!good) { warn(`Doesn't match intersection member.`, {member}); diff --git a/src-runtime/validateReference.js b/src-runtime/validateReference.js index d8846ef..9030a5c 100644 --- a/src-runtime/validateReference.js +++ b/src-runtime/validateReference.js @@ -10,6 +10,7 @@ import {instantiateReference} from "./instantiateReference.js"; import {extendsCheck, resolveForExtends, stripLiteral, evaluateCondition} from "./evaluateCondition.js"; import {deepEqualType} from "./deepEqualType.js"; import {atomStripOptional} from "./atomStripOptional.js"; +import {snapshotMatches} from "./snapshotMatches.js"; /** * Follows strings through typedefs (and materializes mappings) to object * shapes, distributing over unions like homomorphic mapped types do. @@ -219,6 +220,20 @@ function validateReference(value, expect, loc, name, critical, warn, depth) { return false; } return validators.validateArray(value, {type: 'array', elementType: firstArg}, loc, name, critical, warn, depth + 1); + case 'ReadonlyMap': + // Readonly-ness is erased at runtime, same shape as Map. + if (args?.length !== 2) { + warn('ReadonlyMap requires two type arguments.', {expect}); + return false; + } + return validators.validateMap(value, {type: 'map', key: args[0], val: args[1]}, loc, name, critical, warn, depth + 1); + case 'ReadonlySet': + // Readonly-ness is erased at runtime, same shape as Set. + if (!firstArg) { + warn('ReadonlySet requires one type argument.', {expect}); + return false; + } + return validators.validateSet(value, {type: 'set', elementType: firstArg}, loc, name, critical, warn, depth + 1); case 'ConcatArray': // ConcatArray is array-like (length + indexed access) plus join/slice. // Accept anything array-like here; arrays trivially satisfy it. @@ -693,12 +708,24 @@ function validateReference(value, expect, loc, name, critical, warn, depth) { for (let i = 0; i < 10 && inner && inner.type === 'promise'; i++) { inner = inner.elementType; } - if (inner !== firstArg) { - // Was (possibly nested) Promise: resolved values are unobservable - // synchronously, so only the instanceof check applies. - return recurse(value, {type: 'promise', elementType: inner}, loc, name, critical, warn, depth + 1); + // Unwrap to the fulfillment type: a Promise is never assignable to + // its own Awaited type (matching tsc), only the resolved value is. + // A bare `Promise` without an element type unwraps to `unknown`: + // the fulfillment could be nullish, so only non-nullish passes. + return recurse(value, inner ?? 'unknown', loc, name, critical, warn, depth + 1); + } + case 'ReturnType': { + if (!firstArg) { + warn('ReturnType requires one type argument.', {expect}); + return false; + } + const resolved = typeof firstArg === 'string' && typedefs[firstArg] ? typedefs[firstArg] : firstArg; + const ret = resolved && typeof resolved === 'object' && (resolved.type === 'function' || resolved.type === 'new') ? resolved.ret : undefined; + if (ret === undefined) { + warn('ReturnType requires a function type with a known return type.', {expect}); + return false; } - return recurse(value, inner, loc, name, critical, warn, depth + 1); + return recurse(value, ret, loc, name, critical, warn, depth + 1); } case 'NoInfer': { // Blocks inference in TypeScript; the runtime shape is unchanged. @@ -789,7 +816,14 @@ function validateReference(value, expect, loc, name, critical, warn, depth) { return recurse(value, typedefs[refName], loc, name, critical, warn, depth + 1); } if (classes[refName]) { - return value instanceof classes[refName]; + if (value instanceof classes[refName]) { + return true; + } + // Tagged snapshots cross messaging as plain data: `instanceof` fails + // because prototypes do not survive, so accept on the recorded + // constructor name (same nominal rule the differ uses). Hand-forged + // `$type` tags gain nothing a hand-built shape would not already give. + return snapshotMatches(value, refName); } if (value && value.constructor && value.constructor.name === refName) { return true; diff --git a/src-runtime/validateReference.spec.js b/src-runtime/validateReference.spec.js index ad24d0b..f2e6e59 100644 --- a/src-runtime/validateReference.spec.js +++ b/src-runtime/validateReference.spec.js @@ -235,6 +235,126 @@ function testGenericBranchRegistryUpdateSeen() { bumpTypedefVersion(); } } +function testAwaitedUnwrapsPromise() { + // `Awaited>` validates the fulfillment, not the wrapper: a + // Promise is never assignable to its own awaited type (matching tsc), + // only the resolved value is. Nested promises unwrap fully. + if (!validateType(1, expandType('Awaited>'), 'loc', 'name', true, warn, 0)) { + return false; + } + if (!validateType('x', expandType('Awaited>'), 'loc', 'name', true, warn, 0)) { + return false; + } + if (!validateType(1, expandType('Awaited>>'), 'loc', 'name', true, warn, 0)) { + return false; + } + if (validateType(Promise.resolve(1), expandType('Awaited>'), 'loc', 'name', true, warn, 0)) { + return false; + } + if (validateType(null, expandType('Awaited>'), 'loc', 'name', true, warn, 0)) { + return false; + } + return true; +} +function testAwaitedPassesThroughNonPromise() { + // Non-promise arguments are their own awaited type, exactly like tsc. + if (!validateType(1, expandType('Awaited'), 'loc', 'name', true, warn, 0)) { + return false; + } + if (validateType('x', expandType('Awaited'), 'loc', 'name', true, warn, 0)) { + return false; + } + return true; +} +function testAwaitedNeedsArgument() { + // A missing argument fails loudly instead of passing everything. + const warnings = []; + const ret = validateType(1, {type: 'reference', name: 'Awaited', args: []}, 'loc', 'name', true, (...args) => warnings.push(args[0]), 0); + return ret === false && warnings.some((_) => typeof _ === 'string' && _.includes('Awaited requires one type argument')); +} +function testReturnTypeResolves() { + // `ReturnType<() => T>` validates the declared return: tsc agrees both + // ways, and the check needs no call (returns are unreadable at runtime). + if (!validateType('x', expandType('ReturnType<() => string>'), 'loc', 'name', true, warn, 0)) { + return false; + } + if (validateType(1, expandType('ReturnType<() => string>'), 'loc', 'name', true, warn, 0)) { + return false; + } + if (!validateType(1, expandType('ReturnType<(a: number) => number>'), 'loc', 'name', true, warn, 0)) { + return false; + } + return true; +} +function testReturnTypeNeedsFunction() { + // Non-function arguments and missing returns fail loudly: without a + // declared return there is nothing to validate against. + const warnings = []; + const bad = validateType(1, {type: 'reference', name: 'ReturnType', args: ['number']}, 'loc', 'name', true, (...args) => warnings.push(args[0]), 0); + if (bad !== false || !warnings.some((_) => typeof _ === 'string' && _.includes('ReturnType requires a function type'))) { + return false; + } + const missing = []; + const noArg = validateType(1, {type: 'reference', name: 'ReturnType', args: []}, 'loc', 'name', true, (...args) => missing.push(args[0]), 0); + return noArg === false && missing.some((_) => typeof _ === 'string' && _.includes('ReturnType requires one type argument')); +} +function testIntrinsicsEnforceCase() { + // The four case intrinsics check values, not just stringness: tsc + // agrees on every row below. + const cases = [ + ['Uppercase', 'ABC', true], + ['Uppercase', 'abc', false], + ['Lowercase', 'abc', true], + ['Lowercase', 'ABC', false], + ['Capitalize', 'Abc', true], + ['Capitalize', 'abc', false], + ['Uncapitalize', 'aBC', true], + ['Uncapitalize', 'Abc', false], + ]; + for (const [type, value, want] of cases) { + if (validateType(value, expandType(type), 'loc', 'name', true, warn, 0) !== want) { + return false; + } + } + return true; +} +function testIntrinsicsNeedStrings() { + // Non-strings and missing arguments fail loudly instead of passing. + if (validateType(1, expandType('Uppercase'), 'loc', 'name', true, warn, 0)) { + return false; + } + const warnings = []; + const ret = validateType('x', {type: 'reference', name: 'Uppercase', args: []}, 'loc', 'name', true, (...args) => warnings.push(args[0]), 0); + return ret === false && warnings.some((_) => typeof _ === 'string' && _.includes('Uppercase requires one type argument')); +} +function testReadonlyMapSet() { + // Readonly-ness erases at runtime: element checks still apply exactly + // like the mutable shapes, and non-collections fail. + if (!validateType(new Map([['a', 1]]), expandType('ReadonlyMap'), 'loc', 'name', true, warn, 0)) { + return false; + } + if (validateType(new Map([['a', 'x']]), expandType('ReadonlyMap'), 'loc', 'name', true, warn, 0)) { + return false; + } + if (!validateType(new Set([1]), expandType('ReadonlySet'), 'loc', 'name', true, warn, 0)) { + return false; + } + if (validateType(new Set(['x']), expandType('ReadonlySet'), 'loc', 'name', true, warn, 0)) { + return false; + } + return validateType({}, expandType('ReadonlyMap'), 'loc', 'name', true, warn, 0) === false; +} +function testReadonlyCollectionsNeedArgs() { + // Missing type arguments fail loudly instead of passing everything. + const mapWarnings = []; + const mapRet = validateType(new Map(), {type: 'reference', name: 'ReadonlyMap', args: ['string']}, 'loc', 'name', true, (...args) => mapWarnings.push(args[0]), 0); + if (mapRet !== false || !mapWarnings.some((_) => typeof _ === 'string' && _.includes('ReadonlyMap requires two type arguments'))) { + return false; + } + const setWarnings = []; + const setRet = validateType(new Set(), {type: 'reference', name: 'ReadonlySet', args: []}, 'loc', 'name', true, (...args) => setWarnings.push(args[0]), 0); + return setRet === false && setWarnings.some((_) => typeof _ === 'string' && _.includes('ReadonlySet requires one type argument')); +} export const tests = [ testIssue241ArrayLikeValid, testIssue241ArrayLikeInvalid, @@ -252,4 +372,13 @@ export const tests = [ testArrayLikeDirectContract, testGenericBranchFreshNodesValidate, testGenericBranchRegistryUpdateSeen, + testAwaitedUnwrapsPromise, + testAwaitedPassesThroughNonPromise, + testAwaitedNeedsArgument, + testReturnTypeResolves, + testReturnTypeNeedsFunction, + testIntrinsicsEnforceCase, + testIntrinsicsNeedStrings, + testReadonlyMapSet, + testReadonlyCollectionsNeedArgs, ]; diff --git a/src-runtime/validateType.js b/src-runtime/validateType.js index c5a7f76..97ab072 100644 --- a/src-runtime/validateType.js +++ b/src-runtime/validateType.js @@ -34,6 +34,7 @@ import {validateTypedef } from "./validateTypedef.js"; import {validateUnion } from "./validateUnion.js"; import {createTypeFromIndexedAccess} from "./createTypeFromIndexedAccess.js"; import {atomUnwrap} from "./atomUnwrap.js"; +import {snapshotMatches} from "./snapshotMatches.js"; import {validators } from "./validators.js"; import {installWalkTimers } from "./plugin-walk-timers/installWalkTimers.js"; /** @@ -284,7 +285,13 @@ function validateType(value, expect, loc, name, critical = true, warn, depth) { return true; } else if (classes[type]) { // Inheritance check, allow Application for AppBase, allow Entity for GraphNode etc. - return value instanceof classes[type]; + if (value instanceof classes[type]) { + return true; + } + // Tagged snapshots cross messaging as plain data: `instanceof` fails + // because prototypes do not survive, so accept on the recorded + // constructor name (same nominal rule `validateReference` uses). + return snapshotMatches(value, type); } // Unregistered platform constructors: the `window` lookup below only // covers browsers, so a `Float32Array` subclass would fail in diff --git a/src-runtime/validateUnionSparse.spec.js b/src-runtime/validateUnionSparse.spec.js new file mode 100644 index 0000000..525cd95 --- /dev/null +++ b/src-runtime/validateUnionSparse.spec.js @@ -0,0 +1,39 @@ +import {validateUnion} from './validateUnion.js'; +import {expandType} from '../src-transpiler/expandType.js'; +const silent = () => undefined; +// We want: a dense number array to satisfy `Vec4 | number[]` through the +// array member, because the union tries every member and the array fits. +function testDenseArrayPasses() { + const parsed = expandType('Vec4 | number[]'); + return validateUnion([0, 0, 1, 1], parsed, 'loc', 'anchor', true, silent, 0) === true; +} +// We want: a holey tail to fail the strict union, because `validateArray` +// reads every index including absent ones. Over-allocated buffers (grown +// `length`, never-written tail slots) are a host-side concern: callers +// that only consume a prefix can ignore this check via `ignoredChecks`, +// while the validator itself stays strict. +function testHoleyTailFailsStrict() { + const parsed = expandType('Vec4 | number[]'); + const grown = [0, 0, 1, 1]; + grown.length = 8; + return validateUnion(grown, parsed, 'loc', 'anchor', true, silent, 0) === false; +} +// We want: an explicit `undefined` tail to fail too, because present +// `undefined` is a wrong element, not an absent index. +function testUndefinedTailFailsStrict() { + const parsed = expandType('Vec4 | number[]'); + return validateUnion([0, 0, 1, 1, undefined], parsed, 'loc', 'anchor', true, silent, 0) === false; +} +// We want: a wrong-typed element to fail even when dense, so the test +// pins that the hole failure above is about absence, not laxity. +function testWrongElementFails() { + const parsed = expandType('Vec4 | number[]'); + return validateUnion([0, 0, 1, 'x'], parsed, 'loc', 'anchor', true, silent, 0) === false; +} +const tests = [ + testDenseArrayPasses, + testHoleyTailFailsStrict, + testUndefinedTailFailsStrict, + testWrongElementFails, +]; +export {tests}; diff --git a/src-transpiler/Asserter.js b/src-transpiler/Asserter.js index ec0a4bc..94c7024 100644 --- a/src-transpiler/Asserter.js +++ b/src-transpiler/Asserter.js @@ -757,6 +757,21 @@ class Asserter extends Stringifier { `AssignmentPattern for parameter ${name}`); continue; } + } else { + // A plain parameter sits at this index but under a different + // name — unless it is the rest binding itself: `...rest` + // declares `rest`, so a matching JSDoc name is declared and + // the check below emits against it. Anything else names + // nothing declared, so emitting it would reference an + // undeclared variable at runtime (ReferenceError). Skip it + // like the missing-param case below; tsc already reports + // these as TS8024. + const isRestBinding = param.type === 'RestElement' && param.argument && param.argument.type === 'Identifier' && param.argument.name === name; + if (!isRestBinding) { + const loc = this.getName(node); + this.warn(`generateTypeChecks> ${loc}> Missing param: ${nameFancy}`); + continue; + } } } else { const loc = this.getName(node); diff --git a/src-transpiler/expandType.js b/src-transpiler/expandType.js index 5a31376..77f432a 100644 --- a/src-transpiler/expandType.js +++ b/src-transpiler/expandType.js @@ -185,14 +185,16 @@ function toSourceTS(node) { throw Error("Impossible"); } const parameters = node.parameters.map(toSourceTSOrAny); - return ({type: 'function', parameters}); + const ret = toSourceTS(node.type) ?? 'any'; + return ({type: 'function', parameters, ret}); } case JSDocFunctionType: { if (!ts.isJSDocFunctionType(node)) { throw Error("Impossible"); } const parameters = node.parameters.map(toSourceTSOrAny); - return ({type: 'function', parameters}); + const ret = node.type ? toSourceTS(node.type) ?? 'any' : 'any'; + return ({type: 'function', parameters, ret}); } case IndexedAccessType: if (!ts.isIndexedAccessTypeNode(node)) { diff --git a/src-transpiler/expandTypeBabelTS.js b/src-transpiler/expandTypeBabelTS.js index 20e470e..891cec2 100644 --- a/src-transpiler/expandTypeBabelTS.js +++ b/src-transpiler/expandTypeBabelTS.js @@ -163,7 +163,8 @@ function toSourceBabelTS(node) { // expandTypeBabelTS("(a: number, b: number) => number") case 'TSFunctionType': { const parameters = node.parameters.map(toBabelParameterOrAny); - return ({type: 'function', parameters}); + const ret = toSourceBabelTSOrAny(node.typeAnnotation?.typeAnnotation); + return ({type: 'function', parameters, ret}); } case 'TSConstructorType': { const parameters = node.parameters.map(toBabelParameterOrAny); diff --git a/src-transpiler/expandTypeDepFree.js b/src-transpiler/expandTypeDepFree.js index be78057..2db0558 100644 --- a/src-transpiler/expandTypeDepFree.js +++ b/src-transpiler/expandTypeDepFree.js @@ -829,7 +829,7 @@ function expandTypeDepFree(type) { if (func.isNew) { return {type: 'new', parameters, ret}; } - return {type: 'function', parameters}; + return {type: 'function', parameters, ret}; } } // (1) Rest parameters like ...string diff --git a/src-transpiler/expandTypeParity.spec.js b/src-transpiler/expandTypeParity.spec.js index 1df226f..377aa1a 100644 --- a/src-transpiler/expandTypeParity.spec.js +++ b/src-transpiler/expandTypeParity.spec.js @@ -293,7 +293,7 @@ function testMissBecomesAnyParity() { 'keyof ?': {type: 'keyof', argument: 'any'}, '{a: ?}': {type: 'object', properties: {a: 'any'}}, 'string | ?': {type: 'union', members: ['string', 'any']}, - '(a: ?) => string': {type: 'function', parameters: [{type: 'parameter', paramType: 'any', name: 'a'}]}, + '(a: ?) => string': {type: 'function', parameters: [{type: 'parameter', paramType: 'any', name: 'a'}], ret: 'string'}, 'typeof ?': {type: 'union', members: [{type: 'typeof', argument: 'any'}, 'null']}, }; for (const [type, want] of Object.entries(exact)) { diff --git a/src-transpiler/generateTypeChecks.spec.js b/src-transpiler/generateTypeChecks.spec.js new file mode 100644 index 0000000..20197d6 --- /dev/null +++ b/src-transpiler/generateTypeChecks.spec.js @@ -0,0 +1,57 @@ +import {runChecks} from './runChecks.js'; +function testMismatchedParamSkips() { + // A JSDoc `@param` naming nothing declared must not emit a reference to + // it: the generated code would throw ReferenceError at runtime. The + // check is skipped with a warning instead (tsc reports these as TS8024). + const src = `/** @param {string} name - Wrong name. */ + function takeMismatchParam(type) { + return type; + } + takeMismatchParam("x");`; + return runChecks(src, 'takeMismatchParam', (scope, {hits, posted}) => typeof scope.takeMismatchParam === 'function' && hits.length === 0 && posted.length === 0); +} +function testOverloadDocblockMismatchSkips() { + // An `@overload` block whose `@param` names miss the implementation + // parameters skips the same way: no crashing reference is emitted. + const src = `/** + * @overload + * @param {string} name - The script name. + */ + function takeOverloadMismatch(type) { + return type; + } + takeOverloadMismatch("x");`; + return runChecks(src, 'takeOverloadMismatch', (scope, {hits, posted}) => typeof scope.takeOverloadMismatch === 'function' && hits.length === 0 && posted.length === 0); +} +function testMatchingParamStillChecks() { + // Control: a correctly named `@param` still validates and reports. + const src = `/** @param {number} x - The value. */ + function takeMismatchControl(x) { + return x; + } + takeMismatchControl("s");`; + return runChecks(src, 'takeMismatchControl', (scope, {hits, posted}) => hits.length === 1 && posted.length === 1); +} +function testRestBindingStillChecks() { + // `...rest` declares its binding: a matching JSDoc name emits the + // check instead of skipping it as a mismatch. + const passingSrc = `/** @param {...any} rest - R. */ + function takeRestPass(...rest) { + return rest; + } + takeRestPass(1, 2);`; + const passing = runChecks(passingSrc, 'takeRestPass', (scope, {hits, posted}) => typeof scope.takeRestPass === 'function' && hits.length === 0 && posted.length === 0); + const failingSrc = `/** @param {...number} rest - R. */ + function takeRestFail(...rest) { + return rest; + } + takeRestFail(1, "x");`; + const failing = runChecks(failingSrc, 'takeRestFail', (scope, {hits, posted}) => hits.length === 1 && posted.length === 1); + return passing && failing; +} +export const tests = [ + testMismatchedParamSkips, + testOverloadDocblockMismatchSkips, + testMatchingParamStillChecks, + testRestBindingStillChecks, +]; diff --git a/test/parity/cases/arrays-numbers.mjs b/test/parity/cases/arrays-numbers.mjs index 3917c70..3ae229d 100644 --- a/test/parity/cases/arrays-numbers.mjs +++ b/test/parity/cases/arrays-numbers.mjs @@ -3,25 +3,25 @@ * @param {Array} xs - Numbers. * @returns {void} */ -function case01(xs) { +function checkNumberArray(xs) { } /** * @param {[number, string]} pair - A pair. * @returns {void} */ -function case02(pair) { +function checkPair(pair) { } /** * @param {number} n - A finite-or-not number. * @returns {void} */ -function case03(n) { +function checkNumber(n) { } -case01([1, 2]); -case01([1, "x"]); -case02([1, "a"]); -case02([1]); -case02([1, "a", true]); -case03(NaN); // parity-diverges: rti-stricter (NaN always rejected) -case03(Infinity); -case03(1); +checkNumberArray([1, 2]); +checkNumberArray([1, "x"]); +checkPair([1, "a"]); +checkPair([1]); +checkPair([1, "a", true]); +checkNumber(NaN); // parity-diverges: rti-stricter (NaN always rejected) +checkNumber(Infinity); +checkNumber(1); diff --git a/test/parity/cases/builtins.mjs b/test/parity/cases/builtins.mjs new file mode 100644 index 0000000..6573f3c --- /dev/null +++ b/test/parity/cases/builtins.mjs @@ -0,0 +1,96 @@ +/** + * Builtin utility parity: Awaited unwraps promise types, ReturnType + * resolves declared returns, and the case intrinsics enforce letter case. + * tsc is the oracle; error counts per case must agree. + * @param {Awaited>} x - Unwrapped fulfillment. + * @returns {void} + */ +function checkAwaitedPromise(x) { +} +/** + * @param {Awaited} x - Non-promises are their own awaited type. + * @returns {void} + */ +function checkAwaitedPlain(x) { +} +/** + * @param {ReturnType<() => string>} x - Declared return. + * @returns {void} + */ +function checkDeclaredReturn(x) { +} +/** + * @param {Uppercase} x - Uppercase only. + * @returns {void} + */ +function checkUppercase(x) { +} +/** + * @param {Lowercase} x - Lowercase only. + * @returns {void} + */ +function checkLowercase(x) { +} +/** + * @param {Capitalize} x - Capitalized only. + * @returns {void} + */ +function checkCapitalized(x) { +} +/** + * @param {Uncapitalize} x - Uncapitalized only. + * @returns {void} + */ +function checkUncapitalized(x) { +} +/** + * @param {Uppercase<'ab' | 'cd'>} s - Intrinsics distribute over unions. + * @returns {void} + */ +function checkUnionUppercase(s) { +} +/** + * @param {ReadonlyMap} m - Same shape as Map. + * @returns {void} + */ +function checkReadonlyMap(m) { +} +/** + * @param {ReadonlySet} s - Same shape as Set. + * @returns {void} + */ +function checkReadonlySet(s) { +} +/** + * @param {Iterable} it - Shape-only: RTI cannot observe yields. + * @returns {void} + */ +function checkIterable(it) { +} +checkAwaitedPromise(1); +checkAwaitedPromise(Promise.resolve(1)); +checkAwaitedPromise(null); +checkAwaitedPlain(1); +checkAwaitedPlain('x'); +checkDeclaredReturn('x'); +checkDeclaredReturn(1); +checkUppercase('ABC'); +checkUppercase('abc'); +checkUppercase(1); +checkLowercase('abc'); +checkLowercase('ABC'); +checkCapitalized('Abc'); +checkCapitalized('abc'); +checkUncapitalized('aBC'); +checkUncapitalized('Abc'); +checkUnionUppercase('AB'); +checkUnionUppercase('CD'); +checkUnionUppercase('ab'); +checkReadonlyMap(new Map([['a', 1]])); +checkReadonlyMap(new Map([['a', 'x']])); +checkReadonlyMap({}); +checkReadonlySet(new Set([1])); +checkReadonlySet(new Set(['x'])); +checkIterable([1, 2]); +checkIterable(new Set([1])); +checkIterable(new Map([['a', 1]])); // parity-diverges: rti-quieter (yielded entries unobservable, shape-only check) diff --git a/test/parity/cases/conditions.mjs b/test/parity/cases/conditions.mjs index 9f450f2..fea9d04 100644 --- a/test/parity/cases/conditions.mjs +++ b/test/parity/cases/conditions.mjs @@ -3,49 +3,49 @@ * @param {"a" extends string ? number : boolean} x - True branch. * @returns {void} */ -function case01(x) { +function checkExtendsStringTrue(x) { } /** * @param {"a" extends number ? number : boolean} x - False branch. * @returns {void} */ -function case02(x) { +function checkExtendsStringFalse(x) { } /** * Boolean literal checks (regression: `false extends true` failed closed). * @param {false extends true ? number : string} x - False branch. * @returns {void} */ -function case03(x) { +function checkFalseExtendsTrue(x) { } /** * @param {true extends true ? number : string} x - True branch. * @returns {void} */ -function case04(x) { +function checkTrueExtendsTrue(x) { } /** * Number literal checks. * @param {1 extends 2 ? number : string} x - False branch. * @returns {void} */ -function case05(x) { +function checkOneExtendsTwo(x) { } /** * @param {1 extends number ? number : string} x - True branch. * @returns {void} */ -function case06(x) { +function checkOneExtendsNumber(x) { } -case01(1); -case01("a"); -case02(true); -case02(1); -case03("a"); -case03(1); -case04(1); -case04("a"); -case05("a"); -case05(1); -case06(1); -case06("a"); +checkExtendsStringTrue(1); +checkExtendsStringTrue("a"); +checkExtendsStringFalse(true); +checkExtendsStringFalse(1); +checkFalseExtendsTrue("a"); +checkFalseExtendsTrue(1); +checkTrueExtendsTrue(1); +checkTrueExtendsTrue("a"); +checkOneExtendsTwo("a"); +checkOneExtendsTwo(1); +checkOneExtendsNumber(1); +checkOneExtendsNumber("a"); diff --git a/test/parity/cases/functions.mjs b/test/parity/cases/functions.mjs index 8016e6a..1d6c7e0 100644 --- a/test/parity/cases/functions.mjs +++ b/test/parity/cases/functions.mjs @@ -3,15 +3,8 @@ * @param {(text: string) => void} f - A callback. * @returns {void} */ -function case01(f) { +function checkCallback(f) { } -/** - * @param {string} a - First. - * @param {string} b - Second. - * @returns {void} - */ -function twoArgs(a, b) { -} -case01((text) => {}); -case01(1); -case01(twoArgs); // parity-diverges: rti-quieter (arity unchecked) +checkCallback((text) => {}); +checkCallback(1); +checkCallback(function twoArgs(/** @type {string} */ a, /** @type {string} */ b) {}); // parity-diverges: rti-quieter (arity unchecked) diff --git a/test/parity/cases/generics.mjs b/test/parity/cases/generics.mjs index c878c31..23e2a47 100644 --- a/test/parity/cases/generics.mjs +++ b/test/parity/cases/generics.mjs @@ -5,7 +5,7 @@ * @param {T} x - Wrapped value. * @returns {void} */ -function case01(x) { +function checkIdentity(x) { } /** * @template {boolean} T @@ -13,10 +13,10 @@ function case01(x) { * @param {T extends true ? string[] : string} payload - Payload. * @returns {void} */ -function case02(isVerbose, payload) { +function checkVerbosePayload(isVerbose, payload) { } -case01(1); -case02(true, ["Error 1", "Error 2"]); -case02(false, "Single Error"); -case02(true, "Single Error"); -case02(false, ["Error 1"]); +checkIdentity(1); +checkVerbosePayload(true, ["Error 1", "Error 2"]); +checkVerbosePayload(false, "Single Error"); +checkVerbosePayload(true, "Single Error"); +checkVerbosePayload(false, ["Error 1"]); diff --git a/test/parity/cases/keyof.mjs b/test/parity/cases/keyof.mjs index 5fe4c23..85f1e04 100644 --- a/test/parity/cases/keyof.mjs +++ b/test/parity/cases/keyof.mjs @@ -9,23 +9,23 @@ * @param {ComponentName} name - A component name. * @returns {void} */ -function case01(name) { +function checkComponentName(name) { } /** * @param {ComponentMap["camera"]} options - Camera options. * @returns {void} */ -function case02(options) { +function checkCameraOptions(options) { } /** * @param {ComponentMap["camera"] & {fov: number}} options - Intersection. * @returns {void} */ -function case03(options) { +function checkCameraIntersection(options) { } -case01("camera"); -case01("nope"); -case02({fov: 60}); -case02({fov: "60"}); -case03({fov: 60}); -case03({}); +checkComponentName("camera"); +checkComponentName("nope"); +checkCameraOptions({fov: 60}); +checkCameraOptions({fov: "60"}); +checkCameraIntersection({fov: 60}); +checkCameraIntersection({}); diff --git a/test/parity/cases/literals.mjs b/test/parity/cases/literals.mjs index c5c970a..02958e7 100644 --- a/test/parity/cases/literals.mjs +++ b/test/parity/cases/literals.mjs @@ -3,39 +3,39 @@ * @param {string} x - A string. * @returns {void} */ -function case01(x) { +function checkString(x) { } /** * @param {number} n - A number. * @returns {void} */ -function case02(n) { +function checkNumber(n) { } /** * @param {boolean} b - A boolean. * @returns {void} */ -function case03(b) { +function checkBoolean(b) { } /** * @param {"a"} lit - A string literal. * @returns {void} */ -function case04(lit) { +function checkStringLiteral(lit) { } /** * @param {1} one - The literal 1. * @returns {void} */ -function case05(one) { +function checkOne(one) { } -case01(1); -case01("a"); -case02("x"); -case02(2); -case03(1); -case03(true); -case04("b"); -case04("a"); -case05(2); -case05(1); +checkString(1); +checkString("a"); +checkNumber("x"); +checkNumber(2); +checkBoolean(1); +checkBoolean(true); +checkStringLiteral("b"); +checkStringLiteral("a"); +checkOne(2); +checkOne(1); diff --git a/test/parity/cases/objects.mjs b/test/parity/cases/objects.mjs index 4c0792b..95eeb7a 100644 --- a/test/parity/cases/objects.mjs +++ b/test/parity/cases/objects.mjs @@ -4,26 +4,26 @@ * @param {{fov: number}} options - Options. * @returns {void} */ -function case01(options) { +function checkFovOptions(options) { } /** * @param {{fov: number, clearColor: Array}} options - Options. * @returns {void} */ -function case02(options) { +function checkFovColorOptions(options) { } /** * @param {{nested: {enabled: boolean}}} options - Nested options. * @returns {void} */ -function case03(options) { +function checkNestedOptions(options) { } -case01({}); -case01({fov: 60}); -case01({fov: "60"}); -case02({fov: 60, clearColor: [0, 0, 0]}); -case02({fov: 60}); -case02({fov: 60, clearColor: "red"}); -case01({fov: 60, extra: true}); -case03({nested: {enabled: 1}}); -case03({nested: {enabled: true}}); +checkFovOptions({}); +checkFovOptions({fov: 60}); +checkFovOptions({fov: "60"}); +checkFovColorOptions({fov: 60, clearColor: [0, 0, 0]}); +checkFovColorOptions({fov: 60}); +checkFovColorOptions({fov: 60, clearColor: "red"}); +checkFovOptions({fov: 60, extra: true}); +checkNestedOptions({nested: {enabled: 1}}); +checkNestedOptions({nested: {enabled: true}}); diff --git a/test/parity/cases/omit.mjs b/test/parity/cases/omit.mjs index fd8fbc2..6e9178b 100644 --- a/test/parity/cases/omit.mjs +++ b/test/parity/cases/omit.mjs @@ -11,7 +11,7 @@ * @param {Omit} payload - Account details. * @returns {void} */ -function case01(payload) { +function checkAccountPayload(payload) { } /** * @typedef {Readonly>} PublicAccountView @@ -19,16 +19,16 @@ function case01(payload) { * @param {PublicAccountView} account - Read-only view. * @returns {void} */ -function case02(account) { +function checkPublicAccount(account) { } /** * @param {Omit} payload - Account details. * @returns {void} */ -function case03(payload) { +function checkPayloadExcessId(payload) { } -case01({email: "dev@company.com", role: "admin"}); -case01({email: "dev@company.com", role: 1}); -case02({email: "dev@company.com", role: "admin", createdAt: "now"}); -case02({email: 1, role: "admin", createdAt: "now"}); -case03({email: "dev@company.com", role: "admin", id: 1}); +checkAccountPayload({email: "dev@company.com", role: "admin"}); +checkAccountPayload({email: "dev@company.com", role: 1}); +checkPublicAccount({email: "dev@company.com", role: "admin", createdAt: "now"}); +checkPublicAccount({email: 1, role: "admin", createdAt: "now"}); +checkPayloadExcessId({email: "dev@company.com", role: "admin", id: 1}); diff --git a/test/parity/cases/optional-atoms.mjs b/test/parity/cases/optional-atoms.mjs index 8644087..b97472d 100644 --- a/test/parity/cases/optional-atoms.mjs +++ b/test/parity/cases/optional-atoms.mjs @@ -8,56 +8,56 @@ * @param {{fov?: number}} options - Optional prop. * @returns {void} */ -function case01(options) { +function checkOptionalFov(options) { } /** * @param {{readonly label?: string}} options - Readonly optional. * @returns {void} */ -function case02(options) { +function checkReadonlyLabel(options) { } /** * @param {{count?: number | string}} options - Optional union. * @returns {void} */ -function case03(options) { +function checkOptionalCount(options) { } /** * @param {{mode?: "a" | "b"}} options - Optional literal union. * @returns {void} */ -function case04(options) { +function checkOptionalMode(options) { } /** * @param {{[k: string]: number}} lookup - Index signature. * @returns {void} */ -function case05(lookup) { +function checkNumberLookup(lookup) { } /** * @param {{nested?: {enabled: boolean}}} options - Optional object. * @returns {void} */ -function case06(options) { +function checkOptionalNested(options) { } -case01({}); -case01({fov: 60}); -case01({fov: "60"}); -case01({}); -case02({}); -case02({label: "x"}); -case02({label: 1}); -case03({}); -case03({count: 1}); -case03({count: "s"}); -case03({count: true}); -case04({}); -case04({mode: "a"}); -case04({mode: "c"}); -case05({a: 1}); -case05({a: "x"}); -case05({}); -case06({}); -case06({nested: {enabled: true}}); -case06({nested: {enabled: 1}}); -case06({nested: {}}); +checkOptionalFov({}); +checkOptionalFov({fov: 60}); +checkOptionalFov({fov: "60"}); +checkOptionalFov({}); +checkReadonlyLabel({}); +checkReadonlyLabel({label: "x"}); +checkReadonlyLabel({label: 1}); +checkOptionalCount({}); +checkOptionalCount({count: 1}); +checkOptionalCount({count: "s"}); +checkOptionalCount({count: true}); +checkOptionalMode({}); +checkOptionalMode({mode: "a"}); +checkOptionalMode({mode: "c"}); +checkNumberLookup({a: 1}); +checkNumberLookup({a: "x"}); +checkNumberLookup({}); +checkOptionalNested({}); +checkOptionalNested({nested: {enabled: true}}); +checkOptionalNested({nested: {enabled: 1}}); +checkOptionalNested({nested: {}}); diff --git a/test/parity/cases/tower-hostile.mjs b/test/parity/cases/tower-hostile.mjs new file mode 100644 index 0000000..c12d970 --- /dev/null +++ b/test/parity/cases/tower-hostile.mjs @@ -0,0 +1,218 @@ +/** + * Hostile component-tower parity: dense arrays, instances, snapshots and + * nullable members validate identically under tsc and RTI; sparse tails, + * frozen arrays and explicit null document their divergence direction. + */ +class Component { + constructor() { + /** @type {boolean} */ + this.enabled = true; + } +} +class Color { + constructor() { + this.r = 0.5; + this.g = 0.6; + this.b = 0.9; + this.a = 1; + } +} +class Vec2 { + constructor() { + this.x = 0; + this.y = 0; + } +} +class Vec4 { + constructor() { + this.x = 0; + this.y = 0; + this.z = 0; + this.w = 0; + } +} +class ElementComponent extends Component { + constructor() { + super(); + /** @type {Vec4} */ + this._anchor = new Vec4(); + /** @type {Color} */ + this._color = new Color(); + /** @type {Vec2} */ + this._alignment = new Vec2(); + } + /** + * @type {Vec4} + */ + get anchor() { + return this._anchor; + } + /** + * @param {Vec4 | number[]} value + */ + set anchor(value) { + void value; + } + /** + * @type {Color} + */ + get color() { + return this._color; + } + /** + * @param {Color | number[]} value + */ + set color(value) { + void value; + } + /** + * @type {Vec2} + */ + get alignment() { + return this._alignment; + } + /** + * @param {Vec2 | number[]} value + */ + set alignment(value) { + void value; + } +} +class Entity { + constructor() { + this.element = undefined; + this.name = 'x'; + } + /** + * @type {ElementComponent|undefined} + * @readonly + */ + element; + /** + * @type {string} + */ + name = 'x'; +} +/** + * @typedef {{ [K in keyof Entity as NonNullable extends Component ? K : never]: NonNullable }} ComponentMap + */ +/** + * @typedef {keyof ComponentMap & string} ComponentName + */ +/** + * Resolves to `A` when the types `X` and `Y` are identical, otherwise to `B`. + * + * @template X + * @template Y + * @template [A=X] + * @template [B=never] + * @typedef {(() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? A : B} IfEquals + */ +/** + * @template T + * @typedef {{ [P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P> }[keyof T]} WritableKeys + */ +/** + * @template C + * @typedef {{ [K in WritableKeys]: K extends 'system' | 'entity' | `_${string}` ? never : NonNullable extends Function ? never : K }[WritableKeys]} ComponentOptionKeys + */ +/** + * @template C + * @typedef {Partial, keyof C>>>} ComponentOptionsOf + */ +/** + * @typedef {object} ComponentOptionsOverrides + * @property {{ anchor?: Vec4 | number[], color?: Color | number[] }} element + */ +/** + * @template {ComponentName} K + * @typedef {K extends keyof ComponentOptionsOverrides ? ComponentOptionsOverrides[K] : {}} ComponentOptionsOverridesOf + */ +/** + * @template {ComponentName} K + * @typedef {Omit, keyof ComponentOptionsOverridesOf> & ComponentOptionsOverridesOf} MergedComponentOptions + */ +/** + * @template {ComponentName} K + * @typedef {{ [P in keyof MergedComponentOptions]: MergedComponentOptions[P] }} ComponentOptions + */ +/** + * @template {ComponentName | (string & {})} K + * @param {K} type - Component name. + * @param {K extends ComponentName ? ComponentOptions : object} [data] - Options. + * @returns {void} + */ +function addElement(type, data) { +} +/** + * @template {ComponentName | (string & {})} K + * @param {K} type - Component name. + * @param {K extends ComponentName ? ComponentOptions : object} [data] - Options. + * @returns {void} + */ +function addElementInvalid(type, data) { +} +/** + * @template {ComponentName | (string & {})} K + * @param {K} type - Component name. + * @param {K extends ComponentName ? ComponentOptions : object} [data] - Options. + * @returns {void} + */ +function addUnknown(type, data) { +} +/** + * @template {ComponentName | (string & {})} K + * @param {K} type - Component name. + * @param {K extends ComponentName ? ComponentOptions : object} [data] - Options. + * @returns {void} + */ +function addNonStringType(type, data) { +} +/** + * @template {ComponentName | (string & {})} K + * @param {K} type - Component name. + * @param {K extends ComponentName ? ComponentOptions : object} [data] - Options. + * @returns {void} + */ +function addFrozenAnchor(type, data) { +} +/** + * @template {ComponentName | (string & {})} K + * @param {K} type - Component name. + * @param {K extends ComponentName ? ComponentOptions : object} [data] - Options. + * @returns {void} + */ +function addSparseAnchor(type, data) { +} +/** + * @template {ComponentName | (string & {})} K + * @param {K} type - Component name. + * @param {K extends ComponentName ? ComponentOptions : object} [data] - Options. + * @returns {void} + */ +function addTypedArrayAnchor(type, data) { +} +/** + * @template {ComponentName | (string & {})} K + * @param {K} type - Component name. + * @param {K extends ComponentName ? ComponentOptions : object} [data] - Options. + * @returns {void} + */ +function addNullAlignment(type, data) { +} +/** @type {number[]} */ +const sparseAnchor = [0, 0, 1, 1]; +sparseAnchor.length = 6; +addElement('element', {anchor: [0, 0, 1, 1], color: new Color()}); +addElement('element', {anchor: new Vec4(), color: [0.5, 0.6, 0.9]}); +addElementInvalid('element', {alignment: [0, 0]}); +addElementInvalid('element', {anchor: [0, 'x', 1, 1]}); +addElementInvalid('element', {color: 'red'}); +addUnknown('nope', {anything: 1}); +addUnknown('nope', 42); +addUnknown('light'); +addNonStringType(42, {}); +addFrozenAnchor('element', {anchor: Object.freeze([0, 0, 1, 1])}); // parity-diverges: rti-quieter (frozen arrays pass RTI, tsc rejects readonly) +addSparseAnchor('element', {anchor: sparseAnchor}); // parity-diverges: rti-stricter (holes fail RTI, tsc accepts sparse arrays) +addTypedArrayAnchor('element', {anchor: new Float32Array([0, 0, 1, 1])}); +addNullAlignment('element', {alignment: null}); // parity-diverges: rti-quieter (explicit null passes optional slots, tsc-strict rejects it) diff --git a/test/parity/cases/unions-nullable.mjs b/test/parity/cases/unions-nullable.mjs index 5a4bcc8..57d8261 100644 --- a/test/parity/cases/unions-nullable.mjs +++ b/test/parity/cases/unions-nullable.mjs @@ -3,43 +3,43 @@ * @param {string | number} x - Union. * @returns {void} */ -function case01(x) { +function checkStringOrNumber(x) { } /** * @param {?string} x - Nullable shorthand. * @returns {void} */ -function case02(x) { +function checkNullableString(x) { } /** * @param {string} x - Non-nullable. * @returns {void} */ -function case03(x) { +function checkPlainString(x) { } /** * @param {string | undefined} x - Explicit undefined member. * @returns {void} */ -function case04(x) { +function checkStringOrUndefined(x) { } /** * @param {string} [x] - Optional parameter. * @returns {void} */ -function case05(x) { +function checkOptionalString(x) { } -case01(true); -case01(1); -case01("a"); -case02(null); -case02("a"); -case02(undefined); -case03(null); -case03(undefined); -case03("a"); -case04(undefined); -case04(null); -case05(); -case05(1); -case05("a"); +checkStringOrNumber(true); +checkStringOrNumber(1); +checkStringOrNumber("a"); +checkNullableString(null); +checkNullableString("a"); +checkNullableString(undefined); +checkPlainString(null); +checkPlainString(undefined); +checkPlainString("a"); +checkStringOrUndefined(undefined); +checkStringOrUndefined(null); +checkOptionalString(); +checkOptionalString(1); +checkOptionalString("a"); diff --git a/test/parity/cases/unparseable-innards.mjs b/test/parity/cases/unparseable-innards.mjs index 2adf477..5aaa1f7 100644 --- a/test/parity/cases/unparseable-innards.mjs +++ b/test/parity/cases/unparseable-innards.mjs @@ -5,71 +5,71 @@ * @param {Array} x - Unknown element. * @returns {void} */ -function case01(x) { +function checkUnknownArray(x) { } /** * @param {Promise} x - Unknown fulfillment. * @returns {void} */ -function case02(x) { +function checkUnknownPromise(x) { } /** * @param {Set} x - Unknown member. * @returns {void} */ -function case03(x) { +function checkUnknownSet(x) { } /** * @param {Map} x - Unknown key. * @returns {void} */ -function case04(x) { +function checkUnknownKeyMap(x) { } /** * @param {Record} x - Unknown record key. * @returns {void} */ -function case05(x) { +function checkUnknownRecord(x) { } /** * @param {Array} x - Unknown union member. * @returns {void} */ -function case06(x) { +function checkStringOrUnknownArray(x) { } /** * @param {string | ?} x - Top-level unknown member. * @returns {void} */ -function case07(x) { +function checkStringOrUnknown(x) { } /** * @param {?} x - Bare unknown. * @returns {void} */ -function case08(x) { +function checkUnknown(x) { } /** * @param {[string, number?]} x - Unnamed optional tuple element. * @returns {void} */ -function case09(x) { +function checkLoosePair(x) { } -case01([1]); -case01("a"); -case02(1); -case03(new Set([1])); -case03(1); -case04(new Map([["a", 1]])); -case04("x"); -case05({a: 1}); -case05("x"); -case06(["a"]); -case06([1]); -case07("a"); -case07(null); -case08(1); -case08("a"); -case09(["a", 1]); -case09(["a"]); -case09("a"); +checkUnknownArray([1]); +checkUnknownArray("a"); +checkUnknownPromise(1); +checkUnknownSet(new Set([1])); +checkUnknownSet(1); +checkUnknownKeyMap(new Map([["a", 1]])); +checkUnknownKeyMap("x"); +checkUnknownRecord({a: 1}); +checkUnknownRecord("x"); +checkStringOrUnknownArray(["a"]); +checkStringOrUnknownArray([1]); +checkStringOrUnknown("a"); +checkStringOrUnknown(null); +checkUnknown(1); +checkUnknown("a"); +checkLoosePair(["a", 1]); +checkLoosePair(["a"]); +checkLoosePair("a"); diff --git a/test/parity/run.mjs b/test/parity/run.mjs index 660b08c..3ed85d3 100644 --- a/test/parity/run.mjs +++ b/test/parity/run.mjs @@ -4,13 +4,14 @@ * node test/parity/run.mjs * * Fixture convention (`test/parity/cases/*.mjs`): every case is one plain - * function named `caseNN` with JSDoc types, an empty body, and its calls on - * their own lines. tsc errors group by the called name on the error line; + * function with a meaningful lowercase name, JSDoc types, an empty body, + * and its calls on their own lines (definitions and calls share the name: + * `checkString(1);`). tsc errors group by the called name on the error line; * RTI errors group by `msg.loc` (the validated function name). Per case the * error COUNTS must agree — unless the call line carries a divergence * marker, e.g.: * - * case07(NaN); // parity-diverges: rti-stricter (NaN always rejected) + * checkNumber(NaN); // parity-diverges: rti-stricter (NaN always rejected) * * Markers assert their direction, so a fixed gap fails loudly as a stale * baseline instead of silently passing. tsc errors that match no call line @@ -24,8 +25,8 @@ import {fileURLToPath} from 'url'; import {checkWithRti, resetRuntimeState, repoRoot} from './rtiCheck.mjs'; const casesDir = join(dirname(fileURLToPath(import.meta.url)), 'cases'); -const CASE_DEF = /^function (case\d+)\s*\(/gmu; -const CASE_CALL = /(case\d+)\s*\(/u; +const CASE_DEF = /^function ([a-z][a-zA-Z0-9]*)\s*\(/gmu; +const CASE_CALL = /^([a-z][a-zA-Z0-9]*)\s*\(/u; const DIVERGE = /parity-diverges:\s*(rti-stricter|rti-quieter)\s*(.*)$/u; /** * @param {string} file - Fixture basename. @@ -33,6 +34,10 @@ const DIVERGE = /parity-diverges:\s*(rti-stricter|rti-quieter)\s*(.*)$/u; */ function readFixture(file) { const lines = readFileSync(join(casesDir, file), 'utf8').split('\n'); + // A call matches the same plain `name(` shape as a definition: tell them + // apart by the leading `function` keyword. Helper lines (class bodies, + // typedefs, const initializers like `new Map(...)`) never feed CASE_CALL + // because the call sites live on their own lines with the case name first. const defined = [...readFileSync(join(casesDir, file), 'utf8').matchAll(CASE_DEF)].map((_) => _[1]); return {lines, defined}; } @@ -100,8 +105,8 @@ async function checkFileWithRti(file, lines) { const divergences = new Map(); const called = new Set(); lines.forEach((text) => { - // Definition lines (`function case01(`) match the call regex too. - if (/^\s*function\s+case\d+/u.test(text)) { + // Definition lines (`function checkString(`) match the call shape too. + if (/^\s*function\s+/u.test(text)) { return; } const call = CASE_CALL.exec(text); @@ -136,7 +141,7 @@ function compareCases(file, defined, called, tsc, rti, divergences) { const known = new Set(defined); for (const key of rti.keys()) { if (!known.has(key.split(':')[1])) { - rows.push(`FAIL ${key}: RTI error outside any case (name helpers caseNN or fix loc)`); + rows.push(`FAIL ${key}: RTI error outside any case (name helpers or fix loc)`); } } for (const id of defined) { diff --git a/test/typechecking.json b/test/typechecking.json index 70a2598..32f477f 100644 --- a/test/typechecking.json +++ b/test/typechecking.json @@ -51,10 +51,18 @@ "input": "./test/typechecking/ParenthesizedExpression-singleline-input.mjs", "output": "./test/typechecking/ParenthesizedExpression-singleline-output.mjs" }, + { + "input": "./test/typechecking/anchors-diagnosis-input.mjs", + "output": "./test/typechecking/anchors-diagnosis-output.mjs" + }, { "input": "./test/typechecking/arguments-input.mjs", "output": "./test/typechecking/arguments-output.mjs" }, + { + "input": "./test/typechecking/builtin-utilities-input.mjs", + "output": "./test/typechecking/builtin-utilities-output.mjs" + }, { "input": "./test/typechecking/callbacks-input.mjs", "output": "./test/typechecking/callbacks-output.mjs" @@ -267,6 +275,10 @@ "input": "./test/typechecking/playcanvas-tower-conditions-input.mjs", "output": "./test/typechecking/playcanvas-tower-conditions-output.mjs" }, + { + "input": "./test/typechecking/playcanvas-tower-hostile-input.mjs", + "output": "./test/typechecking/playcanvas-tower-hostile-output.mjs" + }, { "input": "./test/typechecking/playcanvas-tower-memo-input.mjs", "output": "./test/typechecking/playcanvas-tower-memo-output.mjs" @@ -283,6 +295,10 @@ "input": "./test/typechecking/playcanvas-tower-nullable-input.mjs", "output": "./test/typechecking/playcanvas-tower-nullable-output.mjs" }, + { + "input": "./test/typechecking/playcanvas-tower-read-input.mjs", + "output": "./test/typechecking/playcanvas-tower-read-output.mjs" + }, { "input": "./test/typechecking/playcanvas-tower-strictnull-input.mjs", "output": "./test/typechecking/playcanvas-tower-strictnull-output.mjs" diff --git a/test/typechecking/JSDocFunctionType-output.mjs b/test/typechecking/JSDocFunctionType-output.mjs index 38a18cd..191ec06 100644 --- a/test/typechecking/JSDocFunctionType-output.mjs +++ b/test/typechecking/JSDocFunctionType-output.mjs @@ -17,6 +17,7 @@ function op(cb, a, b) { "name": "undefined" } ], + "ret": "number", "optional": false }, 'op', 'cb')) { youCanAddABreakpointHere(); diff --git a/test/typechecking/anchors-diagnosis-errors.json b/test/typechecking/anchors-diagnosis-errors.json new file mode 100644 index 0000000..5fb63ab --- /dev/null +++ b/test/typechecking/anchors-diagnosis-errors.json @@ -0,0 +1,8 @@ +{ + "throws": [ + { + "loc": "Entity#addComponent", + "name": "data" + } + ] +} diff --git a/test/typechecking/anchors-diagnosis-input.mjs b/test/typechecking/anchors-diagnosis-input.mjs new file mode 100644 index 0000000..79dd5c3 --- /dev/null +++ b/test/typechecking/anchors-diagnosis-input.mjs @@ -0,0 +1,175 @@ +/** + * Anchors replay: an element tower whose system overrides widen + * anchor/margin (`Vec4 | number[]`) and color (`Color | number[]`) to + * array literals, while alignment stays a bare `Vec2` with no override + * (the engine's getter/setter split, which tsc also rejects). Dense + * arrays satisfy the widened union members and a Color instance matches + * nominally, so only the alignment call warns. Regression pin for the + * report-rendering fix: the worker verdict is exactly one failure, and + * the posted checker-side diagnosis carries exactly that row. + * (`Component` is intentionally non-empty: tsc reads an empty base class + * structurally, so every member would extend it.) + */ +class Component { + constructor() { + /** @type {boolean} */ + this.enabled = true; + } +} +class Color { + constructor() { + this.r = 0.5; + this.g = 0.6; + this.b = 0.9; + this.a = 1; + } +} +class Vec2 { + constructor() { + this.x = 0; + this.y = 0; + } +} +class Vec4 { + constructor() { + this.x = 0; + this.y = 0; + this.z = 0; + this.w = 0; + } +} +class ElementComponent extends Component { + constructor() { + super(); + /** @type {Vec4} */ + this._anchor = new Vec4(); + /** @type {Vec4} */ + this._margin = new Vec4(); + /** @type {Color} */ + this._color = new Color(); + /** @type {Vec2} */ + this._alignment = new Vec2(); + } + /** + * @type {Vec4} + */ + get anchor() { + return this._anchor; + } + /** + * @param {Vec4 | number[]} value + */ + set anchor(value) { + this._anchor = value; + } + /** + * @type {Vec4} + */ + get margin() { + return this._margin; + } + /** + * @param {Vec4 | number[]} value + */ + set margin(value) { + this._margin = value; + } + /** + * @type {Color} + */ + get color() { + return this._color; + } + /** + * @param {Color | number[]} value + */ + set color(value) { + this._color = value; + } + /** + * @type {Vec2} + */ + get alignment() { + return this._alignment; + } + /** + * @param {Vec2 | number[]} value + */ + set alignment(value) { + this._alignment = value; + } +} +class Entity { + constructor() { + this.element = null; + this.name = 'x'; + } + /** + * @type {ElementComponent|undefined} + * @readonly + */ + element; + /** + * @type {Object} + */ + c = {}; + /** + * @type {string} + */ + name = 'x'; + /** + * @template {ComponentName | (string & {})} K + * @param {K} type + * @param {K extends ComponentName ? ComponentOptions : object} [data] + * @returns {*} comp + */ + addComponent(type, data) { + return [type, data]; + } +} +/** + * @typedef {{ [K in keyof Entity as NonNullable extends Component ? K : never]: NonNullable }} ComponentMap + */ +/** + * @typedef {keyof ComponentMap & string} ComponentName + */ +/** + * Resolves to `A` when the types `X` and `Y` are identical, otherwise to `B`. + * + * @template X + * @template Y + * @template [A=X] + * @template [B=never] + * @typedef {(() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? A : B} IfEquals + */ +/** + * @template T + * @typedef {{ [P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P> }[keyof T]} WritableKeys + */ +/** + * @template C + * @typedef {{ [K in WritableKeys]: K extends 'system' | 'entity' | `_${string}` ? never : NonNullable extends Function ? never : K }[WritableKeys]} ComponentOptionKeys + */ +/** + * @template C + * @typedef {Partial, keyof C>>>} ComponentOptionsOf + */ +/** + * @typedef {object} ComponentOptionsOverrides + * @property {{ anchor?: Vec4 | number[], margin?: Vec4 | number[], color?: Color | number[] }} element + */ +/** + * @template {ComponentName} K + * @typedef {K extends keyof ComponentOptionsOverrides ? ComponentOptionsOverrides[K] : {}} ComponentOptionsOverridesOf + */ +/** + * @template {ComponentName} K + * @typedef {Omit, keyof ComponentOptionsOverridesOf> & ComponentOptionsOverridesOf} MergedComponentOptions + */ +/** + * @template {ComponentName} K + * @typedef {{ [P in keyof MergedComponentOptions]: MergedComponentOptions[P] }} ComponentOptions + */ +const e = new Entity(); +e.addComponent('element', {anchor: [0, 0, 1, 1], margin: [14, 12, 14, 12], color: new Color()}); // ok +e.addComponent('element', {anchor: [0, 0, 1, 1], margin: [14, 12, 14, 12], color: new Color(), alignment: [0, 0]}); // warns: alignment has no array override diff --git a/test/typechecking/anchors-diagnosis-output.mjs b/test/typechecking/anchors-diagnosis-output.mjs new file mode 100644 index 0000000..0ee0212 --- /dev/null +++ b/test/typechecking/anchors-diagnosis-output.mjs @@ -0,0 +1,728 @@ +registerTypedef('ComponentMap', { + "type": "mapping", + "iterable": { + "type": "keyof", + "argument": "Entity" + }, + "element": "K", + "result": { + "type": "reference", + "name": "NonNullable", + "args": [ + { + "type": "indexedAccess", + "index": "K", + "object": "Entity" + } + ] + }, + "nameType": { + "type": "condition", + "checkType": { + "type": "reference", + "name": "NonNullable", + "args": [ + { + "type": "indexedAccess", + "index": "K", + "object": "Entity" + } + ] + }, + "extendsType": "Component", + "trueType": "K", + "falseType": "never" + } +}); +registerTypedef('ComponentName', { + "type": "intersection", + "members": [ + { + "type": "keyof", + "argument": "ComponentMap" + }, + "string" + ] +}); +registerTypedef('IfEquals', { + "type": "condition", + "checkType": { + "type": "function", + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "X", + "trueType": 1, + "falseType": 2 + } + }, + "extendsType": { + "type": "function", + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "Y", + "trueType": 1, + "falseType": 2 + } + }, + "trueType": "A", + "falseType": "B" +}, ["X","Y","A","B"]); +registerTypedef('WritableKeys', { + "type": "indexedAccess", + "index": { + "type": "keyof", + "argument": "T" + }, + "object": { + "type": "mapping", + "iterable": { + "type": "keyof", + "argument": "T" + }, + "element": "P", + "result": { + "type": "reference", + "name": "IfEquals", + "args": [ + { + "type": "mapping", + "iterable": "P", + "element": "Q", + "result": { + "type": "indexedAccess", + "index": "P", + "object": "T" + } + }, + { + "type": "mapping", + "iterable": "P", + "element": "Q", + "result": { + "type": "indexedAccess", + "index": "P", + "object": "T" + }, + "readonly": "-" + }, + "P" + ] + }, + "question": "-" + } +}, ["T"]); +registerTypedef('ComponentOptionKeys', { + "type": "indexedAccess", + "index": { + "type": "reference", + "name": "WritableKeys", + "args": [ + "C" + ] + }, + "object": { + "type": "mapping", + "iterable": { + "type": "reference", + "name": "WritableKeys", + "args": [ + "C" + ] + }, + "element": "K", + "result": { + "type": "condition", + "checkType": "K", + "extendsType": { + "type": "union", + "members": [ + "'system'", + "'entity'", + { + "type": "templateLiteral", + "quasis": [ + "_", + "" + ], + "types": [ + "string" + ] + } + ] + }, + "trueType": "never", + "falseType": { + "type": "condition", + "checkType": { + "type": "reference", + "name": "NonNullable", + "args": [ + { + "type": "indexedAccess", + "index": "K", + "object": "C" + } + ] + }, + "extendsType": "Function", + "trueType": "never", + "falseType": "K" + } + } + } +}, ["C"]); +registerTypedef('ComponentOptionsOf', { + "type": "reference", + "name": "Partial", + "args": [ + { + "type": "reference", + "name": "Pick", + "args": [ + "C", + { + "type": "reference", + "name": "Extract", + "args": [ + { + "type": "reference", + "name": "ComponentOptionKeys", + "args": [ + "C" + ] + }, + { + "type": "keyof", + "argument": "C" + } + ] + } + ] + } + ] +}, ["C"]); +registerTypedef('ComponentOptionsOverrides', { + "type": "object", + "properties": { + "element": { + "type": "object", + "properties": { + "anchor": { + "type": "union", + "members": [ + "Vec4", + { + "type": "array", + "elementType": "number" + } + ], + "optional": true + }, + "margin": { + "type": "union", + "members": [ + "Vec4", + { + "type": "array", + "elementType": "number" + } + ], + "optional": true + }, + "color": { + "type": "union", + "members": [ + "Color", + { + "type": "array", + "elementType": "number" + } + ], + "optional": true + } + }, + "optional": false + } + } +}); +registerTypedef('ComponentOptionsOverridesOf', { + "type": "condition", + "checkType": "K", + "extendsType": { + "type": "keyof", + "argument": "ComponentOptionsOverrides" + }, + "trueType": { + "type": "indexedAccess", + "index": "K", + "object": "ComponentOptionsOverrides" + }, + "falseType": { + "type": "object" + } +}, ["K"]); +registerTypedef('MergedComponentOptions', { + "type": "intersection", + "members": [ + { + "type": "reference", + "name": "Omit", + "args": [ + { + "type": "reference", + "name": "ComponentOptionsOf", + "args": [ + { + "type": "indexedAccess", + "index": "K", + "object": "ComponentMap" + } + ] + }, + { + "type": "keyof", + "argument": { + "type": "reference", + "name": "ComponentOptionsOverridesOf", + "args": [ + "K" + ] + } + } + ] + }, + { + "type": "reference", + "name": "ComponentOptionsOverridesOf", + "args": [ + "K" + ] + } + ] +}, ["K"]); +registerTypedef('ComponentOptions', { + "type": "mapping", + "iterable": { + "type": "keyof", + "argument": { + "type": "reference", + "name": "MergedComponentOptions", + "args": [ + "K" + ] + } + }, + "element": "P", + "result": { + "type": "indexedAccess", + "index": "P", + "object": { + "type": "reference", + "name": "MergedComponentOptions", + "args": [ + "K" + ] + } + } +}, ["K"]); + +/** + * Anchors replay: an element tower whose system overrides widen + * anchor/margin (`Vec4 | number[]`) and color (`Color | number[]`) to + * array literals, while alignment stays a bare `Vec2` with no override + * (the engine's getter/setter split, which tsc also rejects). Dense + * arrays satisfy the widened union members and a Color instance matches + * nominally, so only the alignment call warns. Regression pin for the + * report-rendering fix: the worker verdict is exactly one failure, and + * the posted checker-side diagnosis carries exactly that row. + * (`Component` is intentionally non-empty: tsc reads an empty base class + * structurally, so every member would extend it.) + */ +class Component { + constructor() { + /** @type {boolean} */ + this.enabled = true; + } +} +registerClass(Component); +registerTypedef('Component', { + "type": "object", + "properties": { + "enabled": "boolean" + } +}); +class Color { + constructor() { + this.r = 0.5; + this.g = 0.6; + this.b = 0.9; + this.a = 1; + } +} +registerClass(Color); +registerTypedef('Color', { + "type": "object", + "properties": { + "r": "number", + "g": "number", + "b": "number", + "a": "number" + } +}); +class Vec2 { + constructor() { + this.x = 0; + this.y = 0; + } +} +registerClass(Vec2); +registerTypedef('Vec2', { + "type": "object", + "properties": { + "x": "number", + "y": "number" + } +}); +class Vec4 { + constructor() { + this.x = 0; + this.y = 0; + this.z = 0; + this.w = 0; + } +} +registerClass(Vec4); +registerTypedef('Vec4', { + "type": "object", + "properties": { + "x": "number", + "y": "number", + "z": "number", + "w": "number" + } +}); +class ElementComponent extends Component { + constructor() { + super(); + /** @type {Vec4} */ + + this._anchor = new Vec4(); + /** @type {Vec4} */ + + this._margin = new Vec4(); + /** @type {Color} */ + + this._color = new Color(); + /** @type {Vec2} */ + + this._alignment = new Vec2(); + } + + /** + * @type {Vec4} + */ + + get anchor() { + return this._anchor; + } + + /** + * @param {Vec4 | number[]} value + */ + + set anchor(value) { + this._anchor = value; + } + + /** + * @type {Vec4} + */ + + get margin() { + return this._margin; + } + + /** + * @param {Vec4 | number[]} value + */ + + set margin(value) { + this._margin = value; + } + + /** + * @type {Color} + */ + + get color() { + return this._color; + } + + /** + * @param {Color | number[]} value + */ + + set color(value) { + this._color = value; + } + + /** + * @type {Vec2} + */ + + get alignment() { + return this._alignment; + } + + /** + * @param {Vec2 | number[]} value + */ + + set alignment(value) { + this._alignment = value; + } +} +registerClass(ElementComponent); +registerTypedef('ElementComponent', { + "type": "object", + "properties": { + "anchor": "Vec4", + "margin": "Vec4", + "color": "Color", + "alignment": "Vec2", + "_anchor": "Vec4", + "_margin": "Vec4", + "_color": "Color", + "_alignment": "Vec2" + } +}); +class Entity { + constructor() { + this.element = null; + this.name = 'x'; + } + + /** + * @type {ElementComponent|undefined} + * @readonly + */ + + element; + + /** + * @type {Object} + */ + + c = {}; + + /** + * @type {string} + */ + + name = 'x'; + + /** + * @template {ComponentName | (string & {})} K + * @param {K} type + * @param {K extends ComponentName ? ComponentOptions : object} [data] + * @returns {*} comp + */ + + addComponent(type, data) { + const rtiTemplates = { + "K": { + "type": "union", + "members": [ + "ComponentName", + { + "type": "intersection", + "members": [ + "string", + { + "type": "object" + } + ] + } + ] + } + }; + if (!inspectTypeWithTemplates(type, "K", 'Entity#addComponent', 'type', rtiTemplates)) { + youCanAddABreakpointHere(); + } + if (!inspectTypeWithTemplates(data, { + "type": "condition", + "checkType": "K", + "extendsType": "ComponentName", + "trueType": { + "type": "reference", + "name": "ComponentOptions", + "args": [ + "K" + ] + }, + "falseType": { + "type": "object", + "properties": {} + }, + "optional": true + }, 'Entity#addComponent', 'data', rtiTemplates)) { + youCanAddABreakpointHere(); + } + return [type, data]; + } +} +registerClass(Entity); +registerTypedef('Entity', { + "type": "object", + "properties": { + "element": { + "type": "union", + "members": [ + "ElementComponent", + "undefined" + ], + "readonly": true + }, + "c": { + "type": "record", + "key": "string", + "val": "Component" + }, + "name": "string", + "addComponent": "Function" + } +}); + +/** + * @typedef {{ [K in keyof Entity as NonNullable extends Component ? K : never]: NonNullable }} ComponentMap + */ + + +/** + * @typedef {keyof ComponentMap & string} ComponentName + */ + + +/** + * Resolves to `A` when the types `X` and `Y` are identical, otherwise to `B`. + * + * @template X + * @template Y + * @template [A=X] + * @template [B=never] + * @typedef {(() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? A : B} IfEquals + */ + + +/** + * @template T + * @typedef {{ [P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P> }[keyof T]} WritableKeys + */ + + +/** + * @template C + * @typedef {{ [K in WritableKeys]: K extends 'system' | 'entity' | `_${string}` ? never : NonNullable extends Function ? never : K }[WritableKeys]} ComponentOptionKeys + */ + + +/** + * @template C + * @typedef {Partial, keyof C>>>} ComponentOptionsOf + */ + + +/** + * @typedef {object} ComponentOptionsOverrides + * @property {{ anchor?: Vec4 | number[], margin?: Vec4 | number[], color?: Color | number[] }} element + */ + + +/** + * @template {ComponentName} K + * @typedef {K extends keyof ComponentOptionsOverrides ? ComponentOptionsOverrides[K] : {}} ComponentOptionsOverridesOf + */ + + +/** + * @template {ComponentName} K + * @typedef {Omit, keyof ComponentOptionsOverridesOf> & ComponentOptionsOverridesOf} MergedComponentOptions + */ + + +/** + * @template {ComponentName} K + * @typedef {{ [P in keyof MergedComponentOptions]: MergedComponentOptions[P] }} ComponentOptions + */ + + +/** + * @typedef {{ [K in keyof Entity as NonNullable extends Component ? K : never]: NonNullable }} ComponentMap + */ + +/** + * @typedef {keyof ComponentMap & string} ComponentName + */ + +/** + * Resolves to `A` when the types `X` and `Y` are identical, otherwise to `B`. + * + * @template X + * @template Y + * @template [A=X] + * @template [B=never] + * @typedef {(() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? A : B} IfEquals + */ + +/** + * @template T + * @typedef {{ [P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P> }[keyof T]} WritableKeys + */ + +/** + * @template C + * @typedef {{ [K in WritableKeys]: K extends 'system' | 'entity' | `_${string}` ? never : NonNullable extends Function ? never : K }[WritableKeys]} ComponentOptionKeys + */ + +/** + * @template C + * @typedef {Partial, keyof C>>>} ComponentOptionsOf + */ + +/** + * @typedef {object} ComponentOptionsOverrides + * @property {{ anchor?: Vec4 | number[], margin?: Vec4 | number[], color?: Color | number[] }} element + */ + +/** + * @template {ComponentName} K + * @typedef {K extends keyof ComponentOptionsOverrides ? ComponentOptionsOverrides[K] : {}} ComponentOptionsOverridesOf + */ + +/** + * @template {ComponentName} K + * @typedef {Omit, keyof ComponentOptionsOverridesOf> & ComponentOptionsOverridesOf} MergedComponentOptions + */ + +/** + * @template {ComponentName} K + * @typedef {{ [P in keyof MergedComponentOptions]: MergedComponentOptions[P] }} ComponentOptions + */ +const e = new Entity(); +e.addComponent('element', { + anchor: [0, 0, 1, 1], + margin: [14, 12, 14, 12], + color: new Color() +}); // ok + +e.addComponent('element', { + anchor: [0, 0, 1, 1], + margin: [14, 12, 14, 12], + color: new Color(), + alignment: [0, 0] +}); // warns: alignment has no array override + diff --git a/test/typechecking/arguments-errors.json b/test/typechecking/arguments-errors.json index 4faa06f..094f7c0 100644 --- a/test/typechecking/arguments-errors.json +++ b/test/typechecking/arguments-errors.json @@ -1,6 +1,14 @@ { "throws": [ - {"loc": "takeArgs", "name": "args", "value": null}, - {"loc": "takeArgs", "name": "args", "value": {}} + { + "loc": "takeArgs", + "name": "args", + "value": null + }, + { + "loc": "takeArgs", + "name": "args", + "value": {} + } ] } diff --git a/test/typechecking/builtin-utilities-errors.json b/test/typechecking/builtin-utilities-errors.json new file mode 100644 index 0000000..b50c023 --- /dev/null +++ b/test/typechecking/builtin-utilities-errors.json @@ -0,0 +1,71 @@ +{ + "throws": [ + { + "loc": "takeAwaited", + "name": "n", + "value": "[Promise]" + }, + { + "loc": "takeAwaited", + "name": "n", + "value": null + }, + { + "loc": "takeAwaitedPlain", + "name": "n", + "value": "x" + }, + { + "loc": "takeReturned", + "name": "s", + "value": 1 + }, + { + "loc": "takeArity", + "name": "n", + "value": "x" + }, + { + "loc": "takeUpper", + "name": "s", + "value": "abc" + }, + { + "loc": "takeUpper", + "name": "s", + "value": 1 + }, + { + "loc": "takeLower", + "name": "s", + "value": "ABC" + }, + { + "loc": "takeCapital", + "name": "s", + "value": "abc" + }, + { + "loc": "takeUncapital", + "name": "s", + "value": "Abc" + }, + { + "loc": "takeUpperUnion", + "name": "s", + "value": "ab" + }, + { + "loc": "takeReadonlyMap", + "name": "m" + }, + { + "loc": "takeReadonlyMap", + "name": "m" + }, + { + "loc": "takeReadonlySet", + "name": "s" + } + ] +} diff --git a/test/typechecking/builtin-utilities-input.mjs b/test/typechecking/builtin-utilities-input.mjs new file mode 100644 index 0000000..e370cd6 --- /dev/null +++ b/test/typechecking/builtin-utilities-input.mjs @@ -0,0 +1,120 @@ +/** + * Builtin utility coverage: `Awaited` unwraps promise types to their + * fulfillment (a Promise is never assignable to its own awaited type), + * `ReturnType` resolves declared function returns, and the four case + * intrinsics enforce letter case. Every throwing call carries + * `@ts-expect-error`, so a tsc-strict run is green if and only if each + * directive is consumed and nothing else errors. + */ +class Box { + constructor() { + /** @type {number} */ + this.v = 1; + } +} +/** + * @param {Awaited>} n + */ +function takeAwaited(n) { + return n; +} +takeAwaited(1); // ok: the fulfillment type is number +// @ts-expect-error: a Promise is not its own awaited type +takeAwaited(Promise.resolve(1)); +// @ts-expect-error: null is no number +takeAwaited(null); +/** + * @param {Awaited} n + */ +function takeAwaitedPlain(n) { + return n; +} +takeAwaitedPlain(1); // ok: non-promises are their own awaited type +// @ts-expect-error: strings are no numbers +takeAwaitedPlain('x'); +/** + * @param {ReturnType<() => string>} s + */ +function takeReturned(s) { + return s; +} +takeReturned('x'); // ok: the declared return is string +// @ts-expect-error: numbers are no strings +takeReturned(1); +/** + * @param {ReturnType<(a: number) => number>} n + */ +function takeArity(n) { + return n; +} +takeArity(1); // ok: parameters do not affect the return check +// @ts-expect-error: strings are no numbers +takeArity('x'); +/** + * @param {Uppercase} s + */ +function takeUpper(s) { + return s; +} +takeUpper('ABC'); // ok +// @ts-expect-error: lowercase is no uppercase +takeUpper('abc'); +// @ts-expect-error: non-strings never match +takeUpper(1); +/** + * @param {Lowercase} s + */ +function takeLower(s) { + return s; +} +takeLower('abc'); // ok +// @ts-expect-error: uppercase is no lowercase +takeLower('ABC'); +/** + * @param {Capitalize} s + */ +function takeCapital(s) { + return s; +} +takeCapital('Abc'); // ok +// @ts-expect-error: uncapitalized is no capitalized +takeCapital('abc'); +/** + * @param {Uncapitalize} s + */ +function takeUncapital(s) { + return s; +} +takeUncapital('aBC'); // ok +// @ts-expect-error: capitalized is no uncapitalized +takeUncapital('Abc'); +/** + * @param {Uppercase<'ab' | 'cd'>} s + */ +function takeUpperUnion(s) { + return s; +} +takeUpperUnion('AB'); // ok: intrinsics distribute over unions +takeUpperUnion('CD'); // ok +// @ts-expect-error: undistributed members never match +takeUpperUnion('ab'); +/** + * @param {ReadonlyMap} m + */ +function takeReadonlyMap(m) { + return m; +} +takeReadonlyMap(new Map([['a', 1]])); // ok: same shape as Map +// @ts-expect-error: element checks still apply through readonly +takeReadonlyMap(new Map([['a', 'x']])); +// @ts-expect-error: plain objects are no Maps +takeReadonlyMap({}); +/** + * @param {ReadonlySet} s + */ +function takeReadonlySet(s) { + return s; +} +takeReadonlySet(new Set([1])); // ok: same shape as Set +// @ts-expect-error: element checks still apply through readonly +takeReadonlySet(new Set(['x'])); diff --git a/test/typechecking/builtin-utilities-output.mjs b/test/typechecking/builtin-utilities-output.mjs new file mode 100644 index 0000000..8ff62da --- /dev/null +++ b/test/typechecking/builtin-utilities-output.mjs @@ -0,0 +1,343 @@ + +/** + * Builtin utility coverage: `Awaited` unwraps promise types to their + * fulfillment (a Promise is never assignable to its own awaited type), + * `ReturnType` resolves declared function returns, and the four case + * intrinsics enforce letter case. Every throwing call carries + * `@ts-expect-error`, so a tsc-strict run is green if and only if each + * directive is consumed and nothing else errors. + */ +class Box { + constructor() { + /** @type {number} */ + this.v = 1; + } +} +registerClass(Box); +registerTypedef('Box', { + "type": "object", + "properties": { + "v": "number" + } +}); + +/** + * @param {Awaited>} n + */ + +function takeAwaited(n) { + if (!inspectType(n, { + "type": "reference", + "name": "Awaited", + "args": [ + { + "type": "promise", + "elementType": "number" + } + ], + "optional": false + }, 'takeAwaited', 'n')) { + youCanAddABreakpointHere(); + } + return n; +} +takeAwaited(1); // ok: the fulfillment type is number + +// @ts-expect-error: a Promise is not its own awaited type + + // ok: the fulfillment type is number + +// @ts-expect-error: a Promise is not its own awaited type +takeAwaited(Promise.resolve(1)); +// @ts-expect-error: null is no number + +takeAwaited(null); + +/** + * @param {Awaited} n + */ + +function takeAwaitedPlain(n) { + if (!inspectType(n, { + "type": "reference", + "name": "Awaited", + "args": [ + "number" + ], + "optional": false + }, 'takeAwaitedPlain', 'n')) { + youCanAddABreakpointHere(); + } + return n; +} +takeAwaitedPlain(1); // ok: non-promises are their own awaited type + +// @ts-expect-error: strings are no numbers + + // ok: non-promises are their own awaited type + +// @ts-expect-error: strings are no numbers +takeAwaitedPlain('x'); + +/** + * @param {ReturnType<() => string>} s + */ + +function takeReturned(s) { + if (!inspectType(s, { + "type": "reference", + "name": "ReturnType", + "args": [ + { + "type": "function", + "parameters": [], + "ret": "string" + } + ], + "optional": false + }, 'takeReturned', 's')) { + youCanAddABreakpointHere(); + } + return s; +} +takeReturned('x'); // ok: the declared return is string + +// @ts-expect-error: numbers are no strings + + // ok: the declared return is string + +// @ts-expect-error: numbers are no strings +takeReturned(1); + +/** + * @param {ReturnType<(a: number) => number>} n + */ + +function takeArity(n) { + if (!inspectType(n, { + "type": "reference", + "name": "ReturnType", + "args": [ + { + "type": "function", + "parameters": [ + { + "type": "parameter", + "paramType": "number", + "name": "a" + } + ], + "ret": "number" + } + ], + "optional": false + }, 'takeArity', 'n')) { + youCanAddABreakpointHere(); + } + return n; +} +takeArity(1); // ok: parameters do not affect the return check + +// @ts-expect-error: strings are no numbers + + // ok: parameters do not affect the return check + +// @ts-expect-error: strings are no numbers +takeArity('x'); + +/** + * @param {Uppercase} s + */ + +function takeUpper(s) { + if (!inspectType(s, { + "type": "reference", + "name": "Uppercase", + "args": [ + "string" + ], + "optional": false + }, 'takeUpper', 's')) { + youCanAddABreakpointHere(); + } + return s; +} +takeUpper('ABC'); // ok + +// @ts-expect-error: lowercase is no uppercase + + // ok + +// @ts-expect-error: lowercase is no uppercase +takeUpper('abc'); +// @ts-expect-error: non-strings never match + +takeUpper(1); + +/** + * @param {Lowercase} s + */ + +function takeLower(s) { + if (!inspectType(s, { + "type": "reference", + "name": "Lowercase", + "args": [ + "string" + ], + "optional": false + }, 'takeLower', 's')) { + youCanAddABreakpointHere(); + } + return s; +} +takeLower('abc'); // ok + +// @ts-expect-error: uppercase is no lowercase + + // ok + +// @ts-expect-error: uppercase is no lowercase +takeLower('ABC'); + +/** + * @param {Capitalize} s + */ + +function takeCapital(s) { + if (!inspectType(s, { + "type": "reference", + "name": "Capitalize", + "args": [ + "string" + ], + "optional": false + }, 'takeCapital', 's')) { + youCanAddABreakpointHere(); + } + return s; +} +takeCapital('Abc'); // ok + +// @ts-expect-error: uncapitalized is no capitalized + + // ok + +// @ts-expect-error: uncapitalized is no capitalized +takeCapital('abc'); + +/** + * @param {Uncapitalize} s + */ + +function takeUncapital(s) { + if (!inspectType(s, { + "type": "reference", + "name": "Uncapitalize", + "args": [ + "string" + ], + "optional": false + }, 'takeUncapital', 's')) { + youCanAddABreakpointHere(); + } + return s; +} +takeUncapital('aBC'); // ok + +// @ts-expect-error: capitalized is no uncapitalized + + // ok + +// @ts-expect-error: capitalized is no uncapitalized +takeUncapital('Abc'); + +/** + * @param {Uppercase<'ab' | 'cd'>} s + */ + +function takeUpperUnion(s) { + if (!inspectType(s, { + "type": "reference", + "name": "Uppercase", + "args": [ + { + "type": "union", + "members": [ + "'ab'", + "'cd'" + ] + } + ], + "optional": false + }, 'takeUpperUnion', 's')) { + youCanAddABreakpointHere(); + } + return s; +} +takeUpperUnion('AB'); // ok: intrinsics distribute over unions + +takeUpperUnion('CD'); // ok + +// @ts-expect-error: undistributed members never match + + // ok + +// @ts-expect-error: undistributed members never match +takeUpperUnion('ab'); + +/** + * @param {ReadonlyMap} m + */ + +function takeReadonlyMap(m) { + if (!inspectType(m, { + "type": "reference", + "name": "ReadonlyMap", + "args": [ + "string", + "number" + ], + "optional": false + }, 'takeReadonlyMap', 'm')) { + youCanAddABreakpointHere(); + } + return m; +} +takeReadonlyMap(new Map([['a', 1]])); // ok: same shape as Map + +// @ts-expect-error: element checks still apply through readonly + + // ok: same shape as Map + +// @ts-expect-error: element checks still apply through readonly +takeReadonlyMap(new Map([['a', 'x']])); +// @ts-expect-error: plain objects are no Maps + +takeReadonlyMap({}); + +/** + * @param {ReadonlySet} s + */ + +function takeReadonlySet(s) { + if (!inspectType(s, { + "type": "reference", + "name": "ReadonlySet", + "args": [ + "number" + ], + "optional": false + }, 'takeReadonlySet', 's')) { + youCanAddABreakpointHere(); + } + return s; +} +takeReadonlySet(new Set([1])); // ok: same shape as Set + +// @ts-expect-error: element checks still apply through readonly + + // ok: same shape as Set + +// @ts-expect-error: element checks still apply through readonly +takeReadonlySet(new Set(['x'])); diff --git a/test/typechecking/depfree-gaps-errors.json b/test/typechecking/depfree-gaps-errors.json index 9ae5f4b..e944ba3 100644 --- a/test/typechecking/depfree-gaps-errors.json +++ b/test/typechecking/depfree-gaps-errors.json @@ -1 +1,46 @@ -{"throws": [{"loc": "takeFn", "name": "f", "value": 1}, {"loc": "takeNew", "name": "C", "value": 1}, {"loc": "takeFlags", "name": "o", "value": {"a": "x"}}, {"loc": "takeIndex", "name": "m", "value": {"a": "x"}}, {"loc": "takeBigint", "name": "b", "value": 123}, {"loc": "takeTemplate", "name": "t", "value": "xx"}, {"loc": "takeNamedTuple", "name": "t", "value": ["a", "b"]}]} \ No newline at end of file +{ + "throws": [ + { + "loc": "takeFn", + "name": "f", + "value": 1 + }, + { + "loc": "takeNew", + "name": "C", + "value": 1 + }, + { + "loc": "takeFlags", + "name": "o", + "value": { + "a": "x" + } + }, + { + "loc": "takeIndex", + "name": "m", + "value": { + "a": "x" + } + }, + { + "loc": "takeBigint", + "name": "b", + "value": 123 + }, + { + "loc": "takeTemplate", + "name": "t", + "value": "xx" + }, + { + "loc": "takeNamedTuple", + "name": "t", + "value": [ + "a", + "b" + ] + } + ] +} diff --git a/test/typechecking/depfree-gaps-output.mjs b/test/typechecking/depfree-gaps-output.mjs index 4d76838..a6cda67 100644 --- a/test/typechecking/depfree-gaps-output.mjs +++ b/test/typechecking/depfree-gaps-output.mjs @@ -19,6 +19,7 @@ function takeFn(f) { "name": "a" } ], + "ret": "string", "optional": false }, 'takeFn', 'f')) { youCanAddABreakpointHere(); diff --git a/test/typechecking/ifequals-output.mjs b/test/typechecking/ifequals-output.mjs index 27d0215..c34ad34 100644 --- a/test/typechecking/ifequals-output.mjs +++ b/test/typechecking/ifequals-output.mjs @@ -2,11 +2,25 @@ registerTypedef('IfEquals', { "type": "condition", "checkType": { "type": "function", - "parameters": [] + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "X", + "trueType": 1, + "falseType": 2 + } }, "extendsType": { "type": "function", - "parameters": [] + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "Y", + "trueType": 1, + "falseType": 2 + } }, "trueType": "A", "falseType": "B" diff --git a/test/typechecking/ignore-rti-scoped-errors.json b/test/typechecking/ignore-rti-scoped-errors.json index 1b94db7..2b3832f 100644 --- a/test/typechecking/ignore-rti-scoped-errors.json +++ b/test/typechecking/ignore-rti-scoped-errors.json @@ -1,6 +1,13 @@ { "throws": [ - {"loc": "compute", "name": "used", "value": "x"}, - {"loc": "computeStrict", "name": "vertices"} + { + "loc": "compute", + "name": "used", + "value": "x" + }, + { + "loc": "computeStrict", + "name": "vertices" + } ] } diff --git a/test/typechecking/intersection-target-errors.json b/test/typechecking/intersection-target-errors.json index bfb4de3..9c4df78 100644 --- a/test/typechecking/intersection-target-errors.json +++ b/test/typechecking/intersection-target-errors.json @@ -1,5 +1,9 @@ { "throws": [ - {"loc": "takeIntersection", "name": "value", "value": 1} + { + "loc": "takeIntersection", + "name": "value", + "value": 1 + } ] } diff --git a/test/typechecking/keyof-class-errors.json b/test/typechecking/keyof-class-errors.json index 2ce6b31..65614d5 100644 --- a/test/typechecking/keyof-class-errors.json +++ b/test/typechecking/keyof-class-errors.json @@ -1,6 +1,14 @@ { "throws": [ - {"loc": "KeyBox#constructor", "name": "key", "value": "nope"}, - {"loc": "takeKey", "name": "key", "value": "nope"} + { + "loc": "KeyBox#constructor", + "name": "key", + "value": "nope" + }, + { + "loc": "takeKey", + "name": "key", + "value": "nope" + } ] } diff --git a/test/typechecking/location-owners-errors.json b/test/typechecking/location-owners-errors.json index 1238e04..28de73d 100644 --- a/test/typechecking/location-owners-errors.json +++ b/test/typechecking/location-owners-errors.json @@ -1,8 +1,24 @@ { "throws": [ - {"loc": "BitPacking.set", "name": "storage", "value": "nope"}, - {"loc": "BitPacking.get", "name": "storage", "value": "nope"}, - {"loc": "BitPacker.set", "name": "storage", "value": "nope"}, - {"loc": "BitPacker#get", "name": "storage", "value": "nope"} + { + "loc": "BitPacking.set", + "name": "storage", + "value": "nope" + }, + { + "loc": "BitPacking.get", + "name": "storage", + "value": "nope" + }, + { + "loc": "BitPacker.set", + "name": "storage", + "value": "nope" + }, + { + "loc": "BitPacker#get", + "name": "storage", + "value": "nope" + } ] } diff --git a/test/typechecking/map-keys-errors.json b/test/typechecking/map-keys-errors.json index 6c399db..fea6635 100644 --- a/test/typechecking/map-keys-errors.json +++ b/test/typechecking/map-keys-errors.json @@ -1,7 +1,16 @@ { "throws": [ - {"loc": "takeById", "name": "byId"}, - {"loc": "takeUsage", "name": "usage"}, - {"loc": "takeScores", "name": "scores"} + { + "loc": "takeById", + "name": "byId" + }, + { + "loc": "takeUsage", + "name": "usage" + }, + { + "loc": "takeScores", + "name": "scores" + } ] } diff --git a/test/typechecking/mapped-optional-errors.json b/test/typechecking/mapped-optional-errors.json index 3e89c45..2c770d3 100644 --- a/test/typechecking/mapped-optional-errors.json +++ b/test/typechecking/mapped-optional-errors.json @@ -1,10 +1,42 @@ { "throws": [ - {"loc": "takeOptions", "name": "data", "value": {"pos": "x"}}, - {"loc": "takeMerged", "name": "data"}, - {"loc": "takeStrict", "name": "data", "value": {}}, - {"loc": "takeStrict", "name": "data", "value": {"count": 1, "pos": "x"}}, - {"loc": "takePos", "name": "pos"}, - {"loc": "takeOptions", "name": "data", "value": {"pos": [1, "x"]}} + { + "loc": "takeOptions", + "name": "data", + "value": { + "pos": "x" + } + }, + { + "loc": "takeMerged", + "name": "data" + }, + { + "loc": "takeStrict", + "name": "data", + "value": {} + }, + { + "loc": "takeStrict", + "name": "data", + "value": { + "count": 1, + "pos": "x" + } + }, + { + "loc": "takePos", + "name": "pos" + }, + { + "loc": "takeOptions", + "name": "data", + "value": { + "pos": [ + 1, + "x" + ] + } + } ] } diff --git a/test/typechecking/named-subclass-errors.json b/test/typechecking/named-subclass-errors.json index 3d13d78..6e384ec 100644 --- a/test/typechecking/named-subclass-errors.json +++ b/test/typechecking/named-subclass-errors.json @@ -1,6 +1,16 @@ { "throws": [ - {"loc": "takeFloat", "name": "data", "value": {"length": 4}}, - {"loc": "takeFloat", "name": "data", "value": "nope"} + { + "loc": "takeFloat", + "name": "data", + "value": { + "length": 4 + } + }, + { + "loc": "takeFloat", + "name": "data", + "value": "nope" + } ] } diff --git a/test/typechecking/object-constructor-errors.json b/test/typechecking/object-constructor-errors.json index c1f1d6f..cccce06 100644 --- a/test/typechecking/object-constructor-errors.json +++ b/test/typechecking/object-constructor-errors.json @@ -1,6 +1,13 @@ { "throws": [ - {"loc": "takeBuilt", "name": "ctor", "value": null}, - {"loc": "takeBuilt", "name": "ctor"} + { + "loc": "takeBuilt", + "name": "ctor", + "value": null + }, + { + "loc": "takeBuilt", + "name": "ctor" + } ] } diff --git a/test/typechecking/partial-homomorphic-output.mjs b/test/typechecking/partial-homomorphic-output.mjs index 7b5556c..54e753b 100644 --- a/test/typechecking/partial-homomorphic-output.mjs +++ b/test/typechecking/partial-homomorphic-output.mjs @@ -756,7 +756,8 @@ function takeFn(x) { "args": [ { "type": "function", - "parameters": [] + "parameters": [], + "ret": "void" } ], "optional": false diff --git a/test/typechecking/partial-primitives-output.mjs b/test/typechecking/partial-primitives-output.mjs index 8ed38ef..e91a7b9 100644 --- a/test/typechecking/partial-primitives-output.mjs +++ b/test/typechecking/partial-primitives-output.mjs @@ -215,7 +215,8 @@ function takeFn(x) { "args": [ { "type": "function", - "parameters": [] + "parameters": [], + "ret": "void" } ], "optional": false @@ -246,7 +247,8 @@ function takeReqFn(x) { "args": [ { "type": "function", - "parameters": [] + "parameters": [], + "ret": "void" } ], "optional": false diff --git a/test/typechecking/playcanvas-camera-options-errors.json b/test/typechecking/playcanvas-camera-options-errors.json index 34c20a1..c9c3e8b 100644 --- a/test/typechecking/playcanvas-camera-options-errors.json +++ b/test/typechecking/playcanvas-camera-options-errors.json @@ -1,7 +1,25 @@ { "throws": [ - {"loc": "Entity#addComponent", "name": "data", "value": {"clearColor": "x"}}, - {"loc": "Entity#addComponent", "name": "data", "value": {"fov": "x"}}, - {"loc": "Entity#addComponent", "name": "data", "value": {"nope": 1}} + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "clearColor": "x" + } + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "fov": "x" + } + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "nope": 1 + } + } ] } diff --git a/test/typechecking/playcanvas-camera-options-output.mjs b/test/typechecking/playcanvas-camera-options-output.mjs index ee01231..a28aebb 100644 --- a/test/typechecking/playcanvas-camera-options-output.mjs +++ b/test/typechecking/playcanvas-camera-options-output.mjs @@ -48,11 +48,25 @@ registerTypedef('IfEquals', { "type": "condition", "checkType": { "type": "function", - "parameters": [] + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "X", + "trueType": 1, + "falseType": 2 + } }, "extendsType": { "type": "function", - "parameters": [] + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "Y", + "trueType": 1, + "falseType": 2 + } }, "trueType": "A", "falseType": "B" diff --git a/test/typechecking/playcanvas-component-options-output.mjs b/test/typechecking/playcanvas-component-options-output.mjs index 0cf7306..ccabb62 100644 --- a/test/typechecking/playcanvas-component-options-output.mjs +++ b/test/typechecking/playcanvas-component-options-output.mjs @@ -56,11 +56,25 @@ registerTypedef('IfEquals', { "type": "condition", "checkType": { "type": "function", - "parameters": [] + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "X", + "trueType": 1, + "falseType": 2 + } }, "extendsType": { "type": "function", - "parameters": [] + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "Y", + "trueType": 1, + "falseType": 2 + } }, "trueType": "A", "falseType": "B" diff --git a/test/typechecking/playcanvas-tower-conditions-errors.json b/test/typechecking/playcanvas-tower-conditions-errors.json index 0b34a32..2f4f3a5 100644 --- a/test/typechecking/playcanvas-tower-conditions-errors.json +++ b/test/typechecking/playcanvas-tower-conditions-errors.json @@ -1,9 +1,31 @@ { "throws": [ - {"loc": "addByName", "name": "name", "value": "c"}, - {"loc": "addByName", "name": "name", "value": "tags"}, - {"loc": "addByName", "name": "name", "value": "name"}, - {"loc": "addByName", "name": "name", "value": "nope"}, - {"loc": "Entity#addComponent", "name": "data", "value": {"clearColor": "x"}} + { + "loc": "addByName", + "name": "name", + "value": "c" + }, + { + "loc": "addByName", + "name": "name", + "value": "tags" + }, + { + "loc": "addByName", + "name": "name", + "value": "name" + }, + { + "loc": "addByName", + "name": "name", + "value": "nope" + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "clearColor": "x" + } + } ] } diff --git a/test/typechecking/playcanvas-tower-hostile-errors.json b/test/typechecking/playcanvas-tower-hostile-errors.json new file mode 100644 index 0000000..8b992a7 --- /dev/null +++ b/test/typechecking/playcanvas-tower-hostile-errors.json @@ -0,0 +1,87 @@ +{ + "throws": [ + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "alignment": [ + 0, + 0 + ] + } + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "anchor": [ + 0, + "x", + 1, + 1 + ] + } + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "color": "red" + } + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "fov": "x" + } + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "intensity": "x" + } + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "batchGroupId": "x" + } + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "fovv": 60 + } + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": 42 + }, + { + "loc": "Entity#addComponent", + "name": "type", + "value": 42 + }, + { + "loc": "Entity#addComponent", + "name": "data" + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "anchor": { + "0": 0, + "1": 0, + "2": 1, + "3": 1 + } + } + } + ] +} diff --git a/test/typechecking/playcanvas-tower-hostile-input.mjs b/test/typechecking/playcanvas-tower-hostile-input.mjs new file mode 100644 index 0000000..8ec4e95 --- /dev/null +++ b/test/typechecking/playcanvas-tower-hostile-input.mjs @@ -0,0 +1,295 @@ +/** + * Hostile component tower: every component family plus the exotic values + * production throws at it. Dense arrays satisfy `Vec | number[]` unions, + * class instances and tagged snapshots match nominally, `number | null` + * takes both, frozen arrays pass (they are real arrays), and the unknown + * component name falls through to the `object` false branch. What warns: + * mistyped scalars, holes in arrays (RTI rejects sparse tails), typed + * arrays (no `instanceof Array`), explicit null into non-nullable slots, + * undefined into required data, unknown options, and wrong nominal tags. + * Three deliberate divergences from tsc carry `// Expected:` notes: an + * explicit null into an optional-without-`|null` slot and a frozen array + * into `number[]` pass RTI but fail tsc-strict, while a sparse tail fails + * RTI but passes tsc. (`Component` is intentionally non-empty: tsc reads + * an empty base class structurally, so every member would extend it.) + */ +class Component { + constructor() { + /** @type {boolean} */ + this.enabled = true; + } +} +class Color { + constructor() { + this.r = 0.5; + this.g = 0.6; + this.b = 0.9; + this.a = 1; + } +} +class Vec2 { + constructor() { + this.x = 0; + this.y = 0; + } +} +class Vec4 { + constructor() { + this.x = 0; + this.y = 0; + this.z = 0; + this.w = 0; + } +} +class TintedVec4 extends Vec4 { + constructor() { + super(); + /** @type {number} */ + this.tint = 0; + } +} +class ElementComponent extends Component { + constructor() { + super(); + /** @type {Vec4} */ + this._anchor = new Vec4(); + /** @type {Color} */ + this._color = new Color(); + /** @type {Vec2} */ + this._alignment = new Vec2(); + } + /** + * @type {Vec4} + */ + get anchor() { + return this._anchor; + } + /** + * @param {Vec4 | number[]} value + */ + set anchor(value) { + void value; + } + /** + * @type {Color} + */ + get color() { + return this._color; + } + /** + * @param {Color | number[]} value + */ + set color(value) { + void value; + } + /** + * @type {Vec2} + */ + get alignment() { + return this._alignment; + } + /** + * @param {Vec2 | number[]} value + */ + set alignment(value) { + void value; + } +} +class CameraComponent extends Component { + constructor() { + super(); + /** @type {Color} */ + this._clearColor = new Color(); + /** @type {number} */ + this._fov = 45; + } + /** + * @type {Color} + */ + get clearColor() { + return this._clearColor; + } + /** + * @param {Color | number[]} value + */ + set clearColor(value) { + void value; + } + /** + * @type {number} + */ + get fov() { + return this._fov; + } + /** + * @param {number} value + */ + set fov(value) { + void value; + } +} +class LightComponent extends Component { + constructor() { + super(); + /** @type {number} */ + this._intensity = 1; + /** @type {string} */ + this._shape = 'omni'; + } + /** + * @type {number} + */ + get intensity() { + return this._intensity; + } + /** + * @param {number} value + */ + set intensity(value) { + void value; + } + /** + * @type {string} + */ + get shape() { + return this._shape; + } + /** + * @param {string} value + */ + set shape(value) { + void value; + } +} +class Entity { + constructor() { + this.element = undefined; + this.camera = undefined; + this.light = undefined; + this.name = 'x'; + } + /** + * @type {ElementComponent|undefined} + * @readonly + */ + element; + /** + * @type {CameraComponent|undefined} + * @readonly + */ + camera; + /** + * @type {LightComponent|undefined} + * @readonly + */ + light; + /** + * @type {Object} + */ + c = {}; + /** + * @type {string} + */ + name = 'x'; + /** + * @template {ComponentName | (string & {})} K + * @param {K} type + * @param {K extends ComponentName ? ComponentOptions : object} [data] + * @returns {*} comp + */ + addComponent(type, data) { + return [type, data]; + } +} +/** + * @typedef {{ [K in keyof Entity as NonNullable extends Component ? K : never]: NonNullable }} ComponentMap + */ +/** + * @typedef {keyof ComponentMap & string} ComponentName + */ +/** + * Resolves to `A` when the types `X` and `Y` are identical, otherwise to `B`. + * + * @template X + * @template Y + * @template [A=X] + * @template [B=never] + * @typedef {(() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? A : B} IfEquals + */ +/** + * @template T + * @typedef {{ [P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P> }[keyof T]} WritableKeys + */ +/** + * @template C + * @typedef {{ [K in WritableKeys]: K extends 'system' | 'entity' | `_${string}` ? never : NonNullable extends Function ? never : K }[WritableKeys]} ComponentOptionKeys + */ +/** + * @template C + * @typedef {Partial, keyof C>>>} ComponentOptionsOf + */ +/** + * @typedef {object} ComponentOptionsOverrides + * @property {{ anchor?: Vec4 | number[], color?: Color | number[] }} element + * @property {{ clearColor?: Color | number[], batchGroupId?: number | null }} camera + */ +/** + * @template {ComponentName} K + * @typedef {K extends keyof ComponentOptionsOverrides ? ComponentOptionsOverrides[K] : {}} ComponentOptionsOverridesOf + */ +/** + * @template {ComponentName} K + * @typedef {Omit, keyof ComponentOptionsOverridesOf> & ComponentOptionsOverridesOf} MergedComponentOptions + */ +/** + * @template {ComponentName} K + * @typedef {{ [P in keyof MergedComponentOptions]: MergedComponentOptions[P] }} ComponentOptions + */ +const e = new Entity(); +// Dense arrays satisfy the widened unions; instances match nominally. +e.addComponent('element', {anchor: [0, 0, 1, 1], color: new Color()}); // ok +e.addComponent('element', {anchor: new Vec4(), color: [0.5, 0.6, 0.9]}); // ok +e.addComponent('element', {anchor: new TintedVec4()}); // ok: subclass instances satisfy base slots +e.addComponent('camera', {clearColor: new Color(), fov: 60}); // ok +e.addComponent('camera', {clearColor: [0.5, 0.6, 0.9, 1], batchGroupId: null}); // ok +e.addComponent('camera', {batchGroupId: 3}); // ok: nullable takes a number +e.addComponent('light', {intensity: 2, shape: 'spot'}); // ok +// Unknown names fall through to the object false branch; dataless is fine. +e.addComponent('nope', {anything: 1}); // ok +e.addComponent('light'); // ok +// @ts-expect-error: alignment has no array override in the tower +e.addComponent('element', {alignment: [0, 0]}); +// @ts-expect-error: anchor elements must be numbers +e.addComponent('element', {anchor: [0, 'x', 1, 1]}); +// @ts-expect-error: color must be a Color or an array +e.addComponent('element', {color: 'red'}); +// @ts-expect-error: fov must be a number +e.addComponent('camera', {fov: 'x'}); +// @ts-expect-error: intensity must be a number +e.addComponent('light', {intensity: 'x'}); +// @ts-expect-error: batchGroupId must be a number or null +e.addComponent('camera', {batchGroupId: 'x'}); +// Base options are Partial, so every slot is optional and explicit null +// passes optional slots (engine compat): same leniency as fov below. +// Expected: RTI passes explicit null into optional slots, tsc-strict rejects it. +e.addComponent('light', {intensity: null}); // ok +e.addComponent('light', undefined); // ok: dataless-equivalent, the wrapper is optional +// @ts-expect-error: unknown options never match +e.addComponent('camera', {fovv: 60}); +// @ts-expect-error: non-object data never matches the false branch +e.addComponent('nope', 42); +// @ts-expect-error: non-string names never infer +e.addComponent(42, {}); +/** @type {number[]} */ +const frozen = Object.freeze([0, 0, 1, 1]); +// Expected: frozen arrays pass RTI (real arrays) but tsc rejects readonly into number[]. +e.addComponent('element', {anchor: frozen}); +/** @type {number[]} */ +const sparse = [0, 0, 1, 1]; +sparse.length = 6; +// Expected: holes fail RTI element checks while tsc accepts sparse arrays as number[]. +e.addComponent('element', {anchor: sparse}); +// @ts-expect-error: typed arrays are no instanceof Array +e.addComponent('element', {anchor: new Float32Array([0, 0, 1, 1])}); +// Expected: explicit null passes optional slots (engine compat), tsc-strict rejects it. +e.addComponent('camera', {fov: null}); diff --git a/test/typechecking/playcanvas-tower-hostile-output.mjs b/test/typechecking/playcanvas-tower-hostile-output.mjs new file mode 100644 index 0000000..4dd3391 --- /dev/null +++ b/test/typechecking/playcanvas-tower-hostile-output.mjs @@ -0,0 +1,1004 @@ +registerTypedef('ComponentMap', { + "type": "mapping", + "iterable": { + "type": "keyof", + "argument": "Entity" + }, + "element": "K", + "result": { + "type": "reference", + "name": "NonNullable", + "args": [ + { + "type": "indexedAccess", + "index": "K", + "object": "Entity" + } + ] + }, + "nameType": { + "type": "condition", + "checkType": { + "type": "reference", + "name": "NonNullable", + "args": [ + { + "type": "indexedAccess", + "index": "K", + "object": "Entity" + } + ] + }, + "extendsType": "Component", + "trueType": "K", + "falseType": "never" + } +}); +registerTypedef('ComponentName', { + "type": "intersection", + "members": [ + { + "type": "keyof", + "argument": "ComponentMap" + }, + "string" + ] +}); +registerTypedef('IfEquals', { + "type": "condition", + "checkType": { + "type": "function", + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "X", + "trueType": 1, + "falseType": 2 + } + }, + "extendsType": { + "type": "function", + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "Y", + "trueType": 1, + "falseType": 2 + } + }, + "trueType": "A", + "falseType": "B" +}, ["X","Y","A","B"]); +registerTypedef('WritableKeys', { + "type": "indexedAccess", + "index": { + "type": "keyof", + "argument": "T" + }, + "object": { + "type": "mapping", + "iterable": { + "type": "keyof", + "argument": "T" + }, + "element": "P", + "result": { + "type": "reference", + "name": "IfEquals", + "args": [ + { + "type": "mapping", + "iterable": "P", + "element": "Q", + "result": { + "type": "indexedAccess", + "index": "P", + "object": "T" + } + }, + { + "type": "mapping", + "iterable": "P", + "element": "Q", + "result": { + "type": "indexedAccess", + "index": "P", + "object": "T" + }, + "readonly": "-" + }, + "P" + ] + }, + "question": "-" + } +}, ["T"]); +registerTypedef('ComponentOptionKeys', { + "type": "indexedAccess", + "index": { + "type": "reference", + "name": "WritableKeys", + "args": [ + "C" + ] + }, + "object": { + "type": "mapping", + "iterable": { + "type": "reference", + "name": "WritableKeys", + "args": [ + "C" + ] + }, + "element": "K", + "result": { + "type": "condition", + "checkType": "K", + "extendsType": { + "type": "union", + "members": [ + "'system'", + "'entity'", + { + "type": "templateLiteral", + "quasis": [ + "_", + "" + ], + "types": [ + "string" + ] + } + ] + }, + "trueType": "never", + "falseType": { + "type": "condition", + "checkType": { + "type": "reference", + "name": "NonNullable", + "args": [ + { + "type": "indexedAccess", + "index": "K", + "object": "C" + } + ] + }, + "extendsType": "Function", + "trueType": "never", + "falseType": "K" + } + } + } +}, ["C"]); +registerTypedef('ComponentOptionsOf', { + "type": "reference", + "name": "Partial", + "args": [ + { + "type": "reference", + "name": "Pick", + "args": [ + "C", + { + "type": "reference", + "name": "Extract", + "args": [ + { + "type": "reference", + "name": "ComponentOptionKeys", + "args": [ + "C" + ] + }, + { + "type": "keyof", + "argument": "C" + } + ] + } + ] + } + ] +}, ["C"]); +registerTypedef('ComponentOptionsOverrides', { + "type": "object", + "properties": { + "element": { + "type": "object", + "properties": { + "anchor": { + "type": "union", + "members": [ + "Vec4", + { + "type": "array", + "elementType": "number" + } + ], + "optional": true + }, + "color": { + "type": "union", + "members": [ + "Color", + { + "type": "array", + "elementType": "number" + } + ], + "optional": true + } + }, + "optional": false + }, + "camera": { + "type": "object", + "properties": { + "clearColor": { + "type": "union", + "members": [ + "Color", + { + "type": "array", + "elementType": "number" + } + ], + "optional": true + }, + "batchGroupId": { + "type": "union", + "members": [ + "number", + "null" + ], + "optional": true + } + }, + "optional": false + } + } +}); +registerTypedef('ComponentOptionsOverridesOf', { + "type": "condition", + "checkType": "K", + "extendsType": { + "type": "keyof", + "argument": "ComponentOptionsOverrides" + }, + "trueType": { + "type": "indexedAccess", + "index": "K", + "object": "ComponentOptionsOverrides" + }, + "falseType": { + "type": "object" + } +}, ["K"]); +registerTypedef('MergedComponentOptions', { + "type": "intersection", + "members": [ + { + "type": "reference", + "name": "Omit", + "args": [ + { + "type": "reference", + "name": "ComponentOptionsOf", + "args": [ + { + "type": "indexedAccess", + "index": "K", + "object": "ComponentMap" + } + ] + }, + { + "type": "keyof", + "argument": { + "type": "reference", + "name": "ComponentOptionsOverridesOf", + "args": [ + "K" + ] + } + } + ] + }, + { + "type": "reference", + "name": "ComponentOptionsOverridesOf", + "args": [ + "K" + ] + } + ] +}, ["K"]); +registerTypedef('ComponentOptions', { + "type": "mapping", + "iterable": { + "type": "keyof", + "argument": { + "type": "reference", + "name": "MergedComponentOptions", + "args": [ + "K" + ] + } + }, + "element": "P", + "result": { + "type": "indexedAccess", + "index": "P", + "object": { + "type": "reference", + "name": "MergedComponentOptions", + "args": [ + "K" + ] + } + } +}, ["K"]); + +/** + * Hostile component tower: every component family plus the exotic values + * production throws at it. Dense arrays satisfy `Vec | number[]` unions, + * class instances and tagged snapshots match nominally, `number | null` + * takes both, frozen arrays pass (they are real arrays), and the unknown + * component name falls through to the `object` false branch. What warns: + * mistyped scalars, holes in arrays (RTI rejects sparse tails), typed + * arrays (no `instanceof Array`), explicit null into non-nullable slots, + * undefined into required data, unknown options, and wrong nominal tags. + * Three deliberate divergences from tsc carry `// Expected:` notes: an + * explicit null into an optional-without-`|null` slot and a frozen array + * into `number[]` pass RTI but fail tsc-strict, while a sparse tail fails + * RTI but passes tsc. (`Component` is intentionally non-empty: tsc reads + * an empty base class structurally, so every member would extend it.) + */ +class Component { + constructor() { + /** @type {boolean} */ + this.enabled = true; + } +} +registerClass(Component); +registerTypedef('Component', { + "type": "object", + "properties": { + "enabled": "boolean" + } +}); +class Color { + constructor() { + this.r = 0.5; + this.g = 0.6; + this.b = 0.9; + this.a = 1; + } +} +registerClass(Color); +registerTypedef('Color', { + "type": "object", + "properties": { + "r": "number", + "g": "number", + "b": "number", + "a": "number" + } +}); +class Vec2 { + constructor() { + this.x = 0; + this.y = 0; + } +} +registerClass(Vec2); +registerTypedef('Vec2', { + "type": "object", + "properties": { + "x": "number", + "y": "number" + } +}); +class Vec4 { + constructor() { + this.x = 0; + this.y = 0; + this.z = 0; + this.w = 0; + } +} +registerClass(Vec4); +registerTypedef('Vec4', { + "type": "object", + "properties": { + "x": "number", + "y": "number", + "z": "number", + "w": "number" + } +}); +class TintedVec4 extends Vec4 { + constructor() { + super(); + /** @type {number} */ + + this.tint = 0; + } +} +registerClass(TintedVec4); +registerTypedef('TintedVec4', { + "type": "object", + "properties": { + "tint": "number" + } +}); +class ElementComponent extends Component { + constructor() { + super(); + /** @type {Vec4} */ + + this._anchor = new Vec4(); + /** @type {Color} */ + + this._color = new Color(); + /** @type {Vec2} */ + + this._alignment = new Vec2(); + } + + /** + * @type {Vec4} + */ + + get anchor() { + return this._anchor; + } + + /** + * @param {Vec4 | number[]} value + */ + + set anchor(value) { + void value; + } + + /** + * @type {Color} + */ + + get color() { + return this._color; + } + + /** + * @param {Color | number[]} value + */ + + set color(value) { + void value; + } + + /** + * @type {Vec2} + */ + + get alignment() { + return this._alignment; + } + + /** + * @param {Vec2 | number[]} value + */ + + set alignment(value) { + void value; + } +} +registerClass(ElementComponent); +registerTypedef('ElementComponent', { + "type": "object", + "properties": { + "anchor": "Vec4", + "color": "Color", + "alignment": "Vec2", + "_anchor": "Vec4", + "_color": "Color", + "_alignment": "Vec2" + } +}); +class CameraComponent extends Component { + constructor() { + super(); + /** @type {Color} */ + + this._clearColor = new Color(); + /** @type {number} */ + + this._fov = 45; + } + + /** + * @type {Color} + */ + + get clearColor() { + return this._clearColor; + } + + /** + * @param {Color | number[]} value + */ + + set clearColor(value) { + void value; + } + + /** + * @type {number} + */ + + get fov() { + return this._fov; + } + + /** + * @param {number} value + */ + + set fov(value) { + void value; + } +} +registerClass(CameraComponent); +registerTypedef('CameraComponent', { + "type": "object", + "properties": { + "clearColor": "Color", + "fov": "number", + "_clearColor": "Color", + "_fov": "number" + } +}); +class LightComponent extends Component { + constructor() { + super(); + /** @type {number} */ + + this._intensity = 1; + /** @type {string} */ + + this._shape = 'omni'; + } + + /** + * @type {number} + */ + + get intensity() { + return this._intensity; + } + + /** + * @param {number} value + */ + + set intensity(value) { + void value; + } + + /** + * @type {string} + */ + + get shape() { + return this._shape; + } + + /** + * @param {string} value + */ + + set shape(value) { + void value; + } +} +registerClass(LightComponent); +registerTypedef('LightComponent', { + "type": "object", + "properties": { + "intensity": "number", + "shape": "string", + "_intensity": "number", + "_shape": "string" + } +}); +class Entity { + constructor() { + this.element = undefined; + this.camera = undefined; + this.light = undefined; + this.name = 'x'; + } + + /** + * @type {ElementComponent|undefined} + * @readonly + */ + + element; + + /** + * @type {CameraComponent|undefined} + * @readonly + */ + + camera; + + /** + * @type {LightComponent|undefined} + * @readonly + */ + + light; + + /** + * @type {Object} + */ + + c = {}; + + /** + * @type {string} + */ + + name = 'x'; + + /** + * @template {ComponentName | (string & {})} K + * @param {K} type + * @param {K extends ComponentName ? ComponentOptions : object} [data] + * @returns {*} comp + */ + + addComponent(type, data) { + const rtiTemplates = { + "K": { + "type": "union", + "members": [ + "ComponentName", + { + "type": "intersection", + "members": [ + "string", + { + "type": "object" + } + ] + } + ] + } + }; + if (!inspectTypeWithTemplates(type, "K", 'Entity#addComponent', 'type', rtiTemplates)) { + youCanAddABreakpointHere(); + } + if (!inspectTypeWithTemplates(data, { + "type": "condition", + "checkType": "K", + "extendsType": "ComponentName", + "trueType": { + "type": "reference", + "name": "ComponentOptions", + "args": [ + "K" + ] + }, + "falseType": { + "type": "object", + "properties": {} + }, + "optional": true + }, 'Entity#addComponent', 'data', rtiTemplates)) { + youCanAddABreakpointHere(); + } + return [type, data]; + } +} +registerClass(Entity); +registerTypedef('Entity', { + "type": "object", + "properties": { + "element": { + "type": "union", + "members": [ + "ElementComponent", + "undefined" + ], + "readonly": true + }, + "camera": { + "type": "union", + "members": [ + "CameraComponent", + "undefined" + ], + "readonly": true + }, + "light": { + "type": "union", + "members": [ + "LightComponent", + "undefined" + ], + "readonly": true + }, + "c": { + "type": "record", + "key": "string", + "val": "Component" + }, + "name": "string", + "addComponent": "Function" + } +}); + +/** + * @typedef {{ [K in keyof Entity as NonNullable extends Component ? K : never]: NonNullable }} ComponentMap + */ + + +/** + * @typedef {keyof ComponentMap & string} ComponentName + */ + + +/** + * Resolves to `A` when the types `X` and `Y` are identical, otherwise to `B`. + * + * @template X + * @template Y + * @template [A=X] + * @template [B=never] + * @typedef {(() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? A : B} IfEquals + */ + + +/** + * @template T + * @typedef {{ [P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P> }[keyof T]} WritableKeys + */ + + +/** + * @template C + * @typedef {{ [K in WritableKeys]: K extends 'system' | 'entity' | `_${string}` ? never : NonNullable extends Function ? never : K }[WritableKeys]} ComponentOptionKeys + */ + + +/** + * @template C + * @typedef {Partial, keyof C>>>} ComponentOptionsOf + */ + + +/** + * @typedef {object} ComponentOptionsOverrides + * @property {{ anchor?: Vec4 | number[], color?: Color | number[] }} element + * @property {{ clearColor?: Color | number[], batchGroupId?: number | null }} camera + */ + + +/** + * @template {ComponentName} K + * @typedef {K extends keyof ComponentOptionsOverrides ? ComponentOptionsOverrides[K] : {}} ComponentOptionsOverridesOf + */ + + +/** + * @template {ComponentName} K + * @typedef {Omit, keyof ComponentOptionsOverridesOf> & ComponentOptionsOverridesOf} MergedComponentOptions + */ + + +/** + * @template {ComponentName} K + * @typedef {{ [P in keyof MergedComponentOptions]: MergedComponentOptions[P] }} ComponentOptions + */ + + +/** + * @typedef {{ [K in keyof Entity as NonNullable extends Component ? K : never]: NonNullable }} ComponentMap + */ + +/** + * @typedef {keyof ComponentMap & string} ComponentName + */ + +/** + * Resolves to `A` when the types `X` and `Y` are identical, otherwise to `B`. + * + * @template X + * @template Y + * @template [A=X] + * @template [B=never] + * @typedef {(() => T extends X ? 1 : 2) extends (() => T extends Y ? 1 : 2) ? A : B} IfEquals + */ + +/** + * @template T + * @typedef {{ [P in keyof T]-?: IfEquals<{ [Q in P]: T[P] }, { -readonly [Q in P]: T[P] }, P> }[keyof T]} WritableKeys + */ + +/** + * @template C + * @typedef {{ [K in WritableKeys]: K extends 'system' | 'entity' | `_${string}` ? never : NonNullable extends Function ? never : K }[WritableKeys]} ComponentOptionKeys + */ + +/** + * @template C + * @typedef {Partial, keyof C>>>} ComponentOptionsOf + */ + +/** + * @typedef {object} ComponentOptionsOverrides + * @property {{ anchor?: Vec4 | number[], color?: Color | number[] }} element + * @property {{ clearColor?: Color | number[], batchGroupId?: number | null }} camera + */ + +/** + * @template {ComponentName} K + * @typedef {K extends keyof ComponentOptionsOverrides ? ComponentOptionsOverrides[K] : {}} ComponentOptionsOverridesOf + */ + +/** + * @template {ComponentName} K + * @typedef {Omit, keyof ComponentOptionsOverridesOf> & ComponentOptionsOverridesOf} MergedComponentOptions + */ + +/** + * @template {ComponentName} K + * @typedef {{ [P in keyof MergedComponentOptions]: MergedComponentOptions[P] }} ComponentOptions + */ +const e = new Entity(); +// Dense arrays satisfy the widened unions; instances match nominally. + +e.addComponent('element', { + anchor: [0, 0, 1, 1], + color: new Color() +}); // ok + +e.addComponent('element', { + anchor: new Vec4(), + color: [0.5, 0.6, 0.9] +}); // ok + +e.addComponent('element', { + anchor: new TintedVec4() +}); // ok: subclass instances satisfy base slots + +e.addComponent('camera', { + clearColor: new Color(), + fov: 60 +}); // ok + +e.addComponent('camera', { + clearColor: [0.5, 0.6, 0.9, 1], + batchGroupId: null +}); // ok + +e.addComponent('camera', { + batchGroupId: 3 +}); // ok: nullable takes a number + +e.addComponent('light', { + intensity: 2, + shape: 'spot' +}); // ok + +// Unknown names fall through to the object false branch; dataless is fine. + + // ok + +// Unknown names fall through to the object false branch; dataless is fine. +e.addComponent('nope', { + anything: 1 +}); // ok + +e.addComponent('light'); // ok + +// @ts-expect-error: alignment has no array override in the tower + + // ok + +// @ts-expect-error: alignment has no array override in the tower +e.addComponent('element', { + alignment: [0, 0] +}); +// @ts-expect-error: anchor elements must be numbers + +e.addComponent('element', { + anchor: [0, 'x', 1, 1] +}); +// @ts-expect-error: color must be a Color or an array + +e.addComponent('element', { + color: 'red' +}); +// @ts-expect-error: fov must be a number + +e.addComponent('camera', { + fov: 'x' +}); +// @ts-expect-error: intensity must be a number + +e.addComponent('light', { + intensity: 'x' +}); +// @ts-expect-error: batchGroupId must be a number or null + +e.addComponent('camera', { + batchGroupId: 'x' +}); +// Base options are Partial, so every slot is optional and explicit null + +// passes optional slots (engine compat): same leniency as fov below. + +// Expected: RTI passes explicit null into optional slots, tsc-strict rejects it. + + +// Base options are Partial, so every slot is optional and explicit null + +// passes optional slots (engine compat): same leniency as fov below. + +// Expected: RTI passes explicit null into optional slots, tsc-strict rejects it. +e.addComponent('light', { + intensity: null +}); // ok + +e.addComponent('light', undefined); // ok: dataless-equivalent, the wrapper is optional + +// @ts-expect-error: unknown options never match + + // ok: dataless-equivalent, the wrapper is optional + +// @ts-expect-error: unknown options never match +e.addComponent('camera', { + fovv: 60 +}); +// @ts-expect-error: non-object data never matches the false branch + +e.addComponent('nope', 42); +// @ts-expect-error: non-string names never infer + +e.addComponent(42, {}); +/** @type {number[]} */ + +const frozen = Object.freeze([0, 0, 1, 1]); +// Expected: frozen arrays pass RTI (real arrays) but tsc rejects readonly into number[]. + +e.addComponent('element', { + anchor: frozen +}); +/** @type {number[]} */ + +const sparse = [0, 0, 1, 1]; +sparse.length = 6; +// Expected: holes fail RTI element checks while tsc accepts sparse arrays as number[]. + +e.addComponent('element', { + anchor: sparse +}); +// @ts-expect-error: typed arrays are no instanceof Array + +e.addComponent('element', { + anchor: new Float32Array([0, 0, 1, 1]) +}); +// Expected: explicit null passes optional slots (engine compat), tsc-strict rejects it. + +e.addComponent('camera', { + fov: null +}); diff --git a/test/typechecking/playcanvas-tower-memo-errors.json b/test/typechecking/playcanvas-tower-memo-errors.json index 4569e1a..7ce806b 100644 --- a/test/typechecking/playcanvas-tower-memo-errors.json +++ b/test/typechecking/playcanvas-tower-memo-errors.json @@ -1,6 +1,18 @@ { "throws": [ - {"loc": "Entity#addComponent", "name": "data", "value": {"clearColor": "x"}}, - {"loc": "Entity#addComponent", "name": "data", "value": {"intensity": "x"}} + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "clearColor": "x" + } + }, + { + "loc": "Entity#addComponent", + "name": "data", + "value": { + "intensity": "x" + } + } ] } diff --git a/test/typechecking/playcanvas-tower-modifiers-errors.json b/test/typechecking/playcanvas-tower-modifiers-errors.json index a25bc18..2b1f4d2 100644 --- a/test/typechecking/playcanvas-tower-modifiers-errors.json +++ b/test/typechecking/playcanvas-tower-modifiers-errors.json @@ -1,8 +1,26 @@ { "throws": [ - {"loc": "takeCameraKey", "name": "key", "value": "label"}, - {"loc": "takeCameraKey", "name": "key", "value": "nope"}, - {"loc": "takeRequired", "name": "opts", "value": {}}, - {"loc": "takeRequired", "name": "opts", "value": {"a": 1}} + { + "loc": "takeCameraKey", + "name": "key", + "value": "label" + }, + { + "loc": "takeCameraKey", + "name": "key", + "value": "nope" + }, + { + "loc": "takeRequired", + "name": "opts", + "value": {} + }, + { + "loc": "takeRequired", + "name": "opts", + "value": { + "a": 1 + } + } ] } diff --git a/test/typechecking/playcanvas-tower-modifiers-output.mjs b/test/typechecking/playcanvas-tower-modifiers-output.mjs index 85d26ca..c9b3b54 100644 --- a/test/typechecking/playcanvas-tower-modifiers-output.mjs +++ b/test/typechecking/playcanvas-tower-modifiers-output.mjs @@ -3,11 +3,25 @@ registerTypedef('IfEquals', { "type": "condition", "checkType": { "type": "function", - "parameters": [] + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "X", + "trueType": 1, + "falseType": 2 + } }, "extendsType": { "type": "function", - "parameters": [] + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "Y", + "trueType": 1, + "falseType": 2 + } }, "trueType": "A", "falseType": "B" diff --git a/test/typechecking/playcanvas-tower-nonnullable-errors.json b/test/typechecking/playcanvas-tower-nonnullable-errors.json index 7126df1..1926f8d 100644 --- a/test/typechecking/playcanvas-tower-nonnullable-errors.json +++ b/test/typechecking/playcanvas-tower-nonnullable-errors.json @@ -1,7 +1,19 @@ { "throws": [ - {"loc": "takeCameraProp", "name": "prop", "value": "CameraComponent"}, - {"loc": "takeCameraProp", "name": "prop", "value": "name"}, - {"loc": "takeNullName", "name": "name", "value": "name"} + { + "loc": "takeCameraProp", + "name": "prop", + "value": "CameraComponent" + }, + { + "loc": "takeCameraProp", + "name": "prop", + "value": "name" + }, + { + "loc": "takeNullName", + "name": "name", + "value": "name" + } ] } diff --git a/test/typechecking/playcanvas-tower-nullable-errors.json b/test/typechecking/playcanvas-tower-nullable-errors.json index f6e8808..cde40af 100644 --- a/test/typechecking/playcanvas-tower-nullable-errors.json +++ b/test/typechecking/playcanvas-tower-nullable-errors.json @@ -1,15 +1,59 @@ { "throws": [ - {"loc": "takeDoubleNonNullableProp", "name": "prop", "value": "name"}, - {"loc": "takeTripleNullableProp", "name": "prop", "value": "name"}, - {"loc": "takeCameraSlotProp", "name": "prop", "value": "name"}, - {"loc": "takeNestedComponentProp", "name": "prop", "value": "name"}, - {"loc": "takeDeepCameraProp", "name": "prop", "value": "name"}, - {"loc": "takeOptionalNullName", "name": "name", "value": "name"}, - {"loc": "takeInstanceKey", "name": "key", "value": "CameraComponent"}, - {"loc": "takeInstanceKey", "name": "key", "value": "name"}, - {"loc": "takeMultipleComponentName", "name": "name", "value": "missing"}, - {"loc": "takeOnlyActualCameraKeys", "name": "key", "value": "CameraComponent"}, - {"loc": "takeOnlyActualCameraKeys", "name": "key", "value": "name"} + { + "loc": "takeDoubleNonNullableProp", + "name": "prop", + "value": "name" + }, + { + "loc": "takeTripleNullableProp", + "name": "prop", + "value": "name" + }, + { + "loc": "takeCameraSlotProp", + "name": "prop", + "value": "name" + }, + { + "loc": "takeNestedComponentProp", + "name": "prop", + "value": "name" + }, + { + "loc": "takeDeepCameraProp", + "name": "prop", + "value": "name" + }, + { + "loc": "takeOptionalNullName", + "name": "name", + "value": "name" + }, + { + "loc": "takeInstanceKey", + "name": "key", + "value": "CameraComponent" + }, + { + "loc": "takeInstanceKey", + "name": "key", + "value": "name" + }, + { + "loc": "takeMultipleComponentName", + "name": "name", + "value": "missing" + }, + { + "loc": "takeOnlyActualCameraKeys", + "name": "key", + "value": "CameraComponent" + }, + { + "loc": "takeOnlyActualCameraKeys", + "name": "key", + "value": "name" + } ] } diff --git a/test/typechecking/playcanvas-tower-read-errors.json b/test/typechecking/playcanvas-tower-read-errors.json new file mode 100644 index 0000000..efbbc05 --- /dev/null +++ b/test/typechecking/playcanvas-tower-read-errors.json @@ -0,0 +1,30 @@ +{ + "throws": [ + { + "loc": "takeCameras", + "name": "cams", + "value": { + "$type": "CameraComponent", + "_fov": 45, + "enabled": true + } + }, + { + "loc": "takeCamera", + "name": "cam", + "value": { + "$type": "LightComponent", + "_intensity": 1, + "enabled": true + } + }, + { + "loc": "takeCamera", + "name": "cam", + "value": { + "$type": "Component", + "enabled": true + } + } + ] +} diff --git a/test/typechecking/playcanvas-tower-read-input.mjs b/test/typechecking/playcanvas-tower-read-input.mjs new file mode 100644 index 0000000..fc59ba6 --- /dev/null +++ b/test/typechecking/playcanvas-tower-read-input.mjs @@ -0,0 +1,137 @@ +/** + * Read-path tower: `findComponent`/`findComponents` resolve through the + * same conditional as the `addComponent` data parameter, so lookups keep + * their component type when the result flows into the next checked call. + * Unknown names fall back to the base `Component`. (`Component` is + * intentionally non-empty: tsc reads an empty base class structurally, so + * every member would extend it.) + */ +class Component { + constructor() { + /** @type {boolean} */ + this.enabled = true; + } +} +class CameraComponent extends Component { + constructor() { + super(); + /** @type {number} */ + this._fov = 45; + } + /** + * @type {number} + */ + get fov() { + return this._fov; + } + /** + * @param {number} value + */ + set fov(value) { + this._fov = value; + } +} +class LightComponent extends Component { + constructor() { + super(); + /** @type {number} */ + this._intensity = 1; + } + /** + * @type {number} + */ + get intensity() { + return this._intensity; + } + /** + * @param {number} value + */ + set intensity(value) { + this._intensity = value; + } +} +class Entity { + constructor() { + this.camera = undefined; + this.light = undefined; + this.name = 'x'; + } + /** + * @type {CameraComponent|undefined} + * @readonly + */ + camera; + /** + * @type {LightComponent|undefined} + * @readonly + */ + light; + /** + * @type {string} + */ + name = 'x'; + /** + * @template {ComponentName | (string & {})} K + * @param {K} type + * @returns {(K extends ComponentName ? ComponentMap[K] : Component) | null} + */ + findComponent(type) { + if (type === 'camera') { + return /** @type {any} */ (new CameraComponent()); + } + if (type === 'light') { + return /** @type {any} */ (new LightComponent()); + } + return /** @type {any} */ (new Component()); + } + /** + * @template {ComponentName | (string & {})} K + * @param {K} type + * @returns {(K extends ComponentName ? ComponentMap[K] : Component)[]} + */ + findComponents(type) { + if (type === 'camera') { + return /** @type {any} */ ([new CameraComponent()]); + } + if (type === 'light') { + return /** @type {any} */ ([new LightComponent()]); + } + return /** @type {any} */ ([new Component()]); + } +} +/** + * @typedef {{ [K in keyof Entity as NonNullable extends Component ? K : never]: NonNullable }} ComponentMap + */ +/** + * @typedef {keyof ComponentMap & string} ComponentName + */ +/** + * @param {CameraComponent|null} cam + */ +function takeCamera(cam) { + return cam; +} +/** + * @param {Component} comp + */ +function takeComponent(comp) { + return comp; +} +/** + * @param {CameraComponent[]} cams + */ +function takeCameras(cams) { + return cams; +} +const e = new Entity(); +takeCamera(e.findComponent('camera')); // ok: lookup resolves to the component +// Expected: the live lookup never returns null here, so RTI passes the +// instance while tsc rejects the declared `| null`. +takeComponent(e.findComponent('nope')); // ok +takeCameras(e.findComponents('camera')); // ok: arrays of the mapped component +// @ts-expect-error: a single component is no array of them +takeCameras(e.findComponent('camera')); +// @ts-expect-error: lights are no cameras +takeCamera(e.findComponent('light')); +// @ts-expect-error: unknown names are no cameras +takeCamera(e.findComponent('nope')); diff --git a/test/typechecking/playcanvas-tower-read-output.mjs b/test/typechecking/playcanvas-tower-read-output.mjs new file mode 100644 index 0000000..0ca2619 --- /dev/null +++ b/test/typechecking/playcanvas-tower-read-output.mjs @@ -0,0 +1,350 @@ +registerTypedef('ComponentMap', { + "type": "mapping", + "iterable": { + "type": "keyof", + "argument": "Entity" + }, + "element": "K", + "result": { + "type": "reference", + "name": "NonNullable", + "args": [ + { + "type": "indexedAccess", + "index": "K", + "object": "Entity" + } + ] + }, + "nameType": { + "type": "condition", + "checkType": { + "type": "reference", + "name": "NonNullable", + "args": [ + { + "type": "indexedAccess", + "index": "K", + "object": "Entity" + } + ] + }, + "extendsType": "Component", + "trueType": "K", + "falseType": "never" + } +}); +registerTypedef('ComponentName', { + "type": "intersection", + "members": [ + { + "type": "keyof", + "argument": "ComponentMap" + }, + "string" + ] +}); + +/** + * Read-path tower: `findComponent`/`findComponents` resolve through the + * same conditional as the `addComponent` data parameter, so lookups keep + * their component type when the result flows into the next checked call. + * Unknown names fall back to the base `Component`. (`Component` is + * intentionally non-empty: tsc reads an empty base class structurally, so + * every member would extend it.) + */ +class Component { + constructor() { + /** @type {boolean} */ + this.enabled = true; + } +} +registerClass(Component); +registerTypedef('Component', { + "type": "object", + "properties": { + "enabled": "boolean" + } +}); +class CameraComponent extends Component { + constructor() { + super(); + /** @type {number} */ + + this._fov = 45; + } + + /** + * @type {number} + */ + + get fov() { + return this._fov; + } + + /** + * @param {number} value + */ + + set fov(value) { + this._fov = value; + } +} +registerClass(CameraComponent); +registerTypedef('CameraComponent', { + "type": "object", + "properties": { + "fov": "number", + "_fov": "number" + } +}); +class LightComponent extends Component { + constructor() { + super(); + /** @type {number} */ + + this._intensity = 1; + } + + /** + * @type {number} + */ + + get intensity() { + return this._intensity; + } + + /** + * @param {number} value + */ + + set intensity(value) { + this._intensity = value; + } +} +registerClass(LightComponent); +registerTypedef('LightComponent', { + "type": "object", + "properties": { + "intensity": "number", + "_intensity": "number" + } +}); +class Entity { + constructor() { + this.camera = undefined; + this.light = undefined; + this.name = 'x'; + } + + /** + * @type {CameraComponent|undefined} + * @readonly + */ + + camera; + + /** + * @type {LightComponent|undefined} + * @readonly + */ + + light; + + /** + * @type {string} + */ + + name = 'x'; + + /** + * @template {ComponentName | (string & {})} K + * @param {K} type + * @returns {(K extends ComponentName ? ComponentMap[K] : Component) | null} + */ + + findComponent(type) { + const rtiTemplates = { + "K": { + "type": "union", + "members": [ + "ComponentName", + { + "type": "intersection", + "members": [ + "string", + { + "type": "object" + } + ] + } + ] + } + }; + if (!inspectTypeWithTemplates(type, "K", 'Entity#findComponent', 'type', rtiTemplates)) { + youCanAddABreakpointHere(); + } + if (type === 'camera') { + return /** @type {any} */ (new CameraComponent()); + } + if (type === 'light') { + return /** @type {any} */ (new LightComponent()); + } + return /** @type {any} */ (new Component()); + } + + /** + * @template {ComponentName | (string & {})} K + * @param {K} type + * @returns {(K extends ComponentName ? ComponentMap[K] : Component)[]} + */ + + findComponents(type) { + const rtiTemplates = { + "K": { + "type": "union", + "members": [ + "ComponentName", + { + "type": "intersection", + "members": [ + "string", + { + "type": "object" + } + ] + } + ] + } + }; + if (!inspectTypeWithTemplates(type, "K", 'Entity#findComponents', 'type', rtiTemplates)) { + youCanAddABreakpointHere(); + } + if (type === 'camera') { + return /** @type {any} */ ([new CameraComponent()]); + } + if (type === 'light') { + return /** @type {any} */ ([new LightComponent()]); + } + return /** @type {any} */ ([new Component()]); + } +} +registerClass(Entity); +registerTypedef('Entity', { + "type": "object", + "properties": { + "camera": { + "type": "union", + "members": [ + "CameraComponent", + "undefined" + ], + "readonly": true + }, + "light": { + "type": "union", + "members": [ + "LightComponent", + "undefined" + ], + "readonly": true + }, + "name": "string", + "findComponent": "Function", + "findComponents": "Function" + } +}); + +/** + * @typedef {{ [K in keyof Entity as NonNullable extends Component ? K : never]: NonNullable }} ComponentMap + */ + + +/** + * @typedef {keyof ComponentMap & string} ComponentName + */ + + +/** + * @param {CameraComponent|null} cam + */ + + +/** + * @typedef {{ [K in keyof Entity as NonNullable extends Component ? K : never]: NonNullable }} ComponentMap + */ + +/** + * @typedef {keyof ComponentMap & string} ComponentName + */ + +/** + * @param {CameraComponent|null} cam + */ +function takeCamera(cam) { + if (!inspectType(cam, { + "type": "union", + "members": [ + "CameraComponent", + "null" + ], + "optional": false + }, 'takeCamera', 'cam')) { + youCanAddABreakpointHere(); + } + return cam; +} + +/** + * @param {Component} comp + */ + +function takeComponent(comp) { + if (!inspectType(comp, "Component", 'takeComponent', 'comp')) { + youCanAddABreakpointHere(); + } + return comp; +} + +/** + * @param {CameraComponent[]} cams + */ + +function takeCameras(cams) { + if (!inspectType(cams, { + "type": "array", + "elementType": "CameraComponent", + "optional": false + }, 'takeCameras', 'cams')) { + youCanAddABreakpointHere(); + } + return cams; +} +const e = new Entity(); +takeCamera(e.findComponent('camera')); // ok: lookup resolves to the component + +// Expected: the live lookup never returns null here, so RTI passes the + +// instance while tsc rejects the declared `| null`. + + // ok: lookup resolves to the component + +// Expected: the live lookup never returns null here, so RTI passes the + +// instance while tsc rejects the declared `| null`. +takeComponent(e.findComponent('nope')); // ok + +takeCameras(e.findComponents('camera')); // ok: arrays of the mapped component + +// @ts-expect-error: a single component is no array of them + + // ok: arrays of the mapped component + +// @ts-expect-error: a single component is no array of them +takeCameras(e.findComponent('camera')); +// @ts-expect-error: lights are no cameras + +takeCamera(e.findComponent('light')); +// @ts-expect-error: unknown names are no cameras + +takeCamera(e.findComponent('nope')); diff --git a/test/typechecking/playcanvas-tower-strictnull-errors.json b/test/typechecking/playcanvas-tower-strictnull-errors.json index 85760c3..94706c4 100644 --- a/test/typechecking/playcanvas-tower-strictnull-errors.json +++ b/test/typechecking/playcanvas-tower-strictnull-errors.json @@ -1,5 +1,9 @@ { "throws": [ - {"loc": "acceptDirectMaybeCameraResult", "name": "value", "value": "component"} + { + "loc": "acceptDirectMaybeCameraResult", + "name": "value", + "value": "component" + } ] } diff --git a/test/typechecking/playcanvas-tower-utilities-errors.json b/test/typechecking/playcanvas-tower-utilities-errors.json index 17ae0d2..fee01d5 100644 --- a/test/typechecking/playcanvas-tower-utilities-errors.json +++ b/test/typechecking/playcanvas-tower-utilities-errors.json @@ -1,12 +1,56 @@ { "throws": [ - {"loc": "takePartial", "name": "o", "value": {"a": "x"}}, - {"loc": "takeDeep", "name": "o", "value": {"a": "x"}}, - {"loc": "takeRequired", "name": "o", "value": {}}, - {"loc": "takeOmit", "name": "o", "value": {"a": 1}}, - {"loc": "takeOmit", "name": "o", "value": {"b": 1}}, - {"loc": "takeRequiredUnion", "name": "o", "value": {}}, - {"loc": "takeGeneric", "name": "o", "value": {"c": 1}}, - {"loc": "takeGeneric", "name": "o", "value": {"a": "x"}} + { + "loc": "takePartial", + "name": "o", + "value": { + "a": "x" + } + }, + { + "loc": "takeDeep", + "name": "o", + "value": { + "a": "x" + } + }, + { + "loc": "takeRequired", + "name": "o", + "value": {} + }, + { + "loc": "takeOmit", + "name": "o", + "value": { + "a": 1 + } + }, + { + "loc": "takeOmit", + "name": "o", + "value": { + "b": 1 + } + }, + { + "loc": "takeRequiredUnion", + "name": "o", + "value": {} + }, + { + "loc": "takeGeneric", + "name": "o", + "value": { + "c": 1 + } + }, + { + "loc": "takeGeneric", + "name": "o", + "value": { + "a": "x" + } + } ] } diff --git a/test/typechecking/record-keys-errors.json b/test/typechecking/record-keys-errors.json index f2fbf7d..d04da27 100644 --- a/test/typechecking/record-keys-errors.json +++ b/test/typechecking/record-keys-errors.json @@ -1,8 +1,33 @@ { "throws": [ - {"loc": "takeIndexed", "name": "byIndex", "value": {"a": "x"}}, - {"loc": "takeIndexed", "name": "byIndex", "value": {"0": 1}}, - {"loc": "takeNamed", "name": "byName", "value": {"a": "x"}}, - {"loc": "takeRoles", "name": "permissions", "value": {"admin": true, "root": false}} + { + "loc": "takeIndexed", + "name": "byIndex", + "value": { + "a": "x" + } + }, + { + "loc": "takeIndexed", + "name": "byIndex", + "value": { + "0": 1 + } + }, + { + "loc": "takeNamed", + "name": "byName", + "value": { + "a": "x" + } + }, + { + "loc": "takeRoles", + "name": "permissions", + "value": { + "admin": true, + "root": false + } + } ] } diff --git a/test/typechecking/tagged-shapes-errors.json b/test/typechecking/tagged-shapes-errors.json index 1f4284c..1fee7bc 100644 --- a/test/typechecking/tagged-shapes-errors.json +++ b/test/typechecking/tagged-shapes-errors.json @@ -1 +1,47 @@ -{"throws": [{"loc": "taggedShapes", "name": "bare", "value": 123}, {"loc": "taggedShapes", "name": "inArray", "value": [123]}, {"loc": "taggedShapes", "name": "inObject", "value": {"v": 123}}, {"loc": "taggedShapes", "name": "inIndex", "value": {"a": 123}}, {"loc": "taggedShapes", "name": "inMap", "value": {}}, {"loc": "taggedShapes", "name": "inSet", "value": {}}, {"loc": "taggedShapes", "name": "inRecord", "value": {"k": 123}}]} \ No newline at end of file +{ + "throws": [ + { + "loc": "taggedShapes", + "name": "bare", + "value": 123 + }, + { + "loc": "taggedShapes", + "name": "inArray", + "value": [ + 123 + ] + }, + { + "loc": "taggedShapes", + "name": "inObject", + "value": { + "v": 123 + } + }, + { + "loc": "taggedShapes", + "name": "inIndex", + "value": { + "a": 123 + } + }, + { + "loc": "taggedShapes", + "name": "inMap", + "value": {} + }, + { + "loc": "taggedShapes", + "name": "inSet", + "value": {} + }, + { + "loc": "taggedShapes", + "name": "inRecord", + "value": { + "k": 123 + } + } + ] +} diff --git a/test/typechecking/tagged-shapes-output.mjs b/test/typechecking/tagged-shapes-output.mjs index 81c383c..e0596ea 100644 --- a/test/typechecking/tagged-shapes-output.mjs +++ b/test/typechecking/tagged-shapes-output.mjs @@ -54,6 +54,7 @@ export function taggedShapes(bare, inArray, inObject, inFn, inNew, inIndex, inTu "name": "x" } ], + "ret": "K", "optional": false }, 'taggedShapes', 'inFn', rtiTemplates)) { youCanAddABreakpointHere(); diff --git a/test/typechecking/template-abstract-new-errors.json b/test/typechecking/template-abstract-new-errors.json index b6f3108..4eaeb2d 100644 --- a/test/typechecking/template-abstract-new-errors.json +++ b/test/typechecking/template-abstract-new-errors.json @@ -1,5 +1,9 @@ { "throws": [ - {"loc": "AbstractPool#constructor", "name": "constructorFunc", "value": 42} + { + "loc": "AbstractPool#constructor", + "name": "constructorFunc", + "value": 42 + } ] } diff --git a/test/typechecking/template-nested-crossref-errors.json b/test/typechecking/template-nested-crossref-errors.json index bf59c23..6c158f3 100644 --- a/test/typechecking/template-nested-crossref-errors.json +++ b/test/typechecking/template-nested-crossref-errors.json @@ -1,5 +1,12 @@ { "throws": [ - {"loc": "KeyedBox#constructor", "name": "keys", "value": [1, 2]} + { + "loc": "KeyedBox#constructor", + "name": "keys", + "value": [ + 1, + 2 + ] + } ] } diff --git a/test/typechecking/template-new-constraint-errors.json b/test/typechecking/template-new-constraint-errors.json index bfddb46..d789afe 100644 --- a/test/typechecking/template-new-constraint-errors.json +++ b/test/typechecking/template-new-constraint-errors.json @@ -1,5 +1,9 @@ { "throws": [ - {"loc": "Pool#constructor", "name": "constructorFunc", "value": 42} + { + "loc": "Pool#constructor", + "name": "constructorFunc", + "value": 42 + } ] } diff --git a/test/typechecking/template-optional-construct-errors.json b/test/typechecking/template-optional-construct-errors.json index 177f086..6bb2dfd 100644 --- a/test/typechecking/template-optional-construct-errors.json +++ b/test/typechecking/template-optional-construct-errors.json @@ -1,5 +1,9 @@ { "throws": [ - {"loc": "MaybePool#constructor", "name": "constructorFunc", "value": 42} + { + "loc": "MaybePool#constructor", + "name": "constructorFunc", + "value": 42 + } ] } diff --git a/test/typechecking/template-union-new-errors.json b/test/typechecking/template-union-new-errors.json index 7d8428c..be37fa2 100644 --- a/test/typechecking/template-union-new-errors.json +++ b/test/typechecking/template-union-new-errors.json @@ -1,5 +1,9 @@ { "throws": [ - {"loc": "UnionFactory#constructor", "name": "make", "value": 42} + { + "loc": "UnionFactory#constructor", + "name": "make", + "value": 42 + } ] } diff --git a/test/typechecking/typeof-const-literal-errors.json b/test/typechecking/typeof-const-literal-errors.json index d5c0ca9..706f20e 100644 --- a/test/typechecking/typeof-const-literal-errors.json +++ b/test/typechecking/typeof-const-literal-errors.json @@ -1,8 +1,24 @@ { "throws": [ - {"loc": "setMotion", "name": "motion", "value": "bogus"}, - {"loc": "setMotion", "name": "motion", "value": 1}, - {"loc": "takeAnswer", "name": "value", "value": 43}, - {"loc": "takeCount", "name": "value", "value": "6"} + { + "loc": "setMotion", + "name": "motion", + "value": "bogus" + }, + { + "loc": "setMotion", + "name": "motion", + "value": 1 + }, + { + "loc": "takeAnswer", + "name": "value", + "value": 43 + }, + { + "loc": "takeCount", + "name": "value", + "value": "6" + } ] } diff --git a/test/typechecking/typeof-subclass-errors.json b/test/typechecking/typeof-subclass-errors.json index bb1e9ab..768157f 100644 --- a/test/typechecking/typeof-subclass-errors.json +++ b/test/typechecking/typeof-subclass-errors.json @@ -1,7 +1,23 @@ { "throws": [ - {"loc": "registerScript", "name": "script", "value": "[Function Unrelated]"}, - {"loc": "registerScript", "name": "script", "value": {"$type": "PlayerController", "enabled": true, "speed": 1}}, - {"loc": "create", "name": "nameOrType", "value": "[Function Unrelated]"} + { + "loc": "registerScript", + "name": "script", + "value": "[Function Unrelated]" + }, + { + "loc": "registerScript", + "name": "script", + "value": { + "$type": "PlayerController", + "enabled": true, + "speed": 1 + } + }, + { + "loc": "create", + "name": "nameOrType", + "value": "[Function Unrelated]" + } ] } diff --git a/test/typechecking/writable-keys-output.mjs b/test/typechecking/writable-keys-output.mjs index 5893d9a..7d17571 100644 --- a/test/typechecking/writable-keys-output.mjs +++ b/test/typechecking/writable-keys-output.mjs @@ -2,11 +2,25 @@ registerTypedef('IfEquals', { "type": "condition", "checkType": { "type": "function", - "parameters": [] + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "X", + "trueType": 1, + "falseType": 2 + } }, "extendsType": { "type": "function", - "parameters": [] + "parameters": [], + "ret": { + "type": "condition", + "checkType": "T", + "extendsType": "Y", + "trueType": 1, + "falseType": 2 + } }, "trueType": "A", "falseType": "B" diff --git a/test_runtime.js b/test_runtime.js index b4005b8..e822aab 100644 --- a/test_runtime.js +++ b/test_runtime.js @@ -106,6 +106,7 @@ const tests = [ ...(await import('./src-runtime/matchIndexSignature.spec.js' )).tests, ...(await import('./src-runtime/partialHomomorphic.spec.js' )).tests, ...(await import('./src-runtime/tagSnapshot.spec.js' )).tests, + ...(await import('./src-runtime/snapshotMatches.spec.js' )).tests, ...(await import('./src-runtime/collectCandidates.spec.js' )).tests, ...(await import('./src-runtime/strictNullChecks.spec.js' )).tests, ...(await import('./src-runtime/stringifyValue.spec.js' )).tests, @@ -114,6 +115,7 @@ const tests = [ ...(await import('./src-runtime/validateTypeof.spec.js' )).tests, ...(await import('./src-runtime/validateNamedType.spec.js' )).tests, ...(await import('./src-runtime/validateArguments.spec.js' )).tests, + ...(await import('./src-runtime/validateUnionSparse.spec.js' )).tests, ...(await import('./src-runtime/validateMap.spec.js' )).tests, ...(await import('./src-runtime/plugin-windowed/scanMapEntries.spec.js' )).tests, ...(await import('./src-runtime/plugin-windowed/windowedValidateMap.spec.js')).tests, @@ -128,6 +130,9 @@ const tests = [ ...(await import('./src-runtime/classShape.spec.js' )).tests, ...(await import('./src-runtime/instantiateReference.spec.js' )).tests, ...(await import('./src-runtime/createTypeFromMapping.spec.js' )).tests, + ...(await import('./src-runtime/materializeUtility.spec.js' )).tests, + ...(await import('./src-runtime/resolveObjectSide.spec.js' )).tests, + ...(await import('./src-runtime/createTypeFromIndexedAccess.spec.js')).tests, ...(await import('./src-runtime/expandTupleElements.spec.js' )).tests, ...(await import('./src-runtime/applyQuestionModifier.spec.js' )).tests, ...(await import('./src-runtime/applyReadonlyModifier.spec.js' )).tests, @@ -157,6 +162,7 @@ const tests = [ ...(await import('./src-runtime/panelWindows.spec.js' )).tests, ...(await import('./src-runtime/copyText.spec.js' )).tests, ...(await import('./src-runtime/compareText.spec.js' )).tests, + ...(await import('./src-runtime/reportedDiagnosis.spec.js' )).tests, ...(await import('./src-runtime/logFilename.spec.js' )).tests, ...(await import('./src-runtime/globalMessageHost.spec.js' )).tests, ...(await import('./src-runtime/customValidations.spec.js' )).tests, @@ -169,6 +175,7 @@ const tests = [ ...(await import('./src-transpiler/escapeName.spec.js' )).tests, ...(await import('./src-transpiler/runtimeImportNames.spec.js' )).tests, ...(await import('./src-transpiler/runChecks.spec.js' )).tests, + ...(await import('./src-transpiler/generateTypeChecks.spec.js' )).tests, ...(await import('./src-transpiler/noneUnchecked.spec.js' )).tests, ...(await import('./src-transpiler/expandTypeParity.spec.js' )).tests, ...(await import('./src-transpiler/isExpandedObject.spec.js' )).tests,