Extend static three.js support to raycasting

- Compile mesh, line, and point picking with perspective and orthographic camera rays.
- Preserve JavaScript override arguments, array identity, and native subclass values across checked calls.
- Specialize package class helpers and handle unsupported JSDoc namepaths.
This commit is contained in:
Chris Tate
2026-09-30 00:08:54 -05:00
parent 05021faf2f
commit 9b99e1fcf5
27 changed files with 461 additions and 35 deletions
+6 -2
View File
@@ -67,9 +67,13 @@ const results = await Promise.all(jobs); // number[] — compiles
**The `any`/`unknown` boundary**
- `any` without `--dynamic` is generally a compile error (SC2011) — use `unknown` and a checked cast, or opt into the engine. Native Map and Set type arguments may use `any`; their slots use the same checked values as `unknown` without embedding an engine.
- `any` and `unknown` ride locals, parameters, and returns; record and tuple fields hold `unknown`. JavaScript instance fields can also hold native checked values without a JavaScript engine: bare fields start as `undefined`, and redeclaring a field with the same checked-value type resets it in initialization order. Explicit TypeScript `any`/`unknown` class fields, static checked-value fields, statically typed array elements, and union arms remain unsupported in static builds. Program class instances can pass through `unknown` and recover at their exact static class type without losing identity, including classes with internal collections. Dynamic property inspection of a class whose own fields cannot cross the checked-value boundary throws a catchable `TypeError`. Supported native handles also retain identity; JSON-shaped records and arrays retain the copy boundary described below.
- `any` and `unknown` ride locals, parameters, and returns; record and tuple fields hold `unknown`. JavaScript instance fields can also hold native checked values without a JavaScript engine: bare fields start as `undefined`, and redeclaring a field with the same checked-value type resets it in initialization order. Explicit TypeScript `any`/`unknown` class fields, static checked-value fields, statically typed array elements, and union arms remain unsupported in static builds. Program class instances can pass through `unknown` and recover at their exact static class type or a base class without losing identity, including classes with internal collections. Dynamic property inspection of a class whose own fields cannot cross the checked-value boundary throws a catchable `TypeError`. Supported native handles also retain identity. JavaScript argument and return conversions preserve native array references; other JSON-shaped record and array conversions retain the copy boundary described below.
- Operations on `unknown` beyond the supported surface (truthiness, `typeof` narrowing, property access, `+`, `switch`, `throw`) need a checked cast first.
**Three.js**
With `--npm-static=three`, published three.js modules support CPU math, geometry, scene graphs, materials, and a subset of raycasting in native and WASI builds without a JavaScript engine. Raycasting covers mesh intersections, sorted results, recursive traversal, layers, distance limits, perspective and orthographic camera rays, lines, and points. Mesh attribute interpolation can still fail when three.js reads components beyond the end of a vertex attribute; numeric fields cannot retain the resulting `undefined`. WebGL rendering, browser DOM integration, and native graphics windows remain unsupported.
**Type surface**
scriptc typechecks your program in its own type world: the standard `es2025` lib plus its own ambient declarations. In enumerated places those declarations are deliberately different from stock lib or `@types/node`, typed as what actually compiles — `JSON.parse` returns `unknown` (not `any`), and the Promise executor's reject reason is pinned to `Error`. So "programs that typecheck" means scriptc's type world, and a program clean under its own `tsc` can still be redirected here — but never with a bare type error it can't reproduce: the tightened declarations get a second-chance preflight (a program clean in the project's own type world passes, and the affected sites meet per-site diagnostics with rewrite hints instead), and where the shipped fallback declarations lack a lowering for a stock member (`console.table`, `console.time`, ...), the member is declared anyway so the call lands on SC2020 naming the supported alternative. `console.log`/`info`/`debug`/`error`/`warn` themselves take `unknown` arguments and render with Node's console semantics — strings verbatim, everything else through the static `util.inspect`.
@@ -90,7 +94,7 @@ A static-tier program otherwise produces byte-identical stdout and the same exit
**A lying cast on dynamic data throws instead of corrupting memory** — the headline divergence, and the point. `JSON.parse(s) as Config` with mismatched data throws a catchable error naming the offending path (`expected number at $.port, got string`) where JS would silently hand you garbage.
**Structural width subtyping copies.** A record flowing into a strict field-subset shape is copied, not aliased: mutations through the narrower reference are invisible to the original. Same stance at the dynamic boundary: values cross by copy, never by reference.
**Structural width subtyping copies.** A record flowing into a strict field-subset shape is copied, not aliased: mutations through the narrower reference are invisible to the original. Typed records and arrays generally cross the checked-value boundary as copies. JavaScript argument and return conversions preserve native array references, and class instances retain identity at their exact class type or a base class.
**`Object.keys`/`values`/`entries` and `JSON.stringify` report a record's declaration order**, not per-object insertion order. Identical to Node whenever objects are built in declaration order (the overwhelmingly common case).
+26 -10
View File
@@ -70,6 +70,7 @@ export const DYN_KIND = {
export interface DynHost extends WalkerHost {
unitInstanceRef(unionId: string, tag: number): string;
liveDynRefAdapter(t: IrType): { snapshot: string; commit: string };
classSubtypes(className: string): readonly string[];
}
const FN_ATTRS = "#0";
@@ -323,6 +324,29 @@ export class LlDyn {
/* ── dynMatchHelper (walkers.ts, ported) ──────────────────────── */
/** Native subclass capsules also satisfy a checked base-class slot.
* Class layouts share their base prefix, so unboxing retains the same
* pointer and virtual dispatch still uses the object's original vtable. */
private typedRefMatches(B: BlockBuilder, t: IrType): string {
this.host.declare(`declare zeroext i1 @scr_dyn_typed_ref_is(ptr, ptr, ${this.S})`);
const types: IrType[] = isDynTypedRefType(t)
? this.host.classSubtypes(t.className).map((className) => ({ kind: "object", className }))
: [t];
let matched = "false";
for (const type of types) {
const key = typeKey(type);
const next = B.tmp();
B.line(`${next} = call zeroext i1 @scr_dyn_typed_ref_is(ptr %d, ptr ${this.host.cstr(key)}, ${this.S} ${Buffer.byteLength(key, "utf8")})`);
if (matched === "false") matched = next;
else {
const either = B.tmp();
B.line(`${either} = or i1 ${matched}, ${next}`);
matched = either;
}
}
return matched;
}
/** `sc_dm_<n>(ptr d) -> i1` — does this dyn fit T? Never throws. */
dynMatchHelper(t: IrType): string {
const key = typeKey(t);
@@ -332,11 +356,7 @@ export class LlDyn {
this.dynMatchers.set(key, name);
const B = new BlockBuilder();
if (isRefCounted(t) && t.kind !== "dyn") {
this.host.declare(`declare zeroext i1 @scr_dyn_typed_ref_is(ptr, ptr, ${this.S})`);
const matched = B.tmp();
B.line(
`${matched} = call zeroext i1 @scr_dyn_typed_ref_is(ptr %d, ptr ${this.host.cstr(key)}, ${this.S} ${Buffer.byteLength(key, "utf8")})`,
);
const matched = this.typedRefMatches(B, t);
const lRef = B.newLabel("dm.tr");
const lNext = B.newLabel("dm.nt");
B.condBr(matched, lRef, lNext);
@@ -648,12 +668,8 @@ export class LlDyn {
return name;
}
if (isRefCounted(t) && t.kind !== "dyn") {
host.declare(`declare zeroext i1 @scr_dyn_typed_ref_is(ptr, ptr, ${host.sizeType})`);
host.declare(`declare ptr @scr_dyn_typed_ref_unbox(ptr)`);
const matched = B.tmp();
B.line(
`${matched} = call zeroext i1 @scr_dyn_typed_ref_is(ptr %d, ptr ${host.cstr(key)}, ${host.sizeType} ${Buffer.byteLength(key, "utf8")})`,
);
const matched = this.typedRefMatches(B, t);
const lRef = B.newLabel("dc.tr");
const lNext = B.newLabel("dc.nt");
B.condBr(matched, lRef, lNext);
@@ -393,6 +393,11 @@ export class LlEmitter {
cstr: (text) => this.cstr(text),
unitInstanceRef: (unionId, tag) => this.unitInstanceRef(unionId, tag),
liveDynRefAdapter: (type) => this.liveDynRefAdapter(type),
classSubtypes: (name) => {
const target = this.classMetaOf(name);
return [...this.classMeta.values()].filter((meta) =>
target.pre <= meta.pre && meta.pre <= target.post).map((meta) => meta.def.name);
},
};
this.walkers = new LlWalkers(this.shapeHost);
this.dyn = new LlDyn(this.shapeHost);
@@ -2148,11 +2148,14 @@ function runtimeOptionalHofGenericBinding(
/** The implicit-type-parameter slots of a JS function-like: parallel to
* decl.parameters, the param SYMBOL where the slot is a bindable
* implicit-any param (identifier-named, no annotation/JSDoc type, not
* rest/optional/defaulted, never written), null elsewhere. Null overall
* when nothing qualifies — the declaration keeps today's path. */
* implicit param (identifier-named, broad inferred/JSDoc type, not
* rest/optional/defaulted, never written), null elsewhere. Package JS
* methods may also specialize class parameters: JSDoc names a nominal
* class even when the body accepts other objects with the same members.
* Null overall when nothing qualifies — the declaration keeps its ABI. */
export function implicitAnyParamSymbolsOf(lowerer: Lowerer,
decl: ts.FunctionDeclaration | ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction,): (ts.Symbol | null)[] | null {
decl: ts.FunctionDeclaration | ts.MethodDeclaration | ts.FunctionExpression | ts.ArrowFunction,
classParams = false,): (ts.Symbol | null)[] | null {
if (!decl.body) return null;
if (decl.asteriskToken) return null;
if (decl.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)) return null;
@@ -2169,7 +2172,8 @@ function runtimeOptionalHofGenericBinding(
const broadFunction = lowerer.checker.typeToString(t) === "Function";
const broadArray = lowerer.checkerAnyArrayType(t);
const broadPromise = broadPromiseParam(lowerer, t);
if ((t.flags & ts.TypeFlags.Any) === 0 && !broadFunction && !broadArray && !broadPromise) return null;
const classParam = classParams && lowerer.mapTypeOf(t)?.kind === "object";
if ((t.flags & ts.TypeFlags.Any) === 0 && !broadFunction && !broadArray && !broadPromise && !classParam) return null;
const sym = lowerer.checker.getSymbolAtLocation(param.name);
if (!sym) return null;
if (paramWrittenInBody(lowerer, decl.body!, sym, param.name.text)) return null;
@@ -2186,6 +2190,11 @@ function runtimeOptionalHofGenericBinding(
* selects return INFERENCE with DYN as the recursion pin. */
function implicitDeclaredReturn(lowerer: Lowerer, info: GenericFnInfo): IrType | null {
try {
// Static JS helpers can accept a structurally compatible class despite
// narrower JSDoc (for example a Vector2 target documented as Vector3).
// Infer their result from the specialized body as well.
if (info.member?.kind === "static" && info.decl.parameters.some((param, index) =>
info.implicitParams?.[index] && lowerer.mapTypeOf(lowerer.typeOf(param.name))?.kind === "object")) return null;
const broadCallbackSymbols = new Set(
info.decl.parameters.flatMap((param, index) => {
const symbol = info.implicitParams?.[index];
@@ -2360,7 +2369,11 @@ function storedImplicitArgumentType(lowerer: Lowerer, arg: ts.Expression): IrTyp
const shapes: ParamShape[] = info.decl.parameters.map((param, i) =>
info.implicitParams![i] ? { type: DYN, mode: "required" as const } : lowerer.paramShape(param),
);
return internImplicitInstance(lowerer, blame, info, shapes, new Map());
const argTypes = new Map<ts.Symbol, ts.Type>();
for (const symbol of info.implicitParams ?? []) {
if (symbol) argTypes.set(symbol, lowerer.checker.getUnknownType());
}
return internImplicitInstance(lowerer, blame, info, shapes, argTypes);
}
function internImplicitInstance(lowerer: Lowerer, blame: ts.Node,
@@ -11106,7 +11119,10 @@ function lowerUnionObjectMethodCall(
function paramAbisEqual(left: readonly ParamShape[], right: readonly ParamShape[]): boolean {
return left.length === right.length && left.every((shape, i) => {
const other = right[i];
return other !== undefined && shape.mode === other.mode && typeEquals(shape.type, other.type);
return other !== undefined && typeEquals(shape.type, other.type) &&
(shape.mode === other.mode || shape.type.kind === "dyn" &&
(shape.mode === "required" || shape.mode === "omittable") &&
(other.mode === "required" || other.mode === "omittable"));
});
}
@@ -1395,6 +1395,15 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
lowerer.badType(member.name, lowerer.typeOf(member.name));
}
staticMethods.set(member.name.text, { params: shapes, ret: ft.ret, member });
if (implicitMonoFile(decl.getSourceFile()) && !functionLocalClass(decl) &&
inst === undefined && decl.typeParameters === undefined) {
const implicit = implicitAnyParamSymbolsOf(lowerer, member, true);
if (implicit) genericStatics.set(member.name.text, {
decl: member, baseName: member.name.text,
qualifiedName: `%${className}.static:${member.name.text}%implicit`,
typeParams: [], instances: new Map(), implicitParams: implicit,
});
}
}
// GENERIC static methods monomorphize like top-level generic
// functions (`%C.static:m%n`), async ones included — a generic
@@ -1879,7 +1888,7 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
!lowerer.findMethodOn(base, member.name.text) &&
!findGenericMethodOn(lowerer, base, member.name.text)
) {
const implicit = implicitAnyParamSymbolsOf(lowerer, member);
const implicit = implicitAnyParamSymbolsOf(lowerer, member, true);
if (implicit) {
genericMethods.set(member.name.text, {
decl: member,
@@ -1893,6 +1902,14 @@ export function collectClassShapeInner(lowerer: Lowerer, decl: ts.ClassLikeDecla
}
}
const { shapes, funcType: ft } = lowerer.lambdaSignature(member);
// JS subclasses can add parameters to a method. Reserve checked
// argument slots in the base before constructing its vtable; shorter
// bodies ignore them and omitted call arguments become undefined.
const virtualArity = lowerer.virtualJsMethodArity.get(member) ?? shapes.length;
if (virtualArity > shapes.length && !bodyReadsArguments(member) &&
shapes.every((p) => p.type.kind === "dyn" && (p.mode === "required" || p.mode === "omittable"))) {
while (shapes.length < virtualArity) shapes.push({ type: DYN, mode: "omittable" });
}
if (fields.has(mName)) {
lowerer.unsupported("SC1090", member.name, "methods shadowing inherited fields");
}
@@ -3643,6 +3660,12 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce
`calling the static member '${access.name.text}' through a class value (a subclass of '${info.def.name.replace(/^%|^%m\d+\./, "")}' redeclares it, so the runtime class decides which declaration answers)`,
);
}
const specialized = findGenericStaticOn(lowerer, info, access.name.text);
if (specialized?.declarer === found.declarer && specialized.info.implicitParams) {
const instance = implicitCallInstance(lowerer, call, specialized.info);
const args = lowerer.completeArgs(call.arguments, instance.params, loc, call);
return { kind: "call", callee: instance.name, args, type: instance.returnType, loc };
}
if (found.field !== undefined) {
// A func-typed static field in call position: read the global,
// call through the value (the ctor-assigned-callback pattern).
@@ -3832,15 +3855,14 @@ export function staticFieldWriteTarget(lowerer: Lowerer, access: ts.PropertyAcce
* collected subclass graph cannot answer this question yet. The checker
* hierarchy identifies the original declarations through aliases as well. */
export function collectVirtualJsMethods(lowerer: Lowerer, files: readonly ts.SourceFile[]): void {
if (!files.some(implicitMonoFile)) return;
if (!files.some(isJsSourceFile)) return;
const visit = (node: ts.Node): void => {
if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) {
const names = node.members.flatMap((member) =>
const members = node.members.filter((member): member is ts.MethodDeclaration =>
ts.isMethodDeclaration(member) && ts.isIdentifier(member.name) &&
!member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword)
? [member.name.text] : [],
!member.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.StaticKeyword),
);
if (node.heritageClauses?.length && names.length > 0) {
if (node.heritageClauses?.length && members.length > 0) {
const symbol = lowerer.typeOf(node).getSymbol();
const instance = symbol ? lowerer.checker.getDeclaredTypeOfSymbol(symbol) : null;
const seen = new Set<ts.Type>();
@@ -3849,11 +3871,19 @@ export function collectVirtualJsMethods(lowerer: Lowerer, files: readonly ts.Sou
if (target === undefined || !target.isClassOrInterface() || seen.has(target)) return;
seen.add(target);
for (const base of lowerer.checker.getBaseTypes(target)) {
for (const name of names) {
const property = lowerer.checker.getPropertyOfType(base, name);
for (const member of members) {
if (!ts.isIdentifier(member.name)) continue;
const property = lowerer.checker.getPropertyOfType(base, member.name.text);
for (const declaration of property ? lowerer.checker.declarationsOf(property) : []) {
if (ts.isMethodDeclaration(declaration) && implicitMonoFile(declaration.getSourceFile())) {
if (ts.isMethodDeclaration(declaration) && isJsSourceFile(declaration.getSourceFile())) {
lowerer.virtualJsMethods.add(declaration);
if (isJsSourceFile(member.getSourceFile()) && !member.typeParameters &&
!member.asteriskToken && !member.modifiers?.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword) &&
!bodyReadsArguments(member) &&
member.parameters.every((p) => !p.initializer && !p.dotDotDotToken)) {
lowerer.virtualJsMethodArity.set(declaration,
Math.max(lowerer.virtualJsMethodArity.get(declaration) ?? 0, member.parameters.length));
}
}
}
}
@@ -2746,7 +2746,14 @@ export function tryLowerNumericIndexRead(lowerer: Lowerer, operand: IrExpr, loc:
}
const receiver = lowerer.lowerExpr(access.expression);
const argNode = call.arguments[0]!;
const fnArg = lowerer.lowerExpr(argNode);
let fnArg = lowerer.lowerExpr(argNode);
// Reusable JS comparators often accept checked values. Adapt the array's
// element ABI just as other array callbacks do before sorting.
if (fnArg.type.kind === "func" && fnArg.type.params.length <= 2 &&
fnArg.type.params.every((param) => lowerer.coercibleValue(elem, param))) {
const expected = funcOf(fnArg.type.params.map(() => elem), fnArg.type.ret);
if (!typeEquals(fnArg.type, expected)) fnArg = lowerer.coerceToExpected(fnArg, expected);
}
// The comparator receives exactly (a, b); declaring a prefix is
// ordinary TS. Its result must be number (the spec coerces arbitrary
// results — no lowering for that).
@@ -1933,6 +1933,11 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
expr,
);
}
if (expr.name.text === "length" && (recv.type.kind === "array" || recv.type.kind === "string")) {
return recv.type.kind === "array"
? { kind: "arrIntrinsic", method: "length", receiver: recv, args: [], type: F64, loc }
: { kind: "strIntrinsic", method: "length", receiver: recv, args: [], type: F64, loc };
}
}
// `f.name` / `f.length` / own properties on a FUNCTION-typed JS
// value (the mustCall wrapper's function-instance members): read
@@ -1655,6 +1655,9 @@ export class Lowerer {
/** Inferred JS methods participating in an override chain keep a vtable
* ABI instead of call-site specialization. Filled before class collection. */
readonly virtualJsMethods = new Set<ts.MethodDeclaration>();
/** Largest fixed argument list below a JS method, discovered before any
* base vtable signature is collected. Unused checked slots carry undefined. */
readonly virtualJsMethodArity = new Map<ts.MethodDeclaration, number>();
/** The class whose members are lowering — `super` binds lexically to it
* (arrows inside methods lower within this window, so they see it too). */
currentClass: ClassInfo | null = null;
@@ -5333,6 +5336,12 @@ export class Lowerer {
return { kind: "promiseVoidWiden", value: expr, type: expected, loc: expr.loc };
}
if (expected.kind === "dyn" && expr.type.kind !== "dyn") {
// Native void calls still have a JavaScript value: undefined. Keep
// the call's effects before exposing that value to checked JS code.
if (expr.type.kind === "void") {
return { kind: "seqExpr", stmts: [{ kind: "exprStmt", expr, loc: expr.loc }],
result: dynUndefinedExpr(expr.loc), type: DYN, loc: expr.loc };
}
// An error-HIERARCHY object (builtin subclass or user `extends
// Error` class) upcasts to the %Error root first — the caughtToDyn
// encoding (scr_dyn_from_error) carries name/message/code and the
@@ -7838,6 +7847,11 @@ export class Lowerer {
}
}
let e = this.coerceToExpected(expr, expected);
// JavaScript checked slots retain native arrays by reference. Calls,
// returns and argument packs must all share mutations and identity.
if (isJsSourceFile(node.getSourceFile()) && e.kind === "dynFrom" && e.value.type.kind === "array") {
e = { ...e, liveRef: true };
}
// An 'any' value PROVABLY null/undefined (the unit literal itself, or
// a read of a binding nothing ever assigns a non-unit value) flowing
// implicitly into a primitive slot: the validated exit refuses units
@@ -45,6 +45,47 @@ export interface NpmStaticOverloadRewrite {
insertions: readonly { offset: number; length: number }[];
}
/** TypeScript truncates JSDoc inner/instance namepaths (`Owner~Item`,
* `Owner#Item`) to Owner. That unrelated nominal type must not dictate a
* native layout. Keep the unresolved atom checked, preserving containers,
* string literal types, executable source and every diagnostic offset. */
export function applyNpmStaticJsDocNamepaths(sourceFile: ts.SourceFile, source: string): string | null {
const replacements = new Map<number, number>();
const visit = (node: ts.Node): void => {
for (const doc of node.jsDoc ?? []) for (const tag of doc.tags ?? []) {
const expression = tag.typeExpression;
if (expression?.kind !== ts.SyntaxKind.JSDocTypeExpression) continue;
const start = expression.getStart(sourceFile);
if (source[start] !== "{") continue;
let depth = 1;
let quote = "";
for (let i = start + 1; i < tag.getEnd() && depth > 0; i++) {
const char = source[i]!;
if (quote !== "") {
if (char === "\\") i++;
else if (char === quote) quote = "";
continue;
}
if (char === '"' || char === "'" || char === "`") { quote = char; continue; }
if (char === "{") { depth++; continue; }
if (char === "}") { depth--; continue; }
const name = /^[$A-Z_a-z][$\w]*(?:[.#~][$A-Z_a-z][$\w]*)*/.exec(source.slice(i, tag.getEnd()))?.[0];
if (!name) continue;
if (name.includes("~") || name.includes("#")) replacements.set(i, name.length);
i += name.length - 1;
}
}
ts.forEachChild(node, visit);
};
visit(sourceFile);
if (replacements.size === 0) return null;
let text = source;
for (const [offset, length] of replacements) {
text = text.slice(0, offset) + "*" + " ".repeat(length - 1) + text.slice(offset + length);
}
return text;
}
/** Recover nullable class storage erased by a JavaScript bundle. A null
* field whose writes construct one named class or call one static factory (or reset to null)
* keeps that class's native methods instead of an opaque checked value.
@@ -3,6 +3,7 @@ import {
applyNpmStaticDeclarationProperties,
applyNpmStaticDeclarationOverloads,
applyNpmStaticFindReturnWidening,
applyNpmStaticJsDocNamepaths,
applyNpmStaticNullableClassFields,
npmStaticDeclarationReexports,
npmStaticRuntimeClassTargets,
@@ -10,6 +11,34 @@ import {
parseNpmStaticDeclarationOverloads,
} from "./npm-static-declarations.js";
describe("npm-static JSDoc namepaths", () => {
test("keeps unsupported nominal names checked without changing source offsets", () => {
const source = `class Owner {
/** @param {Array<Owner~Item>} [items=[]] @returns {Owner#Result | null} */
collect(items = []) { return items; }
}`;
const text = applyNpmStaticJsDocNamepaths("index.js", source);
expect(text).toBe(source.replace("Owner~Item", "* ").replace("Owner#Result", "* "));
expect(text?.length).toBe(source.length);
expect(applyNpmStaticJsDocNamepaths("index.js", text!)).toBeNull();
});
test("leaves strings, prose, runtime code and valid types alone", () => {
const source = `const text = "/** @type {Owner~Item} */";
// @type {Owner~Item}
/** See Owner~Item. @param {'Owner~Item' | "Owner#Item" | Owner.Item} value */
function keep(value) { return "Owner~Item"; }`;
expect(applyNpmStaticJsDocNamepaths("index.js", source)).toBeNull();
});
test("handles nested record types and repeated names", () => {
const source = `/** @type {{ first: Outer.Inner~Value[], second: Outer.Inner~Value, literal: 'a}~b' }} */
const values = {};`;
const text = applyNpmStaticJsDocNamepaths("index.js", source);
expect(text).toBe(source.replaceAll("Outer.Inner~Value", "* "));
});
});
const declarations = `
export class Chainy {
name(): string;
@@ -13,6 +13,10 @@ export function applyNpmStaticFindReturnWidening(path: string, source: string):
return syntax.applyNpmStaticFindReturnWidening(nodeFrontendServices().parse(path, source, "js"), source);
}
export function applyNpmStaticJsDocNamepaths(path: string, source: string): string | null {
return syntax.applyNpmStaticJsDocNamepaths(nodeFrontendServices().parse(path, source, "js"), source);
}
export function parseNpmStaticDeclarationOverloads(path: string, source: string): NpmStaticDeclarationOverloads {
return syntax.parseNpmStaticDeclarationOverloads(nodeFrontendServices().parse(path, source, "ts"));
}
+5 -3
View File
@@ -379,8 +379,10 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
if (hit !== undefined) return hit ?? undefined;
let rewritten: string | null = null;
try {
const source = trackedReadFile(path);
if (source !== null) {
const original = trackedReadFile(path);
if (original !== null) {
const namepaths = services.jsDocNamepaths(path, original);
const source = namepaths ?? original;
const classFields = services.nullableClassFields(path, source);
const findWidened = services.findReturnWidening(path, classFields?.text ?? source);
const propertyProjected = services.declarationProperties(
@@ -397,7 +399,7 @@ export function npmStaticFsShadow(services: FrontendServices): NpmStaticFsShadow
if (answer !== null && typeof answer === "object") {
reportNpmStaticOffender(target.pkg, answer.degrade);
} else {
rewritten = answer ?? projected?.text ?? propertyProjected?.text ?? findWidened?.text ?? classFields?.text ?? null;
rewritten = answer ?? projected?.text ?? propertyProjected?.text ?? findWidened?.text ?? classFields?.text ?? namepaths;
}
}
} catch {
@@ -63,6 +63,10 @@ export class FrontendServices {
nullableClassFields(path: string, source: string): syntax.NpmStaticOverloadRewrite | null {
return syntax.applyNpmStaticNullableClassFields(this.parse(path, source, "js"), source);
}
jsDocNamepaths(path: string, source: string): string | null {
if (!source.includes("~") && !source.includes("#")) return null;
return syntax.applyNpmStaticJsDocNamepaths(this.parse(path, source, "js"), source);
}
findReturnWidening(path: string, source: string): syntax.NpmStaticOverloadRewrite | null {
return syntax.applyNpmStaticFindReturnWidening(this.parse(path, source, "js"), source);
}
@@ -10,12 +10,15 @@ test("checked class arrays reject unbranded and differently branded elements", a
const entry = join(dir, "main.ts");
writeFileSync(entry, `
class Item { value = 1; }
class Child extends Item { extra = 2; }
class Sibling extends Item { extra = 3; }
class Other { value = 1; }
function check(input: unknown): void {
try { const items = input as Item[]; console.log('accepted', items.length); }
catch (error) { console.log('rejected', error instanceof TypeError); }
}
check([new Item()]);
check([new Child()]);
check([new Other()]);
check([{value: 1}]);
const mixed: unknown[] = [];
@@ -24,6 +27,14 @@ check(mixed);
check(null);
check([1]);
check([]);
function checkChild(input: unknown): void {
try { const child = input as Child; console.log('child', child.extra); }
catch (error) { console.log('wrong child', error instanceof TypeError); }
}
checkChild(new Child());
checkChild(new Item());
checkChild(new Sibling());
checkChild({ value: 1, extra: 2 });
`);
const result = await compile(entry, {
dynamic: false, outDir: dir, outPath: join(dir, "program"),
@@ -34,7 +45,7 @@ check([]);
expect(child.error).toBeUndefined();
expect({status: child.status, signal: child.signal, stdout: child.stdout, stderr: child.stderr}).toEqual({
status: 0, signal: null, stderr: "",
stdout: "accepted 1\nrejected true\nrejected true\nrejected true\nrejected true\nrejected true\naccepted 0\n",
stdout: "accepted 1\naccepted 1\nrejected true\nrejected true\nrejected true\nrejected true\nrejected true\naccepted 0\nchild 2\nwrong child true\nwrong child true\nwrong child true\n",
});
} finally {
rmSync(dir, {recursive: true, force: true});
@@ -11859,6 +11859,30 @@
"<repo>/tests/corpus/native-class-type-predicates.ts"
],
"diags": []
},
"<repo>/tests/corpus/array-sort-unknown-comparator.ts": {
"order": [
"<repo>/tests/corpus/array-sort-unknown-comparator.ts"
],
"diags": []
},
"<repo>/tests/corpus/class-checked-upcast.ts": {
"order": [
"<repo>/tests/corpus/class-checked-upcast.ts"
],
"diags": []
},
"<repo>/tests/corpus/js-checked-array-reference.js": {
"order": [
"<repo>/tests/corpus/js-checked-array-reference.js"
],
"diags": []
},
"<repo>/tests/corpus/js-class-added-parameters.js": {
"order": [
"<repo>/tests/corpus/js-class-added-parameters.js"
],
"diags": []
}
}
}
@@ -0,0 +1,16 @@
class Hit {
distance: number;
label: string;
constructor(distance: number, label: string) { this.distance = distance; this.label = label; }
}
function compare(a: unknown, b: unknown): number {
return (a as Hit).distance - (b as Hit).distance;
}
const first = new Hit(3, 'far'), second = new Hit(1, 'near'), third = new Hit(1, 'tie');
const values = [first, second, third];
console.log('identity', values.sort(compare) === values);
for (const item of values) console.log(item.label, item.distance);
const copy = values.toSorted(compare);
console.log('copy', copy !== values, copy[0] === second);
function numeric(a: unknown, b: unknown): number { return Number(a) - Number(b); }
console.log([9, 1, 3].sort(numeric).join(','));
+24
View File
@@ -0,0 +1,24 @@
class Base {
value = 2;
read(): number { return this.value; }
}
class Middle extends Base {}
class Derived extends Middle {
extra = 3;
read(): number { return this.value + this.extra; }
}
class Unrelated { value = 8; }
const derived = new Derived();
const boxed: unknown = derived;
const base = boxed as Base;
console.log('base', base === derived, base instanceof Derived, base.read());
base.value = 7;
console.log('shared', derived.value, base.read());
const array: unknown = [derived];
const bases = array as Base[];
console.log('array', bases[0] === derived, bases[0]!.read());
const candidate: unknown = derived;
const optional = candidate as Base | undefined;
console.log('union', optional === derived, optional!.read());
const unrelated: unknown = new Unrelated();
console.log('unrelated', unrelated instanceof Base);
@@ -0,0 +1,20 @@
function append(values) { values.push(3); return values; }
function pair(first, second) { console.log('same', first === second); first.push(4); }
function variadic(...values) { values[0].push(5); return values[0]; }
function fromArguments() { arguments[0].push(6); return arguments[0]; }
const values = [1, 2];
console.log('call', append(values) === values, values.join(','));
pair(values, values);
console.log('pair', values.join(','));
console.log('rest', variadic(values) === values, values.join(','));
console.log('arguments', fromArguments(values) === values, values.join(','));
function wrap() { return values; }
console.log('return', wrap() === values);
const object = { values };
object.values.push(7);
console.log('property', object.values === values, values.join(','));
class Empty { visit() {} }
class Collector extends Empty { visit(target) { target.push(8); } }
function dispatch(object, target) { return object.visit(target); }
const result = dispatch(new Collector(), values);
console.log('void', result === undefined, values.join(','));
+22
View File
@@ -0,0 +1,22 @@
class Base {
visit() { console.log("base"); }
dispatch(first, second, third) { this.visit(first, second, third); }
}
class Pair extends Base {
visit(first, second) { console.log("pair", first, second); }
}
class Triple extends Pair {
visit(first, second, third) { console.log("triple", first, second, third); }
parent(first, second) { super.visit(first, second); }
}
class Short extends Base {
visit(first) { console.log("short", first); }
}
function value(n) { console.log("arg", n); return n; }
for (const object of [new Base(), new Pair(), new Triple(), new Short()]) {
object.dispatch(value(1), value(2), value(3));
object.visit();
}
new Triple().parent(4, 5);
const method = new Pair().visit;
method(6, 7);
+1
View File
@@ -1 +1,2 @@
export function exercise(): void;
export function structuralMethods(): void;
+33
View File
@@ -50,3 +50,36 @@ export function exercise() {
new Short().defaulted("direct");
arityCounts();
}
class Pair {
constructor(x, y) { this.x = x; this.y = y; }
/** @param {Pair} other */
add(other) { this.x += other.x; this.y += other.y; return this; }
}
class Triple {
constructor(x, y, z) { this.x = x; this.y = y; this.z = z; }
}
class Helpers {
/** @param {Pair} target @param {Pair} other @returns {Pair} */
static combine(target, other) { target.x += other.x; target.y += other.y; return target; }
}
class InheritedHelpers extends Helpers {}
class OverriddenHelpers extends Helpers {
static combine(target, other) { target.x -= other.x; target.y -= other.y; return target; }
}
export function structuralMethods() {
const pair = new Pair(1, 2);
const triple = new Triple(3, 4, 5);
console.log('instance', pair.add(triple) === pair, pair.x, pair.y);
console.log('static', Helpers.combine(triple, pair) === triple, triple.x, triple.y, triple.z);
console.log('inherited', InheritedHelpers.combine(triple, pair) === triple, triple.x);
console.log('overridden', OverriddenHelpers.combine(triple, pair) === triple, triple.x);
const combine = Helpers.combine;
console.log('static value', combine(pair, pair) === pair, pair.x, pair.y);
const add = pair.add;
try { add(pair); } catch (error) { console.log('unbound', error instanceof TypeError); }
const rows = [triple, new Triple(1, 0, 0)];
function compare(a, b) { return a.x - b.x; }
console.log('sort', rows.sort(compare) === rows, rows[0].x, rows[1].x);
}
+2 -1
View File
@@ -1,2 +1,3 @@
import { exercise } from "virtual-classes";
import { exercise, structuralMethods } from "virtual-classes";
exercise();
structuralMethods();
@@ -33,6 +33,8 @@ export function runFrontendServices(services: FrontendServices, input: string, o
const properties = parseNpmStaticDeclarationProperties(declarationFile);
const nullable = services.nullableClassFields("package.js", request.source);
const widened = services.findReturnWidening("package.js", nullable?.text ?? request.source);
const namepaths = services.jsDocNamepaths("package.js", "/** @returns {Array<Box~Item>} */ function items() { return []; }");
check(namepaths !== null && namepaths.includes("Array<*"), "checked JSDoc namepaths");
const fields = services.declarationProperties("package.js", widened?.text ?? nullable?.text ?? request.source, properties);
const overloads = services.declarationOverloads("package.js", fields?.text ?? widened?.text ?? nullable?.text ?? request.source, methods);
const rewritten = services.rewriteCjs(request.bundled, join(request.directory, "bundle.js"));
+18
View File
@@ -0,0 +1,18 @@
import { Raycaster } from 'three/src/core/Raycaster.js';
import { Vector2 } from 'three/src/math/Vector2.js';
import { Vector3 } from 'three/src/math/Vector3.js';
import { PerspectiveCamera } from 'three/src/cameras/PerspectiveCamera.js';
import { OrthographicCamera } from 'three/src/cameras/OrthographicCamera.js';
const ray = new Raycaster();
const p = new PerspectiveCamera(90, 1, 0.1, 100);
p.position.set(0,0,5); p.updateMatrixWorld();
ray.setFromCamera(new Vector2(0,0),p);
console.log('perspective', ray.ray.origin.z, ray.ray.direction.x, ray.ray.direction.y, ray.ray.direction.z, ray.camera === p);
ray.setFromCamera(new Vector2(0.5,0.5),p);
console.log('offset', ray.ray.direction.x.toFixed(4), ray.ray.direction.y.toFixed(4), ray.ray.direction.z.toFixed(4));
const o = new OrthographicCamera(-2,2,2,-2,0.1,100);
o.position.set(0,0,5); o.updateMatrixWorld();
ray.setFromCamera(new Vector2(0.5,0.5),o);
console.log('orthographic', ray.ray.origin.x.toFixed(4), ray.ray.origin.y.toFixed(4), ray.ray.origin.z.toFixed(4), ray.ray.direction.z, ray.camera === o);
ray.set(new Vector3(1,2,3),new Vector3(0,1,0));
console.log('set', ray.ray.origin.x, ray.ray.origin.y, ray.ray.origin.z, ray.ray.direction.y);
+28
View File
@@ -0,0 +1,28 @@
import { Line } from 'three/src/objects/Line.js';
import { LineSegments } from 'three/src/objects/LineSegments.js';
import { LineLoop } from 'three/src/objects/LineLoop.js';
import { Points } from 'three/src/objects/Points.js';
import { BufferGeometry } from 'three/src/core/BufferGeometry.js';
import { Float32BufferAttribute } from 'three/src/core/BufferAttribute.js';
import { LineBasicMaterial } from 'three/src/materials/LineBasicMaterial.js';
import { PointsMaterial } from 'three/src/materials/PointsMaterial.js';
import { Raycaster } from 'three/src/core/Raycaster.js';
import { Vector3 } from 'three/src/math/Vector3.js';
const geometry = new BufferGeometry();
geometry.setAttribute('position',new Float32BufferAttribute([-1,0,-5,1,0,-5,1,2,-5,-1,2,-5],3));
const ray = new Raycaster(new Vector3(0,0.1,0),new Vector3(0,0,-1));
ray.params.Line.threshold = 0.2;
for (const line of [new Line(geometry,new LineBasicMaterial()), new LineSegments(geometry,new LineBasicMaterial()), new LineLoop(geometry,new LineBasicMaterial())]) {
line.updateMatrixWorld();
const hit = ray.intersectObject(line);
console.log('line', line.type, hit.length, hit[0].distance, hit[0].index, hit[0].point.x, hit[0].point.y, hit[0].object === line);
}
const pg = new BufferGeometry(); pg.setAttribute('position',new Float32BufferAttribute([0,0,-5,1,0,-5,0,0,-3],3));
const points = new Points(pg,new PointsMaterial()); points.updateMatrixWorld();
ray.params.Points.threshold = 0.2;
const hits = ray.intersectObject(points);
console.log('points', hits.length, hits[0].distance, hits[1].distance, Number(hits[0].distanceToRay).toFixed(4), hits[0].index, hits[0].object === points);
ray.params.Points.threshold = 0.05;
console.log('threshold', ray.intersectObject(points).length);
pg.setIndex([0,1]); ray.params.Points.threshold = 0.2;
console.log('indexed', ray.intersectObject(points).length);
+49
View File
@@ -0,0 +1,49 @@
import { Mesh } from 'three/src/objects/Mesh.js';
import { BoxGeometry } from 'three/src/geometries/BoxGeometry.js';
import { BufferGeometry } from 'three/src/core/BufferGeometry.js';
import { Float32BufferAttribute } from 'three/src/core/BufferAttribute.js';
import { MeshBasicMaterial } from 'three/src/materials/MeshBasicMaterial.js';
import { Raycaster } from 'three/src/core/Raycaster.js';
import { Vector3 } from 'three/src/math/Vector3.js';
import { Scene } from 'three/src/scenes/Scene.js';
import { FrontSide, BackSide, DoubleSide } from 'three/src/constants.js';
const material = new MeshBasicMaterial();
const near = new Mesh(new BoxGeometry(2, 2, 2), material);
near.position.z = -5; near.name = 'near';
const far = new Mesh(new BoxGeometry(2, 2, 2), material);
far.position.z = -10; far.name = 'far';
const scene = new Scene(); scene.add(far, near); scene.updateMatrixWorld(true);
const ray = new Raycaster(new Vector3(0.25,0.5,0),new Vector3(0,0,-1));
const hits = ray.intersectObjects([far, near]);
console.log('sorted', hits.length, hits[0].distance, hits[1].distance, hits[0].object === near);
const hit = hits[0];
console.log('hit', hit.point.x, hit.point.y, hit.point.z, hit.normal.z, hit.uv.x, hit.uv.y, hit.faceIndex, hit.face.normal.z, hit.barycoord.x + hit.barycoord.y + hit.barycoord.z);
const target = [];
console.log('target', ray.intersectObject(near, false, target) === target, target.length);
ray.intersectObject(far, false, target);
console.log('append', target.length, target[0].distance, target[1].distance);
target.length = 0;
console.log('clear', ray.intersectObject(scene, true, target) === target, target.length);
console.log('shallow', ray.intersectObject(scene, false).length);
ray.near = 5; console.log('near', ray.intersectObject(scene).length);
ray.near = 0; ray.far = 5; console.log('far', ray.intersectObject(scene).length);
ray.far = Infinity; near.layers.set(1);
console.log('layers', ray.intersectObject(scene).length);
ray.layers.enable(1); console.log('enabled', ray.intersectObject(scene).length);
near.layers.set(0); near.geometry.computeBoundingBox();
ray.set(new Vector3(3,0,0),new Vector3(0,0,-1)); console.log('miss', ray.intersectObject(near).length);
ray.set(new Vector3(0.25,0.5,0),new Vector3(0,0,-1));
material.side = BackSide; console.log('back', ray.intersectObject(near)[0].distance);
material.side = DoubleSide; console.log('double', ray.intersectObject(near).length);
const triangle = new BufferGeometry();
triangle.setAttribute('position', new Float32BufferAttribute([-1,-1,-3,1,-1,-3,0,1,-3],3));
material.side = FrontSide;
const tri = new Mesh(triangle, material); tri.updateMatrixWorld();
const t = ray.intersectObject(tri)[0];
console.log('triangle', t.distance, t.faceIndex, t.face.normal.z, t.uv === undefined, t.normal === undefined);
triangle.setDrawRange(0,0); console.log('drawrange', ray.intersectObject(tri).length);
triangle.setDrawRange(0,3); triangle.addGroup(0,3,1);
const multi = new Mesh(triangle,[new MeshBasicMaterial({side: BackSide}),material]); multi.updateMatrixWorld();
console.log('group', ray.intersectObject(multi)[0].face.materialIndex);
triangle.setIndex([0,1,2]);
console.log('indexed-group', ray.intersectObject(multi)[0].face.materialIndex);
+1 -1
View File
@@ -21,7 +21,7 @@ function run(command: string, args: string[]) {
return { stdout: result.stdout, stderr: result.stderr, status: result.status, signal: result.signal };
}
const cases = ["math", "spatial", "attributes", "geometry", "scene", "materials", "mesh"].flatMap((fixture) =>
const cases = ["math", "spatial", "attributes", "geometry", "scene", "materials", "mesh", "raycast-mesh", "raycast-camera", "raycast-lines-points"].flatMap((fixture) =>
["native", "wasm32-wasi"].map((target) => ({ fixture, target })));
test.for(cases)("published three.js $fixture runs statically through LLVM on $target", async ({ fixture, target }, context) => {
const entry = join(import.meta.dirname, `../fixtures/three/${fixture}.mjs`);