mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-11-08 08:44:37 +08:00
Copy untyped and index-signature spread sources through a checked dynamic object
`{ ...item }` over `unknown`, `any`, or a `Record<string, unknown>` source
was an SC1090 refusal: the record desugar cannot enumerate keys that are
runtime state. These sources now build a checked-dynamic object and
validate it into the target record, copying own enumerable keys in Node's
insertion order. Keys the target does not declare drop, matching the
existing spread stance.
Corpus 8502 covers a `Record<string, unknown>` into a typed record, an
`unknown` clone, and a merge with an explicit later field.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
61ab4fbc25
commit
f9f6d4d23a
@@ -1904,6 +1904,8 @@ export function lowerObjectLiteral(
|
||||
throw new InternalCompilerError(`lowerer bug: spread of unknown shape ${recArm.shapeId}`);
|
||||
fenceAccessorSpreadSource(lowerer, prop, srcShape);
|
||||
if (srcShape.indexValue || shape.indexValue) {
|
||||
const fallback = dynSpreadLiteral(lowerer, expr, type);
|
||||
if (fallback) return fallback;
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
prop,
|
||||
@@ -2162,6 +2164,8 @@ export function lowerObjectLiteral(
|
||||
continue;
|
||||
}
|
||||
if (srcType?.kind !== "record") {
|
||||
const fallback = dynSpreadLiteral(lowerer, expr, type);
|
||||
if (fallback) return fallback;
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
prop,
|
||||
@@ -2175,6 +2179,8 @@ export function lowerObjectLiteral(
|
||||
// Index-signature shapes carry runtime-keyed overflow entries the
|
||||
// field-by-field desugar cannot enumerate — fenced on either side.
|
||||
if (srcShape.indexValue || shape.indexValue) {
|
||||
const fallback = dynSpreadLiteral(lowerer, expr, type);
|
||||
if (fallback) return fallback;
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
prop,
|
||||
@@ -2910,6 +2916,53 @@ function lowerConditionalRecordSpread(
|
||||
};
|
||||
}
|
||||
|
||||
/** A spread source the record desugar cannot enumerate: an untyped
|
||||
* (`unknown`) value, or one whose shape carries an index signature
|
||||
* (`Record<string, unknown>`, `{ [key: string]: T }`) so its keys are
|
||||
* runtime state. Node copies own enumerable keys in insertion order, which
|
||||
* the checked-dynamic object reproduces. */
|
||||
function dynSpreadSource(lowerer: Lowerer, node: ts.Expression): boolean {
|
||||
let e: ts.Expression = node;
|
||||
while (ts.isParenthesizedExpression(e)) e = e.expression;
|
||||
if ((lowerer.typeOf(e).flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0) return true;
|
||||
const indexed = (t: IrType | null): boolean => {
|
||||
if (t?.kind === "record") return lowerer.shapes.get(t.shapeId)?.indexValue !== undefined;
|
||||
if (t?.kind === "union")
|
||||
return (lowerer.unions.get(t.unionId)?.arms ?? []).some((arm) => indexed(arm));
|
||||
return false;
|
||||
};
|
||||
const mapped = lowerer.mapTypeOf(lowerer.typeOf(e));
|
||||
return mapped?.kind === "dyn" || indexed(mapped);
|
||||
}
|
||||
|
||||
/** `expr` as a checked-dynamic object validated into `type` — the
|
||||
* copy-then-check a spread of an untyped or runtime-keyed source needs
|
||||
* (`{ ...item }` out of a `Record<string, unknown>`). Null when no spread
|
||||
* source is of that world or the target shape has no checked extraction:
|
||||
* the caller keeps its own refusal. Keys the target does not declare drop
|
||||
* (divergence 36/68's spread stance). */
|
||||
function dynSpreadLiteral(
|
||||
lowerer: Lowerer,
|
||||
expr: ts.ObjectLiteralExpression,
|
||||
type: IrType,
|
||||
): IrExpr | null {
|
||||
if (type.kind !== "record") return null;
|
||||
const qualifies = (node: ts.Expression): boolean => dynSpreadSource(lowerer, node);
|
||||
if (
|
||||
!expr.properties.nodes.some((prop) => ts.isSpreadAssignment(prop) && qualifies(prop.expression))
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
!canDynCheckTo(
|
||||
type,
|
||||
(id) => lowerer.shapes.get(id),
|
||||
(id) => lowerer.unions.get(id),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
return lowerer.coerceToExpected(lowerDynObjectLiteral(lowerer, expr), type);
|
||||
}
|
||||
|
||||
/** A conditional spread's carrier property: `name: value` or shorthand,
|
||||
* with an identifier/string-literal name. */
|
||||
export type CondSpreadProp = (ts.PropertyAssignment | ts.ShorthandPropertyAssignment) & {
|
||||
@@ -3005,6 +3058,8 @@ function lowerDeclaredSpreadMerge(
|
||||
// absent.
|
||||
for (const f of shape.fields) {
|
||||
if (f.type.kind !== "union" || lowerer.armTag(f.type.unionId, UNDEFINED_T) < 0) {
|
||||
const fallback = dynSpreadLiteral(lowerer, expr, type);
|
||||
if (fallback) return fallback;
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
expr,
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/8502-object-spread-untyped-source.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// Object spreads over sources the record desugar cannot enumerate: an untyped
|
||||
// (`unknown`) value and a runtime-keyed `Record<string, unknown>`. Node copies
|
||||
// own enumerable keys in insertion order; the copy is validated into the
|
||||
// declared record afterwards.
|
||||
type Resource = { uri: string; name: string };
|
||||
function toResource(item: Record<string, unknown>): Resource {
|
||||
return { ...item } as Resource;
|
||||
}
|
||||
const plain = toResource({ uri: "u", name: "n" });
|
||||
console.log(JSON.stringify(plain));
|
||||
const reordered = toResource({ name: "n2", uri: "u2" });
|
||||
console.log(JSON.stringify(reordered), Object.keys(reordered).join(","));
|
||||
|
||||
function withDefault(item: Record<string, unknown>): Resource {
|
||||
return { ...item, name: item.name ?? item.uri } as Resource;
|
||||
}
|
||||
console.log(JSON.stringify(withDefault({ uri: "u3" })));
|
||||
console.log(JSON.stringify(withDefault({ uri: "u4", name: "given" })));
|
||||
|
||||
// A typed record re-spread through an untyped hop keeps the same key order.
|
||||
interface Full {
|
||||
uri: string;
|
||||
name: string;
|
||||
title: string;
|
||||
}
|
||||
function clone(item: Full): Full {
|
||||
const untyped = item as unknown as Record<string, unknown>;
|
||||
return { ...untyped } as unknown as Full;
|
||||
}
|
||||
const cloned = clone({ uri: "u5", name: "n5", title: "t5" });
|
||||
console.log(JSON.stringify(cloned), Object.keys(cloned).join(","));
|
||||
|
||||
// A runtime-keyed base under explicit fields: the later explicit field wins,
|
||||
// and the surviving keys stay in the order Node builds them.
|
||||
interface Config {
|
||||
endpoint: string;
|
||||
retries: number;
|
||||
}
|
||||
function config(overrides: Record<string, unknown>): Config {
|
||||
return { ...overrides, endpoint: "https://example.test" } as Config;
|
||||
}
|
||||
const built = config({ retries: 3 });
|
||||
console.log(JSON.stringify(built), Object.keys(built).join(","));
|
||||
|
||||
// An 'unknown' source merged with a typed one and a later override.
|
||||
function merge(base: unknown, retries: number): Config {
|
||||
return { ...(base as Record<string, unknown>), retries } as Config;
|
||||
}
|
||||
const merged = merge({ endpoint: "https://merge.test" }, 5);
|
||||
console.log(JSON.stringify(merged), Object.keys(merged).join(","));
|
||||
@@ -0,0 +1 @@
|
||||
{ "plain": 2200, "san": 3800 }
|
||||
Reference in New Issue
Block a user