Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/content/docs/coverage.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@ Coverage and `scriptc build` report the same diagnostic codes. Common ones inclu
Some code compiles but can behave differently than under Node.js. Coverage reports the cases it can detect as `SC6xxx` warnings, and `scriptc build` prints the same warnings after a successful build. They never fail a build, but `--fail-on=divergences` can fail a CI job on them.

- `SC6001`: a record passed to, stored as, asserted as, or stored as an array element of a record type with different properties is copied, and the program then writes through the copy or compares it by identity.
- `SC6002`: an object literal's key order differs from its type's declaration order, and the program observes the order through `JSON.stringify`, `Object.keys`, `Object.values`, `Object.entries`, `for...in`, or console output.
- `SC6002`: values of one record type are built in different key orders, so the type keeps its declaration order, an object literal's key order differs from it, and the program observes the order through `JSON.stringify`, `Object.keys`, `Object.values`, `Object.entries`, `for...in`, or console output.
- `SC6003`: `localeCompare` compares a string literal outside the built-in Latin-script collation.
- `SC6004`: a call passes a class instance to a function that casts it to an unrelated class.
- `SC6005`: a `Date` string literal uses a form the native parser does not accept.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ A record converted to a record type with a different set of properties, such as

Typed records with declared fields, indexed records, and arrays retain identity and mutations through native checked storage. Class instances retain identity at their exact type or a base class, and at any interface or object type that a class instance is passed, stored, returned, or asserted as when the program could tell a copy from the instance: values of such a type are checked views of the original object, so writes, identity comparisons, Set and Map keys, `util.inspect`, and `JSON.stringify` through them reach the instance, as in Node.js. These types use checked dynamic storage, which is slower than native record fields, for all of their values, including plain objects. A type whose values are only read field by field, from fields the class does not write after construction and does not implement as accessors, stays a native record and holds a copy. Object spread of a class instance is refused. Dense array literals created directly in checked storage preserve identity, and supported native handles pass by reference.

`Object.keys`, `Object.values`, `Object.entries`, and `JSON.stringify` use a record's declaration order rather than per-object insertion order. Their order matches Node.js when objects are built in declaration order. `SC6002` reports object literals whose order differs where the program observes it.
`Object.keys`, `Object.values`, `Object.entries`, `for...in`, `JSON.stringify`, and `util.inspect` enumerate a record's keys in one order per record type rather than per object. scriptc chooses that order from the order in which the program's object literals create the keys, follows object spread, rest destructuring, and record conversions back to the values they copy, and places an optional key that the program assigns after construction after the keys the object was built with. The order matches Node.js when every value of the type is created in the same order. When values of one record type are created in different orders, including values of two types with the same properties and property types, or when a key is deleted and assigned again, the type keeps the declaration order of its first type instead, and `SC6002` reports object literals whose order differs where the program observes it.

