Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ build
types
docs
/tmp/
src-runtime/version.js
4 changes: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -232,6 +232,10 @@ ASCII punctuation only: no unicode decorations (em dashes, curly quotes,
arrows), and do not sprinkle hyphens and dashes everywhere; use normal
sentences with regular word spacing instead.

## Threat model

Runtime Type Inspector operates against trusted application code and is intended to catch accidental runtime type violations, not defend against deliberate attempts to deceive the inspector. Accidental cross-realm incompatibility (rejecting legitimate foreign values) and accidental misclassification (accepting values that do not satisfy the intended type contract) are both in scope. Deliberate spoofing through mechanisms such as Symbol.toStringTag or fabricated constructor names is explicitly out of scope. Cross-realm brand and constructor checks are pragmatic compatibility heuristics, not security boundaries.

## Fail closed

Safety relevant flags (option switches, validation verdicts, capability checks) must fail closed: a missing, undefined or garbage value disables the behavior, never enables it. Read a flag with plain truthiness (`if (options.checkStats)`) when it flows straight into a branch, or compare `=== true` with a short comment when the verdict is stored in a local first. Never write `options.<flag> !== false`: that reads every non-`false` value, including `undefined` from a typo or a deleted key, as on, so a broken flag silently widens validation instead of narrowing it.
Expand Down
21 changes: 21 additions & 0 deletions src-runtime/classMatches.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
import {snapshotMatches} from './snapshotMatches.js';
/**
* Whether a 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 is what lets the differ tell transport-stripped methods from
* genuinely missing ones. Never throws.
* @param {string|undefined} tag - Snapshot tag of the value.
* @param {string|undefined} className - Expected class name.
* @returns {boolean} True on a nominal match.
* @example
* classMatches('Color', 'Color'); // true (when Color registered)
* classMatches(undefined, 'Color'); // false
*/
function classMatches(tag, className) {
if (typeof tag !== 'string') {
return false;
}
return snapshotMatches({$type: tag}, className);
}
export {classMatches};
34 changes: 34 additions & 0 deletions src-runtime/classMatches.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import {registerClass, classes} from './registerClass.js';
import {classMatches} from './classMatches.js';
class MatchBase {}
class MatchChild extends MatchBase {}
class MatchOther {}
function prepare() {
registerClass(MatchBase);
registerClass(MatchChild);
registerClass(MatchOther);
}
function unprepare() {
delete classes.MatchBase;
delete classes.MatchChild;
delete classes.MatchOther;
}
// Exact tags match; subclass tags satisfy the parent through the
// registered chain; anything else (strangers, unknowns, untagged)
// declines so structural diffing keeps the row.
function testExactAndSubclassMatch() {
prepare();
try {
const exact = classMatches('MatchBase', 'MatchBase') === true;
const sub = classMatches('MatchChild', 'MatchBase') === true;
const stranger = classMatches('MatchOther', 'MatchBase') === false;
const unknown = classMatches('MatchBase', 'NeverHeard') === false;
const untagged = classMatches(undefined, 'MatchBase') === false;
return exact && sub && stranger && unknown && untagged;
} finally {
unprepare();
}
}
export const tests = [
testExactAndSubclassMatch,
];
9 changes: 6 additions & 3 deletions src-runtime/collectCandidates.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {unwrapMember} from "./unwrapMember.js";
import {literalOf} from "./literalOf.js";
import {isArrayValue} from "./isArrayValue.js";
/**
* Collects template inference candidates by walking a runtime value alongside
* its UNSUBSTITUTED expected type. Only positions TypeScript itself infers
Expand Down Expand Up @@ -50,7 +51,9 @@ function collectCandidates(value, expect, out = []) {
}
switch (expect.type) {
case 'object': {
if (value === null || typeof value !== 'object' || value instanceof Array) {
// `isArrayValue` (not `instanceof`): candidates infer from arrays
// shared live across realms too.
if (value === null || typeof value !== 'object' || isArrayValue(value)) {
return out;
}
const {properties} = expect;
Expand All @@ -71,7 +74,7 @@ function collectCandidates(value, expect, out = []) {
return out;
}
case 'tuple': {
if (!(value instanceof Array) || !Array.isArray(expect.elements)) {
if (!isArrayValue(value) || !Array.isArray(expect.elements)) {
return out;
}
for (let i = 0; i < expect.elements.length; i++) {
Expand All @@ -80,7 +83,7 @@ function collectCandidates(value, expect, out = []) {
return out;
}
case 'array':
if (value instanceof Array && expect.elementType !== undefined) {
if (isArrayValue(value) && expect.elementType !== undefined) {
for (const element of value) {
collectCandidates(element, expect.elementType, out);
}
Expand Down
183 changes: 183 additions & 0 deletions src-runtime/crossRealmDiagnosis.spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,183 @@
import vm from 'node:vm';
import {registerClass, classes} from './registerClass.js';
import {registerTypedef, typedefs, bumpTypedefVersion} from './registerTypedef.js';
import {tagSnapshot} from './tagSnapshot.js';
import {explainMismatch} from './explainMismatch.js';
import {unwrapTransparent} from './unwrapTransparent.js';
import {expandType} from '../src-transpiler/expandType.js';
class RealmColor {
constructor() {
Object.assign(this, {r: 0.95, g: 0.96, b: 0.98, a: 1});
}
clone() {
return new RealmColor();
}
lerp() {
return 1;
}
}
class RealmVec4 {
constructor() {
Object.assign(this, {x: 0, y: 0, z: 0, w: 0});
}
dot() {
return 0;
}
}
class RealmVec2 {
constructor() {
Object.assign(this, {x: 0, y: 0});
}
}
function prepareRealmTower() {
for (const cls of [RealmColor, RealmVec4, RealmVec2]) {
registerClass(cls);
}
// Shapes come through the parser like every other spec input, so a
// parser regression breaks these tests instead of silently passing
// against a stale hand-written shape. Harvest-equivalent shapes: data
// props plus required method props, like the engine harvest the panel
// resolves against.
registerTypedef('RealmColor', expandType('{r: number, g: number, b: number, a: number, clone: Function, lerp: Function}'));
registerTypedef('RealmVec4', expandType('{x: number, y: number, z: number, w: number, dot: Function}'));
registerTypedef('RealmVec2', expandType('{x: number, y: number}'));
}
function realmDataExpect() {
return expandType(`{
anchor?: RealmVec4 | number[],
color?: Readonly<RealmColor> | number[],
margin?: RealmVec4 | number[],
alignment?: RealmVec2,
}`);
// Wrapped like the real harvest spells getter/setter pairs: the
// getter's `Readonly<Color>` wins over the setter's `Color`, while the
// snapshot tag stays bare. The differ must unwrap the transparent
// wrapper before the nominal match, mirroring validateReference.
}
function realmLiveValue() {
return {
anchor: [0, 0, 1, 1],
color: new RealmColor(),
margin: [14, 12, 14, 12],
alignment: [0, 0],
};
}
function realmFindingPaths(value) {
const {findings} = explainMismatch(value, realmDataExpect(), 'data');
return findings.map((finding) => `${finding.kind} ${finding.path}`);
}
// Foreign-realm arrays with a tagged snapshot must diff to exactly the
// genuinely failing alignment row: the arrays satisfy their number[]
// members across the realm boundary (brand check, not instanceof) and
// the tagged snapshot resolves nominally without counting its
// transport-stripped methods as defects. Before the fix this printed
// the three production phantom union rows (anchor, color, margin).
function testForeignArraysKeepSingleRow() {
prepareRealmTower();
const foreign = vm.runInNewContext('({anchor: [0, 0, 1, 1], margin: [14, 12, 14, 12]})');
const tagged = tagSnapshot(realmLiveValue());
const value = {
anchor: foreign.anchor,
color: structuredClone(tagged.color),
margin: foreign.margin,
alignment: [0, 0],
};
const paths = realmFindingPaths(value);
return paths.length === 1 && paths[0] === 'wrong data.alignment';
}
// The normal structuredClone transport path stays single-row too.
function testClonedTransportKeepsSingleRow() {
prepareRealmTower();
const paths = realmFindingPaths(structuredClone(tagSnapshot(realmLiveValue())));
return paths.length === 1 && paths[0] === 'wrong data.alignment';
}
// A panel registry without the engine classes still resolves the tagged
// snapshot by tag equality (the tag is the checker's nominal witness),
// so only the alignment row survives the missing registry as well.
function testMissingClassesKeepSingleRow() {
prepareRealmTower();
// Tagging happens checking-side with the full registry; the panel only
// ever sees the tagged snapshot, so tag before wiping the classes.
const transported = structuredClone(tagSnapshot(realmLiveValue()));
const savedClasses = {...classes};
for (const key of Object.keys(classes)) {
delete classes[key];
}
bumpTypedefVersion();
let paths;
try {
paths = realmFindingPaths(transported);
} finally {
Object.assign(classes, savedClasses);
bumpTypedefVersion();
}
return paths.length === 1 && paths[0] === 'wrong data.alignment';
}
// A tagged snapshot with mistyped data still diffs clean inside a union:
// the checker accepts it nominally (the live instance satisfied the
// class), so any row would contradict the verdict. Only the union
// tag-equality shortcut grants this: revert it and the differ falls
// through to the structural walk, which flags the mistyped prop. The
// classes stay wiped so `snapshotMatches` cannot cover for the shortcut.
function testUnionTagMatchSkipsDataCheck() {
prepareRealmTower();
const transported = structuredClone(tagSnapshot(realmLiveValue()));
transported.color.r = 'hot';
const savedClasses = {...classes};
for (const key of Object.keys(classes)) {
delete classes[key];
}
bumpTypedefVersion();
let paths;
try {
paths = realmFindingPaths(transported);
} finally {
Object.assign(classes, savedClasses);
bumpTypedefVersion();
}
return paths.length === 1 && paths[0] === 'wrong data.alignment';
}
// A direct class expect materialized into a shape still exempts the
// transport-stripped methods by tag equality, with no union involved:
// the bare-string top-of-function shortcut never fires (materialization
// changed the node), so only the object-branch nominal match cleans it.
// Revert that line and the missing methods report as defects.
function testDirectShapeExemptsMethodsByTag() {
prepareRealmTower();
const transported = structuredClone(tagSnapshot({color: new RealmColor()}));
const savedClasses = {...classes};
for (const key of Object.keys(classes)) {
delete classes[key];
}
bumpTypedefVersion();
let findings;
try {
findings = explainMismatch(transported.color, 'RealmColor', 'data').findings;
} finally {
Object.assign(classes, savedClasses);
bumpTypedefVersion();
}
return Array.isArray(findings) && findings.length === 0;
}
// Transparent wrappers unwrap to the nominal name they carry; anything
// that changes the runtime shape (Partial, bare strings pass through,
// non-wrappers) is not unwrapped.
function testUnwrapTransparent() {
const readonlyColor = unwrapTransparent(expandType('Readonly<RealmColor>'));
const nested = unwrapTransparent(expandType('NonNullable<NoInfer<RealmColor>>'));
const bare = unwrapTransparent('RealmColor');
const partial = unwrapTransparent(expandType('Partial<RealmColor>'));
const plain = unwrapTransparent(expandType('{a: number}'));
const empty = unwrapTransparent({type: 'reference', name: 'Readonly', args: []});
return readonlyColor === 'RealmColor' && nested === 'RealmColor' &&
bare === 'RealmColor' &&
partial === undefined && plain === undefined && empty === undefined;
}
export const tests = [
testUnwrapTransparent,
testForeignArraysKeepSingleRow,
testClonedTransportKeepsSingleRow,
testMissingClassesKeepSingleRow,
testUnionTagMatchSkipsDataCheck,
testDirectShapeExemptsMethodsByTag,
];
Loading
Loading