mirror of
https://github.com/vercel-labs/scriptc.git
synced 2026-10-27 06:43:19 +08:00
Build dict-spreading literals with other members as checked-dynamic objects
An object literal that spreads an index-signature record but has
members outside the scalar join (an array of labels), an unknown-valued
spread (Record<string, unknown>), or a member a later spread may
overwrite with another type now maps to the checked-dynamic value: it is
built like a JavaScript object literal, so spreads copy their own keys
in order and later keyed writes land on the same object.
'c && { k: v }' spreads contribute k exactly when c is truthy even where
the checker's union of the two does not map (an AbortSignal member), and
a conditional spread onto an 'unknown' field leaves the checked-dynamic
undefined in its empty arm.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
2553818b0c
commit
d968975f34
@@ -1700,7 +1700,11 @@ export function lowerObjectLiteral(
|
||||
}
|
||||
shapeMismatch(prop);
|
||||
}
|
||||
const absent = lowerer.absentFieldValue(fieldType, locOf(prop));
|
||||
// An 'unknown' slot holds the checked-dynamic undefined for the
|
||||
// empty arm, the absent-property read the width copy uses too.
|
||||
const absent =
|
||||
lowerer.absentFieldValue(fieldType, locOf(prop)) ??
|
||||
(fieldType.kind === "dyn" ? dynUndefinedExpr(locOf(prop)) : null);
|
||||
if (!absent) {
|
||||
lowerer.unsupported(
|
||||
"SC1090",
|
||||
@@ -2628,6 +2632,14 @@ export function andSpreadOf(
|
||||
while (ts.isParenthesizedExpression(right)) right = right.expression;
|
||||
if (!ts.isObjectLiteralExpression(right)) return null;
|
||||
const spreadType = lowerer.mapTypeOf(lowerer.typeOf(e));
|
||||
// Every falsy value is a primitive, which spreads nothing, so `c && {
|
||||
// ... }` contributes the literal's members exactly when c is truthy,
|
||||
// whatever c's type. The checker's union of the two decides the
|
||||
// representation where it maps; where it does not (a member typed by a
|
||||
// checked-dynamic value such as an AbortSignal), the syntax decides.
|
||||
if (spreadType === null || spreadType.kind === "dyn") {
|
||||
return syntacticAndSpread(right, e.left);
|
||||
}
|
||||
if (spreadType?.kind !== "union") return null;
|
||||
const arms = lowerer.unions.get(spreadType.unionId)?.arms ?? [];
|
||||
if (
|
||||
@@ -2659,6 +2671,26 @@ export function andSpreadOf(
|
||||
return { cond: e.left, props, whenTrue: true };
|
||||
}
|
||||
|
||||
function syntacticAndSpread(
|
||||
right: ts.ObjectLiteralExpression,
|
||||
cond: ts.Expression,
|
||||
): { cond: ts.Expression; props: CondSpreadProp[]; whenTrue: boolean } | "unsupported" {
|
||||
if (right.properties.nodes.length === 0) return "unsupported";
|
||||
const props: CondSpreadProp[] = [];
|
||||
for (const p of right.properties.nodes) {
|
||||
if (
|
||||
(ts.isPropertyAssignment(p) || ts.isShorthandPropertyAssignment(p)) &&
|
||||
p.name &&
|
||||
(ts.isIdentifier(p.name) || ts.isStringLiteral(p.name))
|
||||
) {
|
||||
props.push(p as CondSpreadProp);
|
||||
} else {
|
||||
return "unsupported";
|
||||
}
|
||||
}
|
||||
return { cond, props, whenTrue: true };
|
||||
}
|
||||
|
||||
/** A conditional spread's carrier property: `name: value` or shorthand,
|
||||
* with an identifier/string-literal name. */
|
||||
export type CondSpreadProp = (ts.PropertyAssignment | ts.ShorthandPropertyAssignment) & {
|
||||
|
||||
@@ -5861,6 +5861,7 @@ function mapRecordType(widened: ts.Type, ctx: TypeMapperCtx): IrType | null {
|
||||
)
|
||||
return DYN;
|
||||
const merged = dictSpreadLiteralShape(widened, inner, ctx);
|
||||
if (merged === "dyn") return DYN;
|
||||
if (merged !== null) return { kind: "record", shapeId: merged };
|
||||
const refined = refinedConstituentShape(widened, inner, ctx);
|
||||
if (refined !== null) return { kind: "record", shapeId: refined };
|
||||
@@ -5889,7 +5890,7 @@ function dictSpreadLiteralShape(
|
||||
widened: ts.Type,
|
||||
parts: RecordShapeParts,
|
||||
ctx: TypeMapperCtx,
|
||||
): string | null {
|
||||
): string | "dyn" | null {
|
||||
if (parts.indexValue) return null;
|
||||
const symbol = widened.getSymbol();
|
||||
if (!symbol || (symbol.flags & ts.SymbolFlags.ObjectLiteral) === 0) return null;
|
||||
@@ -5908,16 +5909,29 @@ function dictSpreadLiteralShape(
|
||||
// mapping.
|
||||
const contextualRaw = checker.getContextualType(literal);
|
||||
const contextual = contextualRaw ? checker.getNonNullableType(contextualRaw) : undefined;
|
||||
// An unknown-valued slot (`Record<string, unknown>`) is the checked-dynamic
|
||||
// value, which has no typed merge of its own: the literal still needs its
|
||||
// own record.
|
||||
// A literal written into a declared slot lowers into that slot (a typed
|
||||
// index-signature record merges into it, a declared record converts),
|
||||
// and its own type keeps its mapping. Only a literal with no declared
|
||||
// destination (an unannotated binding, a type parameter, `any` or
|
||||
// `unknown`, an unknown-valued record) is built by its own type.
|
||||
// A generic argument's context is the instantiated parameter: the
|
||||
// literal's own type when the type argument was inferred from it.
|
||||
const ownContext =
|
||||
contextual !== undefined &&
|
||||
(contextual === widened || contextual.getSymbol()?.declarations.includes(literal) === true);
|
||||
if (
|
||||
contextual &&
|
||||
checker
|
||||
.getIndexInfosOfType(contextual)
|
||||
.some((info) => (info.valueType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) === 0)
|
||||
)
|
||||
return null;
|
||||
!ownContext &&
|
||||
!(contextual.flags & (ts.TypeFlags.TypeParameter | ts.TypeFlags.Any | ts.TypeFlags.Unknown))
|
||||
) {
|
||||
const infos = checker.getIndexInfosOfType(contextual);
|
||||
const unknownValued =
|
||||
infos.length > 0 &&
|
||||
infos.every(
|
||||
(info) => (info.valueType.flags & (ts.TypeFlags.Any | ts.TypeFlags.Unknown)) !== 0,
|
||||
);
|
||||
if (!unknownValued) return null;
|
||||
}
|
||||
const kinds = props.map((p) =>
|
||||
ts.isSpreadAssignment(p) ? spreadSourceKindTs(checker, p.expression) : "member",
|
||||
);
|
||||
@@ -5934,6 +5948,7 @@ function dictSpreadLiteralShape(
|
||||
// pure index-signature record.
|
||||
if (t === widened) return null;
|
||||
const mapped = mapType(t, ctx);
|
||||
if (mapped?.kind === "dyn") return DYN;
|
||||
const shape = mapped?.kind === "record" ? shapes.get(mapped.shapeId) : undefined;
|
||||
return shape?.indexValue && shape.fields.length === 0 && !shape.tuple ? shape.indexValue : null;
|
||||
};
|
||||
@@ -5947,6 +5962,11 @@ function dictSpreadLiteralShape(
|
||||
return true;
|
||||
};
|
||||
const before: string[] = [];
|
||||
// An unknown-valued spread (`Record<string, unknown>`, or another such
|
||||
// literal) makes the object the checked-dynamic value: it is built like
|
||||
// a JavaScript object literal (copy each spread's own keys in order), and
|
||||
// member reads check their declared types.
|
||||
let unknownValued = false;
|
||||
for (const [i, p] of props.entries()) {
|
||||
// A fixed-shape or conditional spread contributes its own members,
|
||||
// which the checker already types exactly (they are in `parts`).
|
||||
@@ -5958,11 +5978,32 @@ function dictSpreadLiteralShape(
|
||||
}
|
||||
if (ts.isSpreadAssignment(p)) {
|
||||
const slot = dictSlotOf(p.expression);
|
||||
if (!slot || !addArms(slot)) return null;
|
||||
// A member written before this spread may be overwritten by it.
|
||||
if (slot?.kind === "dyn") {
|
||||
unknownValued = true;
|
||||
continue;
|
||||
}
|
||||
if (!slot) return null;
|
||||
if (!addArms(slot)) {
|
||||
// A slot outside the scalar join still has the checked-dynamic
|
||||
// representation when it converts to it.
|
||||
if (
|
||||
!canConvertToDyn(
|
||||
slot,
|
||||
(id) => shapes.get(id),
|
||||
(id) => unions.get(id),
|
||||
)
|
||||
)
|
||||
return null;
|
||||
unknownValued = true;
|
||||
}
|
||||
// A member written before this spread may be overwritten by it: the
|
||||
// typed join keeps member types only where the slot is that type;
|
||||
// otherwise the checked-dynamic object (whose member reads check
|
||||
// their types) holds whatever the spread wrote.
|
||||
for (const name of before) {
|
||||
const field = parts.fields.find((f) => f.name === name);
|
||||
if (!field || !typeEquals(field.type, slot)) return null;
|
||||
if (!field) return null;
|
||||
if (!typeEquals(field.type, slot)) unknownValued = true;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
@@ -5972,6 +6013,18 @@ function dictSpreadLiteralShape(
|
||||
before.push(name.text);
|
||||
}
|
||||
if (parts.fields.some((f) => f.name.startsWith("%"))) return null;
|
||||
// Members outside the scalar join (an array of labels beside the tags)
|
||||
// make the object the checked-dynamic value too.
|
||||
if (!unknownValued && !parts.fields.every((f) => addArms(f.type))) unknownValued = true;
|
||||
if (unknownValued) {
|
||||
const getRecord = (id: string) => shapes.get(id);
|
||||
const getUnion = (id: string) => unions.get(id);
|
||||
return parts.fields.every(
|
||||
(f) => f.type.kind === "dyn" || canConvertToDyn(f.type, getRecord, getUnion),
|
||||
)
|
||||
? "dyn"
|
||||
: null;
|
||||
}
|
||||
for (const field of parts.fields) if (!addArms(field.type)) return null;
|
||||
if (![...arms.values()].some((arm) => !isUnitType(arm))) return null;
|
||||
const sorted = [...arms.values()].sort((a, b) => (typeKey(a) < typeKey(b) ? -1 : 1));
|
||||
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"order": [
|
||||
"<repo>/tests/corpus/6475-dynamic-spread-literals.ts"
|
||||
],
|
||||
"diags": []
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
// Object literals spreading index-signature records whose members are not
|
||||
// all scalars, or whose spread values are unknown, are checked-dynamic
|
||||
// objects built like JavaScript's: each spread copies its own keys in
|
||||
// order, conditional spreads add nothing when they do not apply, and later
|
||||
// keyed writes land on the same object.
|
||||
function flatten(obj: unknown, prefix: string): Record<string, unknown> {
|
||||
const out: Record<string, unknown> = {};
|
||||
if (typeof obj === "object" && obj !== null) {
|
||||
for (const [k, v] of Object.entries(obj)) out[`${prefix}.${k}`] = v;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
function summary(status: string, ms: number, bytes: number | undefined, err: string | undefined) {
|
||||
const milestones = { connect: 3, done: 9 };
|
||||
const resultTags = {
|
||||
"git.fetch.status": status,
|
||||
"git.fetch.durationMs": ms,
|
||||
...flatten(milestones, "git.fetch"),
|
||||
...(bytes !== undefined && { "git.fetch.packBytes": bytes }),
|
||||
...(err !== undefined && { "git.fetch.error": err }),
|
||||
};
|
||||
console.log(JSON.stringify({ event: "git.fetch.summary", version: "v1", ...resultTags }));
|
||||
}
|
||||
summary("ok", 12, 100, undefined);
|
||||
summary("failed", 3, undefined, "boom");
|
||||
type SendOptions = { method: string; headersTimeout?: number; signal?: AbortSignal };
|
||||
function send(url: string, options: SendOptions): string {
|
||||
return `${url} ${options.method} ${options.headersTimeout ?? "-"} ${options.signal ? options.signal.aborted : "none"}`;
|
||||
}
|
||||
function callWith(timeout: number | undefined, attemptTimeout: number | undefined): string {
|
||||
return send("u", {
|
||||
headersTimeout: timeout,
|
||||
method: "GET",
|
||||
...(attemptTimeout && { signal: AbortSignal.timeout(attemptTimeout) }),
|
||||
});
|
||||
}
|
||||
console.log(callWith(5, 1000));
|
||||
console.log(callWith(undefined, undefined));
|
||||
console.log(callWith(5, 0));
|
||||
function report(tags: string[], from: string, logs: [string, string][]) {
|
||||
const ddTags = {
|
||||
"errClass.from": from,
|
||||
"errClass.tagLabels": Array.from(new Set(tags)).sort(),
|
||||
"errClass.tags": "",
|
||||
...tags.reduce((obj: Record<string, boolean>, tag: string) => {
|
||||
obj[`errClass.tags.${tag}`] = true;
|
||||
return obj;
|
||||
}, {}),
|
||||
"errClass.logLinesSent": logs.length,
|
||||
"errClass.lastRule": "none",
|
||||
};
|
||||
if (tags.length > 1) {
|
||||
ddTags["errClass.lastRule"] = "rule-2";
|
||||
}
|
||||
for (const tag of ["late"]) {
|
||||
// @ts-expect-error -- keys outside the literal's declared ones
|
||||
ddTags[`errClass.tags.${tag}`] = true;
|
||||
}
|
||||
ddTags["errClass.tags"] = tags.join(",");
|
||||
console.log(JSON.stringify(ddTags), ddTags["errClass.logLinesSent"] + 1);
|
||||
}
|
||||
report(["b", "a"], "build", [["stdout", "x"]]);
|
||||
report([], "init", []);
|
||||
Reference in New Issue
Block a user