mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-08 08:44:37 +08:00
Cross package values at their declared type where the JS literal type lacks members
An opted-in package's JavaScript types its own values. The lowering types
a consumer's view of such a value from that JavaScript, and the
declaration boundary (decl-projection.ts) casts to the declared type only
where the JavaScript world answers `any`. A function that returns an
object literal has a precise JavaScript type, so it never crossed, even
where that literal type lacks members the declarations promise:
- `{ [key: string]: string }` over `{ size: "12px" }`: the consumer read
`settings["font"] ?? "x"` through the literal's fixed key set. The
checker types the miss `any` (a JavaScript literal), but the keyed read
took the declared fields' common type, so the miss trapped with
"record has no key" and aborted the process.
- an expando `out.max = ...` on a non-empty literal, which the JavaScript
checker leaves out of the literal's type: reading `stats(xs).max` was
refused with SC1090.
A package value whose JavaScript type is an object literal that lacks a
declared property or string index signature now crosses at its declared
type, so missing keys read undefined through the index signature and
expando members are read from the object. An index signature whose values
do not project (a package interface) keeps the value untyped (`as any`),
so its reads are the JavaScript reads. Only programs compiled with
--npm-static whose consumers read such values are rewritten.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2a0ada527b
commit
177b2fde49
@@ -519,6 +519,41 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
const stringIndex = (checker: ts.TypeChecker, type: ts.Type): boolean => {
|
||||
try {
|
||||
return checker
|
||||
.getIndexInfosOfType(type)
|
||||
.some((info) => (info.keyType.flags & ts.TypeFlags.String) !== 0);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
/** Whether the JavaScript world types a package value as an object
|
||||
* literal that lacks a member its declaration promises: a property (an
|
||||
* expando `out.max = ...` on a non-empty literal, which the JavaScript
|
||||
* checker does not add to the literal's type) or a string index
|
||||
* signature (`Record<string, string>` over a literal). Lowering the value
|
||||
* at the narrower literal type would drop the member or fix the key set,
|
||||
* so it crosses at its declared type instead. */
|
||||
const missesDeclaredMembers = (jsType: ts.Type, declType: ts.Type): boolean => {
|
||||
if (!(jsType.flags & ts.TypeFlags.Object) || !(declType.flags & ts.TypeFlags.Object))
|
||||
return false;
|
||||
const literal = jsType.getSymbol();
|
||||
if (literal === undefined || (literal.flags & ts.SymbolFlags.ObjectLiteral) === 0) return false;
|
||||
try {
|
||||
if (declChecker.isArrayType(declType) || declChecker.isTupleType(declType)) return false;
|
||||
if (stringIndex(declChecker, declType) && !stringIndex(jsChecker, jsType)) return true;
|
||||
return declChecker
|
||||
.getPropertiesOfType(declType)
|
||||
.some(
|
||||
(prop) =>
|
||||
!String(prop.escapedName).startsWith("__@") &&
|
||||
jsChecker.getPropertyOfType(jsType, String(prop.escapedName)) === undefined,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
const namesPackageSymbol = (node: ts.Node): boolean => {
|
||||
let found = false;
|
||||
const visit = (n: ts.Node): void => {
|
||||
@@ -540,7 +575,7 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
|
||||
const fileEdits: Edit[] = [];
|
||||
/** Candidate expressions (by js node) with their declared type, named
|
||||
* only for the casts that survive suppression. */
|
||||
const exprCandidates = new Map<ts.Node, ts.Type>();
|
||||
const exprCandidates = new Map<ts.Node, ts.Type | "any">();
|
||||
const exprOrder: ts.Node[] = [];
|
||||
/** Reads whose projectable receiver the JavaScript world types `any`:
|
||||
* kept only when nothing types the receiver. */
|
||||
@@ -610,7 +645,14 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
|
||||
return;
|
||||
}
|
||||
const jsType = typeAt(jsChecker, jsNode);
|
||||
if (!isAnyish(jsType)) return;
|
||||
if (jsType === null) return;
|
||||
// A typed JavaScript value crosses only where its object type lacks a
|
||||
// member the declaration promises (missesDeclaredMembers).
|
||||
const typedJs = !isAnyish(jsType);
|
||||
if (typedJs) {
|
||||
const declType = typeAt(declChecker, declNode);
|
||||
if (declType === null || !missesDeclaredMembers(jsType, declType)) return;
|
||||
}
|
||||
// Package-produced?
|
||||
const d = declNode as ts.Expression;
|
||||
let produced = false;
|
||||
@@ -686,7 +728,18 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
|
||||
// no contextual type
|
||||
}
|
||||
const declType = typeAt(declChecker, d);
|
||||
if (declType === null || !printer.projectable(declType)) return;
|
||||
if (declType === null) return;
|
||||
if (!printer.projectable(declType)) {
|
||||
// An index signature over values that do not project (package
|
||||
// interfaces): the literal's fixed key set would still answer for
|
||||
// keys it lacks, so the value stays untyped and every read is the
|
||||
// JavaScript read.
|
||||
if (typedJs && stringIndex(declChecker, declType)) {
|
||||
exprCandidates.set(jsNode, "any");
|
||||
exprOrder.push(jsNode);
|
||||
}
|
||||
return;
|
||||
}
|
||||
exprCandidates.set(jsNode, declType);
|
||||
exprOrder.push(jsNode);
|
||||
};
|
||||
@@ -890,7 +943,8 @@ export function planDeclProjection(input: DeclProjectionInput): DeclProjection |
|
||||
)
|
||||
continue;
|
||||
}
|
||||
const alias = printer.project(exprCandidates.get(e)!);
|
||||
const candidate = exprCandidates.get(e)!;
|
||||
const alias = candidate === "any" ? "any" : printer.project(candidate);
|
||||
if (alias === null) continue;
|
||||
const start = ts.tokenStartOfNode(e);
|
||||
const stmt = e.parent;
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
// A consumer reads members a package's declarations promise but its
|
||||
// JavaScript adds in ways the inferred literal type misses: absent keys of
|
||||
// index signatures read undefined, and expando properties read their values,
|
||||
// as in Node.
|
||||
import { counts, fonts, settings, stats } from "declared-members";
|
||||
|
||||
const key = process.argv.length > 10 ? "size" : "missing";
|
||||
|
||||
const s = settings();
|
||||
console.log(s["font"] ?? "default font", s["size"] ?? "default size", s.family);
|
||||
console.log(s["font"] === undefined, typeof s["weight"], s.weight ?? "dot miss");
|
||||
console.log(s[key] ?? "dynamic miss", s[key], Object.keys(s));
|
||||
|
||||
const c = counts();
|
||||
console.log(c["chars"] ?? -1, c["lines"], c[key] === undefined);
|
||||
|
||||
const st = stats([4, 9, 2]);
|
||||
console.log(st.max, st.sum, st.count, st);
|
||||
|
||||
const f = fonts();
|
||||
console.log(f["title"]?.name ?? "no title", f[key] === undefined, f["body"]?.name, f.code.name);
|
||||
+7
@@ -0,0 +1,7 @@
|
||||
export declare function settings(): { [key: string]: string };
|
||||
export declare function counts(): Record<string, number>;
|
||||
export declare function stats(xs: number[]): { count: number; sum: number; max: number };
|
||||
export interface Font {
|
||||
name: string;
|
||||
}
|
||||
export declare function fonts(): Record<string, Font>;
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
"use strict";
|
||||
// Each function returns an object literal whose JavaScript-inferred type
|
||||
// lacks members that index.d.ts promises.
|
||||
|
||||
// An index signature over a literal built empty, then assigned.
|
||||
function settings() {
|
||||
const out = {};
|
||||
out.size = "12px";
|
||||
out.family = "serif";
|
||||
return out;
|
||||
}
|
||||
|
||||
// An index signature over a literal with fixed keys.
|
||||
function counts() {
|
||||
return { lines: 3, words: 12 };
|
||||
}
|
||||
|
||||
// An expando on a non-empty literal: the checker's literal type has no `max`.
|
||||
function stats(xs) {
|
||||
const out = { count: xs.length, sum: 0 };
|
||||
for (const x of xs) out.sum += x;
|
||||
out.max = Math.max(...xs);
|
||||
return out;
|
||||
}
|
||||
|
||||
// An index signature whose values are a declared interface.
|
||||
function fonts() {
|
||||
return { body: { name: "serif" }, code: { name: "mono" } };
|
||||
}
|
||||
|
||||
exports.settings = settings;
|
||||
exports.counts = counts;
|
||||
exports.stats = stats;
|
||||
exports.fonts = fonts;
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "declared-members",
|
||||
"version": "1.0.0",
|
||||
"main": "index.js",
|
||||
"types": "index.d.ts"
|
||||
}
|
||||
@@ -1207,6 +1207,18 @@ describe(`npm-static pilots${sanitize ? " (sanitized)" : ""}`, () => {
|
||||
expect(nativeRes.exitCode).toBe(nodeRes.exitCode);
|
||||
}, 180_000);
|
||||
|
||||
test("package values cross at the members their declarations promise", async () => {
|
||||
const entry = join(pilotRoot, "declared-members-cli.ts");
|
||||
const binary = await buildStatic(entry, ["declared-members"]);
|
||||
const [nodeRes, nativeRes] = await Promise.all([
|
||||
runBinary("node", [entry]),
|
||||
runBinary(binary, []),
|
||||
]);
|
||||
expect(nativeRes.stdout).toEqual(nodeRes.stdout);
|
||||
expect(comparableStderr(nativeRes.stderr)).toEqual(nodeRes.stderr);
|
||||
expect(nativeRes.exitCode).toBe(nodeRes.exitCode);
|
||||
}, 180_000);
|
||||
|
||||
test("the published Effect Function subpath initializes and runs statically", async () => {
|
||||
const entry = join(pilotRoot, "effect-function-cli.ts");
|
||||
const { coverage } = analyze(entry, { npmStatic: ["effect"] });
|
||||
|
||||
Reference in New Issue
Block a user