Keep package results that pass their argument through out of the declaration boundary

A package function declared to return its own type parameter, which also
types one of its parameters (<T extends object>(obj: T) => T), hands back
the argument or something standing in for it, and the package may track
that object's identity. Effect's Equal.byReference returns a proxy it
keeps in a WeakSet. The declaration-typed boundary cast such a result to
the argument's type and built a separate native record, so
Equal.equals(a, b) on two by-reference objects compared them by value and
printed true where Node prints false. The result's type comes from the
caller's argument, not from data the package declares, so the projection
no longer casts these calls.

The package re-export coverage test now expects three dynamic statements:
since the boundary, readRows's declared number result keeps its type, so
count - 1 in 7004-package-declared-type-reexports is native arithmetic.
This commit is contained in:
Chris Tate
2026-10-23 02:23:33 -05:00
parent 94c00200ff
commit 358d2df536
8 changed files with 127 additions and 12 deletions
@@ -99,6 +99,70 @@ function declaredInPackage(symbol: ts.Symbol | undefined): boolean {
return symbol.declarations.some((d) => isPackageDeclarationFile(ts.getSourceFileOfNode(d)));
}
/** The parts of a call signature's declaration that pass-through detection
* reads, for the declaration kinds a resolved call signature has. */
function signatureParts(node: ts.Node | undefined): {
typeParameters: readonly ts.TypeParameterDeclaration[];
parameters: readonly ts.ParameterDeclaration[];
type: ts.TypeNode | undefined;
} | null {
if (node === undefined) return null;
if (ts.isFunctionTypeNode(node))
return {
typeParameters: node.typeParameters?.nodes ?? [],
parameters: node.parameters.nodes,
type: node.type,
};
if (ts.isFunctionDeclaration(node))
return {
typeParameters: node.typeParameters?.nodes ?? [],
parameters: node.parameters.nodes,
type: node.type,
};
if (ts.isMethodDeclaration(node))
return {
typeParameters: node.typeParameters?.nodes ?? [],
parameters: node.parameters.nodes,
type: node.type,
};
if (ts.isMethodSignatureDeclaration(node))
return {
typeParameters: node.typeParameters?.nodes ?? [],
parameters: node.parameters.nodes,
type: node.type,
};
if (ts.isCallSignatureDeclaration(node))
return {
typeParameters: node.typeParameters?.nodes ?? [],
parameters: node.parameters.nodes,
type: node.type,
};
return null;
}
/** A signature that returns one of its own type parameters which also types
* a parameter (`<T extends object>(obj: T) => T`) hands back the argument
* or something standing in for it, a proxy or a marked object, whose
* identity the package may track (Effect's `Equal.byReference`). Its result
* type comes from the caller's argument, not from data the package
* declares, so the boundary leaves it alone: converting it would build a
* separate native value. */
function passesArgumentThrough(declaration: ts.Node | undefined): boolean {
const sig = signatureParts(declaration);
if (sig === null || sig.type === undefined || !ts.isTypeReferenceNode(sig.type)) return false;
const ret = sig.type;
if (!ts.isIdentifier(ret.typeName) || ret.typeArguments !== undefined) return false;
const name = ret.typeName.text;
if (!sig.typeParameters.some((tp) => tp.name.text === name)) return false;
return sig.parameters.some(
(param) =>
param.type !== undefined &&
ts.isTypeReferenceNode(param.type) &&
ts.isIdentifier(param.type.typeName) &&
param.type.typeName.text === name,
);
}
function declaredInUserFile(symbol: ts.Symbol | undefined): boolean {
if (symbol === undefined) return false;
const decls = symbol.declarations;
@@ -511,16 +575,16 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
if (ts.isCallExpression(d) || ts.isNewExpression(d)) {
const callee = d.expression;
const target = ts.isPropertyAccessExpression(callee) ? callee.name : callee;
produced = declaredInPackage(declSymbol(target));
if (!produced) {
try {
const sig = declChecker.getResolvedSignature(d);
const decl = sig?.declaration;
produced = decl !== undefined && isPackageDeclarationFile(ts.getSourceFileOfNode(decl));
} catch {
produced = false;
}
let decl: ts.Node | undefined;
try {
decl = declChecker.getResolvedSignature(d)?.declaration;
} catch {
decl = undefined;
}
produced =
(declaredInPackage(declSymbol(target)) ||
(decl !== undefined && isPackageDeclarationFile(ts.getSourceFileOfNode(decl)))) &&
!passesArgumentThrough(decl);
} else if (ts.isTaggedTemplateExpression(d)) {
produced = declaredInPackage(declSymbol(d.tag));
} else if (ts.isPropertyAccessExpression(d) || ts.isElementAccessExpression(d)) {
@@ -0,0 +1,6 @@
{
"order": [
"<repo>/tests/fixtures/npm/cases/decl-pass-through/main.ts"
],
"diags": []
}
+16
View File
@@ -0,0 +1,16 @@
// Package functions declared to return their argument's own type
// (`<T extends object>(obj: T) => T`) hand back the argument, or a proxy
// standing in for it, and the package tracks that object's identity: those
// results stay the package's objects. Reads of data the declarations
// describe (`point(): Point`) are still typed.
import { byReference, equals, mark, point } from "ref-marks";
const a = byReference({ n: 42 });
const b = byReference({ n: 42 });
console.log(equals(a, a), equals(a, b), a.n + b.n);
const m = mark({ n: 1 });
const m2 = mark({ n: 1 });
console.log(equals(m, m), equals(m, m2));
const p = point(1, 2);
const q = point(1, 2);
console.log(equals(p, q), p.x + q.y);
@@ -0,0 +1,8 @@
export interface Point {
x: number;
y: number;
}
export declare function byReference<T extends object>(obj: T): T;
export declare const mark: <T extends object>(obj: T) => T;
export declare function equals(a: unknown, b: unknown): boolean;
export declare function point(x: number, y: number): Point;
@@ -0,0 +1,18 @@
const marked = new WeakSet();
export function byReference(obj) {
const proxy = new Proxy(obj, {});
marked.add(proxy);
return proxy;
}
export function mark(obj) {
marked.add(obj);
return obj;
}
export function equals(a, b) {
if (a === b) return true;
if (marked.has(a) || marked.has(b)) return false;
return JSON.stringify(a) === JSON.stringify(b);
}
export function point(x, y) {
return { x, y };
}
@@ -0,0 +1 @@
{ "name": "ref-marks", "version": "1.0.0", "type": "module", "main": "./lib/index.js", "types": "./lib/index.d.ts" }
@@ -0,0 +1 @@
{ "name": "decl-pass-through-case", "private": true, "type": "module" }
+4 -3
View File
@@ -561,13 +561,14 @@ test("types only a package's .d.ts declares stay static through re-exports and .
// shape-kinds re-exports types its .d.ts entry imports; row-module's
// runtime entry is an .mjs file. Without their declared types the
// program's uses of Form, Kind and Row run in the dynamic tier (12
// statements); with them only the packages' own JavaScript and one
// arithmetic on a JavaScript return value do (4).
// statements); with them only the packages' own JavaScript does (3).
// readRows's result keeps its declared number type, so `count - 1` is
// native arithmetic too.
const { coverage } = analyze(
join(repoRoot, "tests/corpus/7004-package-declared-type-reexports/main.ts"),
);
expect(coverage.diagnostics).toEqual([]);
expect(coverage.stats.statementsDynamic).toBe(4);
expect(coverage.stats.statementsDynamic).toBe(3);
});
test("any-typed checked-dynamic operators compile natively", () => {