diff --git a/AGENTS.md b/AGENTS.md
index 55ccb2eab..00ce6bfd1 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -68,6 +68,16 @@ Test location follows scope:
Keep existing tests in place unless a change already touches their organization;
new tests should follow this convention.
+## Numbered registries
+
+Parallel changes must never pick the same number by hand. Each registry has one source of truth and a fast check:
+
+- Diagnostic codes: claim a code by adding its row, in numeric order, to `DIAGNOSTIC_CODES` in `packages/compiler/src/diagnostics/codes.ts` before any site uses it. A shipped construct's code moves to `RETIRED_DIAGNOSTIC_CODES` and is never reused; codes that open work already uses are held in `RESERVED_DIAGNOSTIC_CODES`. `codes.test.ts` fails on a code without a row, a retired code in use, an unused row, or rows out of order, and two claims of one code are a merge conflict or a duplicate key.
+- Dyn handle tags: add a new tag by name only to `SCR_DYNH_NEW_TAGS` in `packages/runtime/src/scr_dyn.h`, in name order; the enum numbers it. Pin a tag to an explicit value in `SCR_DYNH_TAGS` only when the LLVM backend needs its number. The header does not compile when two tags share a value, and `dyn-handle-tags.test.ts` names the problem.
+- Runtime enums and flag bits the backend mirrors (`ScrDynKind`, `ScrMessageKind`, `ScrBytesElem`, `SCR_CLOSURE_KIND_*`, the `%ScrArr` spare flags) are checked against their C declarations by `packages/compiler/src/backend/llvm/runtime-registries.test.ts`. A new mirrored enum or flag set gets a check there, and C code names flag bits instead of writing their values.
+
+Before taking a number in a pinned registry, run `node scripts/registry-claims.mjs`: it lists the diagnostic codes and pinned handle tags that open pull requests claim and reports collisions; `--check` reports only collisions with the current branch and exits 1 when there is one.
+
## Where things live
- `packages/compiler` — the frontend (tsc API to IR), typed IR, validator, serializer, and LLVM backend.
diff --git a/docs/content/docs/cli.mdx b/docs/content/docs/cli.mdx
index 0a82775ca..62ccd0d0d 100644
--- a/docs/content/docs/cli.mdx
+++ b/docs/content/docs/cli.mdx
@@ -139,8 +139,8 @@ With `--sparse`, the profile leaves out the functions the runs never reached. Th
--print <native-link-info|diagnostics>
native-link-info: build an object (equivalent to --emit=obj) and print its machine-readable external link recipe as JSON instead of printing the artifact path. The document names the exact installed precompiled runtime pack and all link inputs, but does not invoke a linker. diagnostics: for build and coverage, print the versioned JSON diagnostics document described in Coverage Reports instead of the human output.
- --fail-on <blockers|divergences|dynamic>
- Coverage only. Exit with status 1 when the entry has blockers that would fail a build, with divergences, also when it has Node divergence warnings, or with dynamic, also when it has the SC6101 warning for untyped code that likely runs often. See Gating builds and CI.
+ --fail-on <blockers|divergences|dynamic|dynamic-slots>
+ Coverage only. Exit with status 1 when the entry has blockers that would fail a build, with divergences, also when it has Node divergence warnings, with dynamic, also when it has the SC6101 warning for untyped code that likely runs often, or with dynamic-slots, also when it has dynamic slots (SC6104). See Gating builds and CI.
--ffi <file>
Bind signature-only TypeScript declarations to native C ABI symbols and link the manifest's archive, object, and system-library inputs. See Native FFI.
diff --git a/docs/content/docs/coverage.mdx b/docs/content/docs/coverage.mdx
index db1193b98..d8719d9f3 100644
--- a/docs/content/docs/coverage.mdx
+++ b/docs/content/docs/coverage.mdx
@@ -83,6 +83,39 @@ Without a profile, a dynamic-tier function counts as likely to run often when a
The warning names at most five functions, most likely to run often first, and gives the total. A function expression is named by its own name, else by the variable, property or class field it is assigned to, else as `anonymous function`. Dynamic-tier code in npm packages is summarized on one line with each package's function count. A build that comes from the cache repeats the warning. `--no-dynamic-tier-warning` leaves it out, and `--fail-on=dynamic` fails a coverage run on it.
+## Dynamic slots
+
+A declared type that has no native representation does not fail the build. The field, parameter, return value, variable or expression that holds it becomes a checked dynamic slot: the parts of the type that compile keep their native layout, and the failing parts hold checked-dynamic values. Coverage lists the slots under the statement counts, and `scriptc build` prints one `SC6104` warning per slot.
+
+```console
+$ cat plugins.ts
+type Hook = (() => void) | AbortSignal | Map string>;
+
+interface Plugin {
+ name: string;
+ hook: Hook;
+}
+
+const plugins: Plugin[] = [{ name: "log", hook: () => console.log("hook ran") }];
+for (const p of plugins) {
+ const hook = p.hook;
+ if (typeof hook === "function") hook();
+ console.log(p.name);
+}
+
+$ scriptc coverage plugins.ts
+
+ statements analyzed 6
+ compile statically 6 (100%)
+ in dynamic tier 1 (operate on values typed only at runtime; --explain-dynamic lists them)
+ dynamic slots 1 (declared types with no static lowering, held as checked-dynamic values; SC6104)
+ module global 'plugins' (declared 'Plugin[]') (dynamic pieces) plugins.ts:8:7
+
+ fully static — all analyzed statements compile to native code.
+```
+
+Here `Plugin` keeps its native `name` field, and only `hook` is checked-dynamic. Reading a dynamic value into typed code checks it against the type that reads it, so data that contradicts the declaration still throws a `TypeError`. Slots with the most dynamic operations are listed first. `--fail-on=dynamic-slots` fails a coverage run when any slot exists, and `--tier=static-only` reports the original errors instead.
+
## Diagnostic codes
Coverage and `scriptc build` report the same diagnostic codes. Common ones include:
@@ -109,7 +142,7 @@ By default, `scriptc coverage` exits with status 0 whenever the program type-che
$ scriptc coverage src/main.ts --fail-on=blockers
```
-`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences, and `--fail-on=dynamic` also fails on the `SC6101` warning for [untyped code that likely runs often](#untyped-code-that-likely-runs-often). A program that doesn't type-check always exits with status 1.
+`--fail-on=blockers` exits with status 1 when the program has blockers that would fail `scriptc build`. Blockers in unreached code and deferred operations don't count. `--fail-on=divergences` also fails on Node divergences, `--fail-on=dynamic` also fails on the `SC6101` warning for [untyped code that likely runs often](#untyped-code-that-likely-runs-often), and `--fail-on=dynamic-slots` also fails on [dynamic slots](#dynamic-slots). A program that doesn't type-check always exits with status 1.
Coverage caches its result. Running it again on an unchanged program, or after edits that only touch comments, reuses the previous verdict. Set `SCRIPTC_NO_CACHE=1` to turn the cache off.
diff --git a/docs/content/docs/limitations.mdx b/docs/content/docs/limitations.mdx
index 0e7afa2b1..88b2ca5fc 100644
--- a/docs/content/docs/limitations.mdx
+++ b/docs/content/docs/limitations.mdx
@@ -48,6 +48,8 @@ title: "Limitations"
../limitations/types-and-class-definitions/unions.mdx
+../limitations/types-and-class-definitions/dynamic-slots.mdx
+
../limitations/types-and-class-definitions/null-and-optional-values.mdx
../limitations/types-and-class-definitions/static-field-updates.mdx
diff --git a/docs/content/limitations/types-and-class-definitions/dynamic-slots.mdx b/docs/content/limitations/types-and-class-definitions/dynamic-slots.mdx
new file mode 100644
index 000000000..102c7ce0a
--- /dev/null
+++ b/docs/content/limitations/types-and-class-definitions/dynamic-slots.mdx
@@ -0,0 +1,5 @@
+- **Dynamic slots:** A field, parameter, return value, variable or expression whose declared type has no native representation compiles as a checked dynamic slot instead of failing the build.
+ The parts of the type that compile keep their native layout, and only the failing parts hold checked-dynamic values.
+ Operations on those values run in the dynamic tier, and reading one into typed code checks it against the type that reads it, so a value that contradicts its declaration throws a `TypeError` when typed code first uses it rather than when it is stored.
+ Each slot is an `SC6104` warning; `scriptc coverage --fail-on=dynamic-slots` fails on them, and `--tier=static-only` turns them back into build errors.
+ Values that scriptc cannot produce at all, such as `WeakRef` instances, still fail where they are created.
diff --git a/packages/compiler/package.json b/packages/compiler/package.json
index d1c39d8ac..3237def0a 100644
--- a/packages/compiler/package.json
+++ b/packages/compiler/package.json
@@ -44,6 +44,9 @@
"typescript": "7.0.2",
"typescript5": "npm:typescript@5.9.3"
},
+ "devDependencies": {
+ "typescript-7.1": "npm:typescript@7.1.0-beta"
+ },
"optionalDependencies": {
"@scriptc/llvm-darwin-arm64": "workspace:*",
"@scriptc/llvm-darwin-x64": "workspace:*",
diff --git a/packages/compiler/src/backend/llvm/common.ts b/packages/compiler/src/backend/llvm/common.ts
index f1bdeb557..11418088c 100644
--- a/packages/compiler/src/backend/llvm/common.ts
+++ b/packages/compiler/src/backend/llvm/common.ts
@@ -32,6 +32,16 @@ export const SCR_ARR_STATES = 16;
/** The `spare` flag of a frozen %ScrArr (SCR_ARR_FROZEN in scr_array.h). */
export const SCR_ARR_FROZEN = 32;
+/** The bits of ScrClosure.function_kind (SCR_CLOSURE_KIND_* in
+ * scr_closure.h; runtime-registries.test.ts checks they agree). */
+export const CLOSURE_KIND = {
+ GENERATOR: 1,
+ ASYNC: 2,
+ OWN_PROTOTYPE: 4,
+ CHECKED_ADAPTER: 8,
+ RECEIVER_FREE: 16,
+} as const;
+
/** Loads the i32 `len` or `cap` of a %ScrArr at `ptr` into `dest` as a
* size-type count. */
export function loadArrCount(
@@ -277,10 +287,6 @@ export function emitFieldAbsentTest(B: BlockBuilder, value: string, undefinedTag
return absent;
}
-/** ScrClosure.function_kind's SCR_FN_RECEIVER_FREE bit (scr_closure.h):
- * the body never observes the ambient receiver. */
-export const CLOSURE_RECEIVER_FREE = 16;
-
/** The right-hand side of a JS function-identity comparison: two closures
* are one function when their identity roots match (a signature adapter
* stands for the function it adapts). Both operands must be closures. */
diff --git a/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts b/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts
index 8c3e5de80..5c5a61a42 100644
--- a/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts
+++ b/packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts
@@ -3,27 +3,52 @@ import { expect, test } from "vitest";
import { DYN_HANDLE_KINDS } from "../../ir/ir.js";
import { DYN_HANDLE_TAG_NUM, TEXT_CODEC_TAG_NUM } from "./dyn.js";
-// SCR_DYNH_TAGS in scr_dyn.h lists the ScrDynHandleTag values by name, each
-// with an explicit value, so changes that add tags edit different lines. The
+// scr_dyn.h declares the ScrDynHandleTag values in two lists: SCR_DYNH_TAGS
+// pins tags to explicit values, and SCR_DYNH_NEW_TAGS names tags added since
+// the last pin, which the enum numbers after the largest pinned value. Both
+// are sorted by name, so changes that add tags edit different lines. The
// header itself fails to compile on a duplicate or out-of-range value; these
// checks report the same problems by name and keep the backend's numbers in
// step.
const header = readFileSync(new URL("../../../../runtime/src/scr_dyn.h", import.meta.url), "utf8");
-const body = /#define SCR_DYNH_TAGS\(X\) \\\n([\s\S]*?)\n\n/.exec(header)![1]!;
-const tags = [...body.matchAll(/^\s*X\(([A-Z0-9_]+), (\d+)\)/gm)].map(([, name, value]) => ({
+const macro = (name: string, param: string) =>
+ new RegExp(`#define ${name}\\(${param}\\)(?: \\\\\\n([\\s\\S]*?))?\\n\\n`).exec(header)![1] ?? "";
+const pinnedBody = macro("SCR_DYNH_TAGS", "X");
+const newBody = macro("SCR_DYNH_NEW_TAGS", "Y");
+const unassignedBody = macro("SCR_DYNH_UNASSIGNED", "Z");
+const pinned = [...pinnedBody.matchAll(/^\s*X\(([A-Z0-9_]+), (\d+)\)/gm)].map(
+ ([, name, value]) => ({
+ name: `SCR_DYNH_${name!}`,
+ value: Number(value),
+ }),
+);
+const lastPinned = Math.max(...pinned.map((tag) => tag.value));
+const added = [...newBody.matchAll(/^\s*Y\(([A-Z0-9_]+)\)/gm)].map(([, name], i) => ({
name: `SCR_DYNH_${name!}`,
- value: Number(value),
+ value: lastPinned + 1 + i,
}));
+const tags = [...pinned, ...added];
+const unassigned = [...unassignedBody.matchAll(/^\s*Z\((\d+)\)/gm)].map(([, value]) =>
+ Number(value),
+);
const capacity = Number(/#define SCR_DYNH_CAPACITY (\d+)/.exec(header)![1]);
+const rows = (body: string, open: string) =>
+ body.split("\n").filter((line) => line.trim().startsWith(open)).length;
-test("every ScrDynHandleTag has an explicit value, in name order", () => {
- const lines = body.split("\n").filter((line) => line.trim().startsWith("X("));
- expect(tags.length).toBe(lines.length);
- expect(tags.map((tag) => tag.name)).toEqual(tags.map((tag) => tag.name).toSorted());
+test("every pinned ScrDynHandleTag has an explicit value, in name order", () => {
+ expect(pinned.length).toBeGreaterThan(0);
+ expect(pinned.length).toBe(rows(pinnedBody, "X("));
+ expect(pinned.map((tag) => tag.name)).toEqual(pinned.map((tag) => tag.name).toSorted());
+});
+
+test("the new tags are listed by name only, in name order", () => {
+ expect(added.length).toBe(rows(newBody, "Y("));
+ expect(added.map((tag) => tag.name)).toEqual(added.map((tag) => tag.name).toSorted());
});
test("ScrDynHandleTag values are unique and below SCR_DYNH_CAPACITY", () => {
- const owners = new Map();
+ const owners = new Map(unassigned.map((value) => [value, "SCR_DYNH_UNASSIGNED"]));
+ expect(owners.size).toBe(unassigned.length);
for (const { name, value } of tags) {
expect(owners.get(value), `${name} = ${value}`).toBeUndefined();
owners.set(value, name);
@@ -31,17 +56,27 @@ test("ScrDynHandleTag values are unique and below SCR_DYNH_CAPACITY", () => {
}
});
-test("the backend's handle tag numbers match scr_dyn.h", () => {
- const values = new Map(tags.map((tag) => [tag.name, tag.value]));
+test("every value up to the largest pinned one is a tag or listed as unassigned", () => {
+ const used = new Set([...pinned.map((tag) => tag.value), ...unassigned]);
+ const gaps = Array.from({ length: lastPinned + 1 }, (_, value) => value).filter(
+ (value) => !used.has(value),
+ );
+ expect(gaps, "add each value to SCR_DYNH_UNASSIGNED with the reason").toEqual([]);
+ for (const value of unassigned) expect(value).toBeLessThan(lastPinned);
+});
+
+test("the backend's handle tag numbers match pinned tags in scr_dyn.h", () => {
+ const values = new Map(pinned.map((tag) => [tag.name, tag.value]));
for (const [kind, num] of Object.entries(DYN_HANDLE_TAG_NUM)) {
const tag = DYN_HANDLE_KINDS.get(kind)?.tag;
expect(tag, kind).toBeDefined();
- expect(values.get(tag!), `${kind} (${tag})`).toBe(num);
+ expect(values.get(tag!), `${kind} (${tag}) must be pinned with this value`).toBe(num);
}
});
-test("the backend's codec handle tag numbers match scr_dyn.h", () => {
- const values = new Map(tags.map((tag) => [tag.name, tag.value]));
+test("the backend's codec handle tag numbers match pinned tags in scr_dyn.h", () => {
+ const values = new Map(pinned.map((tag) => [tag.name, tag.value]));
expect(values.get("SCR_DYNH_TEXT_DECODER")).toBe(TEXT_CODEC_TAG_NUM.TextDecoder);
expect(values.get("SCR_DYNH_TEXT_ENCODER")).toBe(TEXT_CODEC_TAG_NUM.TextEncoder);
+ expect(values.get("SCR_DYNH_STRING_DECODER")).toBe(TEXT_CODEC_TAG_NUM.StringDecoder);
});
diff --git a/packages/compiler/src/backend/llvm/dyn.ts b/packages/compiler/src/backend/llvm/dyn.ts
index 12ea99688..417d88006 100644
--- a/packages/compiler/src/backend/llvm/dyn.ts
+++ b/packages/compiler/src/backend/llvm/dyn.ts
@@ -1,7 +1,14 @@
import { emitPerKeyOrder } from "./key-orders.js";
import { typedRefConstructor } from "./shapes.js";
import { bindLiveArrayOps } from "./expr-stream-bridges.js";
-import { BYTES_ELEM_NUM, SCR_ARR_FIELD, SCR_ARR_STATES, f64Lit, sizeToDouble } from "./common.js";
+import {
+ BYTES_ELEM_NUM,
+ CLOSURE_KIND,
+ SCR_ARR_FIELD,
+ SCR_ARR_STATES,
+ f64Lit,
+ sizeToDouble,
+} from "./common.js";
import { emitUnionPeek, emitUnionTag } from "./union-repr.js";
import {
INLINE_UNION_FIELD,
@@ -145,8 +152,8 @@ const DYN_HANDLE_UNBOX_FN: Record = {
dgramSocket: "scr_dgram_from_dyn",
};
-/** ScrMessageKind (scr_message.h): the node kinds message readers test. */
-const MESSAGE_KIND = {
+/** ScrMessageKind (scr_dyn.h): the node kinds message readers test. */
+export const MESSAGE_KIND = {
UNDEFINED: 0,
NULL: 1,
BOOL: 2,
@@ -3174,7 +3181,7 @@ export class LlDyn {
B.line(`${targetKind} = getelementptr inbounds %ScrClosure, ptr ${a}, i64 0, i32 4`);
const adapterKind = B.tmp();
B.line(
- `${adapterKind} = or i32 ${functionKind}, 8 ; preserves the checked callable in caps[0]`,
+ `${adapterKind} = or i32 ${functionKind}, ${CLOSURE_KIND.CHECKED_ADAPTER} ; preserves the checked callable in caps[0]`,
);
B.line(`store i32 ${adapterKind}, ptr ${targetKind}`);
const box = B.tmp();
diff --git a/packages/compiler/src/backend/llvm/emitter.ts b/packages/compiler/src/backend/llvm/emitter.ts
index f34a9330c..927ce1d36 100644
--- a/packages/compiler/src/backend/llvm/emitter.ts
+++ b/packages/compiler/src/backend/llvm/emitter.ts
@@ -217,7 +217,7 @@ import {
import { LazyCaptures } from "./lazy-captures.js";
import { StackCaptures } from "./stack-captures.js";
import {
- CLOSURE_RECEIVER_FREE,
+ CLOSURE_KIND,
emitFieldAbsentTest,
f64Lit,
SCR_ARR_FIELD,
@@ -7506,15 +7506,15 @@ export class LlEmitter {
return wrapEmitterListener(this, target, adapterFn);
}
- /** ScrClosure.function_kind for a closure over `fn`: generator 1, async
- * 2, own prototype 4, and SCR_FN_RECEIVER_FREE (16) when its body never
+ /** ScrClosure.function_kind for a closure over `fn`: its generator,
+ * async and own prototype bits, and RECEIVER_FREE when its body never
* observes the ambient receiver, so indirect callers skip the bracket. */
closureFunctionKind(fn: IrFunction): number {
return (
- (fn.generator ? 1 : 0) +
- (fn.async ? 2 : 0) +
- (fn.ownsPrototype ? 4 : 0) +
- (this.receiverReaders.ignores(fn) ? CLOSURE_RECEIVER_FREE : 0)
+ (fn.generator ? CLOSURE_KIND.GENERATOR : 0) +
+ (fn.async ? CLOSURE_KIND.ASYNC : 0) +
+ (fn.ownsPrototype ? CLOSURE_KIND.OWN_PROTOTYPE : 0) +
+ (this.receiverReaders.ignores(fn) ? CLOSURE_KIND.RECEIVER_FREE : 0)
);
}
diff --git a/packages/compiler/src/backend/llvm/expr-calls.ts b/packages/compiler/src/backend/llvm/expr-calls.ts
index 7faca5535..88d094800 100644
--- a/packages/compiler/src/backend/llvm/expr-calls.ts
+++ b/packages/compiler/src/backend/llvm/expr-calls.ts
@@ -44,7 +44,7 @@ import {
import { LlvmUnsupportedError } from "./unsupported.js";
import type { LlvmEmitterContext, ExprOf, LlValue } from "./expr-context.js";
import {
- CLOSURE_RECEIVER_FREE,
+ CLOSURE_KIND,
f64Lit,
ffiNativeTypeLl,
ffiNativeParamLl,
@@ -819,7 +819,7 @@ export function emitCallExpr(
`${fromPtr} = getelementptr inbounds %ScrClosure, ptr ${original}, i64 0, i32 4`,
);
B.line(`${from} = load i32, ptr ${fromPtr}`);
- B.line(`${bit} = and i32 ${from}, ${CLOSURE_RECEIVER_FREE}`);
+ B.line(`${bit} = and i32 ${from}, ${CLOSURE_KIND.RECEIVER_FREE}`);
B.line(`${toPtr} = getelementptr inbounds %ScrClosure, ptr ${c}, i64 0, i32 4`);
B.line(`${to} = load i32, ptr ${toPtr}`);
B.line(`${kind} = or i32 ${to}, ${bit}`);
@@ -938,7 +938,7 @@ export function emitCallExpr(
)
: args;
// An entry through the closure's own pointer brackets the call only
- // when the closure may observe the receiver (SCR_FN_RECEIVER_FREE).
+ // when the closure may observe the receiver (SCR_CLOSURE_KIND_RECEIVER_FREE).
const invoke = (
target: string,
bracket: boolean,
@@ -958,7 +958,7 @@ export function emitCallExpr(
`${kindPtr} = getelementptr inbounds %ScrClosure, ptr ${callee.name}, i64 0, i32 4`,
);
B.line(`${kind} = load i32, ptr ${kindPtr}`);
- B.line(`${free} = and i32 ${kind}, ${CLOSURE_RECEIVER_FREE}`);
+ B.line(`${free} = and i32 ${kind}, ${CLOSURE_KIND.RECEIVER_FREE}`);
B.line(`${observes} = icmp eq i32 ${free}, 0`);
const push = B.newLabel("receiver.push"),
pushed = B.newLabel("receiver.call");
diff --git a/packages/compiler/src/backend/llvm/runtime-registries.test.ts b/packages/compiler/src/backend/llvm/runtime-registries.test.ts
new file mode 100644
index 000000000..a704deb61
--- /dev/null
+++ b/packages/compiler/src/backend/llvm/runtime-registries.test.ts
@@ -0,0 +1,100 @@
+import { readFileSync } from "node:fs";
+import { expect, test } from "vitest";
+import { BYTES_ELEM_NUM, CLOSURE_KIND, SCR_ARR_FROZEN, SCR_ARR_STATES } from "./common.js";
+import { DYN_KIND, MESSAGE_KIND } from "./dyn.js";
+
+// The backend writes the numbers of several runtime enums and flag sets
+// into the IR it emits. Each check here reads the C declaration and fails
+// when the backend's copy disagrees, or when two names share a value.
+const source = (file: string) =>
+ readFileSync(new URL(`../../../../runtime/src/${file}`, import.meta.url), "utf8")
+ .replace(/\/\*[\s\S]*?\*\//g, "")
+ .replace(/\/\/.*$/gm, "");
+
+/** The members of `typedef enum ... { ... } ;` with their values. */
+function cEnum(file: string, name: string): Map {
+ const match = new RegExp(`typedef enum(?: \\w+)? \\{([^}]*)\\} ${name};`).exec(source(file));
+ expect(match, `${name} in ${file}`).not.toBeNull();
+ const body = match![1]!;
+ const members = new Map();
+ let next = 0;
+ for (const entry of body
+ .split(",")
+ .map((e) => e.trim())
+ .filter(Boolean)) {
+ const [, member, value] = /^(\w+)(?:\s*=\s*(\d+))?$/.exec(entry)!;
+ const v = value === undefined ? next : Number(value);
+ members.set(member!, v);
+ next = v + 1;
+ }
+ return members;
+}
+
+/** The `#define NAME u` flags of a header. */
+function cFlags(file: string, prefix: string): Map {
+ const re = new RegExp(`^#define ${prefix}(\\w+) (\\d+)u\\b`, "gm");
+ return new Map([...source(file).matchAll(re)].map(([, name, value]) => [name!, Number(value)]));
+}
+
+function expectDistinct(values: Map, what: string): void {
+ const owners = new Map();
+ for (const [name, value] of values) {
+ expect(owners.get(value), `${what}: ${name} = ${value}`).toBeUndefined();
+ owners.set(value, name);
+ }
+}
+
+function expectSingleBits(values: Map, what: string): void {
+ for (const [name, value] of values) {
+ expect(value > 0 && (value & (value - 1)) === 0, `${what}: ${name} = ${value}`).toBe(true);
+ }
+ expectDistinct(values, what);
+}
+
+test("DYN_KIND matches ScrDynKind", () => {
+ const kinds = cEnum("scr_dyn.h", "ScrDynKind");
+ expectDistinct(kinds, "ScrDynKind");
+ for (const [name, value] of Object.entries(DYN_KIND)) {
+ expect(kinds.get(`SCR_DYN_${name}`), name).toBe(value);
+ }
+});
+
+test("MESSAGE_KIND matches ScrMessageKind", () => {
+ const kinds = cEnum("scr_dyn.h", "ScrMessageKind");
+ expectDistinct(kinds, "ScrMessageKind");
+ for (const [name, value] of Object.entries(MESSAGE_KIND)) {
+ expect(kinds.get(`SCR_MESSAGE_${name}`), name).toBe(value);
+ }
+});
+
+test("BYTES_ELEM_NUM matches ScrBytesElem", () => {
+ const elems = cEnum("scr_bytes.h", "ScrBytesElem");
+ expectDistinct(elems, "ScrBytesElem");
+ expect(Object.keys(BYTES_ELEM_NUM).length).toBe(elems.size);
+ for (const [name, value] of Object.entries(BYTES_ELEM_NUM)) {
+ expect(elems.get(`SCR_BYTES_${name.toUpperCase()}`), name).toBe(value);
+ }
+});
+
+test("CLOSURE_KIND matches the SCR_CLOSURE_KIND_* bits", () => {
+ const flags = cFlags("scr_closure.h", "SCR_CLOSURE_KIND_");
+ expect(flags.get("CALL_MASK")).toBe(CLOSURE_KIND.GENERATOR | CLOSURE_KIND.ASYNC);
+ flags.delete("CALL_MASK");
+ expect(flags.get("MASK")).toBe(CLOSURE_KIND.RECEIVER_FREE - 1);
+ flags.delete("MASK");
+ expectSingleBits(flags, "SCR_CLOSURE_KIND");
+ expect(Object.fromEntries(flags)).toEqual(CLOSURE_KIND);
+});
+
+test("the %ScrArr spare flags the backend tests match scr_array.h", () => {
+ const flags = cFlags("scr_array.h", "SCR_ARR_");
+ const spare = new Map(
+ ["INLINE_SLOTS", "HEAD_ROOM", "UNSHIFTED", "HAS_EXT", "STATES", "FROZEN"].map((name) => [
+ name,
+ flags.get(name)!,
+ ]),
+ );
+ expectSingleBits(spare, "%ScrArr spare");
+ expect(spare.get("STATES")).toBe(SCR_ARR_STATES);
+ expect(spare.get("FROZEN")).toBe(SCR_ARR_FROZEN);
+});
diff --git a/packages/compiler/src/cli/usage.ts b/packages/compiler/src/cli/usage.ts
index 84cd744b3..d99d373bd 100644
--- a/packages/compiler/src/cli/usage.ts
+++ b/packages/compiler/src/cli/usage.ts
@@ -42,9 +42,10 @@ Options:
coverage only: exit 1 when the entry path has blockers
(a build would fail), or also when it has Node
divergences, likely-hot functions that use untyped
- values (SC6101), any Node island (eager-node-islands is
- the same until deferred evaluation lands), or a gap
- island (a package scriptc should compile but cannot yet)
+ values (SC6101), dynamic slots (SC6104), any Node
+ island (eager-node-islands is the same until deferred
+ evaluation lands), or a gap island (a package scriptc
+ should compile but cannot yet)
--backend code generator (llvm)
--optimization
native optimization posture (default: release/-O2). dev
diff --git a/packages/compiler/src/contracts/check.ts b/packages/compiler/src/contracts/check.ts
index fe5dc171e..972d9ea12 100644
--- a/packages/compiler/src/contracts/check.ts
+++ b/packages/compiler/src/contracts/check.ts
@@ -26,6 +26,7 @@
* counts (classes.ts programLifetimeClass).
*
* Verified facts land on the IR as `contracts` (IrContracts). */
+import type { DiagnosticCode } from "../diagnostics/codes.js";
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type {
IrClassDef,
@@ -158,7 +159,7 @@ class ContractChecker {
return raw.length > max ? `${raw.slice(0, max - 3)}...` : raw;
}
- report(code: `SC${number}`, loc: SrcLoc, message: string, hint: string): void {
+ report(code: DiagnosticCode, loc: SrcLoc, message: string, hint: string): void {
const key = `${code}\0${contractLocKey(loc)}\0${message}`;
if (this.reported.has(key)) return;
this.reported.add(key);
diff --git a/packages/compiler/src/contracts/int32-elements.ts b/packages/compiler/src/contracts/int32-elements.ts
index 67446f2d1..7d1a6261f 100644
--- a/packages/compiler/src/contracts/int32-elements.ts
+++ b/packages/compiler/src/contracts/int32-elements.ts
@@ -40,6 +40,7 @@
* Holes, present undefined, out-of-range reads and `length` changes keep
* their JavaScript behavior: the contract constrains the values stored, not
* the array's shape. */
+import type { DiagnosticCode } from "../diagnostics/codes.js";
import type { ContractNote, ContractAnnotations } from "../frontend/contract-types.js";
import type {
IrClassDef,
@@ -61,7 +62,7 @@ export interface ElementContractHost {
readonly notes: ContractAnnotations;
readonly functions: ReadonlyMap;
readonly classes: ReadonlyMap;
- report(code: `SC${number}`, loc: SrcLoc, message: string, hint: string): void;
+ report(code: DiagnosticCode, loc: SrcLoc, message: string, hint: string): void;
text(loc: SrcLoc, max?: number): string;
where(loc: SrcLoc | null): string;
displayName(fnName: string): string;
diff --git a/packages/compiler/src/coverage/report.ts b/packages/compiler/src/coverage/report.ts
index d78120f89..b94f43a4f 100644
--- a/packages/compiler/src/coverage/report.ts
+++ b/packages/compiler/src/coverage/report.ts
@@ -650,6 +650,8 @@ export function coverageEnvelope(
declaredType: slot.declaredType,
code: slot.code,
reason: slot.reason,
+ ...(slot.partial === true ? { partial: true } : {}),
+ dynamicOps: slot.operations ?? 0,
})),
}
: {}),
@@ -762,8 +764,15 @@ function renderDynamicSlots(
out.push(
` dynamic slots ${slots.length} ${c(DIM, "(declared types with no static lowering, held as checked-dynamic values; SC6104)")}`,
);
- for (const slot of slots.slice(0, DYNAMIC_SLOTS_SHOWN))
- out.push(` ${dynamicSlotLabel(slot)} ${c(DIM, where(slot.loc))}`);
+ // The costly slots first: most dynamic operations, then source order.
+ const ranked = slots
+ .map((slot, index) => ({ slot, index }))
+ .sort((a, b) => (b.slot.operations ?? 0) - (a.slot.operations ?? 0) || a.index - b.index)
+ .map(({ slot }) => slot);
+ for (const slot of ranked.slice(0, DYNAMIC_SLOTS_SHOWN))
+ out.push(
+ ` ${dynamicSlotLabel(slot)}${slot.partial === true ? " (dynamic pieces)" : ""} ${c(DIM, `${where(slot.loc)}${(slot.operations ?? 0) > 0 ? `, ${slot.operations} dynamic operation${slot.operations === 1 ? "" : "s"}` : ""}`)}`,
+ );
if (slots.length > DYNAMIC_SLOTS_SHOWN)
out.push(` ${c(DIM, `and ${slots.length - DYNAMIC_SLOTS_SHOWN} more`)}`);
}
diff --git a/packages/compiler/src/diagnostics/codes.test.ts b/packages/compiler/src/diagnostics/codes.test.ts
new file mode 100644
index 000000000..9f6b803b0
--- /dev/null
+++ b/packages/compiler/src/diagnostics/codes.test.ts
@@ -0,0 +1,89 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { join } from "node:path";
+import { expect, test } from "vitest";
+import { DIAGNOSTIC_CODES, RESERVED_DIAGNOSTIC_CODES, RETIRED_DIAGNOSTIC_CODES } from "./codes.js";
+import { FENCE_CODES, UNSUPPORTED } from "./diagnostic.js";
+
+const here = import.meta.dirname;
+const packagesDir = join(here, "../../..");
+const codesSource = readFileSync(join(here, "codes.ts"), "utf8");
+
+function sourceFiles(dir: string): string[] {
+ return readdirSync(dir, { recursive: true, withFileTypes: true })
+ .filter((e) => e.isFile() && e.name.endsWith(".ts") && !e.name.endsWith(".test.ts"))
+ .map((e) => join(e.parentPath, e.name));
+}
+
+// Every non-test source file that can name a code.
+const sources = [
+ ...sourceFiles(join(packagesDir, "compiler/src")),
+ ...sourceFiles(join(packagesDir, "cli/src")),
+]
+ .filter((file) => file !== join(here, "codes.ts"))
+ .map((file) => ({ file: file.slice(packagesDir.length), text: readFileSync(file, "utf8") }));
+
+const active = new Set(Object.keys(DIAGNOSTIC_CODES));
+const retired = new Set(Object.keys(RETIRED_DIAGNOSTIC_CODES));
+const reserved = new Set(Object.keys(RESERVED_DIAGNOSTIC_CODES));
+
+/** The keys of one table in codes.ts, as written (duplicates included). */
+function writtenKeys(table: string): string[] {
+ const start = codesSource.indexOf(`export const ${table} = {`);
+ const end = codesSource.indexOf("\n}", start);
+ return [...codesSource.slice(start, end).matchAll(/^ {2}(SC\d{4}):/gm)].map((m) => m[1]!);
+}
+
+test.each(["DIAGNOSTIC_CODES", "RETIRED_DIAGNOSTIC_CODES", "RESERVED_DIAGNOSTIC_CODES"])(
+ "%s is written once per code, in numeric order",
+ (table) => {
+ const keys = writtenKeys(table);
+ expect(keys.length).toBeGreaterThan(0);
+ expect(keys).toEqual([...new Set(keys)].toSorted());
+ },
+);
+
+test("a code is active, retired or reserved, never two of them", () => {
+ for (const code of retired) expect(active.has(code) || reserved.has(code), code).toBe(false);
+ for (const code of reserved) expect(active.has(code), code).toBe(false);
+});
+
+test("every code the sources name has a row in codes.ts", () => {
+ const missing: string[] = [];
+ for (const { file, text } of sources) {
+ for (const [code] of text.matchAll(/\bSC\d{4}\b/g)) {
+ if (!active.has(code) && !retired.has(code) && !reserved.has(code)) {
+ missing.push(`${code} (${file})`);
+ }
+ }
+ }
+ expect(missing, "claim each code in packages/compiler/src/diagnostics/codes.ts").toEqual([]);
+});
+
+test("no source produces a retired code", () => {
+ const produced: string[] = [];
+ for (const { file, text } of sources) {
+ for (const [, code] of text.matchAll(/["'`](SC\d{4})["'`]/g)) {
+ if (retired.has(code!)) produced.push(`${code} (${file})`);
+ }
+ }
+ expect(produced).toEqual([]);
+});
+
+test("every active code is used by some source", () => {
+ const seen = new Set(sources.flatMap(({ text }) => text.match(/\bSC\d{4}\b/g) ?? []));
+ const unused = [...active].filter((code) => !seen.has(code));
+ expect(unused, "retire codes nothing produces any more").toEqual([]);
+});
+
+test("the construct tables only name active codes", () => {
+ for (const code of [...Object.keys(UNSUPPORTED), ...Object.keys(FENCE_CODES)]) {
+ expect(active.has(code), code).toBe(true);
+ }
+});
+
+test("every band in use is documented in diagnostic.ts", () => {
+ const header = readFileSync(join(here, "diagnostic.ts"), "utf8");
+ for (const band of new Set([...active].map((code) => code[2]))) {
+ expect(header, `SC${band}xxx`).toContain(`SC${band}xxx`);
+ }
+});
diff --git a/packages/compiler/src/diagnostics/codes.ts b/packages/compiler/src/diagnostics/codes.ts
new file mode 100644
index 000000000..bbf35cacc
--- /dev/null
+++ b/packages/compiler/src/diagnostics/codes.ts
@@ -0,0 +1,162 @@
+/* Every SC diagnostic code, in one table.
+ *
+ * A code is claimed by adding its row here, in numeric order, before any
+ * site uses it. Two changes that claim the same code then either edit the
+ * same line (a merge conflict) or add the same key twice (TypeScript error
+ * TS1117), and codes.test.ts fails when a source file uses a code that has
+ * no row, uses a retired code, or when a row is out of order. The band of a
+ * code (its first digit) says what kind of diagnostic it is; diagnostic.ts
+ * documents the bands.
+ *
+ * Codes are part of scriptc's user-facing output and never change meaning:
+ * when a construct ships, its code moves to RETIRED_DIAGNOSTIC_CODES instead
+ * of being reused. RESERVED_DIAGNOSTIC_CODES holds codes that open work
+ * already uses, so other changes do not take them. */
+
+/** The active codes, with a short summary of what each reports. */
+export const DIAGNOSTIC_CODES = {
+ SC0001: "TypeScript error passed through",
+ SC0002: "project configuration incompatible with scriptc",
+ SC0003: "malformed imported JSON module",
+ SC0004: "TypeScript checker panic",
+ SC1010: "package imports outside the supported set",
+ SC1012: "default exports/imports (residual edges)",
+ SC1013: "namespace imports (residual edges)",
+ SC1014: "re-exports and export lists",
+ SC1015: "dynamic import()",
+ SC1016: "circular imports",
+ SC1020: "class expressions",
+ SC1030: "var declarations (residual forms)",
+ SC1031: "destructuring",
+ SC1040: "loose equality (== and !=)",
+ SC1042: "logical operators on mixed operand types",
+ SC1043: "comparing non-number, non-string values",
+ SC1045: "increment/decrement in expression position",
+ SC1050: "labeled break/continue",
+ SC1052: "for-in loops",
+ SC1062: "destructuring catch clause bindings",
+ SC1063: "this use of a catch binding",
+ SC1070: "async/await",
+ SC1071: "generators",
+ SC1080: "'this' outside a class method",
+ SC1090: "this syntax (generic construct fence)",
+ SC1100: "operations on 'unknown' values",
+ SC1101: "converting typed values to 'unknown'",
+ SC1110: "comptime evaluation failure",
+ SC1120: "regex feature outside the native engine",
+ SC2001: "type outside the compilable set",
+ SC2002: "record shape flow outside the width-copy rules",
+ SC2003: "union-to-union conversion outside the re-tagging rule",
+ SC2004: "use of a binding whose declaration did not compile",
+ SC2005: "generic call signature in a value's type",
+ SC2006: "index-signature object type outside the supported shape",
+ SC2007: "overloaded function type as a value",
+ SC2008: "intersection type with no lowering",
+ SC2009: "component type outside its container or function slot",
+ SC2010: "runtime JavaScript execution",
+ SC2011: "'any'-typed values and their operations",
+ SC2012: "standard-library form without native lowering",
+ SC2013: "npm package import or value without native lowering",
+ SC2020: "standard-library or @types/node surface with no lowering",
+ SC3001: "LLVM IR tier refusal",
+ SC3002: "target or capability refusal",
+ SC3003: "native helper installation or version failure",
+ SC3004: "native helper code-generation failure",
+ SC4001: "library profile malformed",
+ SC4002: "library export unresolved",
+ SC4003: "library export signature not mappable",
+ SC4004: "async or generator library export",
+ SC4005: "library graph reaches the event loop or ambient process state",
+ SC4007: "generic library export signature",
+ SC4008: "profile-fenced surface reached by a library",
+ SC4009: "contract sidecar projection failure",
+ SC4010: "tabled type declared at several sites",
+ SC4011: "conditional or mapped type producing a tabled type",
+ SC4012: "library inbound-bytes trap (runtime)",
+ SC4013: "library escaped exception trap (runtime)",
+ SC4014: "library range trap (runtime)",
+ SC4015: "library type trap (runtime)",
+ SC4016: "library syntax trap (runtime)",
+ SC4017: "library out-of-memory trap (runtime)",
+ SC4018: "library internal invariant trap (runtime)",
+ SC4019: "library other detected trap (runtime)",
+ SC4020: "npm package imported by a library is not statically eligible",
+ SC4021: "integer boundary: literal not representable",
+ SC4022: "integer boundary: value may not be whole",
+ SC4023: "integer boundary: value out of range",
+ SC4024: "host callback reference the profile cannot serve",
+ SC4025: "host callback invoked before registration (runtime)",
+ SC4026: "library re-entered from a host callback (runtime)",
+ SC5001: "FFI manifest malformed",
+ SC5002: "FFI binding is not an ambient function declaration",
+ SC5003: "FFI signature does not match its native ABI",
+ SC5004: "FFI native build failure",
+ SC6001: "divergence: width copy written or compared through",
+ SC6002: "divergence: observed object key order",
+ SC6003: "divergence: localeCompare outside the built-in collation",
+ SC6004: "divergence: cast to an unrelated class",
+ SC6101: "performance: likely-hot function with untyped values",
+ SC6102: "performance: declaration-typed boundary note",
+ SC6103: "performance: shape inference overlay report",
+ SC6104: "localized failure: slot held as a checked-dynamic value",
+ SC7001: "NoThrow contract on an async function or generator",
+ SC7002: "Int32 contract value not provably int32",
+ SC7003: "Int32 contract cannot be checked",
+ SC7004: "Immutable contract value may be referenced elsewhere",
+ SC7005: "Immutable contract cannot be checked",
+ SC7006: "Acyclic contract in an unchecked position",
+ SC7007: "Acyclic type can lie on a reference cycle",
+ SC7008: "ProgramLifetime class extends another class",
+ SC8002: "Node islands off for a package or module outside the native subset",
+ SC8003: "package marked native is outside the native subset",
+ SC8004: "unknown scriptc.packages or scriptc.nodeIslands configuration",
+ SC8005: "no usable libnode pack for a program that calls into Node.js",
+ SC8006: "Node island modules the build cannot collect",
+ SC8030: "type patch: no range covers the installed copy",
+ SC8031: "type patch: selector matches nothing",
+ SC8032: "type patch: declaration already has the type",
+ SC8033: "type patch: selector ambiguous or not patchable",
+ SC8101: "user JavaScript: constructor function",
+ SC8102: "user JavaScript: prototype mutation",
+ SC8103: "user JavaScript: shape mutation outside the checker's view",
+ SC8104: "user JavaScript: sloppy-mode semantics",
+ SC8105: "user JavaScript: builtin and global patching",
+ SC9001: "internal compiler error",
+ SC9002: "internal error trap in generated code",
+} as const satisfies Record<`SC${number}`, string>;
+
+/** Codes whose construct shipped or whose diagnostic was removed. Never
+ * reuse one: old output and documentation still name it. */
+export const RETIRED_DIAGNOSTIC_CODES = {
+ SC1011: "exports (shipped)",
+ SC1021: "arrow functions and closures (shipped)",
+ SC1022: "nested function declarations (shipped)",
+ SC1023: "object literals (shipped as records)",
+ SC1024: "arrays (shipped)",
+ SC1032: "multiple declarations in one statement (shipped)",
+ SC1033: "let without an initializer (shipped)",
+ SC1041: "bitwise operators (shipped)",
+ SC1051: "do-while loops (shipped)",
+ SC1060: "switch statements (shipped)",
+ SC1061: "exceptions (shipped)",
+ SC1121: "stateful g/y regex tests (shipped)",
+ SC2030: "npm embedding failure",
+ SC4006: "library export refusal (removed)",
+ SC6005: "Date strings outside the native parser's grammar",
+} as const satisfies Record<`SC${number}`, string>;
+
+/** Codes that open work already uses, so no other change claims them. When
+ * that work lands it moves its codes into DIAGNOSTIC_CODES; when it is
+ * abandoned, delete them here. */
+export const RESERVED_DIAGNOSTIC_CODES = {
+ SC8001: "Node islands (ni/* branches)",
+ SC8010: "Node islands (ni/* branches)",
+ SC8011: "Node islands (ni/* branches)",
+ SC8012: "Node islands (ni/* branches)",
+ SC8020: "Node islands (ni/* branches)",
+ SC8021: "Node islands (ni/* branches)",
+ SC8022: "Node islands (ni/* branches)",
+ SC8023: "Node islands (ni/* branches)",
+} as const satisfies Record<`SC${number}`, string>;
+
+export type DiagnosticCode = keyof typeof DIAGNOSTIC_CODES;
diff --git a/packages/compiler/src/diagnostics/diagnostic.ts b/packages/compiler/src/diagnostics/diagnostic.ts
index 51a08dc5d..f1e16a8e0 100644
--- a/packages/compiler/src/diagnostics/diagnostic.ts
+++ b/packages/compiler/src/diagnostics/diagnostic.ts
@@ -1,4 +1,5 @@
-/* Diagnostic codes live in ONE registry. Message strings are never inlined
+/* Diagnostics live in ONE registry, and every code is claimed by its row in
+ * codes.ts (DIAGNOSTIC_CODES). Message strings are never inlined
* at rejection sites — every "not supported yet" the compiler can produce is
* enumerable here, which is the seed of the future coverage report
* ("N constructs prevented static compilation, grouped by code").
@@ -68,6 +69,9 @@
* declaration types were dropped (SC6102), and shape inference's
* overlay report: what it typed and how many inferred facts the
* build checks (SC6103, frontend/shape-inference)
+ * SC7xxx contract types (@scriptc/types): a NoThrow, Int32, Immutable,
+ * Acyclic or ProgramLifetime contract that does not hold or
+ * cannot be checked (SC7001–SC7008, contracts/check.ts)
* SC8xxx Node islands: packages and modules outside the native subset
* that run (or, in report-only mode, would run) in a Node
* island — unknown project configuration (SC8004), a package
@@ -82,6 +86,7 @@
* SC9xxx internal compiler errors (still source-anchored)
*/
import type { SrcLoc } from "../ir/ir.js";
+import type { DiagnosticCode } from "./codes.js";
/* Internal prioritization buckets. NEVER rendered to users — user-facing
* output says only what is and isn't supported (plus hints); scheduling is
@@ -89,7 +94,7 @@ import type { SrcLoc } from "../ir/ir.js";
export type Milestone = "M1" | "M2" | "M3" | "M4" | "M5" | "later";
export interface ScrDiagnostic {
- code: `SC${number}`;
+ code: DiagnosticCode;
message: string;
loc: SrcLoc;
milestone?: Milestone;
@@ -669,7 +674,7 @@ export const FENCE_CODES: Record = {
@@ -38,7 +44,10 @@ export function dynamicSlotLabel(entry: DynamicSlotEntry): string {
export function dynamicSlotDiag(entry: DynamicSlotEntry): ScrDiagnostic {
return {
code: DYNAMIC_SLOT_CODE,
- message: `${dynamicSlotLabel(entry)} is held as a checked-dynamic value: its type has no static lowering (${entry.reason}); reads into typed code are checked`,
+ message:
+ entry.partial === true
+ ? `${dynamicSlotLabel(entry)} keeps its compilable shape with checked-dynamic pieces where its type has no static lowering (${entry.reason}); reads of those pieces into typed code are checked`
+ : `${dynamicSlotLabel(entry)} is held as a checked-dynamic value: its type has no static lowering (${entry.reason}); reads into typed code are checked`,
loc: entry.loc,
hint: "restate the declared type inside the compilable set to keep this slot static; scriptc coverage lists dynamic slots, and --fail-on=dynamic-slots fails on them",
};
diff --git a/packages/compiler/src/diagnostics/dynamic-tier.ts b/packages/compiler/src/diagnostics/dynamic-tier.ts
index 7970d3f42..ff6a79a57 100644
--- a/packages/compiler/src/diagnostics/dynamic-tier.ts
+++ b/packages/compiler/src/diagnostics/dynamic-tier.ts
@@ -26,7 +26,14 @@ export type UntypedCause =
| { kind: "any"; name: string }
| { kind: "json-binding"; name: string }
| { kind: "json" }
- | { kind: "javascript" };
+ | { kind: "javascript" }
+ | {
+ kind: "slot";
+ name: string;
+ declaredType: string;
+ /** The declared type this is a dynamic piece of (push-down). */
+ owner?: string;
+ };
export function causePhrase(cause: UntypedCause): string {
switch (cause.kind) {
@@ -38,6 +45,10 @@ export function causePhrase(cause: UntypedCause): string {
return "untyped `JSON.parse` result";
case "javascript":
return "untyped JavaScript file";
+ case "slot":
+ return cause.owner === undefined
+ ? `\`${cause.name}\` is a dynamic slot (declared \`${cause.declaredType}\`, which does not compile)`
+ : `\`${cause.name}\` is a dynamic piece of \`${cause.owner}\` (declared \`${cause.declaredType}\`, which does not compile)`;
}
}
@@ -95,5 +106,8 @@ export function renderPerformanceWarnings(diags: readonly ScrDiagnostic[]): stri
const blocks = diags.map(
(d) => `warning ${d.code}: ${d.message}${d.hint === undefined ? "" : `\n hint: ${d.hint}`}`,
);
- return `${blocks.join("\n\n")}\n\nThe program compiled. Adding types to these functions lets scriptc compile them to faster code.`;
+ const closing = diags.every((d) => d.code === "SC6104")
+ ? "The program compiled. Declared types that compile keep these slots static."
+ : "The program compiled. Adding types to these functions lets scriptc compile them to faster code.";
+ return `${blocks.join("\n\n")}\n\n${closing}`;
}
diff --git a/packages/compiler/src/diagnostics/envelope.test.ts b/packages/compiler/src/diagnostics/envelope.test.ts
index 535fd5512..6639e2a73 100644
--- a/packages/compiler/src/diagnostics/envelope.test.ts
+++ b/packages/compiler/src/diagnostics/envelope.test.ts
@@ -1,9 +1,10 @@
+import type { DiagnosticCode } from "./codes.js";
import { expect, test } from "vitest";
import type { ScrDiagnostic } from "./diagnostic.js";
import { SourceLocator, buildEnvelope, categoryOf, groupDiagnostics, hintOf } from "./envelope.js";
const diag = (
- code: `SC${number}`,
+ code: DiagnosticCode,
message: string,
start: number,
extra: Partial = {},
diff --git a/packages/compiler/src/diagnostics/envelope.ts b/packages/compiler/src/diagnostics/envelope.ts
index f6f8b9d3d..144e3f860 100644
--- a/packages/compiler/src/diagnostics/envelope.ts
+++ b/packages/compiler/src/diagnostics/envelope.ts
@@ -172,6 +172,8 @@ export interface DiagnosticsEnvelope {
declaredType: string;
code: string;
reason: string;
+ partial?: boolean;
+ dynamicOps: number;
})[];
/** coverage --explain-dynamic only: the report-only tier assignment. */
tiers?: TiersEnvelope;
@@ -239,7 +241,7 @@ export function isDivergenceCode(code: string): boolean {
}
export function categoryOf(diag: ScrDiagnostic): DiagnosticCategory {
- const code = diag.code;
+ const code: string = diag.code;
if (isDivergenceCode(code)) return "divergence";
if (isPerformanceCode(code)) return "performance";
if (code === "SC0001") return diag.typeWorld === true ? "environment" : "invalid-source";
@@ -257,7 +259,7 @@ export function categoryOf(diag: ScrDiagnostic): DiagnosticCategory {
* carries one. */
export function hintOf(diag: ScrDiagnostic): string {
if (diag.hint !== undefined && diag.hint !== "") return diag.hint;
- const code = diag.code;
+ const code: string = diag.code;
if (code === "SC0001")
return "fix the TypeScript error; scriptc only compiles programs that typecheck under its type world (strict null checks, the ES2025 library, Node types)";
if (code === "SC1090")
diff --git a/packages/compiler/src/frontend/contract-types.ts b/packages/compiler/src/frontend/contract-types.ts
index 4ded2a1a0..5905a12b6 100644
--- a/packages/compiler/src/frontend/contract-types.ts
+++ b/packages/compiler/src/frontend/contract-types.ts
@@ -7,6 +7,7 @@
* variable and class field types, and the class markers (Acyclic,
* ProgramLifetime) in heritage clauses. The IR-side checks live in
* ir/contract-check.ts. */
+import type { DiagnosticCode } from "../diagnostics/codes.js";
import type { ScrDiagnostic } from "../diagnostics/diagnostic.js";
import type { SrcLoc } from "../ir/ir.js";
import { locOf } from "./program.js";
@@ -134,7 +135,7 @@ function nameText(node: ts.Node | undefined): string {
}
function contractDiag(
- code: `SC${number}`,
+ code: DiagnosticCode,
loc: SrcLoc,
message: string,
hint: string,
diff --git a/packages/compiler/src/frontend/lowering/call-signatures.ts b/packages/compiler/src/frontend/lowering/call-signatures.ts
index cefe518dd..91eced9c2 100644
--- a/packages/compiler/src/frontend/lowering/call-signatures.ts
+++ b/packages/compiler/src/frontend/lowering/call-signatures.ts
@@ -1,4 +1,5 @@
import { shapeOverlayAnnotated } from "../shape-inference/index.js";
+import { layoutReturnType } from "./structural-instances.js";
import {
typedArgumentsCandidate,
typedArgumentsShapes,
@@ -897,6 +898,7 @@ export function collectSignatureInner(lowerer: Lowerer, decl: ts.FunctionDeclara
lowerer.badType(nameBlame, lowerer.typeOf(nameBlame));
}
+ if (!isAsync && !isGenerator) returnType = layoutReturnType(lowerer, decl, returnType);
const symbol = declSymbolOf(lowerer, decl);
if (!symbol) lowerer.unsupported("SC1090", decl, "this function form");
lowerer.fnSigsBySymbol.set(symbol, {
diff --git a/packages/compiler/src/frontend/lowering/divergences.ts b/packages/compiler/src/frontend/lowering/divergences.ts
index 87169539f..4b13ba363 100644
--- a/packages/compiler/src/frontend/lowering/divergences.ts
+++ b/packages/compiler/src/frontend/lowering/divergences.ts
@@ -177,6 +177,8 @@ export function scanDivergences(
if (use === null || use === undefined || param === undefined || param.type === undefined)
return;
if (!isExistingObjectExpression(arg)) return;
+ // The call kept the argument's layout: the callee has the value itself.
+ if (lowerer.layoutKeptNodes.has(arg)) return;
const copy = safely(() => widthCopyBetween(lowerer, arg, param.type!));
if (copy === undefined || copy === null) return;
out.push(
@@ -185,6 +187,7 @@ export function scanDivergences(
});
}
for (const decl of typedDecls) {
+ if (lowerer.layoutKeptNodes.has(decl.initializer!)) continue;
const name = (decl.name as ts.Identifier).text;
const scope = enclosingScope(decl);
if (scope === undefined) continue;
diff --git a/packages/compiler/src/frontend/lowering/dynamic-tier.ts b/packages/compiler/src/frontend/lowering/dynamic-tier.ts
index 3e0cea20a..03994970c 100644
--- a/packages/compiler/src/frontend/lowering/dynamic-tier.ts
+++ b/packages/compiler/src/frontend/lowering/dynamic-tier.ts
@@ -30,7 +30,7 @@ import { everyExprChild, everyStmtChild } from "../../ir/traverse.js";
import * as ts from "../ts7/adapter.js";
import { isCheckedJsSourceFile, isJsSourceFile } from "../program.js";
import { isNodeModulesPath } from "../resolve.js";
-import type { Lowerer } from "./lowerer.js";
+import type { DynamicSlot, Lowerer } from "./lowerer.js";
export interface HotDynamicFunction {
/** The IR function name (`%Point.norm`, `parse`, ...). */
@@ -208,9 +208,41 @@ function identifierCause(lowerer: Lowerer, id: ts.Identifier, depth = 0): Untype
return null;
}
+/** A dynamic slot (localized failure): the node's declared type degraded
+ * to the checked dynamic, wholly or in pieces. Null while nothing degraded. */
+function slotCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null {
+ if (lowerer.degradedTypes.size === 0) return null;
+ if (!ts.isIdentifier(n) && !ts.isPropertyAccessExpression(n)) return null;
+ // The declared type of the binding or property, not its narrowed flow
+ // type (narrowing makes new checker types).
+ let symbol = lowerer.checker.getSymbolAtLocation(ts.isIdentifier(n) ? n : n.name);
+ if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0)
+ symbol = lowerer.checker.getAliasedSymbol(symbol);
+ const type =
+ symbol !== undefined && (symbol.flags & VALUE_BINDING) !== 0
+ ? lowerer.checker.getTypeOfSymbol(symbol)
+ : lowerer.checker.getTypeAtLocation(n);
+ const id = (type as { id?: number }).id ?? -1;
+ // A whole degraded type, or a dynamic piece of a pushed-down one. A type
+ // that is also a slot of its own belongs to that slot, not to a container
+ // it is a piece of.
+ const owner = lowerer.degradedTypes.has(id) ? undefined : lowerer.lenientPieces.get(id);
+ if (!lowerer.degradedTypes.has(id) && owner === undefined) return null;
+ const text = ts.getTextOfNode(n);
+ const declaredType = lowerer.checker.typeToString(type);
+ return {
+ kind: "slot",
+ name: text.length > 40 ? `${text.slice(0, 37)}...` : text,
+ declaredType,
+ ...(owner !== undefined && owner !== declaredType ? { owner } : {}),
+ };
+}
+
/** Why a node's value has no static type, or null. */
function nodeCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null {
if (isJsonParse(n)) return { kind: "json" };
+ const slot = slotCause(lowerer, n);
+ if (slot !== null) return slot;
const id = rootIdentifier(n);
return id === null ? null : identifierCause(lowerer, id);
}
@@ -219,15 +251,28 @@ function nodeCause(lowerer: Lowerer, n: ts.Node): UntypedCause | null {
* the source does not say. The site's own subexpressions are asked first
* (the operand of a dynamic `for...of`), then its ancestors. */
function causeAt(lowerer: Lowerer, loc: SrcLoc): UntypedCause | null {
+ return causeSiteAt(lowerer, loc)?.cause ?? null;
+}
+
+/** causeAt, with the node that names the cause (none for an untyped
+ * JavaScript file). */
+function causeSiteAt(
+ lowerer: Lowerer,
+ loc: SrcLoc,
+): { cause: UntypedCause; node?: ts.Node } | null {
const sf = lowerer.program.getSourceFile(loc.file);
if (sf === undefined) return null;
if (isJsSourceFile(sf) && !isCheckedJsSourceFile(sf, lowerer.projectCheckJs))
- return { kind: "javascript" };
+ return { cause: { kind: "javascript" } };
const node = nodeAt(sf, loc.start, loc.end);
- let found: UntypedCause | null = null;
+ let found: { cause: UntypedCause; node: ts.Node } | null = null;
+ const ask = (n: ts.Node): void => {
+ const cause = nodeCause(lowerer, n);
+ if (cause !== null) found = { cause, node: n };
+ };
const descend = (n: ts.Node, depth: number): void => {
if (found !== null || depth > 3) return;
- found = nodeCause(lowerer, n);
+ ask(n);
if (found === null)
ts.forEachChild(n, (child) => (descend(child, depth + 1), found !== null || undefined));
};
@@ -237,7 +282,7 @@ function causeAt(lowerer: Lowerer, loc: SrcLoc): UntypedCause | null {
found === null && n !== undefined && n !== sf;
n = n.parent
)
- found = nodeCause(lowerer, n);
+ ask(n);
return found;
}
@@ -254,6 +299,10 @@ function causeOf(lowerer: Lowerer, sites: readonly { loc: SrcLoc }[]): UntypedCa
/** Generated functions (see scanDynamicTier), after any module prefix. */
const GENERATED = /^%*(?:m\d+\.)?%*(?:dyn|caught|init)\./;
+/** Generated helpers that repeat their callers' locations: `%dyn.*` and
+ * `%caught.*`, but not a module's top level (`%init.*`). */
+const HELPERS = /^%*(?:m\d+\.)?%*(?:dyn|caught)\./;
+
/** The likely-hot dynamic-tier functions of `module` (see the header). */
export function scanDynamicTier(lowerer: Lowerer, module: IrModule): DynamicTierReport {
const facts = new Map();
@@ -382,3 +431,69 @@ export function dynamicTierWarning(
first.loc,
);
}
+
+/** Localized failure: dynamic operations attributed to each dynamic slot
+ * of `slots` (by index), over the dynamic-tier functions of the program. A
+ * site counts for the slot the SC6101 cause search names at it: the slot
+ * declared by the binding the site reads (for a dynamic piece, the binding
+ * its access starts from), or else the one slot of the declared type. */
+export function dynamicSlotOperationCounts(
+ lowerer: Lowerer,
+ bodies: readonly IrFunction[],
+ slots: readonly DynamicSlot[],
+): number[] {
+ const counts = slots.map(() => 0);
+ if (lowerer.degradedTypes.size === 0) return counts;
+ for (const fn of bodies) {
+ // Generated helpers repeat their callers' locations; top-level code
+ // (`%init.*`) counts like any function.
+ if (HELPERS.test(fn.name)) continue;
+ for (const site of dynamicOperationSites(fn)) {
+ const found = causeSiteAt(lowerer, site.loc);
+ if (found?.cause.kind !== "slot" || found.node === undefined) continue;
+ const declaredType = found.cause.owner ?? found.cause.declaredType;
+ const index = slotOfSite(lowerer, slots, found.node, declaredType, found.cause.owner);
+ if (index >= 0) counts[index]!++;
+ }
+ }
+ return counts;
+}
+
+/** The index in `slots` of the slot a dynamic operation on `node` (an
+ * identifier or property access the cause search named) belongs to, or -1
+ * when the declared type is not one slot's and no declaration says. */
+function slotOfSite(
+ lowerer: Lowerer,
+ slots: readonly DynamicSlot[],
+ node: ts.Node,
+ declaredType: string,
+ owner: string | undefined,
+): number {
+ const candidates: number[] = [];
+ slots.forEach((slot, i) => {
+ if (slot.failure.declaredType === declaredType) candidates.push(i);
+ });
+ if (candidates.length <= 1) return candidates[0] ?? -1;
+ // The binding: the identifier or property itself for a whole slot, the
+ // root of the access for a piece of a container.
+ const named =
+ owner !== undefined
+ ? rootIdentifier(node)
+ : ts.isPropertyAccessExpression(node)
+ ? node.name
+ : node;
+ if (named === null) return -1;
+ let symbol = lowerer.checker.getSymbolAtLocation(named);
+ if (symbol !== undefined && (symbol.flags & ts.SymbolFlags.Alias) !== 0)
+ symbol = lowerer.checker.getAliasedSymbol(symbol);
+ for (const decl of symbol?.declarations ?? []) {
+ const file = ts.getSourceFileOfNode(decl).fileName;
+ const start = ts.tokenStartOfNode(decl);
+ const index = candidates.find((i) => {
+ const loc = slots[i]!.loc;
+ return loc.file === file && loc.start >= start && loc.start < decl.end;
+ });
+ if (index !== undefined) return index;
+ }
+ return -1;
+}
diff --git a/packages/compiler/src/frontend/lowering/generic-functions.ts b/packages/compiler/src/frontend/lowering/generic-functions.ts
index 647ca52a1..dcd2efc08 100644
--- a/packages/compiler/src/frontend/lowering/generic-functions.ts
+++ b/packages/compiler/src/frontend/lowering/generic-functions.ts
@@ -1652,7 +1652,7 @@ export function implicitMonoFile(sf: ts.SourceFile): boolean {
* parameter symbol — assignment, compound assignment, ++/--, a
* destructuring-assignment target, or a for-in/of cursor. A written
* param's binding could lie after the write, so it stays dyn. */
-function paramWrittenInBody(
+export function paramWrittenInBody(
lowerer: Lowerer,
body: ts.Node,
sym: ts.Symbol,
@@ -2376,18 +2376,46 @@ export function implicitDefaultInstance(
return internImplicitInstance(lowerer, blame, info, shapes, argTypes);
}
-function internImplicitInstance(
+export function internImplicitInstance(
lowerer: Lowerer,
blame: ts.Node,
info: GenericFnInfo,
shapes: ParamShape[],
argTypes: Map,
patternKey = "",
+ declaredReturn?: IrType,
): GenericInstance {
+ return internImplicitInstanceOr(
+ lowerer,
+ blame,
+ info,
+ shapes,
+ argTypes,
+ patternKey,
+ declaredReturn,
+ false,
+ )!;
+}
+
+/** internImplicitInstance; with `fallible`, a body that does not lower
+ * leaves no instance and no diagnostics, and the answer is null (the caller
+ * keeps its own lowering). */
+export function internImplicitInstanceOr(
+ lowerer: Lowerer,
+ blame: ts.Node,
+ info: GenericFnInfo,
+ shapes: ParamShape[],
+ argTypes: Map,
+ patternKey: string,
+ declaredReturn: IrType | undefined,
+ fallible: boolean,
+): GenericInstance | null {
const key = shapes.map((s) => typeKey(s.type)).join(",") + (patternKey ? `|${patternKey}` : "");
let inst = info.instances.get(key);
- if (inst?.implicitState === "failed")
+ if (inst?.implicitState === "failed") {
+ if (fallible) return null;
lowerer.unsupported("SC1090", blame, `the failed specialization of '${info.baseName}'`);
+ }
if (inst) {
// A recursive caller has observed this ABI before inference settles.
// Keep the promised return slot and coerce the completed body's returns.
@@ -2411,7 +2439,8 @@ function internImplicitInstance(
shapes[index]?.type.kind === "dyn" &&
broadPromiseParam(lowerer, lowerer.typeOf(param.name)),
);
- const declared = dynamicBroadPromise ? null : implicitDeclaredReturn(lowerer, info);
+ const declared =
+ declaredReturn ?? (dynamicBroadPromise ? null : implicitDeclaredReturn(lowerer, info));
inst = {
path,
name: keyedHelperName(lowerer, `${info.qualifiedName}%`, key, ""),
@@ -2432,6 +2461,25 @@ function internImplicitInstance(
// JS body must still have a throwing implementation: later references
// (including recursive ones) can already hold its cached signature.
const diagsBefore = lowerer.diags.length;
+ const fencesBefore = lowerer.runtimeFences.length;
+ const totalBefore = lowerer.stats.statementsTotal;
+ const failedBefore = lowerer.stats.statementsFailed;
+ const skippedBefore = lowerer.stats.functionsSkipped;
+ const blockedBefore = fallible ? new Set(lowerer.blockedBindings) : null;
+ const deferredBefore = fallible ? new Set(lowerer.deferredDiags.keys()) : null;
+ /** Undoes what a failed fallible body recorded: the instance's
+ * statements share their symbols with the declaration's own body. */
+ const rollback = (): void => {
+ lowerer.diags.splice(diagsBefore);
+ lowerer.runtimeFences.splice(fencesBefore);
+ lowerer.stats.statementsTotal = totalBefore;
+ lowerer.stats.statementsFailed = failedBefore;
+ lowerer.stats.functionsSkipped = skippedBefore;
+ for (const symbol of [...lowerer.blockedBindings])
+ if (!blockedBefore!.has(symbol)) lowerer.blockedBindings.delete(symbol);
+ for (const symbol of [...lowerer.deferredDiags.keys()])
+ if (!deferredBefore!.has(symbol)) lowerer.deferredDiags.delete(symbol);
+ };
// The instance is cached even when a receiver probe requested it.
// Its body must retain statement fences instead of dropping failures
// into that probe's temporary diagnostic sink and caching empty code.
@@ -2451,10 +2499,22 @@ function internImplicitInstance(
...(info.decl.body ? [info.decl.body] : []),
]);
const fn = lowerer.lowerGenericInstance(info, inst);
+ if (
+ fallible &&
+ (lowerer.diags.length > diagsBefore || lowerer.runtimeFences.length > fencesBefore)
+ ) {
+ inst.implicitState = "failed";
+ rollback();
+ return null;
+ }
lowerer.implicitFns.push(fn);
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
inst.implicitState = "failed";
+ if (fallible) {
+ rollback();
+ return null;
+ }
const types: IrType[] = shapes.map((shape) => shape.type);
if (info.member?.kind === "method")
types.unshift({ kind: "object", className: info.member.cls.def.name });
diff --git a/packages/compiler/src/frontend/lowering/lower-calls.ts b/packages/compiler/src/frontend/lowering/lower-calls.ts
index 3a329c547..380c34615 100644
--- a/packages/compiler/src/frontend/lowering/lower-calls.ts
+++ b/packages/compiler/src/frontend/lowering/lower-calls.ts
@@ -3,6 +3,7 @@ import {
arrayClassReceiver,
lowerArrayClassStaticCall,
} from "./builtins/array-classes.js";
+import { structuralCallInstance } from "./structural-instances.js";
import { checkedPrivateMethodReceiver, privateNameClass } from "./private-brands.js";
import { assertFunctionAliasOf } from "./lower-assert.js";
import { keyedHelperName } from "./stable-names.js";
@@ -1368,6 +1369,16 @@ export function lowerCall(lowerer: Lowerer, expr: ts.CallExpression): IrExpr {
sig &&
!(jsSpreadArgs && spreadNeedsRuntimeArity(lowerer, sig.params, expr.arguments.nodes))
) {
+ const layout = structuralCallInstance(lowerer, expr, sig);
+ if (layout) {
+ return reconcileOverloadReturn(lowerer, expr, {
+ kind: "call",
+ callee: layout.name,
+ args: lowerer.completeArgs(expr.arguments.nodes, layout.params, loc, expr),
+ type: layout.returnType,
+ loc,
+ });
+ }
lowerer.noteEdge(sig.name);
const args = lowerer.completeArgs(expr.arguments.nodes, sig.params, loc, expr);
const symbol = lowerer.resolveValueSymbol(expr.expression);
diff --git a/packages/compiler/src/frontend/lowering/lower-exprs.ts b/packages/compiler/src/frontend/lowering/lower-exprs.ts
index c70826975..55db76117 100644
--- a/packages/compiler/src/frontend/lowering/lower-exprs.ts
+++ b/packages/compiler/src/frontend/lowering/lower-exprs.ts
@@ -1,3 +1,4 @@
+import type { DiagnosticCode } from "../../diagnostics/codes.js";
import {
arrayClassMemberOwner,
arrayOfClassInstance,
@@ -810,7 +811,7 @@ function lowerExprInner(lowerer: Lowerer, expr: ts.Expression): IrExpr {
const entry = UNSUPPORTED_EXPR[expr.kind];
if (entry)
lowerer.unsupported(
- entry.code as `SC${number}` & keyof typeof UNSUPPORTED,
+ entry.code as DiagnosticCode & keyof typeof UNSUPPORTED,
expr,
entry.feature,
);
diff --git a/packages/compiler/src/frontend/lowering/lower-stmts.ts b/packages/compiler/src/frontend/lowering/lower-stmts.ts
index fc259bbd8..8c49ae4d9 100644
--- a/packages/compiler/src/frontend/lowering/lower-stmts.ts
+++ b/packages/compiler/src/frontend/lowering/lower-stmts.ts
@@ -1,3 +1,5 @@
+import type { DiagnosticCode } from "../../diagnostics/codes.js";
+import { keepsInitializerLayout, notePreservedLayout } from "./structural-instances.js";
import { jsGlobalValueByName } from "./global-values.js";
import { lowerTupleAliasDecl, stripTypeWrappers } from "./typed-arguments.js";
import {
@@ -758,10 +760,18 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
// out of tdzPredeclared; a retry after a later declarator degrades
// must initialize the same box again.
const tdzBefore = new Map(lowerer.ctx.tdzPredeclared);
+ // The first attempt's diagnostics: a statement that still fails
+ // after degrading reports them, since a retry does not repeat what
+ // lowered once (a poisoned generic instance) and its own diagnostics
+ // can miss the failure's cause.
+ let firstAttempt: ScrDiagnostic[] | null = null;
try {
for (let attempt = 0; ; attempt++) {
+ const hits = new Set();
try {
- lowerOneStatement(lowerer, stmt, out, usingMarkers, diagsBefore, deferFences);
+ lowerer.withPartialHits(hits, () =>
+ lowerOneStatement(lowerer, stmt, out, usingMarkers, diagsBefore, deferFences),
+ );
break;
} catch (e) {
// Localized failure's statement commit point (design §3.3): a
@@ -769,9 +779,21 @@ export function lowerStmts(lowerer: Lowerer, stmts: readonly ts.Statement[]): Ir
// global degrades that type and the statement lowers again.
// A declaration this statement flushed inside a probe (where
// no retry runs) can retry here too.
- if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw e;
+ firstAttempt ??= lowerer.diags.slice(diagsBefore);
+ const giveUp = (): unknown => {
+ if (attempt > 0) lowerer.restoreFirstAttemptDiags(diagsBefore, firstAttempt!);
+ return e;
+ };
+ if (attempt >= MAX_DEGRADE_RETRIES || deferFences) throw giveUp();
const slot = lowerer.degradeAtCommit(e, STATEMENT_SLOTS);
- if (slot === null && !lowerer.reopenFlushedAtCommit(diagsBefore)) throw e;
+ // Pushed-down types the failed attempt mapped widen to the
+ // whole slot before anything else is given up.
+ if (
+ slot === null &&
+ !lowerer.reopenFlushedAtCommit(diagsBefore) &&
+ !lowerer.widenPartials(hits)
+ )
+ throw giveUp();
// A failed attempt adds nothing to `out` but the TDZ boxes
// forward captures declared (they stay declared), so the
// retry appends after them.
@@ -863,7 +885,8 @@ const MAX_DEGRADE_RETRIES = 16;
/** The slot kinds lowerStmts' commit point degrades: locals, loop
* variables and module globals (stage L1), and the parameters and returns
* of the function expressions, arrows and methods a statement holds
- * (stage L2). */
+ * (stage L2), and the types of the statement's own expressions: casts,
+ * literals with a contextual type, spread clones, temporaries (stage L4). */
const STATEMENT_SLOTS: ReadonlySet = new Set([
"local",
"global",
@@ -871,6 +894,8 @@ const STATEMENT_SLOTS: ReadonlySet = new Set([
"param",
"return",
"field",
+ "member",
+ "expression",
]);
/** One attempt at lowering a statement of lowerStmts into `out`. */
@@ -1766,8 +1791,8 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
const symbol = lowerer.checker.getSymbolAtLocation(stmt.name);
const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined;
if (pre) {
- lowerer.ctx.tdzPredeclared.delete(symbol!);
const value = lowerer.coerceToExpected(lowerer.lowerClassExpression(stmt), DYN);
+ lowerer.ctx.tdzPredeclared.delete(symbol!);
return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(stmt) };
}
const type: IrType = { kind: "classval", className: info.def.name };
@@ -2041,7 +2066,7 @@ export function lowerStmt(lowerer: Lowerer, stmt: ts.Statement): IrStmt | IrStmt
const entry = UNSUPPORTED_STMT[stmt.kind];
if (entry)
lowerer.unsupported(
- entry.code as `SC${number}` & keyof typeof UNSUPPORTED,
+ entry.code as DiagnosticCode & keyof typeof UNSUPPORTED,
stmt,
entry.feature,
);
@@ -5787,8 +5812,8 @@ export function lowerVarDecl(
const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
const pre = symbol ? lowerer.ctx.tdzPredeclared.get(symbol) : undefined;
if (pre) {
- lowerer.ctx.tdzPredeclared.delete(symbol!);
const value = genericBindingStoredValue(lowerer, decl, info);
+ lowerer.ctx.tdzPredeclared.delete(symbol!);
return { kind: "assign", localId: pre.id, value, initializes: true, loc: locOf(decl) };
}
return genericBindingPresenceStore(info, decl);
@@ -5924,6 +5949,16 @@ export function lowerVarDecl(
)
g.type = DYN;
g.type = inferredOptionalRecordType(lowerer, decl, g.type, raw.type);
+ // A module const keeps its initializer's record or array layout, as a
+ // function's const does (keepsInitializerLayout).
+ if (
+ !isVarDeclared(decl) &&
+ (ts.getCombinedNodeFlags(decl) & ts.NodeFlags.Const) !== 0 &&
+ keepsInitializerLayout(lowerer, decl.initializer, raw.type, g.type)
+ ) {
+ g.type = raw.type;
+ notePreservedLayout(lowerer, decl);
+ }
const arithmetic =
raw.type.kind === "union" ? lowerer.unions.get(raw.type.unionId)?.arms : undefined;
if (
@@ -5951,11 +5986,13 @@ export function lowerVarDecl(
// A forward-captured binding pre-declared as a TDZ box (an earlier
// function in this scope captured it — predeclareForwardCapture): the
// binding and its scope-entry varDecl already exist; the source
- // declaration is the one initializing `assign` into the shared box.
+ // declaration is the one initializing `assign` into the shared box. The
+ // box stays pre-declared until the initializer lowers: a statement the
+ // commit point lowers again (localized failure) initializes the same box.
const pre = declSymbol ? lowerer.ctx.tdzPredeclared.get(declSymbol) : undefined;
if (pre && decl.initializer) {
- lowerer.ctx.tdzPredeclared.delete(declSymbol!);
const init = lowerer.lowerExprExpecting(decl.initializer, pre.type);
+ lowerer.ctx.tdzPredeclared.delete(declSymbol!);
return { kind: "assign", localId: pre.id, value: init, initializes: true, loc: locOf(decl) };
}
@@ -6071,6 +6108,17 @@ export function lowerVarDecl(
// Promise.prototype is an ordinary object the checker types Promise.
if (!decl.type && !hasJsTypeAnnotation(decl) && isPromisePrototypeRead(lowerer, decl.initializer))
type = DYN;
+ // A const whose initializer has another record or array layout than the
+ // binding's type keeps the initializer's layout: converting would copy the
+ // value, so the binding would not be the value its initializer names.
+ if (
+ !isLet &&
+ type !== null &&
+ keepsInitializerLayout(lowerer, decl.initializer, init.type, type)
+ ) {
+ type = init.type;
+ notePreservedLayout(lowerer, decl);
+ }
// A typed-arguments function expression (typed-arguments.ts): the
// checker types the binding variadic, but its value has the fixed
// signature every use asserts.
diff --git a/packages/compiler/src/frontend/lowering/lowerer.ts b/packages/compiler/src/frontend/lowering/lowerer.ts
index 46631a27e..7138070e2 100644
--- a/packages/compiler/src/frontend/lowering/lowerer.ts
+++ b/packages/compiler/src/frontend/lowering/lowerer.ts
@@ -1,3 +1,4 @@
+import type { DiagnosticCode } from "../../diagnostics/codes.js";
import { markInferredFactChecks, shapeOverlayFileNames } from "../shape-inference/index.js";
import { npmStaticPackageOfPath } from "../npm-static.js";
import { globalMemberValue } from "./global-values.js";
@@ -187,7 +188,7 @@ import { ClassProtocols } from "./class-protocols.js";
import { earlyReadVarSymbols } from "./var-hoisting.js";
import { recordTextCodecClass } from "../../ir/ir.js";
import { assignLegacyTiers, type DynamicFunction } from "../../ir/tiers.js";
-import { dynamicTierWarning } from "./dynamic-tier.js";
+import { dynamicSlotOperationCounts, dynamicTierWarning } from "./dynamic-tier.js";
import { promoteJsvals } from "../../ir/jsval.js";
import {
analyzeTiers,
@@ -750,6 +751,9 @@ export interface DynamicSlot {
failure: TypeFailure;
code: string;
reason: string;
+ /** The slot keeps its type's compilable shape; only the failing
+ * components are checked-dynamic (push-down, stage L3). */
+ partial?: true;
}
/** Localized failure: rounds of the declaration flush retry. */
@@ -964,7 +968,7 @@ export interface LowerOptions {
}
interface RuntimeFenceFallback {
- code: `SC${number}`;
+ code: DiagnosticCode;
message: string;
}
@@ -1001,6 +1005,14 @@ type RuntimeFenceTarget =
| RuntimeFenceFunctionTarget
| RuntimeFenceClosureTarget;
+/** A degraded checker type (Lowerer.degradedTypes): its lenient mapping and
+ * the failure that degraded it. */
+export interface DegradedType {
+ lenient: IrType | null;
+ code: string;
+ reason: string;
+}
+
/** The Lowerer's pass configuration (see lowerToIr). */
export interface LowererMode {
/** Symbols whose deferred diagnostics reachable emit already flushed —
@@ -1009,10 +1021,14 @@ export interface LowererMode {
/** Localized failure: the types reachable emit degraded (the coverage
* remainder maps them alike, so the declarations reachable emit
* registered with dynamic slots collect there too). */
- degradedTypes?: ReadonlyMap;
+ degradedTypes?: ReadonlyMap;
/** Localized failure: the declarations reachable emit registered with
* dynamic slots (the coverage remainder registers them too). */
retriedSymbols?: ReadonlySet;
+ /** Localized failure: degrade every type to the whole-slot checked
+ * dynamic, never to its pushed-down shape (lowerToIr's comparison
+ * pass). */
+ wholeSlotDegradation?: boolean;
/** LowerOptions.externalTypes, threaded through every lowering pass. */
externalTypes?: ReadonlyMap;
/** LowerOptions.externalTypeSpecifiersByFile, shared by every pass. */
@@ -1230,6 +1246,39 @@ export function lowerToIr(
let result = emitted.result;
let resultLowerer = reachableEmit;
timing("reachable-emit", { reachable: reachable.size });
+ // Localized failure (stage L3): push-down must never cost a failing
+ // program more errors than the whole-slot checked dynamic. A pushed-down
+ // type is baked into the globals, fields and signatures that hold it, so
+ // when the program still fails, the emit runs once more with every
+ // degraded type held whole, and the pass with fewer errors wins (ties
+ // keep push-down). A program that compiles never takes this path.
+ if (result.diagnostics.length > 0 && reachableEmit.hasPartialDegradedTypes()) {
+ const whole = new Lowerer(program, entry, moduleOrder, {
+ frontendServices: options.frontendServices,
+ projectCheckJs: options.projectCheckJs ?? false,
+ targetPlatform,
+ startupCrash,
+ inlineRequireCycleCalls,
+ ffiImports,
+ libraryCallbacks,
+ forkTargets,
+ workerTargets,
+ externalTypes,
+ externalTypeSpecifiersByFile,
+ tierMode,
+ ffiBindingSymbols: ffiValidation.symbolsByName,
+ wholeSlotDegradation: true,
+ });
+ for (const d of extraModuleCycleDiags) whole.pushDiag(d);
+ for (const d of ffiValidation.diagnostics) whole.pushDiag(d);
+ const wholeEmitted = whole.emitReachable(options.libRoots);
+ if (wholeEmitted.result.diagnostics.length < result.diagnostics.length) {
+ reachable = wholeEmitted.reachable;
+ result = wholeEmitted.result;
+ resultLowerer = whole;
+ }
+ timing("whole-slot-relower", { reachable: wholeEmitted.reachable.size });
+ }
// The general stream representation (TypeMapperCtx.generalNodeStreams)
// is a per-program decision: only a program whose reachable code moved
// a general stream into a stdio-represented stream slot lowers again
@@ -1256,6 +1305,7 @@ export function lowerToIr(
ffiBindingSymbols: ffiValidation.symbolsByName,
generalNodeStreams: true,
...(dyn ? { dynNodeStreams: true } : {}),
+ ...(resultLowerer.wholeSlotDegradation ? { wholeSlotDegradation: true } : {}),
});
for (const d of extraModuleCycleDiags) general.pushDiag(d);
for (const d of ffiValidation.diagnostics) general.pushDiag(d);
@@ -1298,6 +1348,7 @@ export function lowerToIr(
externalTypes,
externalTypeSpecifiersByFile,
tierMode,
+ ...(resultLowerer.wholeSlotDegradation ? { wholeSlotDegradation: true } : {}),
});
for (const d of extraModuleCycleDiags) emit.pushDiag(d);
for (const d of ffiValidation.diagnostics) emit.pushDiag(d);
@@ -1319,13 +1370,20 @@ export function lowerToIr(
}
// Localized failure (SC6104): one performance warning per dynamic slot.
if (resultLowerer.dynamicSlots.length > 0) {
- const dynamicSlots: DynamicSlotEntry[] = resultLowerer.dynamicSlots.map((slot) => ({
+ const operations = dynamicSlotOperationCounts(
+ resultLowerer,
+ resultLowerer.dynamicBodies,
+ resultLowerer.dynamicSlots,
+ );
+ const dynamicSlots: DynamicSlotEntry[] = resultLowerer.dynamicSlots.map((slot, i) => ({
+ operations: operations[i]!,
loc: slot.loc,
name: slot.failure.name,
slot: slot.failure.slot,
declaredType: slot.failure.declaredType,
code: slot.code,
reason: slot.reason,
+ ...(slot.partial ? { partial: true } : {}),
}));
result = {
...result,
@@ -2033,6 +2091,15 @@ export class Lowerer {
* exact binding discipline as globals/functions. */
readonly staticCallables = new Map();
readonly genericFnsBySymbol = new Map();
+ /** Typed declarations' instances for arguments that keep their own
+ * record or array layout (structural-instances.ts). */
+ readonly structuralFnInfos = new Map();
+ /** Consts that keep their initializer's record or array layout, with the
+ * initializer's checker type (structural-instances.ts). */
+ readonly preservedLayoutTypes = new Map();
+ /** Arguments and const initializers whose array or record layout the
+ * lowering kept instead of copying (divergences.ts reports no copy there). */
+ readonly layoutKeptNodes = new Set();
/** Object-literal GENERIC methods (`{ m(x: T) {...} }`), interned by
* their function-like node — instances ride the same monomorphization
* queue (objLitGenericFnInfoOf). */
@@ -3892,13 +3959,24 @@ export class Lowerer {
* at a committed slot, with their lenient mapping. Filled only at the
* commit points (degradeAtCommit), so a program that compiles today
* never fills it. */
- readonly degradedTypes = new Map();
+ readonly degradedTypes = new Map();
+ /** A pushed-down (partial) degraded type was mapped (hasPartialDegradedTypes). */
+ private partialDegradedTypesMapped = false;
+ /** The components lenient mappings turned into the checked dynamic, by
+ * checker type id, with the declared type they are pieces of (SC6101
+ * causes and per-slot operation counts). */
+ readonly lenientPieces = new Map();
+ /** One frame per unit a commit point may retry: the pushed-down
+ * (partial) degraded types its lowering mapped (withPartialHits). */
+ readonly partialHits: Set[] = [];
/** The dynamic slots the commit points created: one SC6104 each. */
readonly dynamicSlots: DynamicSlot[] = [];
/** Declarations retryDeferred registered. */
readonly retriedSymbols = new Set();
/** LowererMode.retriedSymbols. */
readonly priorRetriedSymbols: ReadonlySet;
+ /** LowererMode.wholeSlotDegradation. */
+ readonly wholeSlotDegradation: boolean;
/** Bodies run() skipped because their declaration had not registered. */
readonly skippedBodies = new Set();
/** Declarations retryDeferred registered, in order: run() lowers the
@@ -4078,9 +4156,16 @@ export class Lowerer {
) {
// The mode's options, sorted by name; the values derived from them follow.
this.alreadyFlushed = mode.alreadyFlushed ?? new Set();
+ // Lenient forms are IR types of the other pass's shape tables: only the
+ // whole-slot dynamic carries over as is.
if (mode.degradedTypes)
- for (const [id, degraded] of mode.degradedTypes) this.degradedTypes.set(id, degraded);
+ for (const [id, degraded] of mode.degradedTypes)
+ this.degradedTypes.set(id, {
+ ...degraded,
+ lenient: degraded.lenient?.kind === "dyn" ? DYN : null,
+ });
this.priorRetriedSymbols = mode.retriedSymbols ?? new Set();
+ this.wholeSlotDegradation = mode.wholeSlotDegradation ?? false;
this.externalTypes = mode.externalTypes ?? new Map();
this.ffiBindingSymbols = mode.ffiBindingSymbols ?? null;
this.ffiImports = mode.ffiImports ?? [];
@@ -4119,7 +4204,21 @@ export class Lowerer {
resolveBoundAliasTs: this.boundAliasTsResolver,
degradedType: (type) => {
const id = (type as { id?: number }).id;
- return id === undefined ? null : (this.degradedTypes.get(id)?.lenient ?? null);
+ if (id === undefined) return null;
+ const degraded = this.degradedTypes.get(id);
+ if (degraded === undefined) return null;
+ // An entry handed over from another pass (the coverage remainder)
+ // re-derives its lenient form in this lowerer's shape tables; the
+ // checked dynamic cuts the knot while it does.
+ if (degraded.lenient === null) {
+ degraded.lenient = DYN;
+ degraded.lenient = this.lenientType(type);
+ }
+ if (degraded.lenient.kind !== "dyn") {
+ this.partialHits[this.partialHits.length - 1]?.add(id);
+ this.partialDegradedTypesMapped = true;
+ }
+ return degraded.lenient;
},
...(shadow !== undefined ? { canonShadow: shadow } : {}),
isErasing: () => this.erasingDepth > 0,
@@ -9714,7 +9813,7 @@ export class Lowerer {
}
unsupported(
- code: keyof typeof UNSUPPORTED & `SC${number}`,
+ code: keyof typeof UNSUPPORTED & DiagnosticCode,
node: ts.Node,
featureOverride?: string,
hintOverride?: string,
@@ -9781,8 +9880,9 @@ export class Lowerer {
if (!failure.degradable || failure.family === "dyn-field-policy") return null;
if (!slots.has(failure.slot)) return null;
const id = (type as { id?: number }).id;
- // Already degraded and still failing: no progress, commit as today.
- if (id === undefined || this.degradedTypes.has(id)) return null;
+ // Already degraded to the whole-slot dynamic and still failing: no
+ // progress, commit as today.
+ if (id === undefined || this.degradedTypes.get(id)?.lenient?.kind === "dyn") return null;
let committed: ScrDiagnostic | undefined;
for (let i = this.diags.length - 1; i >= 0; i--) {
if (this.diags[i]!.typeFailure !== failure) continue;
@@ -9790,8 +9890,15 @@ export class Lowerer {
this.diags.splice(i, 1);
}
if (committed === undefined) return null;
- this.degradedTypes.set(id, { lenient: DYN, code: committed.code, reason: committed.message });
- return { loc: committed.loc, failure, code: committed.code, reason: committed.message };
+ this.degradeType(type, committed.code, committed.message);
+ const partial = this.degradedTypes.get(id)?.lenient?.kind !== "dyn";
+ return {
+ ...(partial ? { partial: true as const } : {}),
+ loc: committed.loc,
+ failure,
+ code: committed.code,
+ reason: committed.message,
+ };
}
/** A declaration slot whose type maps through degradedTypes is a
@@ -9800,7 +9907,9 @@ export class Lowerer {
noteDegradedSlot(node: ts.Node, type: ts.Type): void {
if (this.diagSink !== null) return;
const degraded = this.degradedTypes.get((type as { id?: number }).id ?? -1);
- if (degraded === undefined) return;
+ // A pushed-down type is one slot, reported where it degraded; only
+ // whole-slot dynamic declarations are reported one by one.
+ if (degraded === undefined || degraded.lenient?.kind !== "dyn") return;
const failure = typeFailureOf(node, this.checker.typeToString(type), "unsupported");
if (failure.slot === "expression" || failure.slot === "member") return;
this.noteDynamicSlot({
@@ -9858,6 +9967,19 @@ export class Lowerer {
this.diags.push(...kept);
}
+ /** A unit that still fails after its retries commits its first
+ * attempt's own diagnostics (`first`, taken before the first
+ * degradation) in place of the last attempt's. The diagnostics the
+ * retries carry (dropAttemptDiags) stay as they are now: a declaration
+ * reopened since may have registered. */
+ restoreFirstAttemptDiags(diagsBefore: number, first: readonly ScrDiagnostic[]): void {
+ const carried = (d: ScrDiagnostic): boolean =>
+ d.rejects !== undefined || this.loweredOnceDiags.has(d);
+ const kept = this.diags.slice(diagsBefore).filter(carried);
+ this.diags.length = diagsBefore;
+ this.diags.push(...first.filter((d) => !carried(d)), ...kept);
+ }
+
/** Lowers one unit whose body-level poison skips it (a generic
* instance), with localized failure's commit point: a degradable type
* failure at one of its declaration slots degrades the type and the unit
@@ -9867,21 +9989,100 @@ export class Lowerer {
const slots: DynamicSlot[] = [];
for (let attempt = 0; ; attempt++) {
const mark = this.diags.length;
+ const hits = new Set();
try {
- const result = lower();
+ const result = this.withPartialHits(hits, lower);
for (const slot of slots) this.noteDynamicSlot(slot);
return result;
} catch (e) {
if (!(e instanceof PoisonError)) throw e;
if (attempt >= MAX_DECLARATION_RETRIES) return null;
const slot = this.degradeAtCommit(e, DECLARATION_SLOTS);
- if (slot === null) return null;
- slots.push(slot);
+ if (slot === null && !this.widenPartials(hits)) return null;
+ if (slot !== null) slots.push(slot);
this.dropAttemptDiags(mark);
}
}
}
+ /** Localized failure's lenient mapping (stage L3, push-down): the type
+ * mapped with each failing component as the checked dynamic, so a record
+ * keeps its static members, an array of records its records, and a
+ * function its arity. The whole slot is the checked dynamic when even
+ * that shape does not map. Throwaway context: nothing memoizes. */
+ lenientType(type: ts.Type): IrType {
+ const { typeMemo: _strictMemo, ...strict } = this.typeCtx;
+ const owner = this.checker.typeToString(type);
+ const mapped = mapType(type, {
+ ...strict,
+ lenient: true,
+ canMemoizeType: () => false,
+ onLenientPiece: (piece) => {
+ const id = (piece as { id?: number }).id;
+ if (id !== undefined && !this.lenientPieces.has(id)) this.lenientPieces.set(id, owner);
+ },
+ });
+ return mapped === null || mapped.kind === "void" ? DYN : mapped;
+ }
+
+ /** Degrades a type for a commit point: its lenient (pushed-down) form
+ * first; a type already degraded that still fails falls back to the
+ * whole-slot checked dynamic. False when nothing is left to degrade. */
+ private degradeType(type: ts.Type, code: string, reason: string): boolean {
+ const id = (type as { id?: number }).id;
+ if (id === undefined) return false;
+ const known = this.degradedTypes.get(id);
+ if (known !== undefined) {
+ if (known.lenient?.kind === "dyn") return false;
+ this.degradedTypes.set(id, { lenient: DYN, code, reason });
+ return true;
+ }
+ this.degradedTypes.set(id, {
+ lenient: this.wholeSlotDegradation ? DYN : this.lenientType(type),
+ code,
+ reason,
+ });
+ return true;
+ }
+
+ /** Some degraded type kept a pushed-down (partial) shape, or one was
+ * mapped before a retry widened it: the IR lowered before the retry keeps
+ * that shape. */
+ hasPartialDegradedTypes(): boolean {
+ if (this.partialDegradedTypesMapped) return true;
+ for (const degraded of this.degradedTypes.values())
+ if (degraded.lenient !== null && degraded.lenient.kind !== "dyn") return true;
+ return false;
+ }
+
+ /** Runs a unit a commit point may retry, recording the pushed-down
+ * types it maps; the frame's hits also count for the enclosing unit. */
+ withPartialHits(hits: Set, run: () => T): T {
+ this.partialHits.push(hits);
+ try {
+ return run();
+ } finally {
+ this.partialHits.pop();
+ const outer = this.partialHits[this.partialHits.length - 1];
+ if (outer !== undefined) for (const id of hits) outer.add(id);
+ }
+ }
+
+ /** Push-down is a refinement, never a new failure: a unit that still
+ * fails after mapping pushed-down types falls back to the whole-slot
+ * checked dynamic for them. True when some type widened (retry). */
+ widenPartials(hits: ReadonlySet): boolean {
+ if (this.tierMode !== "auto" || this.diagSink !== null) return false;
+ let widened = false;
+ for (const id of hits) {
+ const degraded = this.degradedTypes.get(id);
+ if (degraded === undefined || degraded.lenient?.kind === "dyn") continue;
+ degraded.lenient = DYN;
+ widened = true;
+ }
+ return widened;
+ }
+
/** Records a dynamic slot whose unit lowered with it (one per site). */
noteDynamicSlot(slot: DynamicSlot): void {
const seen = this.dynamicSlots.some(
@@ -12985,8 +13186,9 @@ export class Lowerer {
if (collector === undefined) return false;
const slots: DynamicSlot[] = [];
// The types this retry degraded (not those of retries its collections
- // ran), for retryDeferredAtDeclaration to restore when it fails.
- const degraded: number[] = [];
+ // ran) and their entries before it, for retryDeferredAtDeclaration to
+ // restore when it fails.
+ const degraded: { id: number; prior: DegradedType | undefined }[] = [];
try {
return this.retryDeferredRounds(symbol, collector, diags, slots, degraded);
} finally {
@@ -12999,11 +13201,11 @@ export class Lowerer {
collector: { symbolOf: () => ts.Symbol | undefined; collect: () => void },
diags: readonly ScrDiagnostic[],
slots: DynamicSlot[],
- degraded: number[],
+ degraded: { id: number; prior: DegradedType | undefined }[],
): boolean {
let current = diags;
for (let round = 0; round < MAX_DECLARATION_RETRIES; round++) {
- const failing: { diag: ScrDiagnostic; failure: TypeFailure; id: number }[] = [];
+ const failing: { diag: ScrDiagnostic; failure: TypeFailure; type: ts.Type }[] = [];
let retriable = false;
for (const diag of current) {
if (this.retriableDiags.has(diag)) {
@@ -13021,28 +13223,54 @@ export class Lowerer {
const id = (type as { id?: number } | undefined)?.id;
if (
failure === undefined ||
+ type === undefined ||
id === undefined ||
!failure.degradable ||
!DECLARATION_SLOTS.has(failure.slot) ||
- this.degradedTypes.has(id)
+ this.degradedTypes.get(id)?.lenient?.kind === "dyn"
)
return false;
- failing.push({ diag, failure, id });
+ failing.push({ diag, failure, type });
}
// A round makes progress by degrading a type, or (once) by running
// a collection that only waited for another declaration.
if (failing.length === 0 && !(retriable && round === 0)) return false;
- for (const { diag, failure, id } of failing) {
- this.degradedTypes.set(id, { lenient: DYN, code: diag.code, reason: diag.message });
- degraded.push(id);
- slots.push({ loc: diag.loc, failure, code: diag.code, reason: diag.message });
+ for (const { diag, failure, type } of failing) {
+ const id = (type as { id?: number }).id ?? -1;
+ const prior = this.degradedTypes.get(id);
+ degraded.push({
+ id,
+ prior:
+ prior === undefined
+ ? undefined
+ : { lenient: prior.lenient, code: prior.code, reason: prior.reason },
+ });
+ this.degradeType(type, diag.code, diag.message);
+ const partial = this.degradedTypes.get(id)?.lenient?.kind !== "dyn";
+ slots.push({
+ loc: diag.loc,
+ failure,
+ code: diag.code,
+ reason: diag.message,
+ ...(partial ? { partial: true as const } : {}),
+ });
}
this.deferredDiags.delete(symbol);
const lifted = this.liftDynFieldPolicy;
this.liftDynFieldPolicy = true;
- let registered: boolean;
+ // Pushed-down types that did not help widen to the whole slot, and the
+ // collection runs again (a few times at most).
+ let registered = false;
try {
- registered = this.collectDeferring(collector.symbolOf, collector.collect) === null;
+ for (let widen = 0; widen < 4; widen++) {
+ const hits = new Set();
+ registered =
+ this.withPartialHits(hits, () =>
+ this.collectDeferring(collector.symbolOf, collector.collect),
+ ) === null;
+ if (registered || !this.widenPartials(hits)) break;
+ this.deferredDiags.delete(symbol);
+ }
} finally {
this.liftDynFieldPolicy = lifted;
}
@@ -13064,7 +13292,7 @@ export class Lowerer {
return false;
}
- private retryDegraded: number[] = [];
+ private retryDegraded: { id: number; prior: DegradedType | undefined }[] = [];
/** A deferred class with static field initializers is reached where its
* statement evaluates: Node runs the initializers there, referenced or
@@ -13080,7 +13308,11 @@ export class Lowerer {
this.retryDegraded = [];
registered = this.retryDeferred(symbol, diags);
if (!registered) {
- for (const id of this.retryDegraded) this.degradedTypes.delete(id);
+ for (let i = this.retryDegraded.length - 1; i >= 0; i--) {
+ const { id, prior } = this.retryDegraded[i]!;
+ if (prior === undefined) this.degradedTypes.delete(id);
+ else this.degradedTypes.set(id, prior);
+ }
this.deferredDiags.set(symbol, [...diags]);
}
this.retryDegraded = [];
@@ -13546,8 +13778,7 @@ export class Lowerer {
/* ── scoping and captures ─────────────────────────────────────────── */
declareLocal(nameNode: ts.Node, name: string, type: IrType, mutable: boolean): IrLocal {
- if (type.kind === "dyn" && this.degradedTypes.size > 0)
- this.noteDegradedSlot(nameNode, this.typeOf(nameNode));
+ if (this.degradedTypes.size > 0) this.noteDegradedSlot(nameNode, this.typeOf(nameNode));
const symbol = this.checker.getSymbolAtLocation(nameNode);
const local = declareContextLocal(
this.ctx,
diff --git a/packages/compiler/src/frontend/lowering/structural-instances.ts b/packages/compiler/src/frontend/lowering/structural-instances.ts
new file mode 100644
index 000000000..c321bacd7
--- /dev/null
+++ b/packages/compiler/src/frontend/lowering/structural-instances.ts
@@ -0,0 +1,295 @@
+import * as ts from "../ts7/adapter.js";
+import type { IrType } from "../../ir/ir.js";
+import { typeEquals, UNDEFINED_T } from "../../ir/ir.js";
+import type { FnSig, ParamShape } from "./call-signatures.js";
+import { isThisParameter } from "./call-signatures.js";
+import {
+ internImplicitInstanceOr,
+ paramWrittenInBody,
+ type GenericFnInfo,
+ type GenericInstance,
+} from "./generic-functions.js";
+import { nsPathPrefix } from "./lower-namespaces.js";
+import type { Lowerer } from "./lowerer.js";
+
+/** Instances of a typed function for arguments that keep their own layout.
+ *
+ * A record or array whose layout differs from a parameter's declared
+ * structural type converts by copying it (`{ id; name; owned?: true }[]`
+ * into `{ id; name }[]`). JavaScript passes the same object, so the
+ * callee's writes (`push`, index stores, element-field writes) and identity
+ * comparisons see the caller's value. When an argument names an existing
+ * value, the call instead targets an instance of the function lowered with
+ * that parameter bound to the argument's own type: the body reads and
+ * writes the caller's storage. Instances are keyed by the parameter
+ * layouts, so each layout combination compiles once. */
+
+/** The argument expressions that can name a value another reference sees:
+ * bindings, member reads and `this`. Fresh values (literals, calls) have no
+ * other reference, so their copy is not observable. */
+function namesExistingValue(arg: ts.Expression): boolean {
+ let e = arg;
+ for (;;) {
+ if (ts.isParenthesizedExpression(e) || ts.isNonNullExpression(e)) e = e.expression;
+ else if (ts.isAsExpression(e) || ts.isSatisfiesExpression(e)) e = e.expression;
+ else break;
+ }
+ return (
+ ts.isIdentifier(e) ||
+ ts.isPropertyAccessExpression(e) ||
+ ts.isElementAccessExpression(e) ||
+ e.kind === ts.SyntaxKind.ThisKeyword
+ );
+}
+
+/** Whether a value of layout `from` can stand for a value of layout `to`
+ * in code written against `to`: the same layout, or a record that has every
+ * field of `to` at such a layout plus fields `to` lacks that are optional
+ * (`{ id; name; owned?: true }` for `{ id; name }`), or an array of such
+ * elements. Every value that code stores converts into `from` by completing
+ * the optional fields as absent, so reads and writes through either type see
+ * one value. */
+function standsFor(
+ lowerer: Lowerer,
+ from: IrType,
+ to: IrType,
+ assumed: Set = new Set(),
+): boolean {
+ if (typeEquals(from, to)) return true;
+ if (from.kind === "array" && to.kind === "array")
+ return standsFor(lowerer, from.elem, to.elem, assumed);
+ if (from.kind !== "record" || to.kind !== "record") return false;
+ // Recursive layouts (`{ kids: Tree[] }` against `{ kids: Plain[] }`) meet
+ // the same pair again inside their fields; the pair holds there if it
+ // holds for the other fields.
+ const pair = `${from.shapeId}>${to.shapeId}`;
+ if (assumed.has(pair)) return true;
+ assumed.add(pair);
+ const source = lowerer.shapes.get(from.shapeId);
+ const target = lowerer.shapes.get(to.shapeId);
+ if (!source || !target || source.tuple || target.tuple) return false;
+ if (source.indexValue || target.indexValue) return false;
+ for (const field of target.fields) {
+ const kept = source.fields.find((f) => f.name === field.name);
+ if (!kept || !standsFor(lowerer, kept.type, field.type, assumed)) return false;
+ }
+ return source.fields.every(
+ (field) =>
+ target.fields.some((f) => f.name === field.name) ||
+ (field.type.kind === "union" && lowerer.armTag(field.type.unionId, UNDEFINED_T) >= 0),
+ );
+}
+
+/** Whether converting `from` into `to` copies a value that could keep its
+ * own layout instead. */
+function copies(lowerer: Lowerer, from: IrType, to: IrType): boolean {
+ if (typeEquals(from, to) || !standsFor(lowerer, from, to)) return false;
+ const plan = lowerer.widthLiftPlan(from, to);
+ return plan !== null && (plan.how === "arr" || plan.how === "width");
+}
+
+/** The structural-instance info of a typed function declaration, created on
+ * first use and kept on the Lowerer. */
+function structuralInfoOf(
+ lowerer: Lowerer,
+ decl: ts.FunctionDeclaration,
+ symbol: ts.Symbol,
+): GenericFnInfo {
+ const existing = lowerer.structuralFnInfos.get(symbol);
+ if (existing) return existing;
+ const nameText = decl.name?.text ?? "%default";
+ const body = decl.body!;
+ const info: GenericFnInfo = {
+ decl,
+ baseName: nameText,
+ qualifiedName: lowerer.qualify(
+ ts.getSourceFileOfNode(decl),
+ nsPathPrefix(lowerer, decl) + nameText + "%layout",
+ ),
+ typeParams: [],
+ instances: new Map(),
+ implicitParams: decl.parameters.nodes.map((param) => {
+ if (!ts.isIdentifier(param.name) || param.dotDotDotToken || param.initializer) return null;
+ if (param.questionToken) return null;
+ const sym = lowerer.checker.getSymbolAtLocation(param.name);
+ if (!sym || paramWrittenInBody(lowerer, body, sym, param.name.text)) return null;
+ return sym;
+ }),
+ };
+ lowerer.structuralFnInfos.set(symbol, info);
+ return info;
+}
+
+/** The instance a direct call of the declared function `sig` targets when
+ * one of its arguments names a record or array whose layout the parameter
+ * would copy; null when every argument converts as declared. */
+export function structuralCallInstance(
+ lowerer: Lowerer,
+ expr: ts.CallExpression,
+ sig: FnSig,
+): GenericInstance | null {
+ const symbol = lowerer.resolveValueSymbol(expr.expression as ts.Identifier);
+ const decl = symbol?.valueDeclaration;
+ if (!symbol || !decl || !ts.isFunctionDeclaration(decl) || !decl.body) return null;
+ if (
+ decl.asteriskToken ||
+ decl.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.AsyncKeyword)
+ )
+ return null;
+ if (decl.parameters.nodes.some(isThisParameter)) return null;
+ if (symbol.declarations.filter((d) => ts.isFunctionDeclaration(d)).length !== 1) return null;
+ const args = expr.arguments.nodes;
+ if (args.some((a) => ts.isSpreadElement(a))) return null;
+ const params = decl.parameters.nodes;
+ if (sig.params.length !== params.length) return null;
+ const bound: { index: number; type: IrType; checker: ts.Type }[] = [];
+ params.forEach((_, index) => {
+ const arg = args[index];
+ const declared = sig.params[index]!;
+ if (!arg || declared.mode !== "required" || !namesExistingValue(arg)) return;
+ // A signature that widens the declared type's layout (record parameters
+ // whose fields also take undefined) keeps its own boundary conversion.
+ const own = lowerer.mapTypeOf(lowerer.checker.getTypeAtLocation(params[index]!.name));
+ if (!own || !typeEquals(own, declared.type)) return;
+ const checker = preservedLayoutOf(lowerer, arg) ?? lowerer.typeOf(arg);
+ const type = lowerer.mapTypeOf(checker);
+ if (type && copies(lowerer, type, declared.type)) bound.push({ index, type, checker });
+ });
+ if (bound.length === 0) return null;
+ const info = structuralInfoOf(lowerer, decl, symbol);
+ const shapes: ParamShape[] = [...sig.params];
+ const argTypes = new Map();
+ for (const { index, type, checker } of bound) {
+ const sym = info.implicitParams![index];
+ if (!sym) continue;
+ shapes[index] = { type, mode: "required" };
+ argTypes.set(sym, checker);
+ }
+ if (argTypes.size === 0) return null;
+ // A body that returns a bound parameter returns the argument's layout.
+ const previous = lowerer.implicitParamTypes;
+ lowerer.implicitParamTypes = argTypes;
+ let returnType: IrType;
+ try {
+ returnType = layoutReturnType(lowerer, decl, sig.returnType);
+ } finally {
+ lowerer.implicitParamTypes = previous;
+ }
+ const instance = internImplicitInstanceOr(
+ lowerer,
+ expr,
+ info,
+ shapes,
+ argTypes,
+ "",
+ returnType,
+ true,
+ );
+ if (instance) for (const { index } of bound) lowerer.layoutKeptNodes.add(args[index]!);
+ return instance;
+}
+
+/** The layout of the value an existing-value expression names: a binding's
+ * own type, or the element or field of such a value read through `[i]` or
+ * `.name`. Reads of a parameter bound to an argument's layout follow that
+ * layout rather than the parameter's declared type. */
+function layoutOf(lowerer: Lowerer, expression: ts.Expression): IrType | null {
+ let e = expression;
+ while (ts.isParenthesizedExpression(e) || ts.isNonNullExpression(e)) e = e.expression;
+ if (ts.isElementAccessExpression(e)) {
+ const base = layoutOf(lowerer, e.expression);
+ if (
+ base?.kind === "array" &&
+ lowerer.mapTypeOf(lowerer.typeOf(e.argumentExpression))?.kind === "f64"
+ )
+ return base.elem;
+ } else if (ts.isPropertyAccessExpression(e) && ts.isIdentifier(e.name)) {
+ const base = layoutOf(lowerer, e.expression);
+ if (base?.kind === "record") {
+ const field = lowerer.shapes.get(base.shapeId)?.fields.find((f) => f.name === e.name.text);
+ if (field) return field.type;
+ }
+ }
+ return lowerer.mapTypeOf(lowerer.typeOf(e));
+}
+
+/** The return type of a declared function whose every `return` names an
+ * existing value of one record or array layout that the declared return type
+ * would copy (`function items(values: Refined[]): Value[] { return values; }`):
+ * that layout, so the caller receives the value itself. `declared` otherwise. */
+export function layoutReturnType(
+ lowerer: Lowerer,
+ decl: ts.FunctionDeclaration,
+ declared: IrType,
+): IrType {
+ if (!decl.body || (declared.kind !== "array" && declared.kind !== "record")) return declared;
+ // An exported function keeps its declared result: a library build calls it
+ // from outside the program, through the declared ABI.
+ if (decl.modifiers?.nodes.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) return declared;
+ const returned: ts.Expression[] = [];
+ let open = false;
+ ts.walkPreorder(decl.body, (node) => {
+ if (node !== decl.body && ts.isFunctionLike(node)) return "skip";
+ if (ts.isClassDeclaration(node) || ts.isClassExpression(node)) return "skip";
+ if (!ts.isReturnStatement(node)) return undefined;
+ if (!node.expression || !namesExistingValue(node.expression)) open = true;
+ else returned.push(node.expression);
+ return undefined;
+ });
+ if (open || returned.length === 0) return declared;
+ let layout: IrType | null = null;
+ for (const expression of returned) {
+ const type = layoutOf(lowerer, expression);
+ if (!type || !copies(lowerer, type, declared)) return declared;
+ if (layout && !typeEquals(layout, type)) return declared;
+ layout = type;
+ }
+ return layout ?? declared;
+}
+
+/** The initializer type of a const the argument names when the const kept
+ * that initializer's layout (keepsInitializerLayout): the checker types the
+ * binding by its annotation, but its value has the initializer's layout. A
+ * declaration lowers once per function instance, and only some instances
+ * keep a layout, so the recorded type applies only while it maps to the
+ * layout of the binding the current lowering resolves. */
+function preservedLayoutOf(lowerer: Lowerer, arg: ts.Expression): ts.Type | undefined {
+ let e = arg;
+ while (ts.isParenthesizedExpression(e) || ts.isNonNullExpression(e)) e = e.expression;
+ if (!ts.isIdentifier(e)) return undefined;
+ const symbol = lowerer.resolveValueSymbol(e);
+ const recorded = symbol ? lowerer.preservedLayoutTypes.get(symbol) : undefined;
+ if (recorded === undefined) return undefined;
+ const binding = lowerer.peekLocal(e)?.type ?? lowerer.globalOf(e)?.type;
+ const layout = lowerer.mapTypeOf(recorded);
+ return binding && layout && typeEquals(binding, layout) ? recorded : undefined;
+}
+
+/** Whether a `const` binding typed `declared` keeps the record or array
+ * layout `actual` of an initializer that names an existing value or returns
+ * one, instead of converting it by a copy. Fresh literals convert as
+ * declared: their copy is not observable. */
+export function keepsInitializerLayout(
+ lowerer: Lowerer,
+ initializer: ts.Expression,
+ actual: IrType,
+ declared: IrType,
+): boolean {
+ let e = initializer;
+ while (ts.isParenthesizedExpression(e)) e = e.expression;
+ if (!namesExistingValue(e) && !ts.isCallExpression(e)) return false;
+ if (lowerer.isRuntimeOptionalRecordVariant(actual, declared)) return false;
+ return copies(lowerer, actual, declared);
+}
+
+/** Records that the const `decl` keeps its initializer's layout, so calls
+ * that pass it bind the initializer's type (preservedLayoutOf). */
+export function notePreservedLayout(lowerer: Lowerer, decl: ts.VariableDeclaration): void {
+ if (!ts.isIdentifier(decl.name) || !decl.initializer) return;
+ const symbol = lowerer.checker.getSymbolAtLocation(decl.name);
+ if (!symbol) return;
+ const initializer =
+ preservedLayoutOf(lowerer, decl.initializer) ?? lowerer.typeOf(decl.initializer);
+ lowerer.preservedLayoutTypes.set(symbol, initializer);
+ lowerer.layoutKeptNodes.add(decl.initializer);
+}
diff --git a/packages/compiler/src/frontend/lowering/type-failure.ts b/packages/compiler/src/frontend/lowering/type-failure.ts
index 3502a9bbf..31004ca4f 100644
--- a/packages/compiler/src/frontend/lowering/type-failure.ts
+++ b/packages/compiler/src/frontend/lowering/type-failure.ts
@@ -138,7 +138,11 @@ export function typeFailureSlot(node: ts.Node): { slot: TypeFailureSlot; name: s
if (!declared && !ts.isTypeNode(current) && !ts.isTypeNode(parent)) break;
current = parent;
}
- const text = ts.getTextOfNode(node);
+ // A type node names its expression (`raw as Plugin`, not `Plugin`).
+ let named: ts.Node = node;
+ while (ts.isTypeNode(named) && named.parent !== undefined && !ts.isSourceFile(named.parent))
+ named = named.parent;
+ const text = ts.getTextOfNode(named).replace(/\s+/g, " ");
return { slot: "expression", name: text.length > 60 ? `${text.slice(0, 57)}...` : text };
}
diff --git a/packages/compiler/src/frontend/ts7/api.ts b/packages/compiler/src/frontend/ts7/api.ts
index f18675d0e..4e8ef8291 100644
--- a/packages/compiler/src/frontend/ts7/api.ts
+++ b/packages/compiler/src/frontend/ts7/api.ts
@@ -12,8 +12,10 @@ export class Ts7Api {
constructor(rpc: Ts7RpcClient, fs: Ts7FileSystem, collectTiming = false) {
try {
- registerTs7FileSystem(rpc, fs);
- this.session = new Ts7Session(rpc, collectTiming);
+ const session = new Ts7Session(rpc, collectTiming);
+ // Callbacks run inside requests, after initialize chose the protocol.
+ registerTs7FileSystem(rpc, fs, () => session.speaksTs71());
+ this.session = session;
} catch (error) {
rpc.close();
throw error;
diff --git a/packages/compiler/src/frontend/ts7/ast-wire.ts b/packages/compiler/src/frontend/ts7/ast-wire.ts
index 4cba5923a..14b1c4548 100644
--- a/packages/compiler/src/frontend/ts7/ast-wire.ts
+++ b/packages/compiler/src/frontend/ts7/ast-wire.ts
@@ -135,6 +135,11 @@ const HEADER_SIZE_7_1 = 64;
/** SourceKeyword in TypeScript 7.1's kinds. */
const SOURCE_KEYWORD_7_1 = 167;
+/** A TypeScript 7.0 SyntaxKind as TypeScript 7.1 numbers it. */
+export function syntaxKind71(kind: number): number {
+ return kind >= SOURCE_KEYWORD_7_1 ? kind + 1 : kind;
+}
+
/** The node table of a protocol 7.1 response in TypeScript 7.0's terms: each
* kind above SourceKeyword one lower, and each module declaration's child
* mask without the attributes slot. Throws for the nodes 7.0 has no form
@@ -248,7 +253,7 @@ export class AstWireFile {
/** `kind` (TypeScript 7.0's) as this response's protocol numbers it, the
* kind a node handle names. */
kindOnWire(kind: number): number {
- return this.protocol === PROTOCOL_VERSION_7_1 && kind >= SOURCE_KEYWORD_7_1 ? kind + 1 : kind;
+ return this.protocol === PROTOCOL_VERSION_7_1 ? syntaxKind71(kind) : kind;
}
/** A kind as this response's protocol numbers it (a node handle's) in
diff --git a/packages/compiler/src/frontend/ts7/checker.test.ts b/packages/compiler/src/frontend/ts7/checker.test.ts
index cb4a2e5fb..7e7227437 100644
--- a/packages/compiler/src/frontend/ts7/checker.test.ts
+++ b/packages/compiler/src/frontend/ts7/checker.test.ts
@@ -7,6 +7,7 @@ import { Ts7RpcClient } from "./rpc-client.js";
import { registerTs7FileSystem, TS7_FILE_SYSTEM_CALLBACKS } from "./rpc-filesystem.js";
import { spawnTs7Wire } from "./rpc-process.js";
import { ts7Executable } from "./rpc-api.js";
+import { ts7TestServers, type Ts7TestServer } from "./test-servers.js";
import { Ts7Session } from "./session.js";
import { SemanticChecker } from "./semantic-checker.js";
import { SemanticSnapshot } from "./semantic-model.js";
@@ -18,26 +19,36 @@ import type { Identifier, SourceFile } from "./ast-types.js";
const tempRoot = process.platform === "win32" ? tmpdir() : "/tmp";
const protocolPath = (path: string) => path.replaceAll("\\", "/");
-function connect(files: Record) {
+/** The default checker (tsc-ts) and tsc 7.1. */
+const servers: Ts7TestServer[] = [
+ { name: "tsc 7.0", executable: ts7Executable(), ts71: false },
+ ...ts7TestServers().filter((server) => server.ts71),
+];
+
+function connect(server: Ts7TestServer, files: Record) {
const dir = mkdtempSync(join(tempRoot, "scriptc-checker-lifetime-"));
const paths = new Map(
Object.entries(files).map(([name, text]) => [protocolPath(join(dir, name)), text]),
);
const rpc = new Ts7RpcClient(
- spawnTs7Wire(ts7Executable(), [
+ spawnTs7Wire(server.executable, [
"--api",
"--cwd",
dir,
`--callbacks=${TS7_FILE_SYSTEM_CALLBACKS}`,
]),
);
- registerTs7FileSystem(rpc, {
- readFile: (path) => paths.get(path),
- fileExists: (path) => (paths.has(path) ? true : undefined),
- directoryExists: () => undefined,
- realpath: () => undefined,
- getAccessibleEntries: () => undefined,
- });
+ registerTs7FileSystem(
+ rpc,
+ {
+ readFile: (path) => paths.get(path),
+ fileExists: (path) => (paths.has(path) ? true : undefined),
+ directoryExists: () => undefined,
+ realpath: () => undefined,
+ getAccessibleEntries: () => undefined,
+ },
+ () => server.ts71,
+ );
const session = new Ts7Session(rpc);
return {
session,
@@ -65,184 +76,188 @@ const config = JSON.stringify({
files: ["main.ts"],
});
-test("unchanged AST identities receive independent semantic answers after a dependency update", () => {
- const h = connect({
- "tsconfig.json": config,
- "main.ts": 'import { value } from "./dependency.js"; export const result = value;',
- "dependency.ts": "export const value: number = 1;",
- });
- try {
- const first = h.session.updateSnapshot({ openProjects: [h.path("tsconfig.json")] });
- const oldProject = first.getProjects()[0]!;
- const root = oldProject.program.getSourceFile(h.path("main.ts"))!;
- const name = declaration(root, "result").name!;
- const old = new CheckerFacade(oldProject.checker, { project: oldProject.checker.project });
- const oldType = old.getTypeAtLocation(name);
- const oldSymbol = old.getSymbolAtLocation(name)!;
- expect(old.typeToString(oldType)).toBe("number");
- expect(oldSymbol.valueDeclaration).toBe(declaration(root, "result"));
-
- h.paths.set(h.path("dependency.ts"), 'export const value: string = "changed";');
- const second = h.session.updateSnapshot({
- fileChanges: { changed: [h.path("dependency.ts")] },
+for (const server of servers)
+ test(`unchanged AST identities receive independent semantic answers after a dependency update (${server.name})`, () => {
+ const h = connect(server, {
+ "tsconfig.json": config,
+ "main.ts": 'import { value } from "./dependency.js"; export const result = value;',
+ "dependency.ts": "export const value: number = 1;",
});
- const newProject = second.getProjects()[0]!;
- const nextRoot = newProject.program.getSourceFile(h.path("main.ts"))!;
- expect(nextRoot).toBe(root);
- expect(declaration(nextRoot, "result").name).toBe(name);
- const next = new CheckerFacade(newProject.checker, { project: newProject.checker.project });
- const newType = next.getTypeAtLocation(name);
- expect(next.typeToString(newType)).toBe("string");
- expect(newType).not.toBe(oldType);
- expect(next.getSymbolAtLocation(name)).not.toBe(oldSymbol);
- expect(old.typeToString(old.getTypeAtLocation(name))).toBe("number");
- first.dispose();
- expect(() => old.getTypeAtLocation(name)).toThrow("disposed");
- expect(next.getTypeAtLocation(name)).toBe(newType);
- expect(next.typeToString(newType)).toBe("string");
- second.dispose();
- expect(() => next.getTypeAtLocation(name)).toThrow("disposed");
- } finally {
- h.close();
- }
-});
+ try {
+ const first = h.session.updateSnapshot({ openProjects: [h.path("tsconfig.json")] });
+ const oldProject = first.getProjects()[0]!;
+ const root = oldProject.program.getSourceFile(h.path("main.ts"))!;
+ const name = declaration(root, "result").name!;
+ const old = new CheckerFacade(oldProject.checker, { project: oldProject.checker.project });
+ const oldType = old.getTypeAtLocation(name);
+ const oldSymbol = old.getSymbolAtLocation(name)!;
+ expect(old.typeToString(oldType)).toBe("number");
+ expect(oldSymbol.valueDeclaration).toBe(declaration(root, "result"));
-test("project-local type handles never alias across simultaneous checker caches", () => {
- const h = connect({
- "first.json": config,
- "second.json": config,
- "main.ts": "export type Choice = string | number; export const value = 42;",
+ h.paths.set(h.path("dependency.ts"), 'export const value: string = "changed";');
+ const second = h.session.updateSnapshot({
+ fileChanges: { changed: [h.path("dependency.ts")] },
+ });
+ const newProject = second.getProjects()[0]!;
+ const nextRoot = newProject.program.getSourceFile(h.path("main.ts"))!;
+ expect(nextRoot).toBe(root);
+ expect(declaration(nextRoot, "result").name).toBe(name);
+ const next = new CheckerFacade(newProject.checker, { project: newProject.checker.project });
+ const newType = next.getTypeAtLocation(name);
+ expect(next.typeToString(newType)).toBe("string");
+ expect(newType).not.toBe(oldType);
+ expect(next.getSymbolAtLocation(name)).not.toBe(oldSymbol);
+ expect(old.typeToString(old.getTypeAtLocation(name))).toBe("number");
+ first.dispose();
+ expect(() => old.getTypeAtLocation(name)).toThrow("disposed");
+ expect(next.getTypeAtLocation(name)).toBe(newType);
+ expect(next.typeToString(newType)).toBe("string");
+ second.dispose();
+ expect(() => next.getTypeAtLocation(name)).toThrow("disposed");
+ } finally {
+ h.close();
+ }
});
- try {
- const snapshot = h.session.updateSnapshot({
- openProjects: [h.path("first.json"), h.path("second.json")],
+
+for (const server of servers)
+ test(`project-local type handles never alias across simultaneous checker caches (${server.name})`, () => {
+ const h = connect(server, {
+ "first.json": config,
+ "second.json": config,
+ "main.ts": "export type Choice = string | number; export const value = 42;",
});
- const first = snapshot.getProject(h.path("first.json"))!;
- const second = snapshot.getProject(h.path("second.json"))!;
- const root = first.program.getSourceFile(h.path("main.ts"))!;
- expect(second.program.getSourceFile(h.path("main.ts"))).toBe(root);
- const a = new CheckerFacade(first.checker, { project: first.checker.project });
- const b = new CheckerFacade(second.checker, { project: second.checker.project });
- const name = declaration(root, "value").name!;
- const firstType = a.getTypeAtLocation(name);
- const secondType = b.getTypeAtLocation(name);
- expect(firstType.project).not.toBe(secondType.project);
- expect(firstType).not.toBe(secondType);
- expect(a.typeToString(firstType)).toBe(b.typeToString(secondType));
- const alias = declaration(root, "Choice").type!;
- const firstArms = constituentTypes(a.getTypeFromTypeNode(alias));
- const secondArms = constituentTypes(b.getTypeFromTypeNode(alias));
- expect(firstArms).not.toBe(secondArms);
- expect(firstArms.map((arm) => a.typeToString(arm))).toEqual(
- secondArms.map((arm) => b.typeToString(arm)),
- );
- first.checker.project.dispose();
- expect(() => a.getTypeAtLocation(name)).toThrow("disposed");
- expect(b.getTypeAtLocation(name)).toBe(secondType);
- expect(b.getTypeFromTypeNode(alias).getTypes()).toBe(secondArms);
- } finally {
- h.close();
- }
-});
-
-test("all public query and prefetch entry points reject a disposed facade before returning cached or local answers", () => {
- const h = connect({
- "tsconfig.json": config,
- "main.ts": "export const value = 42; export function fn(v: number): number { return v; }",
+ try {
+ const snapshot = h.session.updateSnapshot({
+ openProjects: [h.path("first.json"), h.path("second.json")],
+ });
+ const first = snapshot.getProject(h.path("first.json"))!;
+ const second = snapshot.getProject(h.path("second.json"))!;
+ const root = first.program.getSourceFile(h.path("main.ts"))!;
+ expect(second.program.getSourceFile(h.path("main.ts"))).toBe(root);
+ const a = new CheckerFacade(first.checker, { project: first.checker.project });
+ const b = new CheckerFacade(second.checker, { project: second.checker.project });
+ const name = declaration(root, "value").name!;
+ const firstType = a.getTypeAtLocation(name);
+ const secondType = b.getTypeAtLocation(name);
+ expect(firstType.project).not.toBe(secondType.project);
+ expect(firstType).not.toBe(secondType);
+ expect(a.typeToString(firstType)).toBe(b.typeToString(secondType));
+ const alias = declaration(root, "Choice").type!;
+ const firstArms = constituentTypes(a.getTypeFromTypeNode(alias));
+ const secondArms = constituentTypes(b.getTypeFromTypeNode(alias));
+ expect(firstArms).not.toBe(secondArms);
+ expect(firstArms.map((arm) => a.typeToString(arm))).toEqual(
+ secondArms.map((arm) => b.typeToString(arm)),
+ );
+ first.checker.project.dispose();
+ expect(() => a.getTypeAtLocation(name)).toThrow("disposed");
+ expect(b.getTypeAtLocation(name)).toBe(secondType);
+ expect(b.getTypeFromTypeNode(alias).getTypes()).toBe(secondArms);
+ } finally {
+ h.close();
+ }
});
- try {
- const snapshot = h.session.updateSnapshot({ openProjects: [h.path("tsconfig.json")] });
- const project = snapshot.getProjects()[0]!;
- const root = project.program.getSourceFile(h.path("main.ts"))!;
- const facade = new CheckerFacade(project.checker, { project: project.checker.project });
- const name = declaration(root, "value").name!;
- const fn = declaration(root, "fn");
- const type = facade.getTypeAtLocation(name);
- const symbol = facade.getSymbolAtLocation(name)!;
- const signature = facade.getSignatureFromDeclaration(fn)!;
- const never = project.checker.getNeverType();
- facade.getUnknownType();
- facade.typeToString(type);
- facade.prefetchSourceFile(root);
- const calls: Record unknown> = {
- getCallSignatures: () => facade.getCallSignatures(type),
- getConstructSignatures: () => facade.getConstructSignatures(type),
- getSignaturesOfType: () => facade.getSignaturesOfType(type, SignatureKind.Call),
- resolveName: () => facade.resolveName("value", SymbolFlags.Value, name),
- getResolvedSymbol: () => facade.getResolvedSymbol(name as Identifier),
- getAnyType: () => facade.getAnyType(),
- getNeverType: () => facade.getNeverType(),
- getBigIntType: () => facade.getBigIntType(),
- getPropertyOfType: () => facade.getPropertyOfType(type, "missing"),
- prefetchSourceFile: () => facade.prefetchSourceFile(root),
- prefetchSourceFileStructures: () => facade.prefetchSourceFileStructures([]),
- prefetchRoots: () => facade.prefetchRoots([]),
- prefetchSymbolRoots: () => facade.prefetchSymbolRoots([]),
- prefetchSymbolNodesExact: () => facade.prefetchSymbolNodesExact([]),
- prefetchCollectionTypes: () => facade.prefetchCollectionTypes([]),
- prefetchClassCollection: () => facade.prefetchClassCollection([], []),
- getTypeAtLocation: () => facade.getTypeAtLocation(name),
- getSymbolAtLocation: () => facade.getSymbolAtLocation(name),
- getTypeOfSymbol: () => facade.getTypeOfSymbol(symbol),
- prefetchMemberTypes: () => facade.prefetchMemberTypes([symbol], 0),
- getAliasedSymbol: () => facade.getAliasedSymbol(symbol),
- getDeclaredTypeOfSymbol: () => facade.getDeclaredTypeOfSymbol(symbol),
- getContextualType: () => facade.getContextualType(name),
- getTypeFromTypeNode: () => facade.getTypeFromTypeNode(fn.type!),
- getShorthandAssignmentValueSymbol: () => facade.getShorthandAssignmentValueSymbol(name),
- getResolvedSignature: () => facade.getResolvedSignature(name),
- getSignatureFromDeclaration: () => facade.getSignatureFromDeclaration(fn),
- getReturnTypeOfSignature: () => facade.getReturnTypeOfSignature(signature),
- getTypePredicateOfSignature: () => facade.getTypePredicateOfSignature(signature),
- getBaseTypeOfLiteralType: () => facade.getBaseTypeOfLiteralType(type),
- getConstantValue: () => facade.getConstantValue(name),
- getNonNullableType: () => facade.getNonNullableType(type),
- getPropertiesOfType: () => facade.getPropertiesOfType(type),
- getBaseTypes: () => facade.getBaseTypes(type),
- isNeverType: () => facade.isNeverType(never),
- isTypeAssignableTo: () => facade.isTypeAssignableTo(type, type),
- getIndexInfosOfType: () => facade.getIndexInfosOfType(type),
- getTypeArguments: () => facade.getTypeArguments(type),
- isArrayType: () => facade.isArrayType(type),
- isTupleType: () => facade.isTupleType(type),
- isArrayLikeType: () => facade.isArrayLikeType(type),
- typeToString: () => facade.typeToString(type),
- getTypeOfSymbolAtLocation: () => facade.getTypeOfSymbolAtLocation(symbol, name),
- getUnknownType: () => facade.getUnknownType(),
- getStringType: () => facade.getStringType(),
- getNumberType: () => facade.getNumberType(),
- getBooleanType: () => facade.getBooleanType(),
- getAwaitedType: () => facade.getAwaitedType(type),
- };
- const before = h.rpc.timing().requests;
- facade.dispose();
- facade.dispose();
- for (const [name, call] of Object.entries(calls))
- expect(call, name).toThrow("checker facade is disposed");
- expect(h.rpc.timing().requests).toBe(before);
- expect(project.checker.typeToString(type)).toBe("42");
- } finally {
- h.close();
- }
-});
-test("session shutdown invalidates every retained facade without explicit program disposal", () => {
- const h = connect({ "tsconfig.json": config, "main.ts": "export const value = 1;" });
- try {
- const snapshot = h.session.updateSnapshot({ openProjects: [h.path("tsconfig.json")] });
- const project = snapshot.getProjects()[0]!;
- const facade = new CheckerFacade(project.checker);
- const name = declaration(project.program.getSourceFile(h.path("main.ts"))!, "value").name!;
- facade.getTypeAtLocation(name);
- facade.getNumberType();
- h.session.close();
- expect(() => facade.getTypeAtLocation(name)).toThrow("disposed");
- expect(() => facade.getNumberType()).toThrow("disposed");
- expect(() => new CheckerFacade(project.checker)).toThrow("disposed");
- } finally {
- h.close();
- }
-});
+for (const server of servers)
+ test(`all public query and prefetch entry points reject a disposed facade before returning cached or local answers (${server.name})`, () => {
+ const h = connect(server, {
+ "tsconfig.json": config,
+ "main.ts": "export const value = 42; export function fn(v: number): number { return v; }",
+ });
+ try {
+ const snapshot = h.session.updateSnapshot({ openProjects: [h.path("tsconfig.json")] });
+ const project = snapshot.getProjects()[0]!;
+ const root = project.program.getSourceFile(h.path("main.ts"))!;
+ const facade = new CheckerFacade(project.checker, { project: project.checker.project });
+ const name = declaration(root, "value").name!;
+ const fn = declaration(root, "fn");
+ const type = facade.getTypeAtLocation(name);
+ const symbol = facade.getSymbolAtLocation(name)!;
+ const signature = facade.getSignatureFromDeclaration(fn)!;
+ const never = project.checker.getNeverType();
+ facade.getUnknownType();
+ facade.typeToString(type);
+ facade.prefetchSourceFile(root);
+ const calls: Record unknown> = {
+ getCallSignatures: () => facade.getCallSignatures(type),
+ getConstructSignatures: () => facade.getConstructSignatures(type),
+ getSignaturesOfType: () => facade.getSignaturesOfType(type, SignatureKind.Call),
+ resolveName: () => facade.resolveName("value", SymbolFlags.Value, name),
+ getResolvedSymbol: () => facade.getResolvedSymbol(name as Identifier),
+ getAnyType: () => facade.getAnyType(),
+ getNeverType: () => facade.getNeverType(),
+ getBigIntType: () => facade.getBigIntType(),
+ getPropertyOfType: () => facade.getPropertyOfType(type, "missing"),
+ prefetchSourceFile: () => facade.prefetchSourceFile(root),
+ prefetchSourceFileStructures: () => facade.prefetchSourceFileStructures([]),
+ prefetchRoots: () => facade.prefetchRoots([]),
+ prefetchSymbolRoots: () => facade.prefetchSymbolRoots([]),
+ prefetchSymbolNodesExact: () => facade.prefetchSymbolNodesExact([]),
+ prefetchCollectionTypes: () => facade.prefetchCollectionTypes([]),
+ prefetchClassCollection: () => facade.prefetchClassCollection([], []),
+ getTypeAtLocation: () => facade.getTypeAtLocation(name),
+ getSymbolAtLocation: () => facade.getSymbolAtLocation(name),
+ getTypeOfSymbol: () => facade.getTypeOfSymbol(symbol),
+ prefetchMemberTypes: () => facade.prefetchMemberTypes([symbol], 0),
+ getAliasedSymbol: () => facade.getAliasedSymbol(symbol),
+ getDeclaredTypeOfSymbol: () => facade.getDeclaredTypeOfSymbol(symbol),
+ getContextualType: () => facade.getContextualType(name),
+ getTypeFromTypeNode: () => facade.getTypeFromTypeNode(fn.type!),
+ getShorthandAssignmentValueSymbol: () => facade.getShorthandAssignmentValueSymbol(name),
+ getResolvedSignature: () => facade.getResolvedSignature(name),
+ getSignatureFromDeclaration: () => facade.getSignatureFromDeclaration(fn),
+ getReturnTypeOfSignature: () => facade.getReturnTypeOfSignature(signature),
+ getTypePredicateOfSignature: () => facade.getTypePredicateOfSignature(signature),
+ getBaseTypeOfLiteralType: () => facade.getBaseTypeOfLiteralType(type),
+ getConstantValue: () => facade.getConstantValue(name),
+ getNonNullableType: () => facade.getNonNullableType(type),
+ getPropertiesOfType: () => facade.getPropertiesOfType(type),
+ getBaseTypes: () => facade.getBaseTypes(type),
+ isNeverType: () => facade.isNeverType(never),
+ isTypeAssignableTo: () => facade.isTypeAssignableTo(type, type),
+ getIndexInfosOfType: () => facade.getIndexInfosOfType(type),
+ getTypeArguments: () => facade.getTypeArguments(type),
+ isArrayType: () => facade.isArrayType(type),
+ isTupleType: () => facade.isTupleType(type),
+ isArrayLikeType: () => facade.isArrayLikeType(type),
+ typeToString: () => facade.typeToString(type),
+ getTypeOfSymbolAtLocation: () => facade.getTypeOfSymbolAtLocation(symbol, name),
+ getUnknownType: () => facade.getUnknownType(),
+ getStringType: () => facade.getStringType(),
+ getNumberType: () => facade.getNumberType(),
+ getBooleanType: () => facade.getBooleanType(),
+ getAwaitedType: () => facade.getAwaitedType(type),
+ };
+ const before = h.rpc.timing().requests;
+ facade.dispose();
+ facade.dispose();
+ for (const [name, call] of Object.entries(calls))
+ expect(call, name).toThrow("checker facade is disposed");
+ expect(h.rpc.timing().requests).toBe(before);
+ expect(project.checker.typeToString(type)).toBe("42");
+ } finally {
+ h.close();
+ }
+ });
+
+for (const server of servers)
+ test(`session shutdown invalidates every retained facade without explicit program disposal (${server.name})`, () => {
+ const h = connect(server, { "tsconfig.json": config, "main.ts": "export const value = 1;" });
+ try {
+ const snapshot = h.session.updateSnapshot({ openProjects: [h.path("tsconfig.json")] });
+ const project = snapshot.getProjects()[0]!;
+ const facade = new CheckerFacade(project.checker);
+ const name = declaration(project.program.getSourceFile(h.path("main.ts"))!, "value").name!;
+ facade.getTypeAtLocation(name);
+ facade.getNumberType();
+ h.session.close();
+ expect(() => facade.getTypeAtLocation(name)).toThrow("disposed");
+ expect(() => facade.getNumberType()).toThrow("disposed");
+ expect(() => new CheckerFacade(project.checker)).toThrow("disposed");
+ } finally {
+ h.close();
+ }
+ });
test("program disposal and host close seal cached source and checker access", () => {
const dir = mkdtempSync(join(tempRoot, "scriptc-program-lifetime-"));
@@ -356,37 +371,38 @@ test("property lookup caches hits and misses by type and name within each projec
snapshot.dispose();
});
-test("dependency edits invalidate positive and negative property answers", () => {
- const h = connect({
- "tsconfig.json": config,
- "main.ts": 'import { value } from "./dependency.js"; export const result = value;',
- "dependency.ts": "export const value = { before: 1 };",
- });
- try {
- const first = h.session.updateSnapshot({ openProjects: [h.path("tsconfig.json")] });
- const project = first.getProjects()[0]!;
- const root = project.program.getSourceFile(h.path("main.ts"))!;
- const old = new CheckerFacade(project.checker);
- const type = old.getTypeAtLocation(declaration(root, "result").name!);
- expect(old.getPropertyOfType(type, "before")).toBeDefined();
- expect(old.getPropertyOfType(type, "after")).toBeUndefined();
- h.paths.set(h.path("dependency.ts"), "export const value = { after: 2 };");
- const second = h.session.updateSnapshot({
- fileChanges: { changed: [h.path("dependency.ts")] },
+for (const server of servers)
+ test(`dependency edits invalidate positive and negative property answers (${server.name})`, () => {
+ const h = connect(server, {
+ "tsconfig.json": config,
+ "main.ts": 'import { value } from "./dependency.js"; export const result = value;',
+ "dependency.ts": "export const value = { before: 1 };",
});
- const nextProject = second.getProjects()[0]!;
- const nextRoot = nextProject.program.getSourceFile(h.path("main.ts"))!;
- expect(nextRoot).toBe(root);
- const next = new CheckerFacade(nextProject.checker);
- const nextType = next.getTypeAtLocation(declaration(nextRoot, "result").name!);
- expect(next.getPropertyOfType(nextType, "before")).toBeUndefined();
- expect(next.getPropertyOfType(nextType, "after")).toBeDefined();
- expect(old.getPropertyOfType(type, "before")).toBeDefined();
- expect(old.getPropertyOfType(type, "after")).toBeUndefined();
- } finally {
- h.close();
- }
-});
+ try {
+ const first = h.session.updateSnapshot({ openProjects: [h.path("tsconfig.json")] });
+ const project = first.getProjects()[0]!;
+ const root = project.program.getSourceFile(h.path("main.ts"))!;
+ const old = new CheckerFacade(project.checker);
+ const type = old.getTypeAtLocation(declaration(root, "result").name!);
+ expect(old.getPropertyOfType(type, "before")).toBeDefined();
+ expect(old.getPropertyOfType(type, "after")).toBeUndefined();
+ h.paths.set(h.path("dependency.ts"), "export const value = { after: 2 };");
+ const second = h.session.updateSnapshot({
+ fileChanges: { changed: [h.path("dependency.ts")] },
+ });
+ const nextProject = second.getProjects()[0]!;
+ const nextRoot = nextProject.program.getSourceFile(h.path("main.ts"))!;
+ expect(nextRoot).toBe(root);
+ const next = new CheckerFacade(nextProject.checker);
+ const nextType = next.getTypeAtLocation(declaration(nextRoot, "result").name!);
+ expect(next.getPropertyOfType(nextType, "before")).toBeUndefined();
+ expect(next.getPropertyOfType(nextType, "after")).toBeDefined();
+ expect(old.getPropertyOfType(type, "before")).toBeDefined();
+ expect(old.getPropertyOfType(type, "after")).toBeUndefined();
+ } finally {
+ h.close();
+ }
+ });
test("member type batches are bounded, reuse warm entries, and isolate absent or panicking fields", () => {
const calls: number[][] = [];
diff --git a/packages/compiler/src/frontend/ts7/rpc-filesystem.test.ts b/packages/compiler/src/frontend/ts7/rpc-filesystem.test.ts
index 858a5a9e5..b63a66cb1 100644
--- a/packages/compiler/src/frontend/ts7/rpc-filesystem.test.ts
+++ b/packages/compiler/src/frontend/ts7/rpc-filesystem.test.ts
@@ -15,7 +15,12 @@ const fallback: Ts7FileSystem = {
getAccessibleEntries: () => undefined,
};
-function callback(name: string, fs: Ts7FileSystem, payload = JSON.stringify("/file.ts")) {
+function callback(
+ name: string,
+ fs: Ts7FileSystem,
+ payload = JSON.stringify("/file.ts"),
+ ts71 = false,
+) {
const incoming: number[] = [];
const encoded = new Ts7Wire({
read: () => 0,
@@ -47,7 +52,7 @@ function callback(name: string, fs: Ts7FileSystem, payload = JSON.stringify("/fi
},
}),
);
- registerTs7FileSystem(client, fs);
+ registerTs7FileSystem(client, fs, () => ts71);
let error: unknown;
try {
client.requestText("query", "null");
@@ -88,6 +93,36 @@ test.each(TS7_FILE_SYSTEM_CALLBACKS.split(","))("%s delegates undefined to the s
expect(callback(name, fallback)).toEqual({ kind: 2, text: "", error: undefined, closed: false });
});
+test("TypeScript 7.1 replies carry the same answers as value, missing and useOS", () => {
+ const reply = (name: string, fs: Ts7FileSystem) =>
+ JSON.parse(callback(name, fs, JSON.stringify("/file.ts"), true).text) as unknown;
+ expect(reply("readFile", { ...fallback, readFile: () => "text" })).toEqual({
+ kind: "value",
+ value: "text",
+ });
+ expect(reply("readFile", { ...fallback, readFile: () => "" })).toEqual({
+ kind: "value",
+ value: "",
+ });
+ expect(reply("readFile", { ...fallback, readFile: () => null })).toEqual({ kind: "missing" });
+ for (const name of TS7_FILE_SYSTEM_CALLBACKS.split(","))
+ expect(reply(name, fallback), name).toEqual({ kind: "useOS" });
+ expect(reply("fileExists", { ...fallback, fileExists: () => false })).toEqual({
+ kind: "value",
+ value: false,
+ });
+ expect(reply("realpath", { ...fallback, realpath: () => "/real.ts" })).toEqual({
+ kind: "value",
+ value: "/real.ts",
+ });
+ expect(
+ reply("getAccessibleEntries", {
+ ...fallback,
+ getAccessibleEntries: () => ({ files: ["a.ts"], directories: [] }),
+ }),
+ ).toEqual({ kind: "value", value: { files: ["a.ts"], directories: [] } });
+});
+
test("boolean false and empty directory entries do not fall through", () => {
const fs = {
...fallback,
diff --git a/packages/compiler/src/frontend/ts7/rpc-filesystem.ts b/packages/compiler/src/frontend/ts7/rpc-filesystem.ts
index 601e4473c..f1fcf2265 100644
--- a/packages/compiler/src/frontend/ts7/rpc-filesystem.ts
+++ b/packages/compiler/src/frontend/ts7/rpc-filesystem.ts
@@ -18,31 +18,50 @@ function pathArgument(payload: string): string {
return value;
}
+/** A TypeScript 7.1 callback reply: the value, `missing` for a path the
+ * client hides, or `useOS` to delegate to the server's file system. */
+function reply71(result: unknown): string {
+ if (result === undefined) return '{"kind":"useOS"}';
+ if (result === null) return '{"kind":"missing"}';
+ return JSON.stringify({ kind: "value", value: result });
+}
+
/** Preserve all three readFile answers. An empty reply delegates to the
* native server's filesystem; {content:null} hides a path; {content:""}
- * supplies a present empty file. Conflating these changes module resolution. */
-export function registerTs7FileSystem(client: Ts7RpcClient, fs: Ts7FileSystem): void {
+ * supplies a present empty file. Conflating these changes module resolution.
+ * `ts71` tells whether the server speaks TypeScript 7.1's protocol, whose
+ * replies carry the same three answers as reply71 encodes them. */
+export function registerTs7FileSystem(
+ client: Ts7RpcClient,
+ fs: Ts7FileSystem,
+ ts71: () => boolean = () => false,
+): void {
client.registerCallback("readFile", (payload) => {
const content = fs.readFile(pathArgument(payload));
+ if (ts71()) return reply71(content);
return content === undefined ? "" : JSON.stringify({ content });
});
client.registerCallback("fileExists", (payload) => {
const result = fs.fileExists(pathArgument(payload));
+ if (ts71()) return reply71(result);
if (result === undefined) return "";
return result ? "true" : "false";
});
client.registerCallback("directoryExists", (payload) => {
const result = fs.directoryExists(pathArgument(payload));
+ if (ts71()) return reply71(result);
if (result === undefined) return "";
return result ? "true" : "false";
});
client.registerCallback("realpath", (payload) => {
const result = fs.realpath(pathArgument(payload));
+ if (ts71()) return reply71(result);
if (result === undefined) return "";
return JSON.stringify(result);
});
client.registerCallback("getAccessibleEntries", (payload) => {
const result = fs.getAccessibleEntries(pathArgument(payload));
+ if (ts71()) return reply71(result);
if (result === undefined) return "";
return JSON.stringify(result!);
});
diff --git a/packages/compiler/src/frontend/ts7/semantic-checker.test.ts b/packages/compiler/src/frontend/ts7/semantic-checker.test.ts
index 90a7a25cf..766357b56 100644
--- a/packages/compiler/src/frontend/ts7/semantic-checker.test.ts
+++ b/packages/compiler/src/frontend/ts7/semantic-checker.test.ts
@@ -6,9 +6,9 @@ import { API } from "typescript/unstable/sync";
import type { Node as SdkNode } from "typescript/unstable/ast";
import { AstNode } from "./ast-node.js";
import { Ts7RpcClient } from "./rpc-client.js";
-import { tsgoExecutable } from "./rpc-api.js";
import { spawnTs7Wire } from "./rpc-process.js";
import { Ts7Session } from "./session.js";
+import { ts7TestServers } from "./test-servers.js";
import { SyntaxKind } from "./enums.js";
import { getSourceFileOfNode } from "./node-functions.js";
import { forEachChild } from "./ast.js";
@@ -25,12 +25,14 @@ const source = [
"export function predicate(value: unknown): value is Box { return value instanceof Box; }",
"export function rest(...values: number[]): number { return values.length; }",
"export function receiver(this: Box, value: number): number { return value; }",
+ 'export enum Kind { A = 1, B = "b", C = A + 2, D }',
].join("\n");
let directory: string;
let file: string;
let config: string;
-let session: Ts7Session;
+const servers = ts7TestServers();
+const sessions = new Map();
let oracle: API;
beforeAll(() => {
@@ -47,15 +49,19 @@ beforeAll(() => {
files: [file],
}),
);
- // Compared with the pinned SDK, which runs tsgo.
- session = new Ts7Session(
- new Ts7RpcClient(spawnTs7Wire(tsgoExecutable(), ["--api", "--cwd", directory])),
- );
+ // Compared with the pinned SDK, which runs tsgo 7.0; tsc 7.1 answers these cases the same.
+ for (const server of servers)
+ sessions.set(
+ server.name,
+ new Ts7Session(
+ new Ts7RpcClient(spawnTs7Wire(server.executable, ["--api", "--cwd", directory])),
+ ),
+ );
oracle = new API({ cwd: directory });
});
afterAll(() => {
- session?.close();
+ for (const session of sessions.values()) session.close();
oracle?.close();
if (directory) rmSync(directory, { recursive: true, force: true });
});
@@ -75,82 +81,115 @@ function shape(node: AstNode | SdkNode): unknown {
};
}
-test("synthetic type roots and descendants match the pinned SDK without claiming SourceFile fields", () => {
- const snapshot = session.updateSnapshot({ openProjects: [config] });
- const sdkSnapshot = oracle.updateSnapshot({ openProjects: [config] });
- try {
- const project = snapshot.getProject(config)!;
- const sdk = sdkSnapshot.getProject(config)!;
- const root = project.program.getSourceFile(file)!;
- const expectedRoot = sdk.program.getSourceFile(file)!;
- let checked = 0;
- for (let index = 1; index < 7; index++) {
- const statement = root.statements.nodes[index]!;
- const expectedStatement = expectedRoot.statements[index]!;
- const node = statement.declarationList!.declarations!.nodes[0]!.name!;
- const expectedNode = Reflect.get(expectedStatement, "declarationList").declarations[0]
- .name as SdkNode;
- const type = project.checker.getTypeAtLocation(node)!;
- const expectedType = sdk.checker.getTypeAtLocation(expectedNode)!;
- const actual = project.checker.typeToTypeNode(type, node)!;
- const expected = sdk.checker.typeToTypeNode(expectedType, expectedNode)!;
- expect(shape(actual)).toEqual(shape(expected));
- expect(actual.kind).not.toBe(SyntaxKind.SourceFile);
- expect(actual.file.root).toBe(actual);
- expect(actual.file.node(1)).toBe(actual);
- expect(actual.parent).toBeUndefined();
- expect(() => actual.file.sourceFile).toThrow("expected a source file root");
- expect(() => getSourceFileOfNode(actual)).toThrow("expected a source file root");
- forEachChild(actual, (child) => {
- expect(child.parent).toBe(actual);
- });
- checked++;
- }
- expect(checked).toBe(6);
- expect(root.file.sourceFile).toBe(root);
- expect(getSourceFileOfNode(root)).toBe(root);
- } finally {
- snapshot.dispose();
- sdkSnapshot.dispose();
- }
-});
-
-test("generic, predicate, rest and this-parameter signature fragments match the pinned SDK", () => {
- const snapshot = session.updateSnapshot({ openProjects: [config] });
- const sdkSnapshot = oracle.updateSnapshot({ openProjects: [config] });
- try {
- const project = snapshot.getProject(config)!;
- const sdk = sdkSnapshot.getProject(config)!;
- const root = project.program.getSourceFile(file)!;
- const expectedRoot = sdk.program.getSourceFile(file)!;
- for (let index = 7; index < 11; index++) {
- const node = root.statements.nodes[index]!;
- const expectedNode = expectedRoot.statements[index]!;
- const signature = project.checker.getSignatureFromDeclaration(node)!;
- const expectedSignature = sdk.checker.getSignatureFromDeclaration(expectedNode)!;
- const actual = project.checker.signatureToSignatureDeclaration(
- signature,
- SyntaxKind.FunctionType,
- node,
- )!;
- const expected = sdk.checker.signatureToSignatureDeclaration(
- expectedSignature,
- SyntaxKind.FunctionType,
- expectedNode,
- )!;
- expect(actual.kind).toBe(SyntaxKind.FunctionType);
- expect(shape(actual)).toEqual(shape(expected));
- expect(actual.file.root).toBe(actual);
- const parameters = actual.parameters!.nodes;
- expect(parameters.length).toBeGreaterThan(0);
- for (const parameter of parameters) {
- expect(parameter.parent).toBe(actual);
- expect(actual.file.node(parameter.index)).toBe(parameter);
+for (const server of servers)
+ test(`synthetic type roots and descendants match the pinned SDK without claiming SourceFile fields (${server.name})`, () => {
+ const session = sessions.get(server.name)!;
+ const snapshot = session.updateSnapshot({ openProjects: [config] });
+ const sdkSnapshot = oracle.updateSnapshot({ openProjects: [config] });
+ try {
+ const project = snapshot.getProject(config)!;
+ const sdk = sdkSnapshot.getProject(config)!;
+ const root = project.program.getSourceFile(file)!;
+ const expectedRoot = sdk.program.getSourceFile(file)!;
+ let checked = 0;
+ for (let index = 1; index < 7; index++) {
+ const statement = root.statements.nodes[index]!;
+ const expectedStatement = expectedRoot.statements[index]!;
+ const node = statement.declarationList!.declarations!.nodes[0]!.name!;
+ const expectedNode = Reflect.get(expectedStatement, "declarationList").declarations[0]
+ .name as SdkNode;
+ const type = project.checker.getTypeAtLocation(node)!;
+ const expectedType = sdk.checker.getTypeAtLocation(expectedNode)!;
+ const actual = project.checker.typeToTypeNode(type, node)!;
+ const expected = sdk.checker.typeToTypeNode(expectedType, expectedNode)!;
+ expect(shape(actual)).toEqual(shape(expected));
+ expect(actual.kind).not.toBe(SyntaxKind.SourceFile);
+ expect(actual.file.root).toBe(actual);
+ expect(actual.file.node(1)).toBe(actual);
+ expect(actual.parent).toBeUndefined();
+ expect(() => actual.file.sourceFile).toThrow("expected a source file root");
+ expect(() => getSourceFileOfNode(actual)).toThrow("expected a source file root");
+ forEachChild(actual, (child) => {
+ expect(child.parent).toBe(actual);
+ });
+ checked++;
}
- expect(() => actual.file.sourceFile).toThrow("expected a source file root");
+ expect(checked).toBe(6);
+ expect(root.file.sourceFile).toBe(root);
+ expect(getSourceFileOfNode(root)).toBe(root);
+ } finally {
+ snapshot.dispose();
+ sdkSnapshot.dispose();
}
- } finally {
- snapshot.dispose();
- sdkSnapshot.dispose();
- }
-});
+ });
+
+for (const server of servers)
+ test(`generic, predicate, rest and this-parameter signature fragments match the pinned SDK (${server.name})`, () => {
+ const session = sessions.get(server.name)!;
+ const snapshot = session.updateSnapshot({ openProjects: [config] });
+ const sdkSnapshot = oracle.updateSnapshot({ openProjects: [config] });
+ try {
+ const project = snapshot.getProject(config)!;
+ const sdk = sdkSnapshot.getProject(config)!;
+ const root = project.program.getSourceFile(file)!;
+ const expectedRoot = sdk.program.getSourceFile(file)!;
+ for (let index = 7; index < 11; index++) {
+ const node = root.statements.nodes[index]!;
+ const expectedNode = expectedRoot.statements[index]!;
+ const signature = project.checker.getSignatureFromDeclaration(node)!;
+ const expectedSignature = sdk.checker.getSignatureFromDeclaration(expectedNode)!;
+ const actual = project.checker.signatureToSignatureDeclaration(
+ signature,
+ SyntaxKind.FunctionType,
+ node,
+ )!;
+ const expected = sdk.checker.signatureToSignatureDeclaration(
+ expectedSignature,
+ SyntaxKind.FunctionType,
+ expectedNode,
+ )!;
+ expect(actual.kind).toBe(SyntaxKind.FunctionType);
+ expect(shape(actual)).toEqual(shape(expected));
+ expect(actual.file.root).toBe(actual);
+ const parameters = actual.parameters!.nodes;
+ expect(parameters.length).toBeGreaterThan(0);
+ for (const parameter of parameters) {
+ expect(parameter.parent).toBe(actual);
+ expect(actual.file.node(parameter.index)).toBe(parameter);
+ }
+ expect(() => actual.file.sourceFile).toThrow("expected a source file root");
+ }
+ } finally {
+ snapshot.dispose();
+ sdkSnapshot.dispose();
+ }
+ });
+
+for (const server of servers)
+ test(`enum member constant values match the pinned SDK (${server.name})`, () => {
+ const session = sessions.get(server.name)!;
+ const snapshot = session.updateSnapshot({ openProjects: [config] });
+ const sdkSnapshot = oracle.updateSnapshot({ openProjects: [config] });
+ try {
+ const project = snapshot.getProject(config)!;
+ const sdk = sdkSnapshot.getProject(config)!;
+ const statement = project.program.getSourceFile(file)!.statements.nodes[11]!;
+ const expected = Reflect.get(
+ sdk.program.getSourceFile(file)!.statements[11]!,
+ "members",
+ ) as SdkNode[];
+ const members = statement.members!.nodes;
+ expect(members.map((member) => project.checker.getConstantValue(member))).toEqual(
+ expected.map((member) => sdk.checker.getConstantValue(member)),
+ );
+ expect(members.map((member) => project.checker.getConstantValue(member))).toEqual([
+ 1,
+ "b",
+ 3,
+ 4,
+ ]);
+ } finally {
+ snapshot.dispose();
+ sdkSnapshot.dispose();
+ }
+ });
diff --git a/packages/compiler/src/frontend/ts7/semantic-checker.ts b/packages/compiler/src/frontend/ts7/semantic-checker.ts
index c19f93598..3dc0dd7dd 100644
--- a/packages/compiler/src/frontend/ts7/semantic-checker.ts
+++ b/packages/compiler/src/frontend/ts7/semantic-checker.ts
@@ -12,6 +12,8 @@ import {
SemanticType,
type SemanticDocument,
type SemanticQuery,
+ type Ts71SymbolData,
+ type Ts71TypeData,
present,
} from "./semantic-model.js";
import { SemanticSymbolFlags } from "./semantic-schema.generated.js";
@@ -72,7 +74,8 @@ export interface SemanticCompletionOptions {
triggerCharacter?: string;
includeSymbol?: boolean;
}
-interface CompletionResponse {
+/** `S` is TypeScript 7.0's SymbolResponse or 7.1's Ts71SymbolData. */
+interface CompletionResponse {
isIncomplete: boolean;
entries: {
name: string;
@@ -82,9 +85,36 @@ interface CompletionResponse {
filterText?: string;
detail?: string;
labelDetails?: { detail?: string; description?: string };
- symbol?: SymbolResponse;
+ symbol?: S;
}[];
}
+
+/** TypeScript 7.1's type predicate and index info replies. */
+interface TypePredicateResponse71 {
+ kind: number;
+ parameterIndex: number;
+ parameterName?: string;
+ type?: Ts71TypeData;
+}
+interface IndexInfoResponse71 {
+ keyType: Ts71TypeData;
+ valueType: Ts71TypeData;
+ isReadonly?: boolean;
+ declaration?: string;
+}
+
+/** TypeScript 7.1's getConstantValue answer: the value, with a number's
+ * non-finite values written as "+Infinity", "-Infinity" and "NaN". */
+interface ConstantValueResponse71 {
+ isNumber: boolean;
+ value?: string | number | boolean | null;
+}
+
+interface ReferencedSymbolResponse {
+ definition: string;
+ symbol?: S;
+ references?: string[];
+}
export interface SemanticCompletion {
name: string;
kind: number | undefined;
@@ -369,6 +399,7 @@ export class SemanticChecker {
return this.project.request("isArrayLikeType", { type: type.id });
}
isTupleType(type: SemanticType): boolean {
+ if (this.project.ts71()) return this.project.isTupleType71(type.id);
return this.project.request("isTupleType", { type: type.id });
}
isTypeAssignableTo(source: SemanticType, target: SemanticType): boolean {
@@ -378,6 +409,16 @@ export class SemanticChecker {
});
}
getConstantValue(node: AstNode): string | number | undefined {
+ if (this.project.ts71()) {
+ const data = this.project.request("getConstantValue", {
+ location: node.id,
+ });
+ const value = data?.value;
+ if (typeof value === "number") return value;
+ if (typeof value !== "string") return undefined;
+ if (!data!.isNumber) return value;
+ return value === "+Infinity" ? Infinity : value === "-Infinity" ? -Infinity : NaN;
+ }
return (
this.project.request("getConstantValue", { location: node.id }) ??
undefined
@@ -390,6 +431,20 @@ export class SemanticChecker {
);
}
getTypePredicateOfSignature(signature: SemanticSignature): SemanticTypePredicate | undefined {
+ if (this.project.ts71()) {
+ const data = this.project.request(
+ "getTypePredicateOfSignature",
+ { signature: signature.id },
+ );
+ return data === null
+ ? undefined
+ : {
+ kind: data.kind,
+ parameterIndex: data.parameterIndex,
+ parameterName: data.parameterName,
+ type: data.type === undefined ? undefined : this.project.type71(data.type),
+ };
+ }
const data = this.project.request("getTypePredicateOfSignature", {
signature: signature.id,
});
@@ -403,6 +458,22 @@ export class SemanticChecker {
};
}
getIndexInfosOfType(type: SemanticType): SemanticIndexInfo[] {
+ if (this.project.ts71()) {
+ const data = this.project.request("getIndexInfosOfType", {
+ type: type.id,
+ });
+ return (data ?? []).map(
+ (item) =>
+ new SemanticIndexInfo(
+ this.project.type71(item.keyType),
+ this.project.type71(item.valueType),
+ item.isReadonly ?? false,
+ item.declaration === undefined
+ ? undefined
+ : new SemanticNodeHandle(item.declaration, this.project),
+ ),
+ );
+ }
const data = this.project.request("getIndexInfosOfType", {
type: type.id,
});
@@ -420,8 +491,14 @@ export class SemanticChecker {
}
private getWellKnownSymbols(): WellKnownSymbols {
this.project.ensureActive();
- if (this.wellKnown === undefined)
- this.wellKnown = this.project.request("getWellKnownSymbols");
+ if (this.wellKnown === undefined) {
+ const ids = this.project.request("getWellKnownSymbols");
+ this.wellKnown = {
+ unknown: this.project.snapshotSymbolId(ids.unknown),
+ undefined: this.project.snapshotSymbolId(ids.undefined),
+ arguments: this.project.snapshotSymbolId(ids.arguments),
+ };
+ }
return this.wellKnown;
}
isUnknownSymbol(symbol: SemanticSymbol): boolean {
@@ -445,9 +522,24 @@ export class SemanticChecker {
return (data ?? []).map((handle) => new SemanticNodeHandle(handle, this.project));
}
getReferencedSymbolsForNode(node: AstNode, position: number): SemanticReferencedSymbol[] {
- const data = this.project.request<
- { definition: string; symbol?: SymbolResponse; references?: string[] }[] | null
- >("getReferencedSymbolsForNode", { node: node.id, position });
+ const query = { node: node.id, position };
+ if (this.project.ts71()) {
+ const data = this.project.request[] | null>(
+ "getReferencedSymbolsForNode",
+ query,
+ );
+ return (data ?? []).map((item) => ({
+ definition: new SemanticNodeHandle(item.definition, this.project),
+ symbol: item.symbol === undefined ? undefined : this.project.symbol71(item.symbol),
+ references: (item.references ?? []).map(
+ (handle) => new SemanticNodeHandle(handle, this.project),
+ ),
+ }));
+ }
+ const data = this.project.request[] | null>(
+ "getReferencedSymbolsForNode",
+ query,
+ );
return (data ?? []).map((item) => ({
definition: new SemanticNodeHandle(item.definition, this.project),
symbol: item.symbol === undefined ? undefined : this.project.symbol(item.symbol),
@@ -479,12 +571,36 @@ export class SemanticChecker {
position: number,
options?: SemanticCompletionOptions,
): { isIncomplete: boolean; entries: SemanticCompletion[] } | undefined {
- const data = this.project.request("getCompletionsAtPosition", {
+ const query = {
file,
position,
triggerCharacter: options?.triggerCharacter,
includeSymbol: options?.includeSymbol,
- });
+ };
+ if (this.project.ts71()) {
+ const data = this.project.request | null>(
+ "getCompletionsAtPosition",
+ query,
+ );
+ if (data === null) return undefined;
+ return {
+ isIncomplete: data.isIncomplete,
+ entries: data.entries.map((item) => ({
+ name: item.name,
+ kind: item.kind,
+ sortText: item.sortText,
+ insertText: item.insertText,
+ filterText: item.filterText,
+ detail: item.detail,
+ labelDetails: item.labelDetails,
+ symbol: item.symbol === undefined ? undefined : this.project.symbol71(item.symbol),
+ })),
+ };
+ }
+ const data = this.project.request | null>(
+ "getCompletionsAtPosition",
+ query,
+ );
if (data === null) return undefined;
return {
isIncomplete: data.isIncomplete,
diff --git a/packages/compiler/src/frontend/ts7/semantic-json.ts b/packages/compiler/src/frontend/ts7/semantic-json.ts
index 8913007d3..ea924a039 100644
--- a/packages/compiler/src/frontend/ts7/semantic-json.ts
+++ b/packages/compiler/src/frontend/ts7/semantic-json.ts
@@ -149,7 +149,7 @@ function parseJsonSlice(bytes: Uint8Array, start: number, end: number): T {
/** One element of a reply: null, or the object with leading id `id`. Objects
* the caller already holds under that id are not decoded. */
-function replyElement(
+function replyElement(
bytes: Uint8Array,
start: number,
end: number,
@@ -172,7 +172,7 @@ function replyElement(
* reply is null. Objects the caller already holds under their id, which a
* reply repeats whenever it names them, are returned from `cached` without
* decoding their text; the rest are decoded and handed to `build`. */
-export function decodeSemanticObject(
+export function decodeSemanticObject(
bytes: Uint8Array,
cached: (id: number) => R | undefined,
build: (data: T) => R,
@@ -187,7 +187,7 @@ export function decodeSemanticObject(
/** A checker reply that is an array of objects (each with a numeric `id`) and
* nulls, or null. An empty reply is null. Elements are resolved as in
* decodeSemanticObject. */
-export function decodeSemanticObjects(
+export function decodeSemanticObjects(
bytes: Uint8Array,
cached: (id: number) => R | undefined,
build: (data: T) => R,
diff --git a/packages/compiler/src/frontend/ts7/semantic-model-71.test.ts b/packages/compiler/src/frontend/ts7/semantic-model-71.test.ts
new file mode 100644
index 000000000..62de58443
--- /dev/null
+++ b/packages/compiler/src/frontend/ts7/semantic-model-71.test.ts
@@ -0,0 +1,137 @@
+import { expect, test } from "vitest";
+import { SemanticChecker } from "./semantic-checker.js";
+import { SemanticSnapshot } from "./semantic-model.js";
+import { SyntaxKind, TypeFlags } from "./enums.js";
+
+/** A snapshot on a fake TypeScript 7.1 server: `answer` gets each request's
+ * method and parsed payload and returns the reply's JSON. */
+function server(answer: (method: string, query: Record) => unknown) {
+ const requests: { method: string; query: Record }[] = [];
+ const snapshot = new SemanticSnapshot(
+ 1,
+ {
+ text: (method, payload) => {
+ const query = JSON.parse(payload) as Record;
+ requests.push({ method, query });
+ return JSON.stringify(answer(method, query) ?? null);
+ },
+ binary(method, payload) {
+ const text = this.text(method, payload);
+ // AST replies (typeToTypeNode, signatureToSignatureDeclaration) are empty for none.
+ return text === "null" ? new Uint8Array() : Buffer.from(text, "utf8");
+ },
+ },
+ true,
+ );
+ const project = snapshot.addProject("project", () => undefined);
+ return { snapshot, project, checker: new SemanticChecker(project), requests };
+}
+
+const file = {
+ fileName: "/main.ts",
+ path: "/main.ts",
+ contentHash: "0",
+ parseOptionsKey: "0",
+ scriptKind: 3,
+ nodeId: "77",
+};
+
+test("TypeScript 7.1 symbol references round-trip through local ids", () => {
+ const box = { kind: 0, file, id: 1 };
+ const parent = { kind: 0, file, id: 2 };
+ const { project, checker, requests } = server((method, query) => {
+ if (method === "getTypeAtPosition")
+ return { id: 10, flags: TypeFlags.Object, objectFlags: 1, symbol: { id: 1, file: "77" } };
+ if (method === "getSymbolOfType")
+ return {
+ reference: box,
+ name: "Box",
+ flags: 32,
+ checkFlags: 0,
+ parent: { id: 2, file: "77" },
+ };
+ if (method === "getParentOfSymbol") {
+ expect(query["symbol"]).toEqual(box);
+ expect(query["objectId"]).toBeUndefined();
+ return { reference: parent, name: '"/main"', flags: 512, checkFlags: 0 };
+ }
+ if (method === "getDeclaredTypeOfSymbol") {
+ expect(query["symbol"]).toEqual(box);
+ return { id: 11, flags: TypeFlags.Object, isTupleType: true };
+ }
+ if (method === "getExportsOfModule") {
+ expect(query["symbol"]).toEqual(parent);
+ return [{ reference: box, name: "Box", flags: 32, checkFlags: 0 }];
+ }
+ throw new Error(`unexpected ${method}`);
+ });
+ const type = checker.getTypeAtPosition("/main.ts", 0)!;
+ const symbol = type.getSymbol()!;
+ expect(symbol.escapedName).toBe("Box");
+ // The compact reference in the type and the full one in the symbol name one symbol.
+ expect(project.snapshot.cachedSymbol(symbol.id)).toBe(symbol);
+ const owner = symbol.getParent()!;
+ expect(owner.escapedName).toBe('"/main"');
+ expect(checker.getExportsOfModule(owner)).toEqual([symbol]);
+ const declared = checker.getDeclaredTypeOfSymbol(symbol);
+ expect(checker.isTupleType(declared)).toBe(true);
+ expect(checker.isTupleType(type)).toBe(false);
+ expect(requests.map((request) => request.method)).not.toContain("isTupleType");
+});
+
+test("TypeScript 7.1 requests name types and signatures as 7.1 does", () => {
+ const { project, checker, requests } = server((method) => {
+ if (method === "getApparentType" || method === "getReturnTypeOfSignature")
+ return { id: 12, flags: TypeFlags.Number };
+ if (method === "signatureToSignatureDeclaration") return null;
+ if (method === "getWellKnownSymbols") return { unknown: 5, undefined: 6, arguments: 7 };
+ if (method === "getSymbolAtPosition")
+ return {
+ reference: { kind: 1, snapshot: 1, project: "project", id: 6 },
+ name: "undefined",
+ flags: 3,
+ checkFlags: 0,
+ };
+ throw new Error(`unexpected ${method}`);
+ });
+ const type = project.type({ id: 1, flags: TypeFlags.Object });
+ const signature = project.signature({ id: 3, flags: 0 });
+ checker.getApparentType(type);
+ checker.getReturnTypeOfSignature(signature);
+ checker.signatureToSignatureDeclaration(signature, SyntaxKind.FunctionType);
+ const queries = new Map(requests.map((request) => [request.method, request.query]));
+ expect(queries.get("getApparentType")).toMatchObject({ objectId: 1 });
+ expect(queries.get("getApparentType")!["type"]).toBeUndefined();
+ expect(queries.get("getReturnTypeOfSignature")).toMatchObject({ objectId: 3 });
+ // 7.1 numbers every kind from SourceKeyword up one higher.
+ expect(queries.get("signatureToSignatureDeclaration")).toMatchObject({
+ kind: SyntaxKind.FunctionType + 1,
+ });
+ // A well-known symbol id names a symbol of the snapshot.
+ const undefinedSymbol = checker.getSymbolAtPosition("/main.ts", 0)!;
+ expect(checker.isUndefinedSymbol(undefinedSymbol)).toBe(true);
+ expect(checker.isUnknownSymbol(undefinedSymbol)).toBe(false);
+});
+
+test("TypeScript 7.1 constant values are read from their answer object", () => {
+ const answers = [
+ { isNumber: true, value: 1 },
+ { isNumber: false, value: "a" },
+ { isNumber: true, value: "+Infinity" },
+ { isNumber: true, value: "-Infinity" },
+ { isNumber: true, value: "NaN" },
+ null,
+ ];
+ let next = 0;
+ const { checker } = server((method) => {
+ expect(method).toBe("getConstantValue");
+ return answers[next++];
+ });
+ const node = { id: "2.306./main.ts" } as Parameters[0];
+ expect(checker.getConstantValue(node)).toBe(1);
+ expect(checker.getConstantValue(node)).toBe("a");
+ expect(checker.getConstantValue(node)).toBe(Infinity);
+ expect(checker.getConstantValue(node)).toBe(-Infinity);
+ expect(checker.getConstantValue(node)).toBeNaN();
+ expect(checker.getConstantValue(node)).toBeUndefined();
+});
diff --git a/packages/compiler/src/frontend/ts7/semantic-model.ts b/packages/compiler/src/frontend/ts7/semantic-model.ts
index 04fa8e637..fb438a661 100644
--- a/packages/compiler/src/frontend/ts7/semantic-model.ts
+++ b/packages/compiler/src/frontend/ts7/semantic-model.ts
@@ -6,7 +6,7 @@ import type {
import { AstNode } from "./ast-node.js";
import type { __String } from "./ast-types.js";
import type * as enums from "./enums.generated.js";
-import { parseAstNodeHandle } from "./ast-wire.js";
+import { parseAstNodeHandle, syntaxKind71 } from "./ast-wire.js";
import type { SemanticChecker, SemanticJsDocTag } from "./semantic-checker.js";
import { decodeSemanticObject, decodeSemanticObjects, parseSemanticJson } from "./semantic-json.js";
import {
@@ -44,8 +44,9 @@ export interface SemanticQuery {
position?: number | undefined;
positions?: readonly number[] | undefined;
type?: number | undefined;
- symbol?: number | undefined;
- symbols?: number[] | undefined;
+ /** A symbol's id; on a TypeScript 7.1 server, its reference (SemanticProject.encode). */
+ symbol?: number | Ts71SymbolReference | undefined;
+ symbols?: (number | Ts71SymbolReference)[] | undefined;
signature?: number | undefined;
source?: number | undefined;
target?: number | undefined;
@@ -61,6 +62,214 @@ export interface SemanticQuery {
includeSymbol?: boolean | undefined;
}
+/** A symbol in TypeScript 7.1's protocol: its owner, a source file (kind 0)
+ * or the snapshot and project (kind 1), and its id within that owner. */
+export interface Ts71SymbolReference {
+ kind: number;
+ file?: Ts71SourceFileDescriptor;
+ snapshot?: number;
+ project?: string;
+ id: number;
+}
+
+export interface Ts71SourceFileDescriptor {
+ fileName: string;
+ path: string;
+ contentHash: string;
+ parseOptionsKey: string;
+ scriptKind: number;
+ nodeId: string;
+}
+
+/** A symbol another TypeScript 7.1 response names: its id and, for a symbol
+ * a source file owns, the file's node ID. Without a file, the response's
+ * snapshot and project own it. */
+export interface Ts71CompactReference {
+ id: number;
+ file?: string;
+}
+
+/** A symbol as TypeScript 7.1 reports it. */
+export interface Ts71SymbolData {
+ reference: Ts71SymbolReference;
+ name: string;
+ flags: number;
+ checkFlags: number;
+ declarations?: string[];
+ valueDeclaration?: string;
+ parent?: Ts71CompactReference;
+ exportSymbol?: Ts71CompactReference;
+}
+
+/** A type as TypeScript 7.1 reports it: TypeResponse with compact symbol
+ * references. */
+export interface Ts71TypeData {
+ id: number;
+ flags: number;
+ objectFlags?: number;
+ value?: string | number | boolean | null;
+ freshType?: number;
+ regularType?: number;
+ target?: number;
+ typeParameters?: number[];
+ outerTypeParameters?: number[];
+ localTypeParameters?: number[];
+ elementFlags?: number[];
+ fixedLength?: number;
+ readonly?: boolean;
+ objectType?: number;
+ indexType?: number;
+ checkType?: number;
+ extendsType?: number;
+ baseType?: number;
+ substConstraint?: number;
+ texts?: string[];
+ intrinsicName?: string;
+ isThisType?: boolean;
+ /** Whether the type is a tuple type, which 7.0 answers with isTupleType. */
+ isTupleType?: boolean;
+ aliasTypeArguments?: number[];
+ aliasSymbol?: Ts71CompactReference;
+ symbol?: Ts71CompactReference;
+}
+
+/** A signature as TypeScript 7.1 reports it. */
+export interface Ts71SignatureData {
+ id: number;
+ flags: number;
+ declaration?: string;
+ typeParameters?: number[];
+ parameters?: Ts71CompactReference[];
+ thisParameter?: Ts71CompactReference;
+ target?: number;
+}
+
+/** The methods whose source is a symbol: TypeScript 7.0 names it by
+ * `objectId`, 7.1 by its reference in `symbol`. */
+const SYMBOL_PROPERTY_METHODS = new Set([
+ "getParentOfSymbol",
+ "getExportSymbolOfSymbol",
+ "getMembersOfSymbol",
+ "getExportsOfSymbol",
+]);
+
+/** The methods TypeScript 7.1 gives their type or signature in `objectId`,
+ * where 7.0 takes it in `type` or `signature`. */
+const OBJECT_ID_METHODS_7_1 = new Map([
+ ["getApparentType", "type"],
+ ["getConstraintOfTypeParameter", "type"],
+ ["getNonNullableType", "type"],
+ ["getReturnTypeOfSignature", "signature"],
+]);
+
+/** TypeScript 7.1's symbol references by local id. The semantic model keeps
+ * TypeScript 7.0's numeric symbol ids; on a 7.1 server, each symbol owner
+ * and id gets a local id, and requests send the reference back. A compact
+ * reference gets the local id of the symbol it names, so a later response
+ * with that symbol's full reference finds the same id. */
+export class Ts71SymbolTable {
+ private readonly locals = new Map();
+ private readonly references: (Ts71SymbolReference | undefined)[] = [undefined];
+
+ private allocate(key: string): number {
+ let local = this.locals.get(key);
+ if (local === undefined) {
+ local = this.references.length;
+ this.references.push(undefined);
+ this.locals.set(key, local);
+ }
+ return local;
+ }
+
+ /** The local id of `reference`, which requests then name it by. */
+ fromReference(reference: Ts71SymbolReference): number {
+ const key =
+ reference.kind === 0
+ ? `f${reference.file?.nodeId ?? ""}:${reference.id}`
+ : `s${reference.project ?? ""}:${reference.id}`;
+ const local = this.allocate(key);
+ this.references[local] = reference;
+ return local;
+ }
+
+ /** The local id of a compact reference in a response for `project`. */
+ fromCompact(reference: Ts71CompactReference, project: string): number {
+ return this.allocate(
+ reference.file !== undefined
+ ? `f${reference.file}:${reference.id}`
+ : `s${project}:${reference.id}`,
+ );
+ }
+
+ /** The reference of local id `local`. Throws for a symbol no response has
+ * described yet. */
+ reference(local: number): Ts71SymbolReference {
+ const reference = this.references[local];
+ if (reference === undefined) throw new Error(`Unknown TypeScript 7.1 symbol ${local}`);
+ return reference;
+ }
+
+ /** `data` in TypeScript 7.0's shape, with local ids. */
+ symbolData(data: Ts71SymbolData, project: string): SymbolResponse {
+ const result: SymbolResponse = {
+ id: this.fromReference(data.reference),
+ project,
+ name: data.name,
+ flags: data.flags,
+ checkFlags: data.checkFlags,
+ };
+ if (data.declarations !== undefined) result.declarations = data.declarations;
+ if (data.valueDeclaration !== undefined) result.valueDeclaration = data.valueDeclaration;
+ if (data.parent !== undefined) result.parent = this.fromCompact(data.parent, project);
+ if (data.exportSymbol !== undefined)
+ result.exportSymbol = this.fromCompact(data.exportSymbol, project);
+ return result;
+ }
+
+ typeData(data: Ts71TypeData, project: string): TypeResponse {
+ const result: TypeResponse = { id: data.id, flags: data.flags };
+ if (data.objectFlags !== undefined) result.objectFlags = data.objectFlags;
+ if (data.value !== undefined) result.value = data.value;
+ if (data.freshType !== undefined) result.freshType = data.freshType;
+ if (data.regularType !== undefined) result.regularType = data.regularType;
+ if (data.target !== undefined) result.target = data.target;
+ if (data.typeParameters !== undefined) result.typeParameters = data.typeParameters;
+ if (data.outerTypeParameters !== undefined)
+ result.outerTypeParameters = data.outerTypeParameters;
+ if (data.localTypeParameters !== undefined)
+ result.localTypeParameters = data.localTypeParameters;
+ if (data.elementFlags !== undefined) result.elementFlags = data.elementFlags;
+ if (data.fixedLength !== undefined) result.fixedLength = data.fixedLength;
+ if (data.readonly !== undefined) result.readonly = data.readonly;
+ if (data.objectType !== undefined) result.objectType = data.objectType;
+ if (data.indexType !== undefined) result.indexType = data.indexType;
+ if (data.checkType !== undefined) result.checkType = data.checkType;
+ if (data.extendsType !== undefined) result.extendsType = data.extendsType;
+ if (data.baseType !== undefined) result.baseType = data.baseType;
+ if (data.substConstraint !== undefined) result.substConstraint = data.substConstraint;
+ if (data.texts !== undefined) result.texts = data.texts;
+ if (data.intrinsicName !== undefined) result.intrinsicName = data.intrinsicName;
+ if (data.isThisType !== undefined) result.isThisType = data.isThisType;
+ if (data.aliasTypeArguments !== undefined) result.aliasTypeArguments = data.aliasTypeArguments;
+ if (data.aliasSymbol !== undefined)
+ result.aliasSymbol = this.fromCompact(data.aliasSymbol, project);
+ if (data.symbol !== undefined) result.symbol = this.fromCompact(data.symbol, project);
+ return result;
+ }
+
+ signatureData(data: Ts71SignatureData, project: string): SignatureResponse {
+ const result: SignatureResponse = { id: data.id, flags: data.flags };
+ if (data.declaration !== undefined) result.declaration = data.declaration;
+ if (data.typeParameters !== undefined) result.typeParameters = data.typeParameters;
+ if (data.parameters !== undefined)
+ result.parameters = data.parameters.map((parameter) => this.fromCompact(parameter, project));
+ if (data.thisParameter !== undefined)
+ result.thisParameter = this.fromCompact(data.thisParameter, project);
+ if (data.target !== undefined) result.target = data.target;
+ return result;
+ }
+}
+
export class SemanticProgram {
constructor(private readonly sourceFile: (path: string) => AstNode | undefined) {}
getSourceFile(path: string): AstNode | undefined {
@@ -77,10 +286,16 @@ export class SemanticSnapshot {
private readonly symbols = new Map();
private disposed = false;
+ /** TypeScript 7.1's symbol references, on a server that speaks 7.1. */
+ readonly references: Ts71SymbolTable | undefined;
+
constructor(
readonly id: number,
readonly transport: SemanticTransport,
- ) {}
+ ts71 = false,
+ ) {
+ this.references = ts71 ? new Ts71SymbolTable() : undefined;
+ }
addProject(id: string, sourceFile: (path: string) => AstNode | undefined): SemanticProject {
this.ensureActive();
@@ -128,6 +343,9 @@ export class SemanticSnapshot {
export class SemanticProject {
private readonly types = new Map();
+ /** TypeScript 7.1's isTupleType answers, by type id: 7.1 reports them on
+ * the type instead of answering an isTupleType request. */
+ private readonly tupleTypes = new Map();
private readonly signatures = new Map();
private readonly cleanups: (() => void)[] = [];
private disposed = false;
@@ -150,24 +368,90 @@ export class SemanticProject {
this.cleanups.push(cleanup);
}
- request(method: string, query: SemanticQuery = {}): T {
+ /** `query` for `method` in this project's snapshot. On a TypeScript 7.1
+ * server, symbol ids become their references, and a symbol property
+ * method takes its source symbol's reference in `symbol`. */
+ private encode(method: string, query: SemanticQuery): string {
this.ensureActive();
query.snapshot = this.snapshot.id;
query.project = this.id;
- return parseSemanticJson(this.snapshot.transport.text(method, JSON.stringify(query)));
+ const references = this.snapshot.references;
+ if (references !== undefined) {
+ if (typeof query.symbol === "number") query.symbol = references.reference(query.symbol);
+ if (query.symbols !== undefined)
+ query.symbols = query.symbols.map((symbol) =>
+ typeof symbol === "number" ? references.reference(symbol) : symbol,
+ );
+ if (SYMBOL_PROPERTY_METHODS.has(method) && query.objectId !== undefined) {
+ query.symbol = references.reference(query.objectId);
+ query.objectId = undefined;
+ }
+ // signatureToSignatureDeclaration's kind is a SyntaxKind.
+ if (method === "signatureToSignatureDeclaration" && query.kind !== undefined)
+ query.kind = syntaxKind71(query.kind);
+ const field = OBJECT_ID_METHODS_7_1.get(method);
+ if (field === "type") {
+ query.objectId = query.type;
+ query.type = undefined;
+ } else if (field === "signature") {
+ query.objectId = query.signature;
+ query.signature = undefined;
+ }
+ }
+ return JSON.stringify(query);
+ }
+
+ request(method: string, query: SemanticQuery = {}): T {
+ return parseSemanticJson(this.snapshot.transport.text(method, this.encode(method, query)));
}
requestBinary(method: string, query: SemanticQuery): Uint8Array {
- this.ensureActive();
- query.snapshot = this.snapshot.id;
- query.project = this.id;
- return this.snapshot.transport.binary(method, JSON.stringify(query));
+ return this.snapshot.transport.binary(method, this.encode(method, query));
+ }
+
+ /** A type a TypeScript 7.1 reply holds. */
+ type71(data: Ts71TypeData): SemanticType {
+ const references = this.snapshot.references;
+ if (references === undefined) throw new Error("TypeScript 7.1 type from a 7.0 server");
+ this.tupleTypes.set(data.id, data.isTupleType === true);
+ return this.type(references.typeData(data, this.id));
+ }
+
+ /** Whether a type a TypeScript 7.1 server reported is a tuple type. */
+ isTupleType71(type: number): boolean {
+ const result = this.tupleTypes.get(type);
+ if (result === undefined) throw new Error(`Unknown TypeScript 7.1 type ${type}`);
+ return result;
+ }
+
+ /** Whether the server speaks TypeScript 7.1's protocol. */
+ ts71(): boolean {
+ return this.snapshot.references !== undefined;
+ }
+
+ /** A symbol a TypeScript 7.1 reply holds. */
+ symbol71(data: Ts71SymbolData): SemanticSymbol {
+ const references = this.snapshot.references;
+ if (references === undefined) throw new Error("TypeScript 7.1 symbol from a 7.0 server");
+ return this.symbol(references.symbolData(data, this.id));
+ }
+
+ /** The local id of a symbol id a TypeScript 7.1 server reports as a plain
+ * number (getWellKnownSymbols): a symbol of this project's snapshot. */
+ snapshotSymbolId(id: number): number {
+ const references = this.snapshot.references;
+ return references === undefined ? id : references.fromCompact({ id }, this.id);
}
/** A reply of one type, or undefined for null. */
requestType(method: string, query: SemanticQuery): SemanticType | undefined {
const bytes = this.requestBinary(method, query);
const cached = (id: number): SemanticType | undefined => this.types.get(id);
+ const references = this.snapshot.references;
+ if (references !== undefined) {
+ const build71 = (data: Ts71TypeData): SemanticType => this.type71(data);
+ return decodeSemanticObject(bytes, cached, build71) ?? undefined;
+ }
const build = (data: TypeResponse): SemanticType => this.type(data);
return decodeSemanticObject(bytes, cached, build) ?? undefined;
}
@@ -176,6 +460,11 @@ export class SemanticProject {
requestTypes(method: string, query: SemanticQuery): (SemanticType | undefined)[] {
const bytes = this.requestBinary(method, query);
const cached = (id: number): SemanticType | undefined => this.types.get(id);
+ const references = this.snapshot.references;
+ if (references !== undefined) {
+ const build71 = (data: Ts71TypeData): SemanticType => this.type71(data);
+ return (decodeSemanticObjects(bytes, cached, build71) ?? []).map((item) => item ?? undefined);
+ }
const build = (data: TypeResponse): SemanticType => this.type(data);
return (decodeSemanticObjects(bytes, cached, build) ?? []).map((item) => item ?? undefined);
}
@@ -183,6 +472,14 @@ export class SemanticProject {
/** A reply of one symbol, or undefined for null. */
requestSymbol(method: string, query: SemanticQuery): SemanticSymbol | undefined {
const bytes = this.requestBinary(method, query);
+ const references = this.snapshot.references;
+ if (references !== undefined) {
+ // TypeScript 7.1's symbols start with their reference, not an id.
+ const none = (): SemanticSymbol | undefined => undefined;
+ const build71 = (data: Ts71SymbolData): SemanticSymbol =>
+ this.symbol(references.symbolData(data, this.id));
+ return decodeSemanticObject(bytes, none, build71) ?? undefined;
+ }
const cached = (id: number): SemanticSymbol | undefined => this.snapshot.cachedSymbol(id);
const build = (data: SymbolResponse): SemanticSymbol => this.symbol(data);
return decodeSemanticObject(bytes, cached, build) ?? undefined;
@@ -191,6 +488,13 @@ export class SemanticProject {
/** A reply of symbols and nulls (undefined here), or [] for null. */
requestSymbols(method: string, query: SemanticQuery): (SemanticSymbol | undefined)[] {
const bytes = this.requestBinary(method, query);
+ const references = this.snapshot.references;
+ if (references !== undefined) {
+ const none = (): SemanticSymbol | undefined => undefined;
+ const build71 = (data: Ts71SymbolData): SemanticSymbol =>
+ this.symbol(references.symbolData(data, this.id));
+ return (decodeSemanticObjects(bytes, none, build71) ?? []).map((item) => item ?? undefined);
+ }
const cached = (id: number): SemanticSymbol | undefined => this.snapshot.cachedSymbol(id);
const build = (data: SymbolResponse): SemanticSymbol => this.symbol(data);
return (decodeSemanticObjects(bytes, cached, build) ?? []).map((item) => item ?? undefined);
@@ -200,6 +504,12 @@ export class SemanticProject {
requestSignature(method: string, query: SemanticQuery): SemanticSignature | undefined {
const bytes = this.requestBinary(method, query);
const cached = (id: number): SemanticSignature | undefined => this.signatures.get(id);
+ const references = this.snapshot.references;
+ if (references !== undefined) {
+ const build71 = (data: Ts71SignatureData): SemanticSignature =>
+ this.signature(references.signatureData(data, this.id));
+ return decodeSemanticObject(bytes, cached, build71) ?? undefined;
+ }
const build = (data: SignatureResponse): SemanticSignature => this.signature(data);
return decodeSemanticObject(bytes, cached, build) ?? undefined;
}
@@ -208,6 +518,12 @@ export class SemanticProject {
requestSignatures(method: string, query: SemanticQuery): (SemanticSignature | undefined)[] {
const bytes = this.requestBinary(method, query);
const cached = (id: number): SemanticSignature | undefined => this.signatures.get(id);
+ const references = this.snapshot.references;
+ if (references !== undefined) {
+ const build71 = (data: Ts71SignatureData): SemanticSignature =>
+ this.signature(references.signatureData(data, this.id));
+ return (decodeSemanticObjects(bytes, cached, build71) ?? []).map((item) => item ?? undefined);
+ }
const build = (data: SignatureResponse): SemanticSignature => this.signature(data);
return (decodeSemanticObjects(bytes, cached, build) ?? []).map((item) => item ?? undefined);
}
diff --git a/packages/compiler/src/frontend/ts7/session.test.ts b/packages/compiler/src/frontend/ts7/session.test.ts
index d957fe0dd..5bf50a087 100644
--- a/packages/compiler/src/frontend/ts7/session.test.ts
+++ b/packages/compiler/src/frontend/ts7/session.test.ts
@@ -11,7 +11,7 @@ import {
type Ts7FileSystem,
} from "./rpc-filesystem.js";
import { spawnTs7Wire } from "./rpc-process.js";
-import { tsgoExecutable } from "./rpc-api.js";
+import { ts7TestServers, type Ts7TestServer } from "./test-servers.js";
import { Ts7Session, Ts7SessionProject, Ts7SessionSnapshot } from "./session.js";
import { AstNode } from "./ast-node.js";
import { ts7Diagnostic } from "./diagnostic.js";
@@ -27,10 +27,16 @@ const fallback: Ts7FileSystem = {
getAccessibleEntries: () => undefined,
};
-// These tests compare the session with the pinned SDK, which runs tsgo, so the session runs tsgo too.
-function transport(cwd: string, fs: Ts7FileSystem = fallback, timing = false) {
+// These tests compare the session with the pinned SDK, which runs tsgo 7.0. The session runs
+// tsc 7.0 and tsc 7.1, whose answers to these cases are the same.
+function transport(
+ server: Ts7TestServer,
+ cwd: string,
+ fs: Ts7FileSystem = fallback,
+ timing = false,
+) {
const rpc = new Ts7RpcClient(
- spawnTs7Wire(tsgoExecutable(), [
+ spawnTs7Wire(server.executable, [
"--api",
"--cwd",
cwd,
@@ -38,265 +44,291 @@ function transport(cwd: string, fs: Ts7FileSystem = fallback, timing = false) {
...(timing ? ["--timing"] : []),
]),
);
- registerTs7FileSystem(rpc, fs);
+ registerTs7FileSystem(rpc, fs, () => server.ts71);
return rpc;
}
-function connect(cwd: string, fs: Ts7FileSystem = fallback, timing = false) {
- return new Ts7Session(transport(cwd, fs, timing), timing);
+function connect(server: Ts7TestServer, cwd: string, fs: Ts7FileSystem = fallback, timing = false) {
+ return new Ts7Session(transport(server, cwd, fs, timing), timing);
}
-test("owned snapshots preserve SDK options, diagnostics, metadata and default-project lookup", () => {
- const dir = mkdtempSync(join(tempRoot, "scriptc-session-parity-"));
- const file = protocolPath(join(dir, "main.ts"));
- const config = protocolPath(join(dir, "tsconfig.json"));
- writeFileSync(file, 'export const answer: number = "wrong";\n');
- writeFileSync(
- config,
- JSON.stringify({
- compilerOptions: {
- strict: true,
- target: "esnext",
- types: [],
- declaration: true,
- sourceMap: true,
- paths: { "named/*": ["./*", "../*"] },
- },
- files: [file],
- }),
- );
- const session = connect(dir, fallback, true);
- const sdk = new API({ cwd: dir });
- try {
- expect(session.parseConfigFile(config)).toEqual(sdk.parseConfigFile(config));
- const snapshot = session.updateSnapshot({ openProjects: [config], openFiles: [file] });
- const oracle = sdk.updateSnapshot({ openProjects: [config], openFiles: [file] });
- expect(snapshot).toBeInstanceOf(Ts7SessionSnapshot);
- const project = snapshot.getProject(config)!;
- const expected = oracle.getProject(config)!;
- expect(project).toBeInstanceOf(Ts7SessionProject);
- expect(project.program.getCompilerOptions()).toEqual(expected.program.getCompilerOptions());
- expect(project.rootFiles).toEqual(expected.rootFiles);
- expect(snapshot.getDefaultProjectForFile(file)).toBe(project);
- expect(snapshot.getProject(protocolPath(join(dir, "./tsconfig.json")))).toBe(project);
- expect(project.program.getSourceFileNames()).toEqual(expected.program.getSourceFileNames());
- const source = project.program.getSourceFile(file)!;
- expect(source).toBeInstanceOf(AstNode);
- expect(source.text).toBe(expected.program.getSourceFile(file)!.text);
- const metadata = project.program.getSourceFileMetadata(file)!;
- expect(metadata).toEqual(expected.program.getSourceFileMetadata(file));
- expect(project.program.getSourceFileMetadata(file)).toBe(metadata);
- const missing = protocolPath(join(dir, "missing.ts"));
- expect(project.program.getSourceFile(missing)).toBeUndefined();
- const sourceRequests = session.getTimingInfo().totals.requestCount;
- expect(project.program.getSourceFile(file)).toBe(source);
- expect(project.program.getSourceFile({ uri: pathToFileURL(join(dir, "main.ts")).href })).toBe(
- source,
+for (const server of ts7TestServers())
+ test(`owned snapshots preserve SDK options, diagnostics, metadata and default-project lookup (${server.name})`, () => {
+ const dir = mkdtempSync(join(tempRoot, "scriptc-session-parity-"));
+ const file = protocolPath(join(dir, "main.ts"));
+ const config = protocolPath(join(dir, "tsconfig.json"));
+ writeFileSync(file, 'export const answer: number = "wrong";\n');
+ writeFileSync(
+ config,
+ JSON.stringify({
+ compilerOptions: {
+ strict: true,
+ target: "esnext",
+ types: [],
+ declaration: true,
+ sourceMap: true,
+ paths: { "named/*": ["./*", "../*"] },
+ },
+ files: [file],
+ }),
);
- expect(project.program.getSourceFile(missing)).toBeUndefined();
- expect(session.getTimingInfo().totals.requestCount).toBe(sourceRequests);
- expect(project.program.getSourceFileMetadata(missing)).toBeUndefined();
- const before = session.getTimingInfo().totals.requestCount;
- expect(project.program.getSourceFileMetadata(missing)).toBeUndefined();
- expect(session.getTimingInfo().totals.requestCount).toBe(before);
- for (const name of [
- "getSyntacticDiagnostics",
- "getBindDiagnostics",
- "getSemanticDiagnostics",
- "getSuggestionDiagnostics",
- "getDeclarationDiagnostics",
- ] as const) {
- // scriptc's diagnostics have every member set (ts7Diagnostic); the SDK's leave absent ones out.
- expect(project.program[name](file), name).toEqual(
- expected.program[name](file).map(ts7Diagnostic),
+ const session = connect(server, dir, fallback, true);
+ const sdk = new API({ cwd: dir });
+ try {
+ expect(session.parseConfigFile(config)).toEqual(sdk.parseConfigFile(config));
+ const snapshot = session.updateSnapshot({ openProjects: [config], openFiles: [file] });
+ const oracle = sdk.updateSnapshot({ openProjects: [config], openFiles: [file] });
+ expect(snapshot).toBeInstanceOf(Ts7SessionSnapshot);
+ const project = snapshot.getProject(config)!;
+ const expected = oracle.getProject(config)!;
+ expect(project).toBeInstanceOf(Ts7SessionProject);
+ expect(project.program.getCompilerOptions()).toEqual(expected.program.getCompilerOptions());
+ expect(project.rootFiles).toEqual(expected.rootFiles);
+ expect(snapshot.getDefaultProjectForFile(file)).toBe(project);
+ expect(snapshot.getProject(protocolPath(join(dir, "./tsconfig.json")))).toBe(project);
+ const names = project.program.getSourceFileNames();
+ const expectedNames = expected.program.getSourceFileNames();
+ if (server.ts71) {
+ // tsc 7.1 reads its own lib files, which split some esnext libs into es2026 ones.
+ const isLib = (name: string) => /\/lib\.[^/]*\.d\.ts$/.test(name);
+ expect(names.filter((name) => !isLib(name))).toEqual(
+ expectedNames.filter((name) => !isLib(name)),
+ );
+ for (const lib of ["lib.es5.d.ts", "lib.esnext.d.ts"])
+ expect(
+ names.some((name) => name.endsWith(`/${lib}`)),
+ lib,
+ ).toBe(true);
+ } else {
+ expect(names).toEqual(expectedNames);
+ }
+ const source = project.program.getSourceFile(file)!;
+ expect(source).toBeInstanceOf(AstNode);
+ expect(source.text).toBe(expected.program.getSourceFile(file)!.text);
+ const metadata = project.program.getSourceFileMetadata(file)!;
+ expect(metadata).toEqual(expected.program.getSourceFileMetadata(file));
+ expect(project.program.getSourceFileMetadata(file)).toBe(metadata);
+ const missing = protocolPath(join(dir, "missing.ts"));
+ expect(project.program.getSourceFile(missing)).toBeUndefined();
+ const sourceRequests = session.getTimingInfo().totals.requestCount;
+ expect(project.program.getSourceFile(file)).toBe(source);
+ expect(project.program.getSourceFile({ uri: pathToFileURL(join(dir, "main.ts")).href })).toBe(
+ source,
);
- expect(project.program[name](), name).toEqual(expected.program[name]().map(ts7Diagnostic));
- }
- for (const name of [
- "getProgramDiagnostics",
- "getGlobalDiagnostics",
- "getConfigFileParsingDiagnostics",
- ] as const)
- expect(project.program[name](), name).toEqual(expected.program[name]());
- const library = project.program
- .getSourceFileNames()
- .find((name) => name.endsWith("lib.esnext.d.ts"))!;
- expect(library).toBeDefined();
- expect(
- project.program.isSourceFileDefaultLibrary(project.program.getSourceFile(library)!),
- ).toBe(true);
- expect(project.program.isSourceFileFromExternalLibrary(source)).toBe(false);
- snapshot.dispose();
- expect(snapshot.isDisposed()).toBe(true);
- expect(() => snapshot.getProjects()).toThrow("disposed");
- expect(() => project.program.getSourceFileNames()).toThrow("disposed");
- } finally {
- session.close();
- sdk.close();
- rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test("virtual updates share active ASTs and refetch after releasing their baseline", () => {
- const dir = mkdtempSync(join(tempRoot, "scriptc-session-updates-"));
- const a = protocolPath(join(dir, "a.json")),
- b = protocolPath(join(dir, "b.json"));
- const changed = protocolPath(join(dir, "changed.ts")),
- stable = protocolPath(join(dir, "stable.ts"));
- let content = "export const value = 1;";
- let option = "esnext";
- const overlay: Ts7FileSystem = {
- ...fallback,
- readFile: (file) => {
- if (file === a || file === b)
- return JSON.stringify({
- compilerOptions: { strict: true, target: option, types: [] },
- files: [changed, stable],
- });
- if (file === changed) return content;
- if (file === stable) return "export const stable = true;";
- return undefined;
- },
- fileExists: (file) => ([a, b, changed, stable].includes(file) ? true : undefined),
- };
- const session = connect(dir, overlay, true);
- const sdk = new API({ cwd: dir, fs: overlay });
- try {
- const first = session.updateSnapshot({ openProjects: [a, b] });
- const oracleFirst = sdk.updateSnapshot({ openProjects: [a, b] });
- const oracleStable = oracleFirst.getProject(a)!.program.getSourceFile(stable)!;
- const old = first.getProject(a)!.program.getSourceFile(changed)!;
- const retained = first.getProject(a)!.program.getSourceFile(stable)!;
- expect(first.getProject(b)!.program.getSourceFile(changed)).toBe(old);
- content = 'export const value: number = "wrong";';
- const second = session.updateSnapshot({ fileChanges: { changed: [changed] } });
- const oracleSecond = sdk.updateSnapshot({ fileChanges: { changed: [changed] } });
- const next = second.getProject(a)!.program.getSourceFile(changed)!;
- expect(next).not.toBe(old);
- expect(old.text).toContain("= 1");
- expect(next.text).toBe(content);
- expect(
- second
- .getProject(a)!
- .program.getSemanticDiagnostics(changed)
- .map((d) => d.code),
- ).toEqual([2322]);
- expect(first.getProject(a)!.program.getSemanticDiagnostics(changed)).toEqual([]);
- expect(second.getProject(a)!.program.getSourceFile(stable)).toBe(retained);
- first.dispose();
- second.dispose();
- oracleFirst.dispose();
- oracleSecond.dispose();
- const before = session.getTimingInfo().totals.sourceFilesFetched;
- const third = session.updateSnapshot();
- const oracleThird = sdk.updateSnapshot();
- const refreshed = third.getProject(a)!.program.getSourceFile(stable)!;
- expect(refreshed.text).toBe(retained.text);
- expect(session.getTimingInfo().totals.sourceFilesFetched).toBe(before + 1);
- option = "es2020";
- const fourth = session.updateSnapshot({ fileChanges: { changed: [a, b] } });
- const oracleFourth = sdk.updateSnapshot({ fileChanges: { changed: [a, b] } });
- const reparsed = fourth.getProject(a)!.program.getSourceFile(stable)!;
- expect(reparsed === refreshed).toBe(
- oracleFourth.getProject(a)!.program.getSourceFile(stable) === oracleStable,
- );
- expect(fourth.getProject(a)!.compilerOptions.target).toBe(
- oracleFourth.getProject(a)!.compilerOptions.target,
- );
- expect(fourth.getProject(a)!.compilerOptions.target).not.toBe(
- third.getProject(a)!.compilerOptions.target,
- );
- expect(oracleThird.getProject(a)!.program.getSourceFile(stable)).toBe(oracleStable);
- const closed = session.updateSnapshot({ closeProjects: [b] });
- expect(closed.getProject(b)).toBeUndefined();
- expect(closed.getProject(a)).toBeDefined();
- session.close();
- expect(third.isDisposed() && fourth.isDisposed() && closed.isDisposed()).toBe(true);
- expect(session.cache.size).toBe(0);
- expect(() => session.updateSnapshot()).toThrow("closed");
- expect(() => session.parseConfigFile(a)).toThrow("closed");
- expect(() => session.getTimingInfo()).toThrow("closed");
- } finally {
- session.close();
- sdk.close();
- rmSync(dir, { recursive: true, force: true });
- }
-});
-
-test("transport failure during close seals every snapshot and clears retained ASTs", () => {
- const dir = mkdtempSync(join(tempRoot, "scriptc-session-close-"));
- const file = protocolPath(join(dir, "main.ts"));
- const config = protocolPath(join(dir, "tsconfig.json"));
- writeFileSync(file, "export const answer = 42;");
- writeFileSync(
- config,
- JSON.stringify({ compilerOptions: { strict: true, types: [] }, files: [file] }),
- );
- const rpc = transport(dir);
- const session = new Ts7Session(rpc);
- try {
- const first = session.updateSnapshot({ openProjects: [config] });
- const project = first.getProject(config)!;
- const source = project.program.getSourceFile(file)!;
- const type = project.checker.getTypeAtPosition(file, source.text!.indexOf("answer"))!;
- const second = session.updateSnapshot();
- const secondProject = second.getProject(config)!;
- expect(secondProject.program.getSourceFile(file)).toBe(source);
- const third = session.updateSnapshot();
- expect(session.cache.size).toBe(1);
- rpc.close();
- expect(() => session.close()).toThrow("closed");
- for (const snapshot of [first, second, third]) {
+ expect(project.program.getSourceFile(missing)).toBeUndefined();
+ expect(session.getTimingInfo().totals.requestCount).toBe(sourceRequests);
+ expect(project.program.getSourceFileMetadata(missing)).toBeUndefined();
+ const before = session.getTimingInfo().totals.requestCount;
+ expect(project.program.getSourceFileMetadata(missing)).toBeUndefined();
+ expect(session.getTimingInfo().totals.requestCount).toBe(before);
+ for (const name of [
+ "getSyntacticDiagnostics",
+ "getBindDiagnostics",
+ "getSemanticDiagnostics",
+ "getSuggestionDiagnostics",
+ "getDeclarationDiagnostics",
+ ] as const) {
+ // scriptc's diagnostics have every member set (ts7Diagnostic); the SDK's leave absent ones out.
+ expect(project.program[name](file), name).toEqual(
+ expected.program[name](file).map(ts7Diagnostic),
+ );
+ // tsc 7.1's lib files differ from 7.0's, and so do their diagnostics.
+ const own = (list: T[]) =>
+ server.ts71
+ ? list.filter((item) => !/\/lib\.[^/]*\.d\.ts$/.test(item.fileName ?? ""))
+ : list;
+ expect(own(project.program[name]()), name).toEqual(
+ own(expected.program[name]().map(ts7Diagnostic)),
+ );
+ }
+ for (const name of [
+ "getProgramDiagnostics",
+ "getGlobalDiagnostics",
+ "getConfigFileParsingDiagnostics",
+ ] as const)
+ expect(project.program[name](), name).toEqual(expected.program[name]());
+ const library = project.program
+ .getSourceFileNames()
+ .find((name) => name.endsWith("lib.esnext.d.ts"))!;
+ expect(library).toBeDefined();
+ expect(
+ project.program.isSourceFileDefaultLibrary(project.program.getSourceFile(library)!),
+ ).toBe(true);
+ expect(project.program.isSourceFileFromExternalLibrary(source)).toBe(false);
+ snapshot.dispose();
expect(snapshot.isDisposed()).toBe(true);
- expect(() => snapshot.getProjects()).toThrow("closed");
- expect(() => snapshot.dispose()).not.toThrow();
+ expect(() => snapshot.getProjects()).toThrow("disposed");
+ expect(() => project.program.getSourceFileNames()).toThrow("disposed");
+ } finally {
+ session.close();
+ sdk.close();
+ rmSync(dir, { recursive: true, force: true });
}
- expect(() => secondProject.checker.getNumberType()).toThrow("disposed");
- expect(() => secondProject.program.getSourceFile(file)).toThrow("closed");
- expect(() => type.getRegularType()).toThrow("disposed");
- expect(session.cache.size).toBe(0);
- expect(() => session.close()).not.toThrow();
- } finally {
- session.close();
- rmSync(dir, { recursive: true, force: true });
- }
-});
+ });
-test("a released latest snapshot cannot lend stale ASTs to the same project", () => {
- const dir = mkdtempSync(join(tempRoot, "scriptc-session-released-"));
- const file = protocolPath(join(dir, "main.ts"));
- const config = protocolPath(join(dir, "tsconfig.json"));
- let source = "export const value = 1;";
- const overlay: Ts7FileSystem = {
- ...fallback,
- readFile: (path) =>
- path === file
- ? source
- : path === config
- ? JSON.stringify({ compilerOptions: { noLib: true, types: [] }, files: [file] })
- : null,
- fileExists: (path) => path === file || path === config,
- };
- const session = connect(dir, overlay);
- try {
- const first = session.updateSnapshot({ openProjects: [config] });
- const old = first.getProject(config)!.program.getSourceFile(file)!;
- first.dispose();
- source = 'export const replacement = "new";';
- const second = session.updateSnapshot({ fileChanges: { changed: [file] } });
- const current = second.getProject(config)!.program.getSourceFile(file)!;
- expect(current.text).toBe(source);
- expect(current).not.toBe(old);
- expect(old.text).toBe("export const value = 1;");
- second.dispose();
- const deleted = session.updateSnapshot({ fileChanges: { deleted: [file] } });
- deleted.dispose();
- source = "export const third = true;";
- const third = session.updateSnapshot({ fileChanges: { created: [file] } });
- expect(third.getProject(config)!.program.getSourceFile(file)!.text).toBe(source);
- expect(getTextOfNode(old.statements.nodes[0]!)).toBe("export const value = 1;");
- } finally {
- session.close();
- rmSync(dir, { recursive: true, force: true });
- }
-});
+for (const server of ts7TestServers())
+ test(`virtual updates share active ASTs and refetch after releasing their baseline (${server.name})`, () => {
+ const dir = mkdtempSync(join(tempRoot, "scriptc-session-updates-"));
+ const a = protocolPath(join(dir, "a.json")),
+ b = protocolPath(join(dir, "b.json"));
+ const changed = protocolPath(join(dir, "changed.ts")),
+ stable = protocolPath(join(dir, "stable.ts"));
+ let content = "export const value = 1;";
+ let option = "esnext";
+ const overlay: Ts7FileSystem = {
+ ...fallback,
+ readFile: (file) => {
+ if (file === a || file === b)
+ return JSON.stringify({
+ compilerOptions: { strict: true, target: option, types: [] },
+ files: [changed, stable],
+ });
+ if (file === changed) return content;
+ if (file === stable) return "export const stable = true;";
+ return undefined;
+ },
+ fileExists: (file) => ([a, b, changed, stable].includes(file) ? true : undefined),
+ };
+ const session = connect(server, dir, overlay, true);
+ const sdk = new API({ cwd: dir, fs: overlay });
+ try {
+ const first = session.updateSnapshot({ openProjects: [a, b] });
+ const oracleFirst = sdk.updateSnapshot({ openProjects: [a, b] });
+ const oracleStable = oracleFirst.getProject(a)!.program.getSourceFile(stable)!;
+ const old = first.getProject(a)!.program.getSourceFile(changed)!;
+ const retained = first.getProject(a)!.program.getSourceFile(stable)!;
+ expect(first.getProject(b)!.program.getSourceFile(changed)).toBe(old);
+ content = 'export const value: number = "wrong";';
+ const second = session.updateSnapshot({ fileChanges: { changed: [changed] } });
+ const oracleSecond = sdk.updateSnapshot({ fileChanges: { changed: [changed] } });
+ const next = second.getProject(a)!.program.getSourceFile(changed)!;
+ expect(next).not.toBe(old);
+ expect(old.text).toContain("= 1");
+ expect(next.text).toBe(content);
+ expect(
+ second
+ .getProject(a)!
+ .program.getSemanticDiagnostics(changed)
+ .map((d) => d.code),
+ ).toEqual([2322]);
+ expect(first.getProject(a)!.program.getSemanticDiagnostics(changed)).toEqual([]);
+ expect(second.getProject(a)!.program.getSourceFile(stable)).toBe(retained);
+ first.dispose();
+ second.dispose();
+ oracleFirst.dispose();
+ oracleSecond.dispose();
+ const before = session.getTimingInfo().totals.sourceFilesFetched;
+ const third = session.updateSnapshot();
+ const oracleThird = sdk.updateSnapshot();
+ const refreshed = third.getProject(a)!.program.getSourceFile(stable)!;
+ expect(refreshed.text).toBe(retained.text);
+ expect(session.getTimingInfo().totals.sourceFilesFetched).toBe(before + 1);
+ option = "es2020";
+ const fourth = session.updateSnapshot({ fileChanges: { changed: [a, b] } });
+ const oracleFourth = sdk.updateSnapshot({ fileChanges: { changed: [a, b] } });
+ const reparsed = fourth.getProject(a)!.program.getSourceFile(stable)!;
+ expect(reparsed === refreshed).toBe(
+ oracleFourth.getProject(a)!.program.getSourceFile(stable) === oracleStable,
+ );
+ expect(fourth.getProject(a)!.compilerOptions.target).toBe(
+ oracleFourth.getProject(a)!.compilerOptions.target,
+ );
+ expect(fourth.getProject(a)!.compilerOptions.target).not.toBe(
+ third.getProject(a)!.compilerOptions.target,
+ );
+ expect(oracleThird.getProject(a)!.program.getSourceFile(stable)).toBe(oracleStable);
+ const closed = session.updateSnapshot({ closeProjects: [b] });
+ expect(closed.getProject(b)).toBeUndefined();
+ expect(closed.getProject(a)).toBeDefined();
+ session.close();
+ expect(third.isDisposed() && fourth.isDisposed() && closed.isDisposed()).toBe(true);
+ expect(session.cache.size).toBe(0);
+ expect(() => session.updateSnapshot()).toThrow("closed");
+ expect(() => session.parseConfigFile(a)).toThrow("closed");
+ expect(() => session.getTimingInfo()).toThrow("closed");
+ } finally {
+ session.close();
+ sdk.close();
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+for (const server of ts7TestServers())
+ test(`transport failure during close seals every snapshot and clears retained ASTs (${server.name})`, () => {
+ const dir = mkdtempSync(join(tempRoot, "scriptc-session-close-"));
+ const file = protocolPath(join(dir, "main.ts"));
+ const config = protocolPath(join(dir, "tsconfig.json"));
+ writeFileSync(file, "export const answer = 42;");
+ writeFileSync(
+ config,
+ JSON.stringify({ compilerOptions: { strict: true, types: [] }, files: [file] }),
+ );
+ const rpc = transport(server, dir);
+ const session = new Ts7Session(rpc);
+ try {
+ const first = session.updateSnapshot({ openProjects: [config] });
+ const project = first.getProject(config)!;
+ const source = project.program.getSourceFile(file)!;
+ const type = project.checker.getTypeAtPosition(file, source.text!.indexOf("answer"))!;
+ const second = session.updateSnapshot();
+ const secondProject = second.getProject(config)!;
+ expect(secondProject.program.getSourceFile(file)).toBe(source);
+ const third = session.updateSnapshot();
+ expect(session.cache.size).toBe(1);
+ rpc.close();
+ expect(() => session.close()).toThrow("closed");
+ for (const snapshot of [first, second, third]) {
+ expect(snapshot.isDisposed()).toBe(true);
+ expect(() => snapshot.getProjects()).toThrow("closed");
+ expect(() => snapshot.dispose()).not.toThrow();
+ }
+ expect(() => secondProject.checker.getNumberType()).toThrow("disposed");
+ expect(() => secondProject.program.getSourceFile(file)).toThrow("closed");
+ expect(() => type.getRegularType()).toThrow("disposed");
+ expect(session.cache.size).toBe(0);
+ expect(() => session.close()).not.toThrow();
+ } finally {
+ session.close();
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
+
+for (const server of ts7TestServers())
+ test(`a released latest snapshot cannot lend stale ASTs to the same project (${server.name})`, () => {
+ const dir = mkdtempSync(join(tempRoot, "scriptc-session-released-"));
+ const file = protocolPath(join(dir, "main.ts"));
+ const config = protocolPath(join(dir, "tsconfig.json"));
+ let source = "export const value = 1;";
+ const overlay: Ts7FileSystem = {
+ ...fallback,
+ readFile: (path) =>
+ path === file
+ ? source
+ : path === config
+ ? JSON.stringify({ compilerOptions: { noLib: true, types: [] }, files: [file] })
+ : null,
+ fileExists: (path) => path === file || path === config,
+ };
+ const session = connect(server, dir, overlay);
+ try {
+ const first = session.updateSnapshot({ openProjects: [config] });
+ const old = first.getProject(config)!.program.getSourceFile(file)!;
+ first.dispose();
+ source = 'export const replacement = "new";';
+ const second = session.updateSnapshot({ fileChanges: { changed: [file] } });
+ const current = second.getProject(config)!.program.getSourceFile(file)!;
+ expect(current.text).toBe(source);
+ expect(current).not.toBe(old);
+ expect(old.text).toBe("export const value = 1;");
+ second.dispose();
+ const deleted = session.updateSnapshot({ fileChanges: { deleted: [file] } });
+ deleted.dispose();
+ source = "export const third = true;";
+ const third = session.updateSnapshot({ fileChanges: { created: [file] } });
+ expect(third.getProject(config)!.program.getSourceFile(file)!.text).toBe(source);
+ expect(getTextOfNode(old.statements.nodes[0]!)).toBe("export const value = 1;");
+ } finally {
+ session.close();
+ rmSync(dir, { recursive: true, force: true });
+ }
+ });
diff --git a/packages/compiler/src/frontend/ts7/session.ts b/packages/compiler/src/frontend/ts7/session.ts
index 082bb79a6..9223c897f 100644
--- a/packages/compiler/src/frontend/ts7/session.ts
+++ b/packages/compiler/src/frontend/ts7/session.ts
@@ -12,7 +12,7 @@ import type {
Ts7CompilerOptionsData,
Ts7ConfigData,
Ts7DiagnosticData,
- Ts7InitializeData,
+ Ts7ProjectChangeData,
Ts7ProjectData,
Ts7SnapshotData,
Ts7SourceMetadata,
@@ -36,6 +36,52 @@ interface Ts7ReportedSnapshotData extends Ts7SnapshotData {
projects: Ts7ReportedProjectData[];
}
+/** initialize's answer. TypeScript 7.0 reports useCaseSensitiveFileNames;
+ * 7.1 reports caseSensitivity (1 when case-sensitive) instead, which is how
+ * a session tells the two protocols apart. */
+interface Ts7InitializeReply {
+ currentDirectory: string;
+ useCaseSensitiveFileNames?: boolean;
+ caseSensitivity?: number;
+}
+
+/** A project as TypeScript 7.1 reports it: the parsed command line carries
+ * the root files and options, and a project need not have a config file. */
+interface Ts71ProjectData {
+ id: string;
+ configFileName?: string;
+ parsedCommandLine: { fileNames: string[]; options: Ts7ProjectCompilerOptions };
+}
+
+/** createSnapshot's and updateSnapshot's answer in TypeScript 7.1. An update
+ * lists only the projects added or replaced since its base snapshot, and a
+ * changed project's entry is null when its files are not listed. */
+interface Ts71SnapshotData {
+ snapshot: number;
+ projects: Ts71ProjectData[];
+ changes?: {
+ changedProjects?: Record;
+ removedProjects?: string[];
+ };
+}
+
+/** TypeScript 7.1's compiler options in 7.0's values: 7.1 swaps JsxEmit's
+ * React (2) and ReactNative (3). */
+function options71(options: T): T {
+ if (options.jsx === 2) options.jsx = 3;
+ else if (options.jsx === 3) options.jsx = 2;
+ return options;
+}
+
+function project71(data: Ts71ProjectData): Ts7ReportedProjectData {
+ return {
+ id: data.id,
+ configFileName: data.configFileName ?? data.id,
+ compilerOptions: options71(data.parsedCommandLine.options),
+ rootFiles: data.parsedCommandLine.fileNames,
+ };
+}
+
export interface Ts7FileChanges {
changed?: SemanticDocument[];
created?: SemanticDocument[];
@@ -55,7 +101,7 @@ interface ProgramQuery {
snapshot?: number;
project?: string;
file?: SemanticDocument;
- files?: string[];
+ files?: SemanticDocument[];
}
/** The answer of getDiagnosticsOfCheckedFiles, as the server sends it. */
@@ -73,6 +119,8 @@ export class Ts7Session {
private readonly snapshots = new Set();
private latest: Ts7SessionSnapshot | undefined;
private paths: Ts7Paths | undefined;
+ /** Whether the server speaks TypeScript 7.1's protocol (initialize). */
+ private ts71 = false;
private closed = false;
constructor(
@@ -116,22 +164,41 @@ export class Ts7Session {
initialize(): Ts7Paths {
this.ensureOpen();
if (this.paths === undefined) {
- const data = parseSemanticJson(this.text("initialize", "null"));
- this.paths = new Ts7Paths(data.currentDirectory, data.useCaseSensitiveFileNames);
+ const data = parseSemanticJson(this.text("initialize", "null"));
+ this.ts71 = data.caseSensitivity !== undefined;
+ this.paths = new Ts7Paths(
+ data.currentDirectory,
+ this.ts71 ? data.caseSensitivity === 1 : data.useCaseSensitiveFileNames === true,
+ );
}
return this.paths;
}
parseConfigFile(file: SemanticDocument): Ts7ConfigData {
this.initialize();
- return this.request("parseConfigFile", { file });
+ const data = this.request("parseConfigFile", { file });
+ // TypeScript 7.1 also reports the raw config, errors and compileOnSave.
+ return this.ts71 ? { options: options71(data.options), fileNames: data.fileNames } : data;
+ }
+
+ /** Whether initialize found a TypeScript 7.1 server; false before it. */
+ speaksTs71(): boolean {
+ return this.ts71;
+ }
+
+ /** Whether the server speaks TypeScript 7.1's protocol. */
+ usesTs71Protocol(): boolean {
+ this.initialize();
+ return this.ts71;
}
updateSnapshot(params: Ts7Update = {}): Ts7SessionSnapshot {
const paths = this.initialize();
- const data = parseSemanticJson(
- this.text("updateSnapshot", JSON.stringify(params)),
- );
+ const data = this.ts71
+ ? this.snapshot71(params)
+ : parseSemanticJson(
+ this.text("updateSnapshot", JSON.stringify(params)),
+ );
const snapshot = new Ts7SessionSnapshot(data, this, paths);
const previous = this.latest;
if (previous !== undefined) {
@@ -147,9 +214,56 @@ export class Ts7Session {
return snapshot;
}
+ /** TypeScript 7.1 derives a snapshot from a base snapshot the client
+ * names (updateSnapshot), or creates the first one (createSnapshot). The
+ * base is the latest snapshot: the server keeps it until the next update,
+ * even after the client disposes it (release), so open projects and files
+ * carry over as they do on a 7.0 server. An update lists only the projects
+ * that changed; the others carry over from the base. */
+ private snapshot71(params: Ts7Update): Ts7ReportedSnapshotData {
+ const changes: Record = {};
+ if (params.openProjects !== undefined) changes["openProjects"] = params.openProjects;
+ if (params.closeProjects !== undefined) changes["closeProjects"] = params.closeProjects;
+ if (params.openFiles !== undefined) changes["openFiles"] = params.openFiles;
+ if (params.closeFiles !== undefined) changes["closeFiles"] = params.closeFiles;
+ if (params.fileChanges !== undefined) changes["fileNotifications"] = params.fileChanges;
+ // A 7.1 snapshot leaves a project whose files changed dirty until asked
+ // to update its program; a 7.0 snapshot's programs are always current.
+ changes["ensurePrograms"] = true;
+ const base = this.latest;
+ const data = parseSemanticJson(
+ base === undefined
+ ? this.text("createSnapshot", JSON.stringify(changes))
+ : this.text("updateSnapshot", JSON.stringify({ snapshot: base.id, changes })),
+ );
+ if (base !== undefined && base.isDisposed())
+ this.binary("release", JSON.stringify({ snapshot: base.id }));
+ const projects = new Map();
+ const removed = new Set(data.changes?.removedProjects ?? []);
+ if (base !== undefined)
+ for (const project of base.projectData)
+ if (!removed.has(project.id)) projects.set(project.id, project);
+ for (const project of data.projects) projects.set(project.id, project71(project));
+ // A changed project without a file list keeps none of its cached files.
+ const changedProjects: Record = {};
+ const removedProjects = [...removed];
+ for (const [id, change] of Object.entries(data.changes?.changedProjects ?? {})) {
+ if (change === null) removedProjects.push(id);
+ else changedProjects[id] = change;
+ }
+ return {
+ snapshot: data.snapshot,
+ projects: [...projects.values()],
+ changes: { changedProjects, removedProjects },
+ };
+ }
+
release(snapshot: Ts7SessionSnapshot): void {
this.snapshots.delete(snapshot);
if (snapshot !== this.latest) this.cache.release(snapshot.id);
+ // A 7.1 server keeps the latest snapshot as the next update's base
+ // (snapshot71), which releases it after deriving from it.
+ if (this.ts71 && snapshot === this.latest) return;
this.binary("release", JSON.stringify({ snapshot: snapshot.id }));
}
@@ -180,6 +294,8 @@ export class Ts7Session {
export class Ts7SessionSnapshot {
readonly id: number;
+ /** The projects as the server reported them (TypeScript 7.0's shape). */
+ readonly projectData: readonly Ts7ReportedProjectData[];
readonly semantic: SemanticSnapshot;
private readonly projects = new Map();
private disposed = false;
@@ -190,10 +306,15 @@ export class Ts7SessionSnapshot {
readonly paths: Ts7Paths,
) {
this.id = data.snapshot;
- this.semantic = new SemanticSnapshot(this.id, {
- text: (method, payload) => this.session.text(method, payload),
- binary: (method, payload) => this.session.binary(method, payload),
- });
+ this.projectData = data.projects;
+ this.semantic = new SemanticSnapshot(
+ this.id,
+ {
+ text: (method, payload) => this.session.text(method, payload),
+ binary: (method, payload) => this.session.binary(method, payload),
+ },
+ session.speaksTs71(),
+ );
for (const item of data.projects) {
const project = new Ts7SessionProject(item, this, session);
this.projects.set(paths.canonical(item.configFileName), project);
@@ -214,10 +335,15 @@ export class Ts7SessionSnapshot {
}
getDefaultProjectForFile(file: SemanticDocument): Ts7SessionProject | undefined {
this.ensureActive();
- const data = this.session.request("getDefaultProjectForFile", {
- snapshot: this.id,
- file,
- });
+ const query = { snapshot: this.id, file };
+ if (this.session.usesTs71Protocol()) {
+ const data = this.session.request("getDefaultProjectForFile", query);
+ return data === null ? undefined : this.getProject(project71(data).configFileName);
+ }
+ const data = this.session.request(
+ "getDefaultProjectForFile",
+ query,
+ );
return data === null ? undefined : this.getProject(data.configFileName);
}
isDisposed(): boolean {
@@ -344,17 +470,29 @@ export class Ts7SessionProgram {
isSourceFileDefaultLibrary(file: AstNode): boolean {
return this.getSourceFileMetadataByPath(file.path)?.isDefaultLibrary ?? false;
}
+ /** A diagnostics request for `file`, or for the whole program. TypeScript
+ * 7.1 takes the file in a list (`files`). */
+ private diagnosticsQuery(file?: SemanticDocument): ProgramQuery {
+ if (file === undefined || !this.session.usesTs71Protocol()) return this.query(file);
+ const query = this.query();
+ query.files = [file];
+ return query;
+ }
private diagnostics(method: string, file?: SemanticDocument): Ts7Diagnostic[] {
const result: Ts7Diagnostic[] = [];
- for (const data of this.session.request(method, this.query(file)) ??
- [])
+ for (const data of this.session.request(
+ method,
+ this.diagnosticsQuery(file),
+ ) ?? [])
result.push(ts7Diagnostic(data));
return result;
}
/** The diagnostics of `method` for `file`, or for the whole program, as
* the server sends them. */
diagnosticData(method: string, file?: SemanticDocument): Ts7DiagnosticData[] {
- return this.session.request(method, this.query(file)) ?? [];
+ return (
+ this.session.request(method, this.diagnosticsQuery(file)) ?? []
+ );
}
/** The semantic diagnostics of each of `files` and the global diagnostics,
* after the server checks only those files (getDiagnosticsOfCheckedFiles),
diff --git a/packages/compiler/src/frontend/ts7/test-servers.ts b/packages/compiler/src/frontend/ts7/test-servers.ts
new file mode 100644
index 000000000..68e1e4f0c
--- /dev/null
+++ b/packages/compiler/src/frontend/ts7/test-servers.ts
@@ -0,0 +1,28 @@
+import { createRequire } from "node:module";
+import { dirname, join } from "node:path";
+import { tsgoExecutable } from "./rpc-api.js";
+
+/** An API server the TypeScript client tests run against. */
+export interface Ts7TestServer {
+ /** The label tests append to their names. */
+ name: string;
+ executable: string;
+ /** Whether the server speaks TypeScript 7.1's protocol. */
+ ts71: boolean;
+}
+
+const require = createRequire(import.meta.url);
+
+/** tsc 7.0.2 from the pinned `typescript` package and tsc 7.1.0-beta from
+ * the `typescript-7.1` test dependency, so the client's protocol 5 and
+ * protocol 9 paths answer the same cases. For tests only. */
+export function ts7TestServers(): Ts7TestServer[] {
+ const root = dirname(require.resolve("typescript-7.1/package.json"));
+ const { default: getExePath } = require(join(root, "lib/getExePath.js")) as {
+ default: () => string;
+ };
+ return [
+ { name: "tsc 7.0", executable: tsgoExecutable(), ts71: false },
+ { name: "tsc 7.1", executable: getExePath(), ts71: true },
+ ];
+}
diff --git a/packages/compiler/src/frontend/type-mapper.ts b/packages/compiler/src/frontend/type-mapper.ts
index b2fc774f2..40ca10b38 100644
--- a/packages/compiler/src/frontend/type-mapper.ts
+++ b/packages/compiler/src/frontend/type-mapper.ts
@@ -1287,6 +1287,14 @@ export interface TypeMapperCtx {
* only after the strict mapping (and its bound-alias retry) returns null,
* at every depth, so every occurrence of a degraded type maps alike. */
degradedType?: (type: ts.Type) => IrType | null;
+ /** Localized failure's lenient mapping (Lowerer.lenientType): a nested
+ * component whose mapping fails maps to the checked dynamic instead, so a
+ * failing type keeps its outer shape with dynamic pieces. Only ever set
+ * on a throwaway context without memoization. */
+ lenient?: boolean;
+ /** With `lenient`: told each component the lenient mapping turned into
+ * the checked dynamic (Lowerer.lenientPieces). */
+ onLenientPiece?: (type: ts.Type) => void;
/** Canonical normalization's shadow mode (canon-map.ts): called after
* each successful top-level mapping; installed only under
* SCRIPTC_CANON_SHADOW=1. It records, and never changes a result. */
@@ -1916,6 +1924,13 @@ function mapTypeEntry(type: ts.Type, ctx: TypeMapperCtx): IrType | null {
contextResolutions++;
}
}
+ // Push-down (stage L3): in a lenient mapping, the smallest failing
+ // component is the checked dynamic.
+ if (mapped === null && ctx.lenient === true && !topLevel) {
+ mapped = DYN;
+ contextResolutions++;
+ ctx.onLenientPiece?.(type);
+ }
if (
mapped !== null &&
memoKey !== null &&
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-degraded-initializer.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-degraded-initializer.ts.json
new file mode 100644
index 000000000..90cdeee2d
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-degraded-initializer.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/localized-failure-degraded-initializer.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-expressions.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-expressions.ts.json
new file mode 100644
index 000000000..97f6c7ce3
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-expressions.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/localized-failure-expressions.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-push-down.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-push-down.ts.json
new file mode 100644
index 000000000..554c74bac
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/localized-failure-push-down.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/localized-failure-push-down.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/width-layout-identity.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/width-layout-identity.ts.json
new file mode 100644
index 000000000..543e1f640
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/width-layout-identity.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/width-layout-identity.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/width-layout-parameter-writes.ts.json b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/width-layout-parameter-writes.ts.json
new file mode 100644
index 000000000..6aa2085bb
--- /dev/null
+++ b/packages/compiler/test/ts7/baselines/order-parity/tests/corpus/width-layout-parameter-writes.ts.json
@@ -0,0 +1,6 @@
+{
+ "order": [
+ "/tests/corpus/width-layout-parameter-writes.ts"
+ ],
+ "diags": []
+}
diff --git a/packages/runtime/src/scr_closure.h b/packages/runtime/src/scr_closure.h
index cfca90313..89f0378af 100644
--- a/packages/runtime/src/scr_closure.h
+++ b/packages/runtime/src/scr_closure.h
@@ -82,6 +82,24 @@ void scr_box_set_f64(ScrBox *b, double v);
void scr_box_set_bool(ScrBox *b, bool v);
void scr_box_set_ref(ScrBox *b, void *v); /* releases old, owns new */
+/* The bits of ScrClosure.function_kind. The LLVM backend stores them too:
+ * CLOSURE_KIND in packages/compiler/src/backend/llvm/common.ts mirrors this
+ * list, and runtime-registries.test.ts checks that the two agree and that
+ * the bits are distinct. The low two bits (SCR_CLOSURE_KIND_CALL_MASK) are
+ * the function's call kind: 0 ordinary, 1 generator, 2 async, 3 async
+ * generator. */
+#define SCR_CLOSURE_KIND_GENERATOR 1u
+#define SCR_CLOSURE_KIND_ASYNC 2u
+#define SCR_CLOSURE_KIND_CALL_MASK 3u
+#define SCR_CLOSURE_KIND_OWN_PROTOTYPE 4u /* an ordinary function with its own prototype */
+#define SCR_CLOSURE_KIND_CHECKED_ADAPTER 8u /* a checked callable adapter: the callable is in caps[0] */
+/* The body never observes the ambient receiver (the `this` an unbound call
+ * binds), so compiled callers skip the receiver bracket around an indirect
+ * call. Set only by the compiler; the kind proper is
+ * function_kind & SCR_CLOSURE_KIND_MASK. */
+#define SCR_CLOSURE_KIND_RECEIVER_FREE 16u
+#define SCR_CLOSURE_KIND_MASK 15u
+
typedef struct ScrClosure {
size_t rc; /* SIZE_MAX = immortal (interned top-level function value) */
void *fn;
@@ -98,7 +116,7 @@ typedef struct ScrClosure {
* (like the dyn→closure edge): a cycle through it is merely never
* collected. */
ScrBox *props;
- uint32_t function_kind; /* low bits: 0 ordinary, 1 generator, 2 async, 3 async generator; bit 2: ordinary own prototype; bit 3: checked callable adapter in caps[0]; bit 4: SCR_FN_RECEIVER_FREE */
+ uint32_t function_kind; /* SCR_CLOSURE_KIND_* bits */
/* The root's `length` as boxed values report it; meaningful only when
* identity is set (the root's own boxes carry their arity). */
uint32_t identity_length;
@@ -117,13 +135,6 @@ typedef struct ScrClosure {
ScrBox *caps[];
} ScrClosure;
-/* function_kind bit 4: the body never observes the ambient receiver (the
- * `this` an unbound call binds), so compiled callers skip the receiver
- * bracket around an indirect call. Set only by the compiler; the kind
- * proper is function_kind & SCR_FN_KIND_MASK. */
-#define SCR_FN_RECEIVER_FREE 16u
-#define SCR_FN_KIND_MASK 15u
-
ScrClosure *scr_closure_new(void *fn, size_t ncaps); /* +1; caller fills caps with +1 box refs */
static inline ScrClosure *scr_closure_retain(ScrClosure *c) {
diff --git a/packages/runtime/src/scr_dyn.h b/packages/runtime/src/scr_dyn.h
index 5bb56f082..98d4aef0b 100644
--- a/packages/runtime/src/scr_dyn.h
+++ b/packages/runtime/src/scr_dyn.h
@@ -129,17 +129,30 @@ typedef enum {
* lowerings (the http/net receiver surface); new kinds join by adding a
* tag + an ops registration in their owning unit.
*
- * SCR_DYNH_TAGS is the one list of tags: X(NAME, value) declares
- * SCR_DYNH_NAME. Rows are sorted by name and every value is explicit and
- * permanent: the LLVM backend hardcodes some of them (DYN_HANDLE_TAG_NUM in
- * packages/compiler/src/backend/llvm/dyn.ts). A new tag goes in its sorted
- * place with the next value after the largest one in use, below
- * SCR_DYNH_CAPACITY. Two changes that add tags therefore edit different
- * lines and merge without a textual conflict; when they picked the same
- * value, scr_dynh_tags_distinct below has a duplicate case label and every
- * runtime unit fails to compile, and a value at or above the capacity
- * fails its _Static_assert. packages/compiler/src/backend/llvm/dyn-handle-tags.test.ts
- * checks the same properties and that the backend's numbers match. */
+ * Two lists declare the tags. SCR_DYNH_TAGS holds the PINNED tags: X(NAME,
+ * value) declares SCR_DYNH_NAME with an explicit, permanent value, rows
+ * sorted by name. SCR_DYNH_NEW_TAGS holds tags added since the last pin, by
+ * NAME ONLY: Y(NAME), rows sorted by name. A new tag goes in its sorted place
+ * in SCR_DYNH_NEW_TAGS, so a branch never picks a number and two branches
+ * that add tags never collide; the enum numbers the new tags after the
+ * largest pinned value, in list order. Values are an ABI only between the
+ * compiler and the runtime pack of one build, so a new tag added before
+ * another one in name order may renumber it; nothing outside the build
+ * depends on it.
+ *
+ * Pin a new tag (move it to SCR_DYNH_TAGS with the value it has now: the
+ * largest pinned value + 1 for the first row of SCR_DYNH_NEW_TAGS, + 2 for
+ * the second, and so on) only when the LLVM backend needs its number: DYN_HANDLE_TAG_NUM and
+ * TEXT_CODEC_TAG_NUM in packages/compiler/src/backend/llvm/dyn.ts may only
+ * name pinned tags. Pin the new tags in name order, oldest first, in a change
+ * of its own, so their values do not move.
+ *
+ * Checks: a pinned value at or above SCR_DYNH_CAPACITY fails its
+ * _Static_assert, two tags with one value are a duplicate case label in
+ * scr_dynh_tags_distinct below, and the header does not compile.
+ * dyn-handle-tags.test.ts checks the same properties by name, that both lists
+ * are sorted, that every value below the largest pinned one is a tag or
+ * listed in SCR_DYNH_UNASSIGNED, and that the backend's numbers match. */
#define SCR_DYNH_TAGS(X) \
X(ABORT_CONTROLLER, 15) /* native static-fetch AbortController */ \
X(ABORT_SIGNAL, 8) /* native static-fetch AbortSignal */ \
@@ -225,24 +238,55 @@ typedef enum {
X(WEB_WRITER, 75) /* WritableStreamDefaultWriter (scr_fetch.c) */ \
X(WORKER, 35)
+/* Tags added since the last pin, by name only (see above). */
+#define SCR_DYNH_NEW_TAGS(Y)
+
+/* Values below the largest pinned one that no tag holds, each with the
+ * reason. Never reuse one without removing it here. */
+#define SCR_DYNH_UNASSIGNED(Z) \
+ Z(63) /* skipped: claimed by a branch that took another number */
+
+/* One member per pinned tag, sized value + 1, so its size is the largest
+ * pinned value + 1: the new tags are numbered from there. Never instantiated. */
+union ScrDynhPinnedSpan {
+#define SCR_DYNH_SPAN_ROW(name, value) char name[(value) + 1];
+ SCR_DYNH_TAGS(SCR_DYNH_SPAN_ROW)
+#undef SCR_DYNH_SPAN_ROW
+};
+
typedef enum {
#define SCR_DYNH_ENUM_ROW(name, value) SCR_DYNH_##name = value,
SCR_DYNH_TAGS(SCR_DYNH_ENUM_ROW)
#undef SCR_DYNH_ENUM_ROW
+ SCR_DYNH__LAST_PINNED = (int)sizeof(union ScrDynhPinnedSpan) - 1,
+#define SCR_DYNH_NEW_ROW(name) SCR_DYNH_##name,
+ SCR_DYNH_NEW_TAGS(SCR_DYNH_NEW_ROW)
+#undef SCR_DYNH_NEW_ROW
} ScrDynHandleTag;
#define SCR_DYNH_RANGE_ROW(name, value) \
_Static_assert((value) < SCR_DYNH_CAPACITY, "SCR_DYNH_" #name " is not below SCR_DYNH_CAPACITY");
SCR_DYNH_TAGS(SCR_DYNH_RANGE_ROW)
#undef SCR_DYNH_RANGE_ROW
+#define SCR_DYNH_NEW_RANGE_ROW(name) \
+ _Static_assert(SCR_DYNH_##name < SCR_DYNH_CAPACITY, "SCR_DYNH_" #name " is not below SCR_DYNH_CAPACITY");
+SCR_DYNH_NEW_TAGS(SCR_DYNH_NEW_RANGE_ROW)
+#undef SCR_DYNH_NEW_RANGE_ROW
-/* Never called: a case label per tag, so two tags with one value are a
- * duplicate case value and the header does not compile. */
+/* Never called: a case label per tag and per unassigned value, so two tags
+ * with one value, or a tag on an unassigned value, are a duplicate case
+ * value and the header does not compile. */
static inline void scr_dynh_tags_distinct(ScrDynHandleTag tag) {
- switch (tag) {
+ switch ((int)tag) {
#define SCR_DYNH_CASE_ROW(name, value) case SCR_DYNH_##name:
SCR_DYNH_TAGS(SCR_DYNH_CASE_ROW)
#undef SCR_DYNH_CASE_ROW
+#define SCR_DYNH_NEW_CASE_ROW(name) case SCR_DYNH_##name:
+ SCR_DYNH_NEW_TAGS(SCR_DYNH_NEW_CASE_ROW)
+#undef SCR_DYNH_NEW_CASE_ROW
+#define SCR_DYNH_UNASSIGNED_CASE_ROW(value) case value:
+ SCR_DYNH_UNASSIGNED(SCR_DYNH_UNASSIGNED_CASE_ROW)
+#undef SCR_DYNH_UNASSIGNED_CASE_ROW
break;
}
}
diff --git a/packages/runtime/src/scr_dyn_builtin_class.c b/packages/runtime/src/scr_dyn_builtin_class.c
index 0647a65c0..b176e1a42 100644
--- a/packages/runtime/src/scr_dyn_builtin_class.c
+++ b/packages/runtime/src/scr_dyn_builtin_class.c
@@ -1441,7 +1441,7 @@ static void bc_teardown(void) {
static ScrDyn *bc_new_class(int kind, ScrDyn *parent) {
ScrClosure *closure = scr_closure_new(NULL, 1);
- closure->function_kind = 4; /* an ordinary function with its own prototype */
+ closure->function_kind = SCR_CLOSURE_KIND_OWN_PROTOTYPE;
closure->caps[0] = scr_box_new(SCR_BOX_F64);
scr_box_set_f64(closure->caps[0], kind);
ScrDyn *ctor = scr_dyn_new_func(closure, bc_construct, 1, "native:events", bc_names[kind]);
diff --git a/packages/runtime/src/scr_json.c b/packages/runtime/src/scr_json.c
index 1893ec193..d0526f538 100644
--- a/packages/runtime/src/scr_json.c
+++ b/packages/runtime/src/scr_json.c
@@ -4572,8 +4572,8 @@ bool scr_dyn_util_type_is(const ScrDyn *value, const ScrStr *probe) {
if (PROBE("isRegExp")) return value->kind == SCR_DYN_HANDLE && value->v.handle.tag == SCR_DYNH_REGEXP;
if (PROBE("isPromise")) return value->kind == SCR_DYN_PROMISE;
if (PROBE("isProxy")) return value->kind == SCR_DYN_PROXY;
- if (PROBE("isAsyncFunction")) return value->kind == SCR_DYN_FUNC && (scr_closure_identity(value->v.fn.clo)->function_kind & 2) != 0;
- if (PROBE("isGeneratorFunction")) return value->kind == SCR_DYN_FUNC && (scr_closure_identity(value->v.fn.clo)->function_kind & 1) != 0;
+ if (PROBE("isAsyncFunction")) return value->kind == SCR_DYN_FUNC && (scr_closure_identity(value->v.fn.clo)->function_kind & SCR_CLOSURE_KIND_ASYNC) != 0;
+ if (PROBE("isGeneratorFunction")) return value->kind == SCR_DYN_FUNC && (scr_closure_identity(value->v.fn.clo)->function_kind & SCR_CLOSURE_KIND_GENERATOR) != 0;
if (PROBE("isGeneratorObject")) return scr_dyn_generator(value);
if (PROBE("isDate")) return value->kind == SCR_DYN_HANDLE && value->v.handle.tag == SCR_DYNH_DATE;
if (PROBE("isMapIterator")) return scr_native_iterator_over(value, 2);
@@ -5850,7 +5850,7 @@ void scr_dyn_fence_member(ScrDyn *object, const ScrStr *key) {
}
ScrDyn *scr_dyn_new_func(ScrClosure *clo, ScrDynThunk thunk, uint32_t arity, const char *sig, const char *name) {
- if (clo->function_kind & 8) {
+ if (clo->function_kind & SCR_CLOSURE_KIND_CHECKED_ADAPTER) {
ScrDyn *original = scr_box_get_ref(clo->caps[0]);
scr_closure_release(clo);
return original;
@@ -6009,7 +6009,7 @@ ScrDyn *scr_dyn_bind(ScrDyn *target, ScrDyn *const *args, size_t argc) {
scr_str_release(prefix);
scr_str_release(suffix);
ScrClosure *closure = scr_closure_new(NULL, 4);
- closure->function_kind = scr_closure_identity(target->v.fn.clo)->function_kind & 3;
+ closure->function_kind = scr_closure_identity(target->v.fn.clo)->function_kind & SCR_CLOSURE_KIND_CALL_MASK;
for (size_t i = 0; i < 3; i++) closure->caps[i] = scr_box_new_obj(scr_dyn_retain_v, scr_dyn_release_v, scr_dyn_trace_v);
closure->caps[3] = scr_box_new(SCR_BOX_STR);
scr_box_set_ref(closure->caps[0], scr_dyn_retain(target));
@@ -6829,7 +6829,7 @@ static ScrDyn *scr_dyn_fn_properties(const ScrDyn *function) {
scr_box_set_ref(box, table);
owner->props = box;
if (function->v.fn.class_obj) function->v.fn.class_obj->static_data = scr_box_retain(box);
- if (owner->function_kind & 4) {
+ if (owner->function_kind & SCR_CLOSURE_KIND_OWN_PROTOTYPE) {
ScrDyn *prototype = scr_dyn_new_obj();
scr_dyn_obj_set(prototype, "constructor", 11, scr_dyn_retain((ScrDyn *)function));
prototype->v.obj.entries[0].enumerable = false;
@@ -6908,7 +6908,7 @@ ScrDyn *scr_dyn_class_base_prototype(ScrDyn *constructor) {
default: break;
}
if (constructor->kind != SCR_DYN_FUNC || constructor->v.fn.class_obj ||
- (scr_closure_identity(constructor->v.fn.clo)->function_kind & SCR_FN_KIND_MASK) != 4) {
+ (scr_closure_identity(constructor->v.fn.clo)->function_kind & SCR_CLOSURE_KIND_MASK) != SCR_CLOSURE_KIND_OWN_PROTOTYPE) {
static const char message[] = "Class extends value is not a native ordinary constructor";
scr_throw_error_msg(SCR_ERR_TYPE, message, sizeof message - 1);
return NULL;
@@ -7057,7 +7057,7 @@ void scr_dyn_name_class_prototype(ScrDyn *prototype, const ScrStr *name) {
* A constructor may replace the fresh receiver with another object. */
ScrDyn *scr_dyn_construct(const ScrDyn *callee, const ScrDyn *args, const ScrStr *what) {
if (callee->kind != SCR_DYN_FUNC || callee->v.fn.class_obj ||
- (scr_closure_identity(callee->v.fn.clo)->function_kind & SCR_FN_KIND_MASK) != 4)
+ (scr_closure_identity(callee->v.fn.clo)->function_kind & SCR_CLOSURE_KIND_MASK) != SCR_CLOSURE_KIND_OWN_PROTOTYPE)
return scr_bytes_construct(callee, args, what);
ScrDyn *prototype = scr_dyn_fn_get(callee, "prototype", 9);
if (!prototype) return NULL;
@@ -9193,7 +9193,7 @@ bool scr_dyn_is_callable(const ScrDyn *value) {
int scr_dyn_function_kind(const ScrDyn *value) {
while (value->kind == SCR_DYN_PROXY) value = value->v.proxy.target;
return value->kind == SCR_DYN_FUNC
- ? (int)(scr_closure_identity(value->v.fn.clo)->function_kind & SCR_FN_KIND_MASK)
+ ? (int)(scr_closure_identity(value->v.fn.clo)->function_kind & SCR_CLOSURE_KIND_MASK)
: 0;
}
@@ -14602,7 +14602,7 @@ static ScrDyn *scr_function_constructor_call(ScrClosure *closure, ScrDyn *const
}
static ScrDyn *scr_function_constructor(uint32_t kind) {
- kind &= 3;
+ kind &= SCR_CLOSURE_KIND_CALL_MASK;
static const char *const names[] = {"Function", "GeneratorFunction", "AsyncFunction", "AsyncGeneratorFunction"};
if (!scr_function_constructors_registered) {
scr_atexit(scr_function_constructors_cleanup);
@@ -14632,7 +14632,7 @@ ScrDyn *scr_dyn_fn_get(const ScrDyn *d, const char *key, size_t key_len) {
return NULL;
}
if (!d->v.fn.class_obj && key_len == 9 && !memcmp(key, "prototype", 9) && !owner->props) {
- if (owner->function_kind & 1) {
+ if (owner->function_kind & SCR_CLOSURE_KIND_GENERATOR) {
static const char message[] = "Native generator function prototypes have no lowering";
scr_throw_error_msg_code(SCR_ERR_ERROR, message, sizeof message - 1, "SC2020");
return NULL;
diff --git a/packages/runtime/src/scr_util_callable.c b/packages/runtime/src/scr_util_callable.c
index 17076b078..e85486aea 100644
--- a/packages/runtime/src/scr_util_callable.c
+++ b/packages/runtime/src/scr_util_callable.c
@@ -311,7 +311,7 @@ ScrDyn *scr_util_deprecate(ScrDyn *fn, ScrDyn *msg, ScrDyn *code, ScrDyn *option
return NULL;
}
ScrClosure *closure = scr_closure_new(NULL, UC_DEP_CAPS);
- closure->function_kind = 4; /* an ordinary function with its own prototype */
+ closure->function_kind = SCR_CLOSURE_KIND_OWN_PROTOTYPE;
closure->caps[UC_DEP_FN] = uc_box(scr_dyn_retain(fn));
closure->caps[UC_DEP_MSG] = uc_box(scr_dyn_retain(msg));
closure->caps[UC_DEP_CODE] = uc_box(scr_dyn_retain(code));
@@ -557,7 +557,7 @@ ScrDyn *scr_util_callbackify(ScrDyn *original) {
return NULL;
}
ScrClosure *closure = scr_closure_new(NULL, 1);
- closure->function_kind = 4; /* an ordinary function with its own prototype */
+ closure->function_kind = SCR_CLOSURE_KIND_OWN_PROTOTYPE;
closure->caps[0] = uc_box(scr_dyn_retain(original));
ScrDyn *callbackified =
scr_dyn_new_func(closure, uc_callbackified_call, 0, "native:util.callbackify", "callbackified");
@@ -708,7 +708,7 @@ static ScrDyn *uc_debug_impl(bool enabled, ScrDyn *set) {
scr_str_release(text);
}
ScrClosure *closure = scr_closure_new(NULL, 1);
- closure->function_kind = 4;
+ closure->function_kind = SCR_CLOSURE_KIND_OWN_PROTOTYPE;
closure->caps[0] = uc_box(scr_dyn_retain(set));
impl = scr_dyn_new_func(closure, uc_debug_write, 0, "native:util.debuglog", "debug");
} else {
diff --git a/packages/runtime/src/scr_util_timing.c b/packages/runtime/src/scr_util_timing.c
index 13651f5ff..b26839c11 100644
--- a/packages/runtime/src/scr_util_timing.c
+++ b/packages/runtime/src/scr_util_timing.c
@@ -951,7 +951,7 @@ static UtState *ut_state_new(bool throttle, ScrDyn *signal) {
* refers to the returned function) stays collectable. */
static ScrDyn *ut_build(UtState *s, ScrDyn *wrapped) {
ScrClosure *closure = scr_closure_new(NULL, 2);
- closure->function_kind = 4; /* an ordinary function with its own prototype */
+ closure->function_kind = SCR_CLOSURE_KIND_OWN_PROTOTYPE;
UtOwner *owner = malloc(sizeof *owner);
if (!owner) ut_oom();
owner->rc = 1;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 047aeaafd..619c4b7fb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -170,6 +170,10 @@ importers:
typescript5:
specifier: npm:typescript@5.9.3
version: typescript@5.9.3
+ devDependencies:
+ typescript-7.1:
+ specifier: npm:typescript@7.1.0-beta
+ version: typescript@7.1.0-beta
optionalDependencies:
'@scriptc/llvm-darwin-arm64':
specifier: workspace:*
@@ -1500,120 +1504,246 @@ packages:
cpu: [ppc64]
os: [aix]
+ '@typescript/typescript-aix-ppc64@7.1.0-beta':
+ resolution: {integrity: sha512-DXDG+jIWxBHGdoO70tcHON33rCnSzsEZj9P13oyf/r/KEGIo6awsdSNVNjAw0uMwEpqRu75IrgCcW58YcVkZHg==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [aix]
+
+ '@typescript/typescript-android-arm64@7.1.0-beta':
+ resolution: {integrity: sha512-UZx6PxoQavuvaVTrSf3aw0Oy/J/uJpRylipA0kzc038jMvZXh0UtE8vnQp20FqtdgD5bhXZ0aJrRJyoTSUO58Q==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [android]
+
'@typescript/typescript-darwin-arm64@7.0.2':
resolution: {integrity: sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [darwin]
+ '@typescript/typescript-darwin-arm64@7.1.0-beta':
+ resolution: {integrity: sha512-q7mxblcyX0E7hXsY4k+Q6KWKK5mGmfVq+BDesdTgRVgjodk27aIxER77Zyj9+TgSTlCje8F7+JM6f6hl2BuNdw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [darwin]
+
'@typescript/typescript-darwin-x64@7.0.2':
resolution: {integrity: sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [darwin]
+ '@typescript/typescript-darwin-x64@7.1.0-beta':
+ resolution: {integrity: sha512-D5EYqG5Wm/guw9+0j8+Mn1Jbr/gfS/VtFTuoH8PJtFbcqeDaQGMulc7SW8q51QG6Y3wGOGX3nbsTOw6fIKWWAA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [darwin]
+
'@typescript/typescript-freebsd-arm64@7.0.2':
resolution: {integrity: sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [freebsd]
+ '@typescript/typescript-freebsd-arm64@7.1.0-beta':
+ resolution: {integrity: sha512-9A0XX0tcnH9qPdolSKKzSjTb93dlB0UEcAJbHYEsrFX2fkFNSum9+dvkQQJ9vLc+7vxHetDPujq9Qe6wx+Xhow==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [freebsd]
+
'@typescript/typescript-freebsd-x64@7.0.2':
resolution: {integrity: sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [freebsd]
+ '@typescript/typescript-freebsd-x64@7.1.0-beta':
+ resolution: {integrity: sha512-EDf/dBHfY0TxX3NmUTek0hbffeyZlh3+ls6WQW3ICB8vYxwYLFnzg7wkgEUvl9eRlpiJ/nmc2S8arPMzINJ7rg==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [freebsd]
+
'@typescript/typescript-linux-arm64@7.0.2':
resolution: {integrity: sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [linux]
+ '@typescript/typescript-linux-arm64@7.1.0-beta':
+ resolution: {integrity: sha512-hNqQBgQ5b5oGqN76QqbAU6CwYhJZ6iOcWc2kPTUs2OentgpxZktETziH6KIyRFJPaiBncognwn2ZLu3DNhHiWw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [linux]
+
'@typescript/typescript-linux-arm@7.0.2':
resolution: {integrity: sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==}
engines: {node: '>=16.20.0'}
cpu: [arm]
os: [linux]
+ '@typescript/typescript-linux-arm@7.1.0-beta':
+ resolution: {integrity: sha512-AvN9HmzOQh46G4312ooSgMHhbB7DtnLwg8D9mo324X49B7weMwtJrBG1MxT9IyI8kzDd7YRtW+1hWGun7WG7Qw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm]
+ os: [linux]
+
'@typescript/typescript-linux-loong64@7.0.2':
resolution: {integrity: sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==}
engines: {node: '>=16.20.0'}
cpu: [loong64]
os: [linux]
+ '@typescript/typescript-linux-loong64@7.1.0-beta':
+ resolution: {integrity: sha512-xbUustlBrFflt2pNEyG5ye+d4WGPc/rfr2xK29MXeobPSw297JqwuKRrvbKM5o3fE/eUM/c+urVm6jQ3IaMVTw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [loong64]
+ os: [linux]
+
'@typescript/typescript-linux-mips64el@7.0.2':
resolution: {integrity: sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==}
engines: {node: '>=16.20.0'}
cpu: [mips64el]
os: [linux]
+ '@typescript/typescript-linux-mips64el@7.1.0-beta':
+ resolution: {integrity: sha512-fS4/G7/0bXZ3NSq3nBMAXyg65I3tO4S7j08VNQ2g1aNC/qq79gE7Qne1BNjVsjMFfHBVBFjQYoTQCjmVU2iFlg==}
+ engines: {node: '>=16.20.0'}
+ cpu: [mips64el]
+ os: [linux]
+
'@typescript/typescript-linux-ppc64@7.0.2':
resolution: {integrity: sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==}
engines: {node: '>=16.20.0'}
cpu: [ppc64]
os: [linux]
+ '@typescript/typescript-linux-ppc64@7.1.0-beta':
+ resolution: {integrity: sha512-0upCCxYPX8X6gOwkvfv+Ee7/ufyy7k97mTrGE0e5BxLlnUEa+w+rtgnv2dsKcxFdwdeZYVlZKCqgN/xCBV08+A==}
+ engines: {node: '>=16.20.0'}
+ cpu: [ppc64]
+ os: [linux]
+
'@typescript/typescript-linux-riscv64@7.0.2':
resolution: {integrity: sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==}
engines: {node: '>=16.20.0'}
cpu: [riscv64]
os: [linux]
+ '@typescript/typescript-linux-riscv64@7.1.0-beta':
+ resolution: {integrity: sha512-0jKU9YRgOPRgiy5V/UWv37kB53PaTpIbwGCbbOMUPPAkrjVRNOIAJaz7Wa7s3H7mdDebX1LopUEPEJR+7Xa7zg==}
+ engines: {node: '>=16.20.0'}
+ cpu: [riscv64]
+ os: [linux]
+
'@typescript/typescript-linux-s390x@7.0.2':
resolution: {integrity: sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==}
engines: {node: '>=16.20.0'}
cpu: [s390x]
os: [linux]
+ '@typescript/typescript-linux-s390x@7.1.0-beta':
+ resolution: {integrity: sha512-j+d04s2kxaPQh6DLPb01sknAQfzsrdSZy15jBRHyLEZoaaQ19dVjbFRlkNVvYwUG7ZdP8+y5b/7RmPxdtHUKdQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [s390x]
+ os: [linux]
+
'@typescript/typescript-linux-x64@7.0.2':
resolution: {integrity: sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [linux]
+ '@typescript/typescript-linux-x64@7.1.0-beta':
+ resolution: {integrity: sha512-kcD51ihf+pnI5KT4uQQYG04OpwznaZYBlIQhDhl8fflFd27NktxXsUeguxZyvUMyWfum7IF2bBS6Jv7ox0AgFQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [linux]
+
'@typescript/typescript-netbsd-arm64@7.0.2':
resolution: {integrity: sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [netbsd]
+ '@typescript/typescript-netbsd-arm64@7.1.0-beta':
+ resolution: {integrity: sha512-8W85YyoGK+rgGoSU9jJQUpELbrKcZcbdJk2jPNQ6UKDKI7ryPlPFTrV2jCE5RQuv4Tt96hMDTc33s4YfRrLFBA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [netbsd]
+
'@typescript/typescript-netbsd-x64@7.0.2':
resolution: {integrity: sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [netbsd]
+ '@typescript/typescript-netbsd-x64@7.1.0-beta':
+ resolution: {integrity: sha512-iGsTE0ve2PG0AcyZinwAR0LnGEZhu/y4dceS0hZsYCaKdYETvQtvtlNWlmYKempsBr6C7Il7dMXHVk+VFm9u0g==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [netbsd]
+
'@typescript/typescript-openbsd-arm64@7.0.2':
resolution: {integrity: sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [openbsd]
+ '@typescript/typescript-openbsd-arm64@7.1.0-beta':
+ resolution: {integrity: sha512-Z2FERdHB9fAKI+KGqdWn04O7uNlnB+VYiEm6INE1aZuzzLNd3KBqQ6IpKUJF2ny4GigGU6nVu75XD9tAUODPng==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [openbsd]
+
'@typescript/typescript-openbsd-x64@7.0.2':
resolution: {integrity: sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [openbsd]
+ '@typescript/typescript-openbsd-x64@7.1.0-beta':
+ resolution: {integrity: sha512-NVG3qeyCq/rJEOhaRjR3LsWgjrjo5vJW25omVLeHAVnpKIG4pE4cenwwKGR06DiJ1s0QTOymcJeKXMEpsiNBVg==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [openbsd]
+
'@typescript/typescript-sunos-x64@7.0.2':
resolution: {integrity: sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [sunos]
+ '@typescript/typescript-sunos-x64@7.1.0-beta':
+ resolution: {integrity: sha512-ovAdoWXEn2B5/fyWG69pLU3FWmQwPWOjQV5TFbcxtpnOv+R3g8ySGHl1NYRq7Lb/GYdFDvaGcZ+SiU7iKuiMsA==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [sunos]
+
'@typescript/typescript-win32-arm64@7.0.2':
resolution: {integrity: sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==}
engines: {node: '>=16.20.0'}
cpu: [arm64]
os: [win32]
+ '@typescript/typescript-win32-arm64@7.1.0-beta':
+ resolution: {integrity: sha512-lJbo71IEOfL7ZZIZnArRaOLqjn9BAfoSzyUQn0aSjfSpDZL4ru0afUsdBfo6PQEjD6ssdmi0n/RwaPADX6bsvQ==}
+ engines: {node: '>=16.20.0'}
+ cpu: [arm64]
+ os: [win32]
+
'@typescript/typescript-win32-x64@7.0.2':
resolution: {integrity: sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==}
engines: {node: '>=16.20.0'}
cpu: [x64]
os: [win32]
+ '@typescript/typescript-win32-x64@7.1.0-beta':
+ resolution: {integrity: sha512-UKcHvbybY2tDTvFW2b1vwiAvDlIRooo69lh8CXWVhOQejcvpM5+IgbiFW9EIwR5Wi5X/v+BdN7gdWbigBuJ3rw==}
+ engines: {node: '>=16.20.0'}
+ cpu: [x64]
+ os: [win32]
+
'@vercel/backends@0.8.27':
resolution: {integrity: sha512-WW9hfF0mWZDQr23RcjLgVUIoo3V07aq25t6f3RSbq0yIQ2zKZ41x8+0nhNKjzAP7GB7yFmwX4UOv4wV2fWqDDQ==}
@@ -2659,6 +2789,11 @@ packages:
engines: {node: '>=16.20.0'}
hasBin: true
+ typescript@7.1.0-beta:
+ resolution: {integrity: sha512-cjWjZAJDsmZzsNC5GwHNlW0YPM65jZa5oR0KyQcYX3KSVIfTvtBq//vewISyatKk4OLi5grZjHhIVE9fdrw1UQ==}
+ engines: {node: '>=16.20.0'}
+ hasBin: true
+
uid-promise@1.0.0:
resolution: {integrity: sha512-R8375j0qwXyIu/7R0tjdF06/sElHqbmdmWC9M2qQHpEVbvE4I5+38KJI7LUUmQMp7NVq4tKHiBMkT0NFM453Ig==}
@@ -3560,63 +3695,126 @@ snapshots:
'@typescript/typescript-aix-ppc64@7.0.2':
optional: true
+ '@typescript/typescript-aix-ppc64@7.1.0-beta':
+ optional: true
+
+ '@typescript/typescript-android-arm64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-darwin-arm64@7.0.2':
optional: true
+ '@typescript/typescript-darwin-arm64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-darwin-x64@7.0.2':
optional: true
+ '@typescript/typescript-darwin-x64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-freebsd-arm64@7.0.2':
optional: true
+ '@typescript/typescript-freebsd-arm64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-freebsd-x64@7.0.2':
optional: true
+ '@typescript/typescript-freebsd-x64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-linux-arm64@7.0.2':
optional: true
+ '@typescript/typescript-linux-arm64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-linux-arm@7.0.2':
optional: true
+ '@typescript/typescript-linux-arm@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-linux-loong64@7.0.2':
optional: true
+ '@typescript/typescript-linux-loong64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-linux-mips64el@7.0.2':
optional: true
+ '@typescript/typescript-linux-mips64el@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-linux-ppc64@7.0.2':
optional: true
+ '@typescript/typescript-linux-ppc64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-linux-riscv64@7.0.2':
optional: true
+ '@typescript/typescript-linux-riscv64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-linux-s390x@7.0.2':
optional: true
+ '@typescript/typescript-linux-s390x@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-linux-x64@7.0.2':
optional: true
+ '@typescript/typescript-linux-x64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-netbsd-arm64@7.0.2':
optional: true
+ '@typescript/typescript-netbsd-arm64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-netbsd-x64@7.0.2':
optional: true
+ '@typescript/typescript-netbsd-x64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-openbsd-arm64@7.0.2':
optional: true
+ '@typescript/typescript-openbsd-arm64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-openbsd-x64@7.0.2':
optional: true
+ '@typescript/typescript-openbsd-x64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-sunos-x64@7.0.2':
optional: true
+ '@typescript/typescript-sunos-x64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-win32-arm64@7.0.2':
optional: true
+ '@typescript/typescript-win32-arm64@7.1.0-beta':
+ optional: true
+
'@typescript/typescript-win32-x64@7.0.2':
optional: true
+ '@typescript/typescript-win32-x64@7.1.0-beta':
+ optional: true
+
'@vercel/backends@0.8.27(@emnapi/core@1.11.3)(@emnapi/runtime@1.11.3)(rollup@4.62.2)':
dependencies:
'@vercel/build-utils': 13.36.0
@@ -4920,6 +5118,30 @@ snapshots:
'@typescript/typescript-win32-arm64': 7.0.2
'@typescript/typescript-win32-x64': 7.0.2
+ typescript@7.1.0-beta:
+ optionalDependencies:
+ '@typescript/typescript-aix-ppc64': 7.1.0-beta
+ '@typescript/typescript-android-arm64': 7.1.0-beta
+ '@typescript/typescript-darwin-arm64': 7.1.0-beta
+ '@typescript/typescript-darwin-x64': 7.1.0-beta
+ '@typescript/typescript-freebsd-arm64': 7.1.0-beta
+ '@typescript/typescript-freebsd-x64': 7.1.0-beta
+ '@typescript/typescript-linux-arm': 7.1.0-beta
+ '@typescript/typescript-linux-arm64': 7.1.0-beta
+ '@typescript/typescript-linux-loong64': 7.1.0-beta
+ '@typescript/typescript-linux-mips64el': 7.1.0-beta
+ '@typescript/typescript-linux-ppc64': 7.1.0-beta
+ '@typescript/typescript-linux-riscv64': 7.1.0-beta
+ '@typescript/typescript-linux-s390x': 7.1.0-beta
+ '@typescript/typescript-linux-x64': 7.1.0-beta
+ '@typescript/typescript-netbsd-arm64': 7.1.0-beta
+ '@typescript/typescript-netbsd-x64': 7.1.0-beta
+ '@typescript/typescript-openbsd-arm64': 7.1.0-beta
+ '@typescript/typescript-openbsd-x64': 7.1.0-beta
+ '@typescript/typescript-sunos-x64': 7.1.0-beta
+ '@typescript/typescript-win32-arm64': 7.1.0-beta
+ '@typescript/typescript-win32-x64': 7.1.0-beta
+
uid-promise@1.0.0: {}
undici-types@5.26.5: {}
diff --git a/scripts/registry-claims.mjs b/scripts/registry-claims.mjs
new file mode 100644
index 000000000..ac561a2b8
--- /dev/null
+++ b/scripts/registry-claims.mjs
@@ -0,0 +1,213 @@
+#!/usr/bin/env node
+/* Lists the numbers that open pull requests claim in the repository's
+ * numbered registries, and reports the ones two changes claim differently:
+ *
+ * diagnostic codes a code an added line names that origin/main has no
+ * row for in packages/compiler/src/diagnostics/codes.ts
+ * dyn handle tags an added pinned row X(NAME, value) in
+ * packages/runtime/src/scr_dyn.h
+ *
+ * The current branch (its changes since it left origin/main) counts as one
+ * more claimant. Run it before picking a number, or before pushing a change
+ * that takes one; it needs `gh` access to the repository.
+ *
+ * node scripts/registry-claims.mjs every claim and every collision
+ * node scripts/registry-claims.mjs --check only collisions with this branch;
+ * exits 1 when there is one
+ *
+ * A pull request whose diff cannot be read is named in the output, and the
+ * run exits 2 (unless it found a collision), since its claims are missing. */
+import { execFile, execFileSync } from "node:child_process";
+import { readFileSync } from "node:fs";
+import { promisify } from "node:util";
+
+const run = promisify(execFile);
+const check = process.argv.includes("--check");
+const git = (...args) =>
+ execFileSync("git", args, {
+ encoding: "utf8",
+ maxBuffer: 1 << 30,
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+
+const CODES = "packages/compiler/src/diagnostics/codes.ts";
+const DYN = "packages/runtime/src/scr_dyn.h";
+
+git("fetch", "-q", "origin", "main");
+// The codes origin/main has: its active and retired rows (a reserved code is
+// still a claim), or, before codes.ts existed there, every code its sources name.
+const baseCodes = new Map();
+const codesSource = (() => {
+ try {
+ return git("show", `origin/main:${CODES}`);
+ } catch {
+ return null;
+ }
+})();
+if (codesSource !== null) {
+ const reserved = codesSource.indexOf("export const RESERVED_DIAGNOSTIC_CODES");
+ for (const m of codesSource.matchAll(/^ {2}(SC\d{4}): "([^"]*)"/gm)) {
+ if (reserved < 0 || m.index < reserved) baseCodes.set(m[1], m[2]);
+ }
+} else {
+ const named = git("grep", "-ohE", "SC[0-9]{4}", "origin/main", "--", "packages/*/src/**");
+ for (const code of named.split("\n").filter(Boolean)) baseCodes.set(code, "");
+}
+const baseTags = new Map(
+ [...git("show", `origin/main:${DYN}`).matchAll(/^\s*X\(([A-Z0-9_]+), (\d+)\)/gm)].map(
+ ([, name, value]) => [Number(value), name],
+ ),
+);
+
+/** The claims of one unified diff: [registry, number, label] triples. */
+function claimsOf(diff) {
+ const claims = [];
+ let file = "";
+ let table = ""; // the codes.ts table the current line is in
+ for (const line of diff.split("\n")) {
+ if (line.startsWith("+++ ")) {
+ file = line.slice(4).replace(/^b\//, "");
+ continue;
+ }
+ const section = /^(?:@@.*@@ |[ +])export const (\w+)/.exec(line);
+ if (section) table = section[1];
+ if (!line.startsWith("+")) continue;
+ // A reserved row holds a code for other work; it is not this change's claim.
+ if (file === CODES && table === "RESERVED_DIAGNOSTIC_CODES") continue;
+ if (file === DYN) {
+ const tag = /^\+\s*X\(([A-Z0-9_]+), (\d+)\)/.exec(line);
+ if (tag) claims.push(["dyn handle tag", Number(tag[2]), tag[1]]);
+ }
+ if (!file.startsWith("packages/")) continue;
+ const row = file === CODES ? /^\+ {2}(SC\d{4}): "([^"]*)"/.exec(line) : null;
+ if (row) {
+ if (baseCodes.get(row[1]) !== row[2]) claims.push(["diagnostic code", row[1], row[2]]);
+ continue;
+ }
+ for (const [code] of line.matchAll(/\bSC\d{4}\b/g)) {
+ if (!baseCodes.has(code))
+ claims.push(["diagnostic code", code, `${file}: ${line.slice(1).trim()}`]);
+ }
+ }
+ return claims;
+}
+
+const prs = JSON.parse(
+ (
+ await run("gh", [
+ "pr",
+ "list",
+ "--state",
+ "open",
+ "--limit",
+ "300",
+ "--json",
+ "number,title,headRefName,baseRefName",
+ ])
+ ).stdout,
+);
+const head = git("rev-parse", "--abbrev-ref", "HEAD").trim();
+const here = `this branch (${head})`;
+
+// A stack of pull requests (each based on the previous one's branch) is one
+// claimant: a later PR's diff only shows what it adds to its base.
+const byHead = new Map(prs.map((pr) => [pr.headRefName, pr]));
+const stackOf = (pr) => {
+ while (byHead.has(pr.baseRefName) && byHead.get(pr.baseRefName) !== pr)
+ pr = byHead.get(pr.baseRefName);
+ return pr.headRefName;
+};
+const stacks = new Map();
+const claimant = (key) => stacks.get(key) ?? stacks.set(key, { names: [], claims: [] }).get(key);
+
+const queue = [...prs];
+// Pull requests whose diff could not be read: their claims are missing.
+const unread = [];
+await Promise.all(
+ Array.from({ length: 8 }, async () => {
+ for (let pr = queue.shift(); pr; pr = queue.shift()) {
+ const diff = await run("gh", ["pr", "diff", String(pr.number)], { maxBuffer: 1 << 30 }).then(
+ (r) => r.stdout,
+ (error) => {
+ const reason = String(error.stderr || error.message)
+ .trim()
+ .split("\n")[0];
+ unread.push(`#${pr.number} (${reason})`);
+ return null;
+ },
+ );
+ const stack = claimant(stackOf(pr));
+ stack.names.push({ number: pr.number, title: pr.title });
+ if (diff !== null) stack.claims.push(...claimsOf(diff));
+ }
+ }),
+);
+// This branch is its own PR's claimant when it has one open.
+const mine = claimant(byHead.has(head) ? stackOf(byHead.get(head)) : head);
+mine.names.push({ number: Infinity, title: here });
+// Committed and uncommitted changes since the branch left origin/main, and
+// untracked files as wholly added.
+const untracked = git("ls-files", "--others", "--exclude-standard", "-z")
+ .split("\0")
+ .filter(Boolean);
+mine.claims.push(
+ ...claimsOf(git("diff", git("merge-base", "origin/main", "HEAD").trim())),
+ ...untracked
+ .map((file) => claimsOf(`+++ b/${file}\n` + readFileSync(file, "utf8").replace(/^/gm, "+")))
+ .flat(),
+);
+// A stack reads "#1 #2 #3: ".
+const sources = [...stacks.values()].map(({ names, claims }) => {
+ names.sort((a, b) => a.number - b.number);
+ const prs = names.filter((n) => n.number !== Infinity).map((n) => `#${n.number}`);
+ const title = names[0].title;
+ const name = prs.length === 0 ? title : `${prs.join(" ")}: ${title}`;
+ return {
+ name: names.some((n) => n.title === here) && prs.length > 0 ? `${name} (${here})` : name,
+ claims,
+ };
+});
+
+// registry → number → label → claimants. Each claimant labels a number once:
+// with the codes.ts row that claims it when it adds one, otherwise with the
+// first line that names it.
+const table = new Map();
+for (const { name, claims } of sources) {
+ const labelOf = new Map();
+ for (const [registry, number, label] of claims) {
+ const key = `${registry}\0${number}`;
+ const row = registry === "dyn handle tag" || !label.includes(": ");
+ if (!labelOf.has(key) || (row && !labelOf.get(key).row))
+ labelOf.set(key, { registry, number, label, row });
+ }
+ for (const { registry, number, label } of labelOf.values()) {
+ const numbers = table.get(registry) ?? table.set(registry, new Map()).get(registry);
+ const labels = numbers.get(number) ?? numbers.set(number, new Map()).get(number);
+ (labels.get(label) ?? labels.set(label, []).get(label)).push(name);
+ }
+}
+
+let collisions = 0;
+for (const [registry, numbers] of [...table].sort()) {
+ for (const [number, labels] of [...numbers].sort(([a], [b]) => (a < b ? -1 : 1))) {
+ const onMain = registry === "dyn handle tag" ? baseTags.get(number) : undefined;
+ const clash = labels.size > 1 || (onMain !== undefined && !labels.has(onMain));
+ const ours = [...labels.values()].some((who) => who.some((name) => name.includes(here)));
+ if (check && !(clash && ours)) continue;
+ if (clash) collisions++;
+ console.log(`${clash ? "COLLISION" : "claimed"} ${registry} ${number}`);
+ if (onMain !== undefined && !labels.has(onMain)) console.log(` origin/main: ${onMain}`);
+ for (const [label, who] of labels) console.log(` ${label}\n ${who.join("\n ")}`);
+ }
+}
+if (unread.length > 0) {
+ console.log(
+ `could not read the diff of ${unread.length} pull request${unread.length === 1 ? "" : "s"}; ${unread.length === 1 ? "its" : "their"} claims are missing:`,
+ );
+ for (const pr of unread.sort()) console.log(` ${pr}`);
+}
+if (collisions === 0)
+ console.log(
+ `${check ? "no collisions with this branch" : "no collisions"}${unread.length > 0 ? " among the diffs read" : ""}`,
+ );
+process.exitCode = check && collisions > 0 ? 1 : unread.length > 0 ? 2 : 0;
diff --git a/tests/corpus/localized-failure-degraded-initializer.ts b/tests/corpus/localized-failure-degraded-initializer.ts
new file mode 100644
index 000000000..9265c113f
--- /dev/null
+++ b/tests/corpus/localized-failure-degraded-initializer.ts
@@ -0,0 +1,30 @@
+// Localized failure, stage L4, at a forward-captured binding: functions
+// declared before a const capture it, and the const's initializer has an
+// expression whose type does not compile. The statement lowers again with
+// that expression degraded, and it initializes the binding the functions
+// captured: before the declaration a call throws ReferenceError, after it
+// the call reads the value.
+type Hook = Map string> | AbortSignal | (() => number) | Set;
+interface Plugin {
+ name: string;
+ hook: Hook;
+}
+function load(text: string): unknown {
+ return JSON.parse(text);
+}
+function main(): void {
+ function nameOf(): string {
+ return plugin.name;
+ }
+ function attempt(): string {
+ try {
+ return nameOf();
+ } catch (e) {
+ return e instanceof ReferenceError ? "ReferenceError" : "other";
+ }
+ }
+ console.log(attempt());
+ const plugin = { name: (load('{"name":"tags","hook":null}') as Plugin).name };
+ console.log(attempt(), nameOf().length);
+}
+main();
diff --git a/tests/corpus/localized-failure-expressions.ts b/tests/corpus/localized-failure-expressions.ts
new file mode 100644
index 000000000..e41e16fae
--- /dev/null
+++ b/tests/corpus/localized-failure-expressions.ts
@@ -0,0 +1,24 @@
+// Localized failure, stage L4: expressions whose own type does not
+// compile (casts, literals with an unmappable contextual type, spread
+// clones, temporaries).
+type Hook = Map string> | AbortSignal | (() => number) | Set;
+interface Plugin {
+ name: string;
+ hook: Hook;
+}
+function load(text: string): unknown {
+ return JSON.parse(text);
+}
+function count(p: { name: string }): number {
+ return p.name.length;
+}
+const raw = load('{"name":"tags","hook":null}');
+// A cast to a type that does not compile.
+console.log(count(raw as Plugin));
+// A literal whose contextual type does not compile, used without a slot.
+console.log(count({ name: "lit", hook: () => 1 } as Plugin));
+// A spread clone of such a value.
+const base = { name: "base", hook: new Set(["a"]) };
+console.log(count({ ...(base as Plugin), name: "clone" }));
+// A temporary: an array literal of them, mapped.
+console.log([{ name: "a", hook: () => 2 } as Plugin, { name: "bb", hook: new Set() } as Plugin].map((p) => p.name).join("+"));
diff --git a/tests/corpus/localized-failure-push-down.ts b/tests/corpus/localized-failure-push-down.ts
new file mode 100644
index 000000000..a10a453f8
--- /dev/null
+++ b/tests/corpus/localized-failure-push-down.ts
@@ -0,0 +1,52 @@
+// Localized failure, stage L3: push-down. A declared type that does not
+// compile keeps its compilable shape; only the failing component is a
+// checked dynamic piece.
+type Hook = Map string> | AbortSignal | (() => number) | Set;
+
+interface Plugin {
+ name: string;
+ priority: number;
+ hook: Hook;
+}
+
+class Registry {
+ plugins: Plugin[] = [];
+ byName = new Map();
+ pending: Promise | undefined;
+ add(p: Plugin): void {
+ this.plugins.push(p);
+ this.byName.set(p.name, p);
+ }
+ total(): number {
+ let sum = 0;
+ for (const p of this.plugins) sum += p.priority;
+ return sum;
+ }
+ names(): string {
+ return this.plugins.map((p) => p.name).join(",");
+ }
+ run(name: string): string {
+ const p = this.byName.get(name);
+ if (p === undefined) return "missing";
+ const h = p.hook;
+ return typeof h === "function" ? `${p.name}=${h()}` : h instanceof Set ? `${p.name}#${h.size}` : "other";
+ }
+}
+
+type Handler = (event: string, extra: Hook) => number;
+function dispatch(handlers: Handler[], event: string): number {
+ let n = 0;
+ for (const h of handlers) n += h(event, () => event.length);
+ return n;
+}
+
+const r = new Registry();
+const counter: Plugin = { name: "counter", priority: 2, hook: () => 41 };
+r.add(counter);
+r.add({ name: "tags", priority: 5, hook: new Set(["a", "b"]) });
+console.log(r.total(), r.names(), r.run("counter"), r.run("tags"), r.run("x"));
+counter.priority = 10;
+console.log(r.total(), r.byName.get("counter") === counter, JSON.stringify(r.plugins.map((p) => [p.name, p.priority])));
+r.pending = Promise.resolve(counter);
+r.pending.then((p) => console.log("pending", p.name, p === counter));
+console.log(dispatch([(e, x) => e.length, (e, x) => (typeof x === "function" ? x() * 2 : 0)], "hello"));
diff --git a/tests/corpus/width-layout-identity.ts b/tests/corpus/width-layout-identity.ts
new file mode 100644
index 000000000..fcae6faac
--- /dev/null
+++ b/tests/corpus/width-layout-identity.ts
@@ -0,0 +1,68 @@
+// An array or record passed to a parameter, returned, or bound to a const
+// under a type that lacks some of its optional fields stays the same value:
+// identity comparisons and writes through any of the names see one object.
+
+interface Entry {
+ key: string;
+ hits: number;
+ note?: string;
+}
+type Counted = { key: string; hits: number };
+
+function same(list: Counted[], other: Entry[]): boolean {
+ return (list as unknown) === other;
+}
+function keep(list: Counted[]): Counted[] {
+ return list;
+}
+function first(list: Counted[]): Counted {
+ return list[0]!;
+}
+function grow(list: Counted[], key: string): Counted[] {
+ list.push({ key, hits: 0 });
+ return list;
+}
+
+const entries: Entry[] = [{ key: "a", hits: 1, note: "first" }];
+console.log(same(entries, entries), keep(entries) === entries, first(entries) === entries[0]);
+const grown = grow(entries, "b");
+grown.push({ key: "c", hits: 2 });
+console.log(grown === entries, entries.length, entries.map((e) => e.key).join(","));
+
+function local(list: Entry[]): boolean {
+ const view: Counted[] = list;
+ const kept = keep(list);
+ view[0]!.hits += 10;
+ return view === list && kept === list;
+}
+console.log(local(entries), entries[0]!.hits, entries[0]!.note);
+
+// An annotated const that kept its initializer's layout is that value when it
+// is passed on: the callee's writes reach both names.
+function track(list: Counted[], key: string): void {
+ list.push({ key, hits: 0 });
+}
+function viaAlias(): string {
+ const source: Entry[] = [{ key: "s", hits: 1, note: "kept" }];
+ const view: Counted[] = source;
+ track(view, "t");
+ return `${view.length} ${source.length} ${view === source}`;
+}
+console.log(viaAlias());
+const moduleSource: Entry[] = [{ key: "m", hits: 1 }];
+const moduleView: Counted[] = moduleSource;
+track(moduleView, "n");
+console.log(moduleView.length, moduleSource.length, moduleView === moduleSource);
+
+// The same function body lowered for a wider argument and for its own type:
+// each lowering passes its own binding on.
+function addTwice(list: Counted[], key: string): number {
+ const alias: Counted[] = list;
+ track(alias, key);
+ track(alias, `${key}2`);
+ return list.length;
+}
+const wideList: Entry[] = [{ key: "w", hits: 1, note: "n" }];
+const plainList: Counted[] = [{ key: "p", hits: 1 }];
+console.log(addTwice(wideList, "a"), addTwice(plainList, "b"), addTwice(wideList, "c"));
+console.log(wideList.length, plainList.length);
diff --git a/tests/corpus/width-layout-parameter-writes.ts b/tests/corpus/width-layout-parameter-writes.ts
new file mode 100644
index 000000000..07d474352
--- /dev/null
+++ b/tests/corpus/width-layout-parameter-writes.ts
@@ -0,0 +1,96 @@
+// An array or record passed to a parameter whose type lacks some of its
+// optional fields is the caller's value inside the callee: pushes, index
+// stores, element-field writes and removals through the parameter reach the
+// caller's array, as in Node.
+
+interface Param {
+ id: string;
+ name: string;
+ owned?: true;
+}
+type Slot = { id: string; name: string };
+
+function addSlot(params: Slot[], name: string): void {
+ params.push({ id: `${name}.0`, name });
+}
+function rename(params: Slot[], index: number, name: string): void {
+ params[index]!.name = name;
+}
+function replace(params: Slot[], index: number, slot: Slot): void {
+ params[index] = slot;
+}
+function removeFirst(params: Slot[]): string | undefined {
+ return params.shift()?.name;
+}
+function forward(params: Slot[], name: string): void {
+ addSlot(params, name);
+ addSlot(params, `${name}2`);
+}
+function names(params: Slot[]): string {
+ return params.map((p) => p.name).join(",");
+}
+
+const params: Param[] = [{ id: "a.0", name: "a", owned: true }];
+addSlot(params, "b");
+rename(params, 0, "first");
+replace(params, 1, { id: "z.0", name: "z" });
+forward(params, "c");
+console.log(params.length, JSON.stringify(params));
+console.log(removeFirst(params), params.length, params[0]?.owned, names(params));
+params.splice(1, 1);
+console.log(names(params));
+
+const holder = { list: params };
+addSlot(holder.list, "d");
+console.log(holder.list.length, params.length, holder.list === params);
+
+// A record parameter: writes to its fields reach the caller's record, and the
+// optional field the parameter's type lacks stays as the caller left it.
+interface Counter {
+ label: string;
+ hits: number;
+ note?: string;
+}
+function bump(target: { label: string; hits: number }): void {
+ target.hits += 1;
+ target.label = target.label.toUpperCase();
+}
+const counter: Counter = { label: "c", hits: 1 };
+const noted: Counter = { label: "n", hits: 0, note: "kept" };
+bump(counter);
+bump(counter);
+bump(noted);
+console.log(JSON.stringify(counter), "note" in counter, JSON.stringify(noted));
+
+// Recursion through the same parameter.
+function fill(params: Slot[], n: number): void {
+ if (n === 0) return;
+ addSlot(params, `r${n}`);
+ fill(params, n - 1);
+}
+const deep: Param[] = [];
+fill(deep, 3);
+console.log(names(deep), deep.length);
+
+// Recursive layouts: a tree with an optional field passed where a tree
+// without it is expected.
+interface Tree {
+ label: string;
+ kids: Tree[];
+ note?: string;
+}
+interface Plain {
+ label: string;
+ kids: Plain[];
+}
+function addKid(t: Plain, label: string): void {
+ t.kids.push({ label, kids: [] });
+}
+function size(t: Plain): number {
+ let n = 1;
+ for (const k of t.kids) n += size(k);
+ return n;
+}
+const tree: Tree = { label: "r", kids: [{ label: "a", kids: [], note: "leaf" }] };
+addKid(tree, "b");
+console.log(size(tree), tree.kids.map((k) => k.label).join(","), tree.kids[0]!.note);
diff --git a/tests/coverage-fixtures/dynamic-slot-hot.ts b/tests/coverage-fixtures/dynamic-slot-hot.ts
new file mode 100644
index 000000000..65672ffc1
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-slot-hot.ts
@@ -0,0 +1,27 @@
+// A dynamic slot read in a loop: SC6101 names the slot, and coverage
+// counts the dynamic operations on it.
+type Hook = (() => number) | AbortSignal | Map string>;
+
+interface Plugin {
+ name: string;
+ hook: Hook;
+}
+
+const plugins: Plugin[] = [
+ { name: "one", hook: () => 1 },
+ { name: "two", hook: () => 2 },
+];
+
+function sum(rounds: number): number {
+ let total = 0;
+ for (let i = 0; i < rounds; i++) {
+ for (const p of plugins) {
+ const hook = p.hook;
+ if (typeof hook === "function") total += hook();
+ else total += (hook as { size?: number }).size ?? 0;
+ }
+ }
+ return total;
+}
+
+console.log(sum(10));
diff --git a/tests/coverage-fixtures/dynamic-slot-same-type.ts b/tests/coverage-fixtures/dynamic-slot-same-type.ts
new file mode 100644
index 000000000..61954c6f6
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-slot-same-type.ts
@@ -0,0 +1,16 @@
+// Two dynamic slots of one declared type: the operations in the loop are
+// on `hot`, and `cold` is read once.
+type Hook = (() => number) | AbortSignal | Map string>;
+
+let cold: Hook = () => 1;
+let hot: Hook = () => 2;
+
+function run(rounds: number): number {
+ let total = 0;
+ for (let i = 0; i < rounds; i++) {
+ if (typeof hot === "function") total += hot();
+ }
+ return total;
+}
+
+console.log(run(10), typeof cold);
diff --git a/tests/coverage-fixtures/dynamic-slot-shared.ts b/tests/coverage-fixtures/dynamic-slot-shared.ts
new file mode 100644
index 000000000..21624a479
--- /dev/null
+++ b/tests/coverage-fixtures/dynamic-slot-shared.ts
@@ -0,0 +1,23 @@
+// A type that degrades as a piece of a container (`Plugin.hook`) and as a
+// slot of its own (`current`): the operations on `current` belong to
+// `current`, not to the container.
+type Hook = (() => number) | AbortSignal | Map string>;
+
+interface Plugin {
+ name: string;
+ hook: Hook;
+}
+
+const plugins: Plugin[] = [{ name: "one", hook: () => 1 }];
+
+let current: Hook = () => 2;
+
+function run(rounds: number): number {
+ let total = 0;
+ for (let i = 0; i < rounds; i++) {
+ if (typeof current === "function") total += current();
+ }
+ return total;
+}
+
+console.log(run(10), plugins.length);
diff --git a/tests/harness/__snapshots__/coverage-explain-localized-failure.txt b/tests/harness/__snapshots__/coverage-explain-localized-failure.txt
index 31a933b26..9780a528e 100644
--- a/tests/harness/__snapshots__/coverage-explain-localized-failure.txt
+++ b/tests/harness/__snapshots__/coverage-explain-localized-failure.txt
@@ -1,10 +1,7 @@
dynamic tier (tier assignment, report-only; --tier=auto)
- 5 functions already operate on dynamic values (4 statements)
+ 2 functions already operate on dynamic values (2 statements)
Base.constructor tests/coverage-fixtures/localized-failure.ts:57:1: dynObjLit at tests/coverage-fixtures/localized-failure.ts:58:16
- Session.constructor tests/coverage-fixtures/localized-failure.ts:12:1: dynArrLit at tests/coverage-fixtures/localized-failure.ts:13:27
- Session.count tests/coverage-fixtures/localized-failure.ts:14:3: %dyn.class.property at tests/coverage-fixtures/localized-failure.ts:15:12
Holder.constructor tests/coverage-fixtures/localized-failure.ts:28:1: dynObjLit at tests/coverage-fixtures/localized-failure.ts:29:16
- %dyn.class.property.0 tests/coverage-fixtures/localized-failure.ts:15:12: %dyn.class.readPropertiesOrEmpty at tests/coverage-fixtures/localized-failure.ts:15:12
2 demotion requests (refusals and fences the dynamic tier would replace)
diff --git a/tests/harness/corpus-costs/localized-failure-degraded-initializer.ts.json b/tests/harness/corpus-costs/localized-failure-degraded-initializer.ts.json
new file mode 100644
index 000000000..3e8d4b6ac
--- /dev/null
+++ b/tests/harness/corpus-costs/localized-failure-degraded-initializer.ts.json
@@ -0,0 +1 @@
+{ "plain": 3500 }
diff --git a/tests/harness/corpus-costs/localized-failure-expressions.ts.json b/tests/harness/corpus-costs/localized-failure-expressions.ts.json
new file mode 100644
index 000000000..1254aa6e4
--- /dev/null
+++ b/tests/harness/corpus-costs/localized-failure-expressions.ts.json
@@ -0,0 +1 @@
+{ "plain": 4000 }
diff --git a/tests/harness/corpus-costs/localized-failure-push-down.ts.json b/tests/harness/corpus-costs/localized-failure-push-down.ts.json
new file mode 100644
index 000000000..1254aa6e4
--- /dev/null
+++ b/tests/harness/corpus-costs/localized-failure-push-down.ts.json
@@ -0,0 +1 @@
+{ "plain": 4000 }
diff --git a/tests/harness/corpus-costs/width-layout-identity.ts.json b/tests/harness/corpus-costs/width-layout-identity.ts.json
new file mode 100644
index 000000000..4f6ccc65c
--- /dev/null
+++ b/tests/harness/corpus-costs/width-layout-identity.ts.json
@@ -0,0 +1 @@
+{ "plain": 4655, "san": 33380 }
diff --git a/tests/harness/corpus-costs/width-layout-parameter-writes.ts.json b/tests/harness/corpus-costs/width-layout-parameter-writes.ts.json
new file mode 100644
index 000000000..de667a408
--- /dev/null
+++ b/tests/harness/corpus-costs/width-layout-parameter-writes.ts.json
@@ -0,0 +1 @@
+{ "plain": 2940, "san": 38518 }
diff --git a/tests/harness/coverage.test.ts b/tests/harness/coverage.test.ts
index f8fa0da03..82e541140 100644
--- a/tests/harness/coverage.test.ts
+++ b/tests/harness/coverage.test.ts
@@ -461,6 +461,8 @@ test("a local or module global whose declared type does not compile becomes a dy
["global", "all", "(number | Callbacks)[]", "SC2009"],
["local", "local", "Callbacks | undefined", "SC2009"],
]);
+ // The loop over `all` is top-level code: its dynamic operations count too.
+ expect(coverage.dynamicSlots?.find((s) => s.name === "all")?.operations).toBeGreaterThan(0);
expect(coverage.performance?.filter((d) => d.code === "SC6104")).toHaveLength(3);
expect(coveragePasses(coverage, "blockers")).toBe(true);
expect(coveragePasses(coverage, "dynamic")).toBe(true);
@@ -485,6 +487,34 @@ test("a local or module global whose declared type does not compile becomes a dy
expect(strict.diagnostics.map((d) => d.code)).toContain("SC2009");
});
+test("dynamic slots rank by their dynamic operations and name themselves in SC6101", () => {
+ const { coverage } = analyze(fixture("dynamic-slot-hot.ts"));
+ const slots = coverage.dynamicSlots ?? [];
+ expect(slots.find((s) => s.name === "plugins")?.partial).toBe(true);
+ expect(slots.some((s) => (s.operations ?? 0) > 0)).toBe(true);
+ const hot = coverage.performance?.find((d) => d.code === "SC6101");
+ expect(hot?.message).toContain("`hook` is a dynamic piece of `Plugin[]` (declared `Hook`");
+});
+
+test("a type that is a dynamic piece and a slot of its own counts toward its own slot", () => {
+ const { coverage } = analyze(fixture("dynamic-slot-shared.ts"));
+ const slots = coverage.dynamicSlots ?? [];
+ expect(slots.find((s) => s.name === "current")?.operations).toBeGreaterThan(0);
+ expect(slots.find((s) => s.name === "plugins")?.operations ?? 0).toBe(0);
+ const hot = coverage.performance?.find((d) => d.code === "SC6101");
+ expect(hot?.message).toContain("`current` is a dynamic slot (declared `Hook`");
+});
+
+test("dynamic slots of one declared type count their own operations", () => {
+ const { coverage } = analyze(fixture("dynamic-slot-same-type.ts"));
+ const slots = coverage.dynamicSlots ?? [];
+ const hot = slots.find((s) => s.name === "hot")?.operations ?? 0;
+ const cold = slots.find((s) => s.name === "cold")?.operations ?? 0;
+ expect(hot).toBeGreaterThan(cold);
+ const hotWarning = coverage.performance?.find((d) => d.code === "SC6101");
+ expect(hotWarning?.message).toContain("`hot` is a dynamic slot (declared `Hook`");
+});
+
test("CLI: --tier and --explain-dynamic", () => {
const scriptcCli = join(repoRoot, "packages/cli/src/main.ts");
const outDir = mkdtempSync(join(tmpdir(), "scriptc-tier-"));