A record converted from a checked value, such as `const rows: { score: number }[] = JSON.parse(text)`, keeps the properties its type does not declare. `JSON.stringify`, `Object.keys`, `Object.values`, `Object.entries`, `util.inspect`, and untyped views of the record see them, together with the declared fields, in the source object's key order. `Object.values` and `Object.entries` check those values against their static result type and throw a `TypeError` if one does not fit. Copies of such a record made by object spread, `Object.assign`, rest destructuring, or conversion to another record type contain only the declared fields. Index-signature records list declared fields before the other keys.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -603,6 +603,10 @@ function buildRecordProjection(
const localId = parameter + ".0";
const receiver = varRef(localId, source, loc);
const result: IrType = { kind: "record", shapeId: target.id };
// A record source's copy keeps the source's key order; a class
// instance's fields keep today's table.
const copyOf = (node: IrExpr): IrExpr =>
source.kind === "record" ? lowerer.keyOrder.copy(node, source.shapeId) : node;
return {
name,
params: [{ localId, name: parameter, type: source }],
Expand All @@ -611,7 +615,7 @@ function buildRecordProjection(
body: [
{
kind: "return",
value: {
value: copyOf({
kind: "recordLit",
fields: target.fields.map((field) => {
const conversion = plan.get(field.name)!;
Expand Down Expand Up @@ -661,7 +665,7 @@ function buildRecordProjection(
}),
type: result,
loc,
},
}),
loc,
},
],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -810,6 +810,7 @@ export function lowerRecordOvfCaptureHelper(
// Every source declared field must flow into the keyed-write slot —
// in DECLARATION order (insertion order below; declaredOrder omits
// internal '%'-fields, which never enter the copy).
lowerer.keyOrder.observed.add(from.id);
const orderedFields = from.declaredOrder
? from.declaredOrder.flatMap((n) => {
const f = from.fields.find((x) => x.name === n);
Expand Down Expand Up @@ -1097,6 +1098,7 @@ export function lowerObjectAssignIndexShape(
const from = lowerer.shapes.get(srcIr.shapeId);
if (!from || from.tuple) return null;
if (from.fields.some((f) => f.name.startsWith("%"))) return null;
lowerer.keyOrder.observed.add(from.id);
const orderedFields = from.declaredOrder
? from.declaredOrder.flatMap((n) => {
const f = from.fields.find((x) => x.name === n);
Expand Down
17 changes: 15 additions & 2 deletions packages/compiler/src/frontend/lowering/divergences.ts
Original file line number Diff line number Diff line change
Expand Up @@ -781,7 +781,11 @@ function keyOrderFinding(
written: readonly string[],
assignedNames: ReadonlySet<string>,
): { native: string; node: string; detail: "reordered" | "added-later" } | null {
const present = declared.filter((name) => written.includes(name));
// A shape whose table the witness pass re-chose (key-order-witness.ts)
// enumerates that table, not the checker's declaration order.
const table = witnessTable(lowerer, contextual);
const native = table ?? declared;
const present = native.filter((name) => written.includes(name) && declared.includes(name));
const writtenKnown = written.filter((name) => declared.includes(name));
if (present.join("\0") !== writtenKnown.join("\0") && writtenKnown.length >= 2) {
return { native: present.join(", "), node: writtenKnown.join(", "), detail: "reordered" };
Expand All @@ -797,12 +801,21 @@ function keyOrderFinding(
const prop = checker.getPropertyOfType(contextual, name);
if (prop === undefined || (prop.flags & ts.SymbolFlags.Optional) === 0) continue;
const nodeOrder = [...writtenKnown, name];
const nativeOrder = declared.filter((d) => nodeOrder.includes(d));
const nativeOrder = native.filter((d) => nodeOrder.includes(d));
if (table !== null && nativeOrder.join("\0") === nodeOrder.join("\0")) continue;
return { native: nativeOrder.join(", "), node: nodeOrder.join(", "), detail: "added-later" };
}
return null;
}

/** The re-chosen enumeration table of the record shape a contextual type
* lowered to, or null when the witness pass kept today's table. */
function witnessTable(lowerer: Lowerer, contextual: ts.Type): readonly string[] | null {
const mapped = safely(() => lowerer.mapTypeOf(contextual));
if (mapped?.kind !== "record" || !lowerer.keyOrder.changed.has(mapped.shapeId)) return null;
return lowerer.shapes.get(mapped.shapeId)?.declaredOrder ?? null;
}

function classDeclarationOfTypeNode(
lowerer: Lowerer,
node: ts.TypeNode,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import { PoisonError, neverTaintedJsType } from "../lowerer.js";
import type { Lowerer } from "../lowerer.js";
import { lowerIndexMergeHelper } from "../containers/indexed-objects.js";
import type { IndexMergeContributor } from "../containers/indexed-objects.js";
import type { KeyOrderSegment } from "../key-order-witness.js";
import {
PERF_ENTRY_MARKER,
isGenericCallableMemberType,
Expand Down Expand Up @@ -1596,6 +1597,8 @@ export function lowerObjectLiteral(
// Member names DROPPED from the shape (JS deferral — unlowerable pure
// member reads narrow away; see the catch in the property loop).
const droppedNames = new Set<string>();
// The order this literal creates its keys in (key-order-witness.ts).
const orderSegments: KeyOrderSegment[] = [];
for (const prop of expr.properties.nodes) {
// A later contributor can mutate an earlier source or overwrite a
// value with effects. Snapshot pending fields at their source position,
Expand Down Expand Up @@ -1651,6 +1654,7 @@ export function lowerObjectLiteral(
v = lowerer.coerceInto(valueNode, v, fieldType);
if (!typeEquals(v.type, fieldType)) lowerer.badType(valueNode, lowerer.typeOf(valueNode));
conditionalNames.add(name);
orderSegments.push({ kind: "key", name, definite: false });
fields.push({
name,
value: {
Expand Down Expand Up @@ -1758,6 +1762,7 @@ export function lowerObjectLiteral(
}
}
const srcRef = (): IrExpr => srcLowered ?? lowerer.lowerExpr(srcNode);
orderSegments.push({ kind: "spread", shapeId: recArm.shapeId, maybeAbsent: true });
for (const f of srcShape.fields) {
if (laterNames.has(f.name)) continue;
const targetType = fieldTypes.get(f.name);
Expand Down Expand Up @@ -1993,6 +1998,12 @@ export function lowerObjectLiteral(
laterNames.add(propNameText(lowerer, later.name));
}
}
// A checked-dynamic source's keys are a run-time fact.
orderSegments.push(
srcLowered.type.kind === "dyn"
? { kind: "opaque" }
: { kind: "spread", shapeId: srcType.shapeId, maybeAbsent: false },
);
for (const f of srcShape.fields) {
if (laterNames.has(f.name)) continue;
let targetType = fieldTypes.get(f.name);
Expand Down Expand Up @@ -2307,6 +2318,7 @@ export function lowerObjectLiteral(
const at = fields.findIndex((x) => x.name === name);
if (at >= 0) fields.splice(at, 1);
fields.push({ name, value });
orderSegments.push({ kind: "key", name, definite: true });
}
// Optional fields may be omitted: the field holds its absent state.
// tsc rejects omission of required fields before lowering.
Expand Down Expand Up @@ -2335,7 +2347,10 @@ export function lowerObjectLiteral(
fields.push({ name: f.name, value: absent });
}
}
const result: IrExpr = { kind: "recordLit", fields, type, loc };
const result: IrExpr = lowerer.keyOrder.literal(
{ kind: "recordLit", fields, type, loc },
orderSegments,
);
return spreadStmts.length === 0
? result
: { kind: "seqExpr", stmts: spreadStmts, result, type, loc };
Expand Down
Loading
Loading