Compare commits

...
16 Commits
Author SHA1 Message Date
Aiden Cline 1d4d9b5fa5 fix(ai): preserve OpenAI Chat image URLs (#48862) 2026-09-13 23:43:39 -05:00
Aiden Cline 752d980770 fix(ai): map Vertex service tier to header (#48886) 2026-09-13 23:43:16 -05:00
Aiden Cline 07225172df fix(ai): serialize undefined historical tool input (#48863) 2026-09-13 23:43:07 -05:00
Aiden Cline 223b0271e6 test: align platform and endpoint expectations (#48916) 2026-09-14 04:25:34 +00:00
Luke Parker 93a37958a7 fix(session-ui): remove sticky patch header gap (#48909) 2026-09-14 13:41:07 +10:00
Dax Raad 40f4778296 refactor(protocol): refine audited endpoints 2026-09-13 23:10:37 -04:00
Dax e9bb6d490b refactor(tui): remove terminal pane setting 2026-09-13 23:06:13 -04:00
Aiden Cline 1e697962bd fix: restore required typecheck status (#48913) 2026-09-13 22:03:22 -05:00
Aiden Cline 4418df5d62 test(app): align worker status URL (#48911) 2026-09-13 21:55:47 -05:00
Aiden Cline 052be04466 test: align coverage with v2 refactors (#48900) 2026-09-13 21:44:49 -05:00
Aiden Cline cd3a64b225 test: align status and shell expectations (#48906) 2026-09-13 21:32:42 -05:00
Dax Raad 48c9a0a8de feat(release): add interactive review 2026-09-13 22:22:27 -04:00
Aiden Cline 48875190ef feat(codemode): fail runaway recursion with a RangeError at 10000 nested calls (#48891) 2026-09-13 21:04:36 -05:00
opencode-agent[bot]andHona 476432de1e fix(core): restore Windows Git fast path (#48879)
Co-authored-by: Hona <10430890+Hona@users.noreply.github.com>
2026-09-14 12:03:03 +10:00
Luke Parker a71bb4d38c fix(app): match notice updates to used label styling (#48895) 2026-09-14 12:02:16 +10:00
Aiden Cline 5d3019e5a1 refactor(codemode): materialize interpreter failures once and locate them at the call boundary (#48770) 2026-09-13 20:19:31 -05:00
120 changed files with 2295 additions and 1600 deletions
+1
View File
@@ -9,6 +9,7 @@ on:
jobs:
check:
name: typecheck
runs-on: blacksmith-4vcpu-ubuntu-2404
steps:
- name: Checkout repository
+1
View File
@@ -32,6 +32,7 @@ target
# Local dev files
opencode-dev
UPCOMING_CHANGELOG.md
RELEASE_REVIEW.md
logs/
*.bun-build
tsconfig.tsbuildinfo
+18 -18
View File
@@ -120,17 +120,17 @@ Review endpoints in document order. For each endpoint, select one disposition an
| [ ] 033 | `POST` | `/api/integration/{integrationID}/connect/command` | `integration.command.connect` | | |
| [ ] 034 | `GET` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.status` | | |
| [ ] 035 | `DELETE` | `/api/integration/{integrationID}/connect/command/{attemptID}` | `integration.command.cancel` | | |
| [ ] 036 | `GET` | `/api/mcp` | `mcp.list` | | |
| [ ] 037 | `PUT` | `/api/mcp/{server}` | `mcp.add` | | |
| [ ] 038 | `DELETE` | `/api/mcp/{server}` | `mcp.remove` | | |
| [ ] 039 | `POST` | `/api/mcp/{server}/connect` | `mcp.connect` | | |
| [ ] 040 | `POST` | `/api/mcp/{server}/disconnect` | `mcp.disconnect` | | |
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | |
| [ ] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | | |
| [ ] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | | |
| [ ] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | | |
| [ ] 045 | `GET` | `/api/websearch/provider` | `websearch.providers` | | |
| [ ] 046 | `POST` | `/api/websearch` | `websearch.query` | | |
| [x] 036 | `GET` | `/api/mcp` | `mcp.list` | Keep | MCP inventory and connection status retained. |
| [x] 037 | `PUT` | `/api/experimental/mcp/{server}` | `experimental.mcp.add` | Experimental-only | Runtime-only MCP override; does not persist configuration. |
| [x] 038 | `DELETE` | `/api/experimental/mcp/{server}` | `experimental.mcp.remove` | Experimental-only | Runtime removal override; missing server returns `404`. |
| [x] 039 | `POST` | `/api/experimental/mcp/{server}/connect` | `experimental.mcp.connect` | Experimental-only | Runtime connection override retained outside the stable API. |
| [x] 040 | `POST` | `/api/experimental/mcp/{server}/disconnect` | `experimental.mcp.disconnect` | Experimental-only | Runtime disconnection override retained outside the stable API. |
| [ ] 041 | `GET` | `/api/mcp/resource` | `mcp.resource.catalog` | | Deferred for later review. |
| [x] 042 | `PATCH` | `/api/credential/{credentialID}` | `credential.update` | Change | Removed redundant location query; credentials and events are global. |
| [x] 043 | `DELETE` | `/api/credential/{credentialID}` | `credential.remove` | Change | Removed redundant location query; credentials and events are global. |
| [x] 044 | `POST` | `/api/credential/{credentialID}/activate` | `credential.activate` | Change | Removed redundant location query; credentials and events are global. |
| [x] 045 | `GET` | `/api/websearch/provider` | `websearch.providers` | Keep | Provider availability remains location-scoped; singular resource path retained. |
| [x] 046 | `POST` | `/api/websearch` | `websearch.query` | Keep | Unknown provider remains an invalid request; published time documented as Unix epoch milliseconds. |
## Group 4: Session lifecycle
@@ -138,13 +138,13 @@ Review endpoints in document order. For each endpoint, select one disposition an
| Done | Method | Path | Operation ID | Decision | Notes |
|---|---|---|---|---|---|
| [ ] 047 | `GET` | `/api/session` | `session.list` | | |
| [ ] 048 | `POST` | `/api/session` | `session.create` | | |
| [ ] 049 | `GET` | `/api/session/stats` | `session.stats` | | |
| [ ] 050 | `GET` | `/api/session/active` | `session.active` | | |
| [ ] 051 | `GET` | `/api/session/{sessionID}` | `session.get` | | |
| [ ] 052 | `DELETE` | `/api/session/{sessionID}` | `session.remove` | | |
| [ ] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | | |
| [x] 047 | `GET` | `/api/session` | `session.list` | Keep | Existing filtering, ordering, and cursor contract retained for now. |
| [x] 048 | `POST` | `/api/session` | `session.create` | Keep | Existing creation contract retained; model reference includes optional variant. |
| [x] 049 | `GET` | `/api/experimental/session/stats` | `experimental.session.stats` | Experimental-only | Session analytics retained outside the stable API commitment. |
| [x] 050 | `GET` | `/api/session/active` | `session.active` | Keep | Status record retained for future active-state expansion. |
| [x] 051 | `GET` | `/api/session/{sessionID}` | `session.get` | Keep | Specific session read and typed `404` retained. |
| [x] 052 | `DELETE` | `/api/session/{sessionID}` | `session.remove` | Keep | Session and child deletion with typed `404` retained. |
| [x] 053 | `POST` | `/api/session/{sessionID}/fork` | `session.fork` | Change | Request now accepts optional branded `before` message ID; omission copies full history. |
| [ ] 054 | `POST` | `/api/session/{sessionID}/agent` | `session.switchAgent` | | |
| [ ] 055 | `POST` | `/api/session/{sessionID}/model` | `session.switchModel` | | |
| [ ] 056 | `POST` | `/api/session/{sessionID}/rename` | `session.rename` | | |
+1
View File
@@ -14,6 +14,7 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@ast-grep/cli": "0.44.0",
"@opencode/client": "workspace:*",
"@tsconfig/bun": "catalog:",
"@types/mime-types": "3.0.1",
"@types/react": "19.2.17",
+1
View File
@@ -116,6 +116,7 @@
"devDependencies": {
"@actions/artifact": "5.0.1",
"@ast-grep/cli": "0.44.0",
"@opencode/client": "workspace:*",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@tsconfig/bun": "catalog:",
+1 -1
View File
@@ -522,7 +522,7 @@ const lowerToolCall = (part: ToolCallPart, providerMetadataKey: string): OpenRes
call_id: part.id,
name: part.name,
namespace: part.namespace,
arguments: ProviderShared.encodeJson(part.input),
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
}
}
+6 -2
View File
@@ -315,7 +315,7 @@ const lowerToolCall = (part: ToolCallPart, options: LoweringOptions): OpenAIChat
type: "function",
function: {
name: part.name,
arguments: ProviderShared.encodeJson(part.input),
arguments: ProviderShared.encodeJson(part.input === undefined ? {} : part.input),
},
})
@@ -323,7 +323,11 @@ const lowerMedia = Effect.fn("OpenAIChat.lowerMedia")(function* (part: MediaPart
const media = ProviderShared.normalizeMedia(part)
if (!media.mime.startsWith("image/"))
return yield* ProviderShared.invalidRequest(`OpenAI Chat does not support media type ${part.mediaType}`)
return { type: "image_url" as const, image_url: { url: media.dataUrl } }
const url =
typeof part.data === "string" && (part.data.startsWith("https://") || part.data.startsWith("http://"))
? part.data
: media.dataUrl
return { type: "image_url" as const, image_url: { url } }
})
const openAICompatibleReasoningContent = (native: unknown) =>
+5 -1
View File
@@ -37,7 +37,7 @@ export type Settings = ProviderPackage.Settings &
}
const fromRequest = Effect.fn("GoogleVertex.fromRequest")(function* (request: LLMRequest) {
const body = yield* Gemini.protocol.body.from(request)
const { serviceTier: _, ...body } = yield* Gemini.protocol.body.from(request)
// Vertex's native REST schema rejects `id` on FunctionCall/FunctionResponse parts with HTTP 400,
// unlike AI Studio, so history minted there cannot be lowered verbatim.
const contents = body.contents.map((content) => ({
@@ -75,6 +75,10 @@ const route = Route.make({
return `/${model.startsWith("endpoints/") ? model : `models/${model}`}:streamGenerateContent?alt=sse`
}),
auth: Auth.none,
headers: ({ request }): Record<string, string> => {
const serviceTier = request.providerOptions?.serviceTier
return typeof serviceTier === "string" ? { "x-vertex-ai-llm-shared-request-type": serviceTier } : {}
},
framing: Framing.sse,
})
@@ -75,6 +75,45 @@ describe("Google Vertex providers", () => {
}),
)
it.effect("maps service tiers to the Vertex shared PayGo header", () =>
Effect.gen(function* () {
const response = yield* LLMClient.generate(
LLM.request({
model: GoogleVertex.configure({
accessToken: "vertex-token",
location: "global",
project: "vertex-project",
providerOptions: { serviceTier: "flex" },
}).model("gemini-2.5-flash"),
prompt: "Say hello.",
}),
).pipe(
Effect.provide(
dynamicResponse((input) =>
Effect.gen(function* () {
const request = yield* HttpClientRequest.toWeb(input.request).pipe(Effect.orDie)
expect(request.headers.get("x-vertex-ai-llm-shared-request-type")).toBe("flex")
expect(yield* Effect.promise(() => request.json())).not.toHaveProperty("serviceTier")
return input.respond(
sseEvents({
candidates: [
{
content: { role: "model", parts: [{ text: "Hello." }] },
finishReason: "STOP",
},
],
}),
{ headers: { "content-type": "text/event-stream" } },
)
}),
),
),
)
expect(response.text).toBe("Hello.")
}),
)
it.effect("strips function call ids Vertex does not accept from lowered bodies", () =>
Effect.gen(function* () {
const prepared = yield* compileRequest(
@@ -0,0 +1,73 @@
import { expect } from "bun:test"
import { Effect, Schema } from "effect"
import { LLM, Message, ToolCallPart } from "../../src/index.js"
import { OpenAIChat } from "../../src/protocols/openai-chat.js"
import { OpenAIResponses } from "../../src/protocols/openai-responses.js"
import { Auth } from "../../src/route/auth.js"
import { compileRequest } from "../../src/route/client.js"
import { it } from "../lib/effect.js"
for (const route of [OpenAIChat.route, OpenAIResponses.route]) {
const model = route
.with({ endpoint: { baseURL: "https://api.openai.test/v1" }, auth: Auth.bearer("test") })
.model({ id: "gpt-4.1-mini" })
const chat = route.id === "openai-chat"
it.effect(`${route.id} serializes schema-valid undefined historical tool input as an empty object`, () =>
Effect.gen(function* () {
const message = Schema.decodeUnknownSync(Message)({
role: "assistant",
content: [{ type: "tool-call", id: "call_1", name: "lookup", input: undefined }],
})
expect(Schema.is(Message)(message)).toBe(true)
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Look up the value."),
message,
Message.tool({ id: "call_1", name: "lookup", result: "Missing input", resultType: "error" }),
],
}),
)
if (chat)
expect(prepared.body.messages).toContainEqual({
role: "assistant",
content: null,
tool_calls: [{ id: "call_1", type: "function", function: { name: "lookup", arguments: "{}" } }],
})
if (!chat)
expect(prepared.body.input).toContainEqual({
type: "function_call",
call_id: "call_1",
name: "lookup",
arguments: "{}",
})
expect(message.content[0]).toEqual({ type: "tool-call", id: "call_1", name: "lookup", input: undefined })
}),
)
it.effect(`${route.id} preserves defined historical tool inputs`, () =>
Effect.gen(function* () {
for (const [input, encoded] of [
[null, "null"],
[[], "[]"],
[42, "42"],
["invalid", '"invalid"'],
[{ value: "original" }, '{"value":"original"}'],
] as const) {
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.assistant([ToolCallPart.make({ id: "call_1", name: "lookup", input })]),
Message.tool({ id: "call_1", name: "lookup", result: "Invalid input", resultType: "error" }),
],
}),
)
if (chat) expect(prepared.body.messages[0].tool_calls[0].function.arguments).toBe(encoded)
if (!chat) expect(prepared.body.input[0].arguments).toBe(encoded)
}
}),
)
}
@@ -698,6 +698,54 @@ describe("OpenAI Chat route", () => {
}),
)
it.effect("preserves HTTP and HTTPS image URLs in user content", () =>
Effect.gen(function* () {
const urls = ["https://example.com/image.png?size=64#preview", "http://example.com/image.jpg"]
const prepared = yield* compileRequest(
LLM.request({
model,
prompt: urls.map((data) => ({ type: "media" as const, mediaType: "image/png", data })),
}),
)
expect(prepared.body.messages).toEqual([
{ role: "user", content: urls.map((url) => ({ type: "image_url", image_url: { url } })) },
])
}),
)
it.effect("preserves remote image URLs from tool results", () =>
Effect.gen(function* () {
const url = "https://example.com/tool-image.png?version=2"
const prepared = yield* compileRequest(
LLM.request({
model,
messages: [
Message.user("Describe the image."),
Message.assistant([ToolCallPart.make({ id: "call_image", name: "read_image", input: {} })]),
Message.tool({
id: "call_image",
name: "read_image",
resultType: "content",
result: [
{ type: "text", text: "Image attached." },
{ type: "file", mime: "image/png", uri: url },
],
}),
],
}),
)
expect(prepared.body.messages).toContainEqual({
role: "tool",
tool_call_id: "call_image",
content: "Image attached.",
})
expect(prepared.body.messages.at(-1)).toEqual({
role: "user",
content: [{ type: "image_url", image_url: { url } }],
})
}),
)
it.effect("rejects non-image media that cannot be lowered", () =>
Effect.gen(function* () {
const error = yield* compileRequest(
@@ -30,16 +30,16 @@ for (const shared of [true, false]) {
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/mcp**", async (route) => {
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/connect") {
if (url.pathname === "/api/experimental/mcp/figma-desktop/connect") {
connected.add(target)
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
if (url.pathname === "/api/experimental/mcp/figma-desktop/disconnect") {
connected.delete(target)
return route.fulfill({ status: 204 })
}
@@ -72,7 +72,7 @@ for (const shared of [true, false]) {
await expect(toggle).toBeChecked()
await expect(toggle).toBeEnabled()
expect(connected).toEqual(new Set([workspace]))
expect(requests).toContainEqual({ path: "/api/mcp/figma-desktop/connect", directory: workspace })
expect(requests).toContainEqual({ path: "/api/experimental/mcp/figma-desktop/connect", directory: workspace })
expect(requests).toContainEqual({ path: "/api/mcp/resource", directory: workspace })
expect(requests.every((request) => request.directory === workspace)).toBe(true)
await testInfo.attach("workspace-connected", { body: await page.screenshot(), contentType: "image/png" })
@@ -82,7 +82,7 @@ for (const shared of [true, false]) {
await expect(toggle).not.toBeChecked()
await expect(toggle).toBeEnabled()
expect(connected.size).toBe(0)
expect(requests).toContainEqual({ path: "/api/mcp/figma-desktop/disconnect", directory: workspace })
expect(requests).toContainEqual({ path: "/api/experimental/mcp/figma-desktop/disconnect", directory: workspace })
expect(requests.every((request) => request.directory === workspace)).toBe(true)
})
}
@@ -109,16 +109,16 @@ for (const surface of ["popover", "dialog"] as const) {
sessions: [{ id: sessionID, projectID, directory: workspace, title }],
pageMessages: () => ({ items: [] }),
})
await page.route("**/api/mcp**", async (route) => {
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
requests.push({ path: url.pathname, directory: target })
if (url.pathname === "/api/mcp/figma-desktop/disconnect") {
if (url.pathname === "/api/experimental/mcp/figma-desktop/disconnect") {
state.status = "disabled"
return route.fulfill({ status: 204 })
}
if (url.pathname === "/api/mcp/figma-desktop/connect") {
if (url.pathname === "/api/experimental/mcp/figma-desktop/connect") {
state.status = state.fail ? "failed" : "connected"
// Connection failures are reported by the refreshed status, not the HTTP response.
return route.fulfill({ status: 204 })
@@ -167,7 +167,7 @@ for (const surface of ["popover", "dialog"] as const) {
await expect(toggle).toBeChecked({ checked: surface === "popover" })
await expect(toggle).toBeEnabled()
expect(requests.filter((request) => request.path.endsWith("/connect"))).toEqual([
{ path: "/api/mcp/figma-desktop/connect", directory: workspace },
{ path: "/api/experimental/mcp/figma-desktop/connect", directory: workspace },
])
expect(requests.every((request) => request.directory === workspace)).toBe(true)
await expect(toast).toHaveCSS("opacity", "1")
@@ -343,7 +343,7 @@ async function openDraft(page: Page, worktree = "main", options: { git?: boolean
(route) => route.fulfill({ json: { location: { directory }, data: { branch: {} } } }),
)
}
await page.route("**/api/mcp**", async (route) => {
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const target = url.searchParams.get("location[directory]") ?? directory
@@ -0,0 +1,212 @@
import { expect, test } from "@playwright/test"
import { timelinePresets } from "@opencode/session-ui/timeline/detail"
import { createTwoFilesPatch } from "diff"
import {
assistantMessage,
setupTimeline,
textPart,
toolPart,
userMessage,
} from "../performance/timeline-stability/fixture"
const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("")
const after = before.replaceAll(" = ", " = 1 + ")
const files = ["src/a.ts", "src/b.ts"].map((file) => ({
file,
status: "modified",
additions: 80,
deletions: 80,
patch: createTwoFilesPatch(file, file, before, after),
}))
const scenarios = [
{
name: "grouped patch",
placement: "grouped",
tools: [toolPart("prt_sticky_patch", "patch", "completed", {}, { metadata: { files } })],
files: ["a", "b"],
title: false,
},
{
name: "standalone patch",
placement: "separate",
tools: [toolPart("prt_sticky_patch", "patch", "completed", {}, { metadata: { files } })],
files: ["a", "b"],
title: false,
},
{
name: "grouped edit with title",
placement: "grouped",
tools: [
toolPart(
"prt_sticky_edit",
"edit",
"completed",
{ path: "src/a.ts", oldString: before, newString: after },
{ metadata: { files: [files[0]] } },
),
],
files: ["a"],
title: true,
},
{
name: "running edit input fallback",
placement: "grouped",
tools: [toolPart("prt_sticky_edit", "edit", "running", { path: "src/a.ts", oldString: before, newString: after })],
files: ["a"],
title: true,
},
{
name: "grouped write",
placement: "grouped",
tools: [toolPart("prt_sticky_write", "write", "completed", { path: "src/a.ts", content: after })],
files: ["a"],
title: false,
},
{
name: "running write input fallback",
placement: "grouped",
tools: [toolPart("prt_sticky_write", "write", "running", { path: "src/a.ts", content: after })],
files: ["a"],
title: false,
},
{
name: "merged edit write and patch",
placement: "separate",
tools: [
toolPart("prt_sticky_edit", "edit", "completed", {}, { metadata: { files: [files[0]] } }),
toolPart("prt_sticky_write", "write", "completed", { path: "src/b.ts", content: after }),
toolPart(
"prt_sticky_patch",
"patch",
"completed",
{},
{ metadata: { files: [{ ...files[0], file: "src/c.ts" }] } },
),
],
files: ["a", "b", "c"],
title: false,
},
{
name: "created and deleted patch files",
placement: "grouped",
tools: [
toolPart(
"prt_sticky_patch",
"patch",
"completed",
{},
{
metadata: {
files: [
{
file: "src/a.ts",
status: "added",
additions: 80,
deletions: 0,
patch: createTwoFilesPatch("src/a.ts", "src/a.ts", "", after),
},
{
file: "src/b.ts",
status: "deleted",
additions: 0,
deletions: 80,
patch: createTwoFilesPatch("src/b.ts", "src/b.ts", before, ""),
},
],
},
},
),
],
files: ["a", "b"],
title: false,
},
] as const
for (const scenario of scenarios) {
for (const width of [1400, 390]) {
for (const direction of ["ltr", "rtl"]) {
test(`${scenario.name}: file headers stay flush at ${width}px in ${direction}`, async ({ page }, info) => {
await setupTimeline(page, {
messages: [
userMessage(),
assistantMessage([...scenario.tools, textPart("prt_after_patch", "Following explanation.\n\n".repeat(60))]),
],
settings: {
timelineDetail: {
...timelinePresets[2].value,
edit: { placement: scenario.placement, details: "collapsed" },
},
},
reducedMotion: true,
viewport: { width, height: 900 },
})
await page.evaluate((direction) => (document.documentElement.dir = direction), direction)
if (scenario.placement === "grouped") {
await page.locator('[data-component="context-tool-group-trigger"]').click()
}
const patch = page.locator('[data-scope="apply-patch"]')
await expect(patch).toHaveCount(1)
const scroller = page.locator('[data-slot="session-timeline-scroll"] .scroll-view__viewport')
const toolTitle = scroller.locator('[data-slot="collapsible-trigger"][data-locked]')
await expect(toolTitle).toHaveCount(scenario.title ? 1 : 0)
await expect(scroller.locator("[data-session-title]")).toHaveCount(width === 1400 ? 1 : 0)
for (const file of scenario.files) {
const name = new RegExp(`${file}\\.ts`)
const trigger = patch.getByRole("button", { name })
const header = patch.getByRole("heading", { name })
await expect(trigger).toHaveAttribute("aria-expanded", "false")
await trigger.click()
await expect(trigger).toHaveAttribute("aria-expanded", "true")
const content = patch.getByRole("region", { name })
await expect
.poll(() => content.evaluate((element) => element.getBoundingClientRect().height))
.toBeGreaterThan(900)
// Leave follow-latest mode before positioning the viewport inside this file.
await scroller.hover()
await page.mouse.wheel(0, -100)
await content.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!
viewport.scrollTop += element.getBoundingClientRect().top - viewport.getBoundingClientRect().top + 160
})
await expect
.poll(() =>
content.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!
return element.getBoundingClientRect().top - viewport.getBoundingClientRect().top
}),
)
.toBeLessThan(0)
await expect
.poll(() =>
header.evaluate((element) => {
const viewport = element.closest<HTMLElement>(".scroll-view__viewport")!
const title = viewport.querySelector("[data-session-title]")?.firstElementChild
const toolTitle = element
.closest('[data-component="edit-tool"]')
?.querySelector('[data-slot="collapsible-trigger"][data-locked]')
const top = viewport.getBoundingClientRect().top + (title?.getBoundingClientRect().height ?? 0)
const rect = element.getBoundingClientRect()
const trigger = element.querySelector("button")!
return {
gap: Math.abs(rect.top - top - (toolTitle?.getBoundingClientRect().height ?? 0)),
titleGap: toolTitle ? Math.abs(toolTitle.getBoundingClientRect().top - top) : 0,
clickable: trigger.contains(
document.elementFromPoint(rect.left + rect.width / 2, rect.top + rect.height / 2),
),
}
}),
)
.toEqual({ gap: 0, titleGap: 0, clickable: true })
await page.screenshot({ path: info.outputPath(`${file}.png`) })
}
await scroller.evaluate((element) => (element.scrollTop = element.scrollHeight))
await expect(
patch.getByRole("heading", { name: new RegExp(`${scenario.files.at(-1)}\\.ts`) }),
).not.toBeInViewport()
})
}
}
}
@@ -6,7 +6,7 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
await mockStressTimeline(page)
const state = { enabled: true }
const writes: string[] = []
await page.route("**/api/mcp**", (route) => {
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
const directory = url.searchParams.get("location[directory]")
@@ -62,7 +62,7 @@ test("every MCP row hit area toggles exactly once and keeps the submenu open", a
await expect(submenu).toBeVisible()
if (target === "keyboard") await expect(toggle).toBeFocused()
expect(writes).toHaveLength(index + 1)
expect(writes[index]).toBe(`/api/mcp/figma/${enabled ? "connect" : "disconnect"}`)
expect(writes[index]).toBe(`/api/experimental/mcp/figma/${enabled ? "connect" : "disconnect"}`)
}
})
@@ -72,7 +72,7 @@ test("MCP authentication starts before a slow resource catalog finishes", async
const attempts: string[] = []
const resources = Promise.withResolvers<void>()
await context.route("https://auth.example.test/**", (route) => route.fulfill({ body: "Sign in" }))
await page.route("**/api/mcp**", async (route) => {
await page.route(/\/api\/(?:experimental\/)?mcp(?:[/?]|$)/, async (route) => {
if (route.request().method() === "OPTIONS") return route.fallback()
const url = new URL(route.request().url())
if (url.pathname.endsWith("/connect")) {
@@ -113,7 +113,7 @@ test("passes through non-event fetches", async ({ page }) => {
return response.json()
})
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: ["http://localhost"] })
expect(health).toEqual({ version: "2.0.0", pid: 1, urls: [] })
await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1)
})
@@ -96,7 +96,7 @@ test("single-server settings expose scoped pages without a server picker", async
(request) => request.method() === "PATCH" && new URL(request.url()).pathname === "/api/experimental/config",
)
await page.getByRole("option", { name: "bash", exact: true }).click()
expect((await updated).postDataJSON()).toEqual({ shell: "/bin/bash" })
expect((await updated).postDataJSON()).toEqual({ shell: "bash" })
})
test("project settings open as a nested autosaving view", async ({ page }) => {
@@ -335,7 +335,7 @@ fixture("upgrades the legacy shared precache only after old tabs close", async (
fixture("does not substitute cached HTML for API or missing asset navigations", async ({ page, site }) => {
await install(page, site.url)
const api = await page.goto(`${site.url}/api/status`)
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: [site.url] })
expect(await api?.json()).toEqual({ version: "test", pid: 1, urls: ["http://localhost"] })
expect(api?.fromServiceWorker()).toBe(false)
const asset = await page.goto(`${site.url}/_assets/missing.js`)
expect(asset?.status()).toBe(404)
@@ -69,7 +69,7 @@ export const DialogFork: Component = () => {
const dir = base64Encode(location().directory)
serverSDK.api.session
.fork({ sessionID, boundary: { type: "before", messageID: item.id } })
.fork({ sessionID, before: item.id })
.then((forked) => {
data.session.remember(forked)
dialog.close()
@@ -105,7 +105,7 @@ export const SettingsProviders: Component<{
const credentials = integration.data?.connections.filter((item) => item.type === "credential") ?? []
if (credentials.length === 0) throw new Error(`No removable credentials found for ${name}`)
await Promise.all(
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id, location })),
credentials.map((credential) => serverSdk.api.credential.remove({ credentialID: credential.id })),
)
showToast({
variant: "success",
-1
View File
@@ -263,7 +263,6 @@ export function make(input: { readonly client: OpenCodeClient; readonly connecti
forkSession: async (params) => {
const forked = await input.client.session.fork({
sessionID: params.sessionId,
boundary: { type: "through" },
})
const state = await attach(forked, forked.location.directory, params.mcpServers ?? [])
await replay(state)
@@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
import { createClient, loadIntegrations, location, request } from "./shared"
import { createClient, loadIntegrations, request } from "./shared"
import { chooseCredential, chooseIntegration } from "./account"
export default Runtime.handler(
@@ -35,7 +35,7 @@ const logout = Effect.fn("cli.auth.logout.run")(function* (input: {
const credentialID = yield* chooseCredential(integration, "log out", input.credential)
const progress = spinner()
progress.start("Removing credential...")
yield* request((signal) => client.credential.remove({ credentialID, location }, { signal })).pipe(
yield* request((signal) => client.credential.remove({ credentialID }, { signal })).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Removed account from ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to remove credential", 1))),
)
@@ -3,7 +3,7 @@ import { Effect, Option } from "effect"
import { Commands } from "../../commands"
import { Runtime } from "../../../framework/runtime"
import { handlePromptErrors, requireInteractive } from "../../../ui/prompt"
import { createClient, loadIntegrations, location, request } from "./shared"
import { createClient, loadIntegrations, request } from "./shared"
import { chooseCredential, chooseIntegration } from "./account"
export default Runtime.handler(
@@ -35,7 +35,7 @@ const switchAccount = Effect.fn("cli.auth.switch.run")(function* (input: {
const credentialID = yield* chooseCredential(integration, "switch to", input.credential)
const progress = spinner()
progress.start("Switching account...")
yield* request((signal) => client.credential.activate({ credentialID, location }, { signal })).pipe(
yield* request((signal) => client.credential.activate({ credentialID }, { signal })).pipe(
Effect.tap(() => Effect.sync(() => progress.stop(`Switched account for ${integration.name}`))),
Effect.tapCause(() => Effect.sync(() => progress.stop("Failed to switch account", 1))),
)
@@ -29,7 +29,7 @@ export default Runtime.handler(
yield* Effect.forEach(
credentials,
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id, location })),
(connection) => Effect.promise(() => client.credential.remove({ credentialID: connection.id })),
{ discard: true },
)
process.stdout.write(`Removed OAuth credentials for ${input.name}` + EOL)
+2 -2
View File
@@ -112,7 +112,7 @@ async function selectSession(input: {
return {
session: input.fork
? await input.client.session
.fork({ sessionID: explicit.id, boundary: { type: "through" } }, ...requestOptions(input.signal))
.fork({ sessionID: explicit.id }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
@@ -126,7 +126,7 @@ async function selectSession(input: {
return {
session: input.fork
? await input.client.session
.fork({ sessionID: selected.id, boundary: { type: "through" } }, ...requestOptions(input.signal))
.fork({ sessionID: selected.id }, ...requestOptions(input.signal))
.catch((error) => {
throw new SessionTargetMutationError(error)
})
@@ -236,6 +236,7 @@ describe("acp service directory behavior", () => {
headers: [{ name: "Authorization", value: "Bearer x" }],
}
let created = 0
const mcp = "/api/experimental/mcp/"
await using fixture = makeACPFixture({
fetch(request) {
if (request.method === "POST" && request.path === "/api/session") {
@@ -245,7 +246,7 @@ describe("acp service directory behavior", () => {
if (request.method === "GET" && request.path === "/api/session/ses_1") {
return Response.json({ data: makeSession("ses_1") })
}
if (request.method === "PUT" && request.path.startsWith("/api/mcp/")) {
if (request.method === "PUT" && request.path.startsWith(mcp)) {
return new Response(null, { status: 204 })
}
return undefined
@@ -257,9 +258,9 @@ describe("acp service directory behavior", () => {
await fixture.service.resumeSession({ cwd: "/workspace", sessionId: "ses_1", mcpServers: [changed] })
await fixture.service.newSession({ cwd: "/workspace", mcpServers: [local] })
const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith("/api/mcp/"))
const adds = fixture.requests.filter((request) => request.method === "PUT" && request.path.startsWith(mcp))
expect(adds).toHaveLength(4)
expect(adds.filter((request) => request.path === "/api/mcp/tools").map((request) => request.body)).toEqual([
expect(adds.filter((request) => request.path === `${mcp}tools`).map((request) => request.body)).toEqual([
{
config: {
type: "local",
@@ -282,7 +283,7 @@ describe("acp service directory behavior", () => {
},
},
])
expect(adds.find((request) => request.path === "/api/mcp/docs")?.body).toEqual({
expect(adds.find((request) => request.path === `${mcp}docs`)?.body).toEqual({
config: {
type: "remote",
url: "https://example.com/mcp",
@@ -168,7 +168,7 @@ describe("acp service lifecycle", () => {
method: "POST",
path: "/api/session/ses_loaded/fork",
query: {},
body: { boundary: { type: "through" } },
body: {},
})
})
+3 -2
View File
@@ -24,7 +24,8 @@ describe("acp service", () => {
if (url.pathname === "/api/command")
return Response.json({ location, data: [{ name: "review", template: "" }] })
if (url.pathname === "/api/session" && request.method === "POST") return Response.json({ data: session })
if (url.pathname === "/api/mcp/docs" && request.method === "PUT") return new Response(null, { status: 204 })
if (url.pathname === "/api/experimental/mcp/docs" && request.method === "PUT")
return new Response(null, { status: 204 })
return new Response(null, { status: 404 })
},
})
@@ -53,7 +54,7 @@ describe("acp service", () => {
expect(result.configOptions?.map((option) => option.id)).toEqual(["model", "effort", "mode"])
expect(requests).toContainEqual({
method: "PUT",
path: "/api/mcp/docs",
path: "/api/experimental/mcp/docs",
body: {
config: { type: "local", command: ["bun", "docs.ts"], environment: { TOKEN: "x" } },
},
+3 -1
View File
@@ -68,6 +68,7 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
const root = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-cli-acp-"))
const home = path.join(root, "workspace")
const config = path.join(root, "config")
const models = path.join(root, "models.json")
const skills = path.join(root, "skills")
await Promise.all([fs.mkdir(home, { recursive: true }), fs.mkdir(config, { recursive: true })])
if (options.skill) {
@@ -93,6 +94,7 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
path.join(config, "opencode.json"),
JSON.stringify(verifierConfig(`http://127.0.0.1:${llm.port}/v1`, options.skill ? skills : undefined)),
)
await Bun.write(models, "{}")
const processes = new Set<AcpProcess>()
return {
@@ -106,7 +108,7 @@ export async function createAcpFixture(options: { readonly skill?: string } = {}
OPENCODE_CONFIG: undefined,
OPENCODE_CONFIG_CONTENT: undefined,
OPENCODE_DISABLE_AUTOUPDATE: "true",
OPENCODE_MODELS_PATH: undefined,
OPENCODE_MODELS_PATH: models,
...extraEnv,
}),
})
+4 -14
View File
@@ -217,7 +217,7 @@ export type SessionRemoveInput = { readonly sessionID: Session.ID }
export type SessionRemoveOutput = void
export type SessionRemoveOperation<E = never> = (input: SessionRemoveInput) => Effect.Effect<SessionRemoveOutput, E>
export type SessionForkInput = { readonly sessionID: Session.ID; readonly boundary: Session.ForkRequestBoundary }
export type SessionForkInput = { readonly sessionID: Session.ID; readonly before?: SessionMessage.ID | undefined }
export type SessionForkOutput = Session.Info
export type SessionForkOperation<E = never> = (input: SessionForkInput) => Effect.Effect<SessionForkOutput, E>
@@ -1641,29 +1641,19 @@ export interface McpApi<E = never> {
readonly resource: { readonly catalog: McpResourceCatalogOperation<E> }
}
export type CredentialUpdateInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined
readonly label: string
}
export type CredentialUpdateInput = { readonly credentialID: Credential.ID; readonly label: string }
export type CredentialUpdateOutput = void
export type CredentialUpdateOperation<E = never> = (
input: CredentialUpdateInput,
) => Effect.Effect<CredentialUpdateOutput, E>
export type CredentialActivateInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined
}
export type CredentialActivateInput = { readonly credentialID: Credential.ID }
export type CredentialActivateOutput = void
export type CredentialActivateOperation<E = never> = (
input: CredentialActivateInput,
) => Effect.Effect<CredentialActivateOutput, E>
export type CredentialRemoveInput = {
readonly credentialID: Credential.ID
readonly location?: { readonly directory?: string | undefined } | undefined
}
export type CredentialRemoveInput = { readonly credentialID: Credential.ID }
export type CredentialRemoveOutput = void
export type CredentialRemoveOperation<E = never> = (
input: CredentialRemoveInput,
+5 -10
View File
@@ -430,7 +430,7 @@ const EndpointSessionRemove = (raw: RawClient["server.session"]) => (input: Sess
const EndpointSessionFork = (raw: RawClient["server.session"]) => (input: SessionForkInput) =>
preserveEffect<SessionForkOutput>()(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { boundary: input["boundary"] } }).pipe(
raw["session.fork"]({ params: { sessionID: input["sessionID"] }, payload: { before: input["before"] } }).pipe(
Effect.mapError(mapClientError),
Effect.map((value) => value.data),
),
@@ -969,25 +969,20 @@ const EndpointCredentialUpdate = (raw: RawClient["server.credential"]) => (input
preserveEffect<CredentialUpdateOutput>()(
raw["credential.update"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
payload: { label: input["label"] },
}).pipe(Effect.mapError(mapClientError)),
)
const EndpointCredentialActivate = (raw: RawClient["server.credential"]) => (input: CredentialActivateInput) =>
preserveEffect<CredentialActivateOutput>()(
raw["credential.activate"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
raw["credential.activate"]({ params: { credentialID: input["credentialID"] } }).pipe(
Effect.mapError(mapClientError),
),
)
const EndpointCredentialRemove = (raw: RawClient["server.credential"]) => (input: CredentialRemoveInput) =>
preserveEffect<CredentialRemoveOutput>()(
raw["credential.remove"]({
params: { credentialID: input["credentialID"] },
query: { location: input["location"] },
}).pipe(Effect.mapError(mapClientError)),
raw["credential.remove"]({ params: { credentialID: input["credentialID"] } }).pipe(Effect.mapError(mapClientError)),
)
const adaptGroupCredential = (raw: RawClient["server.credential"]) => ({
+11 -14
View File
@@ -517,7 +517,7 @@ export function make(options: ClientOptions) {
request<{ readonly data: SessionStatsOutput }>(
{
method: "GET",
path: `/api/session/stats`,
path: `/api/experimental/session/stats`,
query: {
from: input?.["from"],
to: input?.["to"],
@@ -613,7 +613,7 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/session/${encodeURIComponent(input.sessionID)}/fork`,
body: { boundary: input["boundary"] },
body: { before: input["before"] },
successStatus: 200,
declaredStatuses: [400, 401, 404],
empty: false,
@@ -1129,7 +1129,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { key: input["key"], answer: input["answer"], label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1156,7 +1156,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/oauth/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1169,7 +1169,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { code: input["code"] },
successStatus: 204,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: true,
},
requestOptions,
@@ -1196,7 +1196,7 @@ export function make(options: ClientOptions) {
query: { location: input["location"] },
body: { methodID: input["methodID"], label: input["label"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1208,7 +1208,7 @@ export function make(options: ClientOptions) {
path: `/api/integration/${encodeURIComponent(input.integrationID)}/connect/command/${encodeURIComponent(input.attemptID)}`,
query: { location: input["location"] },
successStatus: 200,
declaredStatuses: [400, 401],
declaredStatuses: [400, 401, 404],
empty: false,
},
requestOptions,
@@ -1244,7 +1244,7 @@ export function make(options: ClientOptions) {
request<McpAddOutput>(
{
method: "PUT",
path: `/api/mcp/${encodeURIComponent(input.server)}`,
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}`,
query: { location: input["location"] },
body: { config: input["config"] },
successStatus: 204,
@@ -1257,7 +1257,7 @@ export function make(options: ClientOptions) {
request<McpRemoveOutput>(
{
method: "DELETE",
path: `/api/mcp/${encodeURIComponent(input.server)}`,
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
@@ -1269,7 +1269,7 @@ export function make(options: ClientOptions) {
request<McpConnectOutput>(
{
method: "POST",
path: `/api/mcp/${encodeURIComponent(input.server)}/connect`,
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}/connect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
@@ -1281,7 +1281,7 @@ export function make(options: ClientOptions) {
request<McpDisconnectOutput>(
{
method: "POST",
path: `/api/mcp/${encodeURIComponent(input.server)}/disconnect`,
path: `/api/experimental/mcp/${encodeURIComponent(input.server)}/disconnect`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401, 404],
@@ -1310,7 +1310,6 @@ export function make(options: ClientOptions) {
{
method: "PATCH",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input["location"] },
body: { label: input["label"] },
successStatus: 204,
declaredStatuses: [400, 401],
@@ -1323,7 +1322,6 @@ export function make(options: ClientOptions) {
{
method: "POST",
path: `/api/credential/${encodeURIComponent(input.credentialID)}/activate`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
@@ -1335,7 +1333,6 @@ export function make(options: ClientOptions) {
{
method: "DELETE",
path: `/api/credential/${encodeURIComponent(input.credentialID)}`,
query: { location: input["location"] },
successStatus: 204,
declaredStatuses: [400, 401],
empty: true,
+21 -12
View File
@@ -2510,6 +2510,24 @@ export type IntegrationNotFoundError = {
export const isIntegrationNotFoundError = (value: unknown): value is IntegrationNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "IntegrationNotFoundError"
export type IntegrationAttemptNotFoundError = {
readonly _tag: "IntegrationAttemptNotFoundError"
readonly integrationID: string
readonly attemptID: string
readonly message: string
}
export const isIntegrationAttemptNotFoundError = (value: unknown): value is IntegrationAttemptNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "IntegrationAttemptNotFoundError"
export type IntegrationMethodNotFoundError = {
readonly _tag: "IntegrationMethodNotFoundError"
readonly integrationID: string
readonly methodID: string
readonly message: string
}
export const isIntegrationMethodNotFoundError = (value: unknown): value is IntegrationMethodNotFoundError =>
typeof value === "object" && value !== null && "_tag" in value && value["_tag"] === "IntegrationMethodNotFoundError"
export type McpServerNotFoundError = {
readonly _tag: "McpServerNotFoundError"
readonly server: string
@@ -3832,9 +3850,7 @@ export type SessionRemoveOutput = void
export type SessionForkInput = {
readonly sessionID: { readonly sessionID: string }["sessionID"]
readonly boundary: {
readonly boundary: { readonly type: "before"; readonly messageID: string } | { readonly type: "through" }
}["boundary"]
readonly before?: { readonly before?: string | undefined }["before"]
}
export type SessionForkOutput = { data: SessionInfo }["data"]
@@ -4691,23 +4707,16 @@ export type McpResourceCatalogOutput = { location: LocationPublicRef; data: McpR
export type CredentialUpdateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
readonly label: { readonly label: string }["label"]
}
export type CredentialUpdateOutput = void
export type CredentialActivateInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
export type CredentialActivateInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] }
export type CredentialActivateOutput = void
export type CredentialRemoveInput = {
readonly credentialID: { readonly credentialID: string }["credentialID"]
readonly location?: { readonly location?: { readonly directory?: string | undefined } | undefined }["location"]
}
export type CredentialRemoveInput = { readonly credentialID: { readonly credentialID: string }["credentialID"] }
export type CredentialRemoveOutput = void
+2 -4
View File
@@ -239,12 +239,10 @@ test("credential.activate uses the public HTTP contract", async () => {
},
})
await client.credential.activate({ credentialID: "cred_work", location: { directory: "/tmp/project" } })
await client.credential.activate({ credentialID: "cred_work" })
expect(request?.method).toBe("POST")
expect(request?.url).toBe(
"http://localhost:3000/api/credential/cred_work/activate?location%5Bdirectory%5D=%2Ftmp%2Fproject",
)
expect(request?.url).toBe("http://localhost:3000/api/credential/cred_work/activate")
})
test("integration connections optionally submit a form answer", async () => {
+13
View File
@@ -100,6 +100,10 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Function declarations, function expressions, and arrow functions.
- [x] Synchronous and `async` functions.
- [x] Closures, recursion, default parameters, rest parameters, and destructured parameters.
- [x] A call depth limit of 10000: deeper nesting throws a catchable `RangeError: Maximum call stack size exceeded`
at the overflowing call instead of running until the timeout. Callbacks invoked by built-ins count below the
call that invoked the built-in, and a resumed `await` starts from depth 0 as in JS, so long async chains such
as recursive pagination are unaffected.
- [x] Expression and block function bodies.
- [x] User callbacks for the supported Array, Map, Set, URLSearchParams, sort, string-replacement, and `Array.from`
mapper APIs, with one shared acceptance rule everywhere including promise reactions.
@@ -440,3 +444,12 @@ ultimate source of truth. Upstream test262 files run verbatim from `test/test262
- [x] Failures raised by the interpreter are `TypeError`s unless JavaScript names them otherwise (`RangeError`,
`ReferenceError`, `SyntaxError`, `URIError`), so `e instanceof TypeError` and `e.constructor === TypeError`
hold. Unsupported syntax reached at runtime is a `SyntaxError`; awaited tool failures stay plain `Error`.
Host errors escaping a built-in (`(1).toFixed(200)`) become the same-named program error at the call.
A failure raised inside a promise a built-in created (`Promise.all(1)`, `Promise.race([])`, a resolution cycle)
is located at the call that created the promise.
- [x] One failure is one error object: every `catch`, rejection handler, and `allSettled` reason for the same
failure sees the identical value, so `a === b` holds after awaiting the same rejected promise twice.
- [x] Rethrowing an interpreter failure keeps its diagnostic: `catch (e) { throw e }` still reports the original
kind and source location. Uncaught errors report as `name: message` whoever raised them, as
`Error.prototype.toString` would (`TypeError: Cannot read properties of null (reading 'foo').`,
`TypeError: bad input`); other thrown values report as `Uncaught: <value>`.
+47 -29
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import type { Diagnostic } from "../codemode.js"
import { ToolError } from "../tool-error.js"
import { toData, ToolRuntimeError } from "../data.js"
import { type AstNode, formatLocation, InterpreterRuntimeError, ProgramThrow, sourceLocation } from "./model.js"
import { type AstNode, formatLocation, PendingThrow, ProgramThrow, sourceLocation, typeError } from "./model.js"
import { containsRuntimeReference } from "./references.js"
import { createErrorValue, type ErrorType, isErrorType } from "./intrinsics.js"
import { constructor, methods, prototypeFrom, receiver } from "./native.js"
@@ -20,10 +20,10 @@ import { type Runner } from "./runner.js"
import { coerceToString } from "../stdlib/value.js"
export const normalizeError = (error: unknown): Diagnostic => {
if (error instanceof InterpreterRuntimeError) {
if (error instanceof PendingThrow) {
return {
kind: error.kind,
message: `${error.message}${formatLocation(error.node)}`,
message: `${error.type}: ${error.message}${formatLocation(error.node)}`,
...(error.node?.loc ? { location: sourceLocation(error.node) } : {}),
...(error.suggestions ? { suggestions: error.suggestions } : {}),
}
@@ -43,14 +43,15 @@ export const normalizeError = (error: unknown): Diagnostic => {
if (error instanceof ProgramThrow) {
const value = error.value
if (value instanceof ProgramError) {
return value.host ? normalizeError(value.host) : { kind: "ExecutionFailure", message: errorToString(value) }
}
let message: string
if (containsRuntimeReference(value)) {
// Never expose runtime reference internals through thrown values.
message = "a non-data value"
} else if (typeof value === "string") {
message = value
} else if (value instanceof ProgramObject && typeof get(value, "message") === "string") {
message = get(value, "message") as string
} else {
try {
message = JSON.stringify(toData(value, "Thrown value")) ?? String(value)
@@ -81,14 +82,47 @@ export const normalizeError = (error: unknown): Diagnostic => {
}
}
export const caughtErrorValue = <R>(runner: Runner<R>, thrown: unknown): unknown => {
/**
* Gives a failure the source location of the expression that raised it, keeping the first one attached. Host errors
* that escape a built-in become the equivalent program error here.
*/
export const locate = (error: unknown, node?: AstNode): unknown => {
if (error instanceof PendingThrow) {
if (error.node === undefined && node) error.node = node
return error
}
if (error instanceof Error && !(error instanceof ToolError) && !(error instanceof ToolRuntimeError)) {
return new PendingThrow(isErrorType(error.name) ? error.name : "Error", error.message, node)
}
return error
}
/** The program value a handler receives for a failure; one failure always yields the same value. */
export const materialize = <R>(runner: Runner<R>, thrown: unknown): unknown => {
if (thrown instanceof ProgramThrow) return thrown.value
const prototypes = runner.prototypes
if (thrown instanceof InterpreterRuntimeError) return createErrorValue(prototypes[thrown.type], thrown.message)
if (thrown instanceof PendingThrow) {
if (thrown.value === undefined) {
thrown.value = createErrorValue(prototypes[thrown.type], thrown.message)
thrown.value.host = thrown
}
return thrown.value
}
const type = thrown instanceof Error && isErrorType(thrown.name) ? thrown.name : "Error"
return createErrorValue(prototypes[type], normalizeError(thrown).message)
}
/** Error.prototype.toString: `name: message`, omitting whichever side is empty. */
const errorToString = (self: ProgramObject): string => {
const name = get(self, "name")
const message = get(self, "message")
const shownName = name === undefined ? "Error" : coerceToString(name)
const shownMessage = message === undefined ? "" : coerceToString(message)
if (shownMessage === "") return shownName
if (shownName === "") return shownMessage
return `${shownName}: ${shownMessage}`
}
export const createAggregateErrorValue = <R>(
runner: Runner<R>,
errors: Array<unknown>,
@@ -104,13 +138,10 @@ const constructAggregateErrorValue = <R>(
runner: Runner<R>,
args: Array<unknown>,
proto: ProgramObject,
node: AstNode,
): Effect.Effect<ProgramError, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0], node)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new AggregateError(...) expects a synchronous iterable of errors.", node)
}
const cursor = yield* runner.syncIterator(args[0])
if (cursor === undefined) throw typeError("new AggregateError(...) expects a synchronous iterable of errors.")
const errors: Array<unknown> = []
while (true) {
const step = yield* cursor.next
@@ -125,34 +156,21 @@ const constructAggregateErrorValue = <R>(
export const errorGlobal = <R>(type: ErrorType, runner: Runner<R>) => {
const protos = runner.prototypes
const prototype = protos[type]
const construct = (args: Array<unknown>, newTarget: Callable, node: AstNode) => {
const construct = (args: Array<unknown>, newTarget: Callable) => {
const proto = prototypeFrom(newTarget, prototype)
return type === "AggregateError"
? constructAggregateErrorValue(runner, args, proto, node)
? constructAggregateErrorValue(runner, args, proto)
: Effect.sync(() => createErrorValue(proto, args[0] === undefined ? undefined : coerceToString(args[0])))
}
const ctor: NativeFunction<R> = constructor<R>(protos, prototype, {
name: type,
length: type === "AggregateError" ? 2 : 1,
call: (_, args, node) => construct(args, ctor, node),
call: (_, args) => construct(args, ctor),
construct,
})
if (type === "Error") {
methods(protos, prototype, [
[
"toString",
0,
(thisValue, _, node) => {
const self = receiver(ProgramObject, thisValue, "Error.prototype.toString", node)
const name = get(self, "name")
const message = get(self, "message")
const shownName = name === undefined ? "Error" : coerceToString(name)
const shownMessage = message === undefined ? "" : coerceToString(message)
if (shownMessage === "") return shownName
if (shownName === "") return shownMessage
return `${shownName}: ${shownMessage}`
},
],
["toString", 0, (thisValue) => errorToString(receiver(ProgramObject, thisValue, "Error.prototype.toString"))],
])
}
return ctor
+2 -2
View File
@@ -9,7 +9,7 @@ import { ToolRuntime } from "../tool-runtime.js"
import { normalizeError } from "./errors.js"
import { createPrototypes } from "./intrinsics.js"
import type { Host } from "./globals.js"
import { InterpreterRuntimeError } from "./model.js"
import { PendingThrow } from "./model.js"
import { PromiseRuntime } from "./promises.js"
import { Runtime } from "./runtime.js"
@@ -122,7 +122,7 @@ const parseProgram = (code: string): Program => {
const transpiled = transpile(`async function __codemode__() {\n${code}\n}`)
if (transpiled.error !== undefined) {
throw new InterpreterRuntimeError(`Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
throw new PendingThrow("SyntaxError", `Failed to parse TypeScript: ${transpiled.error}`, undefined, "ParseError")
}
const bodyStart = transpiled.outputText.indexOf("{") + 1
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { fn, type Method, methods, receiver } from "./native.js"
import { type AstNode, AsyncIteratorSymbol, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
import { AsyncIteratorSymbol, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
import { define, hidden, ProgramGenerator } from "./objects.js"
import type { PromiseRuntime } from "./promises.js"
import type { Runner } from "./runner.js"
@@ -14,9 +14,9 @@ export const generatorGlobals = <R>(runner: Runner<R>, promises: PromiseRuntime<
const request = (kind: GeneratorRequestKind): Method => [
kind,
1,
(thisValue: unknown, args: Array<unknown>, node: AstNode) => {
const generator = receiver(ProgramGenerator, thisValue, `${label}.prototype.${kind}`, node)
const requested = generator.request(kind, args[0], node) as Effect.Effect<unknown, unknown, R>
(thisValue: unknown, args: Array<unknown>) => {
const generator = receiver(ProgramGenerator, thisValue, `${label}.prototype.${kind}`)
const requested = generator.request(kind, args[0]) as Effect.Effect<unknown, unknown, R>
return generator.asynchronous ? promises.create(requested) : requested
},
]
+6 -9
View File
@@ -16,7 +16,7 @@ import { ToolReference } from "../tool-runtime.js"
import { errorGlobal } from "./errors.js"
import { errorTypes } from "./intrinsics.js"
import { constants, constructor, native } from "./native.js"
import { type AstNode, AsyncIteratorSymbol, InterpreterRuntimeError, IteratorSymbol } from "./model.js"
import { AsyncIteratorSymbol, IteratorSymbol, typeError } from "./model.js"
import { generatorGlobals } from "./generators.js"
import { promiseGlobal, type PromiseRuntime } from "./promises.js"
import type { Runner } from "./runner.js"
@@ -32,27 +32,24 @@ export type Host<R> = {
// Function.prototype.constructor exists so `fn.constructor === Function` holds; dynamic code is unsupported.
const functionGlobal = <R>(runner: Runner<R>) => {
const reject = (_: unknown, __: Array<unknown>, node: AstNode) =>
const reject = () =>
Effect.sync(() => {
throw new InterpreterRuntimeError("The Function constructor is not supported; write the function inline.", node)
throw typeError("The Function constructor is not supported; write the function inline.")
})
return constructor<R>(runner.prototypes, runner.prototypes.Function, {
name: "Function",
length: 1,
call: reject,
construct: (args, _, node) => reject(undefined, args, node),
construct: reject,
})
}
const symbolGlobal = <R>(runner: Runner<R>) => {
const symbol = native<R>(runner.prototypes, {
name: "Symbol",
call: (_, __, node) =>
call: () =>
Effect.sync(() => {
throw new InterpreterRuntimeError(
"Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.",
node,
)
throw typeError("Symbol is not callable; only Symbol.asyncIterator and Symbol.iterator are available.")
}),
callback: false,
})
+24 -18
View File
@@ -1,10 +1,17 @@
import type { Node } from "acorn"
import { Context } from "effect"
import type { ErrorType } from "./intrinsics.js"
import type { DiagnosticKind } from "../codemode.js"
import type { ProgramError } from "./objects.js"
/** Any parsed node; the interpreter narrows on `type` and reads `loc` for diagnostics. */
export type AstNode = Node
/** The program call a built-in is running under: where to locate failures born inside it, and how deep the stack is there. */
export const CallSite = Context.Reference<{ readonly node?: AstNode; readonly depth: number }>("codemode/CallSite", {
defaultValue: () => ({ depth: 0 }),
})
export type Binding = {
mutable: boolean
value: unknown
@@ -33,32 +40,31 @@ export class GeneratorReturn {
export const OptionalShortCircuit: unique symbol = Symbol("codemode.optional-short-circuit")
export class InterpreterRuntimeError extends Error {
readonly node?: AstNode
/**
* A failure raised by the interpreter or a built-in. It travels as a defect and becomes one program Error object
* the first time a handler observes it, so every observer of the same failure sees the same value.
*/
export class PendingThrow {
node?: AstNode
value?: ProgramError
constructor(
message: string,
/** The JS error class a program sees when it catches this failure. */
readonly type: ErrorType,
readonly message: string,
node?: AstNode,
readonly kind: DiagnosticKind = "ExecutionFailure",
readonly suggestions?: ReadonlyArray<string>,
/** The JS error class a program sees when it catches this failure. */
readonly type: ErrorType = "TypeError",
) {
super(message)
this.name = "InterpreterRuntimeError"
if (node) this.node = node
}
}
/** Attaches a source location to a failure raised where none was known, such as inside a property accessor. */
export const locate = (error: unknown, node: AstNode): unknown =>
error instanceof InterpreterRuntimeError && error.node === undefined
? new InterpreterRuntimeError(error.message, node, error.kind, error.suggestions, error.type)
: error
const failure = (type: ErrorType) => (message: string, node?: AstNode) =>
new InterpreterRuntimeError(message, node, "ExecutionFailure", undefined, type)
const failure = (type: ErrorType, kind?: DiagnosticKind) => (message: string, node?: AstNode) =>
new PendingThrow(type, message, node, kind)
export const typeError = failure("TypeError")
export const invalidData = failure("TypeError", "InvalidDataValue")
export const rangeError = failure("RangeError")
export const referenceError = failure("ReferenceError")
export const syntaxError = failure("SyntaxError")
@@ -68,13 +74,13 @@ export const uriError = failure("URIError")
export const supportedSyntaxMessage =
"This is a restricted JavaScript-like language. Supported: plain and async functions, data literals, destructuring, standard control flow, await and Promise, and built-ins such as Array, Object, Math, JSON, Date, RegExp, Map, Set, and URL. Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols. Use plain functions and data objects instead."
export const unsupportedSyntax = (kind: string, node: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError(
export const unsupportedSyntax = (kind: string, node: AstNode): PendingThrow =>
new PendingThrow(
"SyntaxError",
`Syntax '${kind}' is not supported. ${supportedSyntaxMessage}`,
node,
"UnsupportedSyntax",
[supportedSyntaxMessage],
"SyntaxError",
)
export const isRecord = (value: unknown): value is Record<string, unknown> =>
+13 -16
View File
@@ -1,19 +1,19 @@
import { Effect } from "effect"
import type { Prototypes } from "./intrinsics.js"
import { type AstNode, InterpreterRuntimeError } from "./model.js"
import { typeError } from "./model.js"
import { type Callable, define, frozen, hidden, NativeFunction, type NativeOptions, ProgramObject } from "./objects.js"
import { describeValue } from "./references.js"
/** A native function body: a plain value, a thrown `InterpreterRuntimeError`, or an Effect. */
export type Impl = (thisValue: unknown, args: Array<unknown>, node: AstNode) => unknown
/** A native function body: a plain value, a thrown `PendingThrow`, or an Effect. */
export type Impl = (thisValue: unknown, args: Array<unknown>) => unknown
// The dispatch in `Frame.invokeCallable` suspends every native call, so a synchronous throw here is a defect.
const lift =
<R>(impl: Impl) =>
(thisValue: unknown, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> =>
Effect.suspend(() => {
const result = impl(thisValue, args, node)
return Effect.isEffect(result) ? (result as Effect.Effect<unknown, unknown, R>) : Effect.succeed(result)
})
(thisValue: unknown, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const result = impl(thisValue, args)
return Effect.isEffect(result) ? (result as Effect.Effect<unknown, unknown, R>) : Effect.succeed(result)
}
export const native = <R>(protos: Prototypes, options: NativeOptions<R>): NativeFunction<R> =>
new NativeFunction<R>(protos.Function, options)
@@ -44,12 +44,10 @@ export const constructor = <R>(
}
/** The `call` of a constructor that JS requires to be invoked with `new`. */
export const requiresNew =
(name: string) =>
(_: unknown, __: Array<unknown>, node: AstNode): Effect.Effect<never, unknown, never> =>
Effect.sync(() => {
throw new InterpreterRuntimeError(`Constructor ${name} requires 'new'.`, node)
})
export const requiresNew = (name: string) => (): Effect.Effect<never, unknown, never> =>
Effect.sync(() => {
throw typeError(`Constructor ${name} requires 'new'.`)
})
/** The instance prototype for `new` via `newTarget.prototype`, falling back to the built-in's own. */
export const prototypeFrom = (newTarget: Callable, fallback: ProgramObject): ProgramObject => {
@@ -62,8 +60,7 @@ export const receiver = <T extends ProgramObject>(
cls: abstract new (...args: never) => T,
thisValue: unknown,
method: string,
node?: AstNode,
): T => {
if (thisValue instanceof cls) return thisValue
throw new InterpreterRuntimeError(`${method} called on incompatible receiver ${describeValue(thisValue)}.`, node)
throw typeError(`${method} called on incompatible receiver ${describeValue(thisValue)}.`)
}
+14 -17
View File
@@ -1,6 +1,12 @@
import type { BlockStatement, Expression, Pattern } from "acorn"
import type { Effect, Fiber } from "effect"
import { type AstNode, AsyncIteratorSymbol, type Binding, type GeneratorRequestKind, IteratorSymbol } from "./model.js"
import {
AsyncIteratorSymbol,
type Binding,
type GeneratorRequestKind,
IteratorSymbol,
type PendingThrow,
} from "./model.js"
/** Property attributes, as in a JS property descriptor. */
export type Attributes = {
@@ -42,7 +48,10 @@ export class ProgramArray extends ProgramObject {
}
/** An object with the [[ErrorData]] slot: what `Error.prototype.toString` and the host boundary recognize as an error. */
export class ProgramError extends ProgramObject {}
export class ProgramError extends ProgramObject {
/** The interpreter failure this error materialized from, so rethrowing it keeps the diagnostic kind and location. */
host?: PendingThrow
}
export abstract class Callable extends ProgramObject {
constructor(proto: ProgramObject, name: string, length: number) {
@@ -67,16 +76,8 @@ export class ProgramFunction extends Callable {
}
}
export type NativeCall<R> = (
thisValue: unknown,
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
export type NativeConstruct<R> = (
args: Array<unknown>,
newTarget: Callable,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
export type NativeCall<R> = (thisValue: unknown, args: Array<unknown>) => Effect.Effect<unknown, unknown, R>
export type NativeConstruct<R> = (args: Array<unknown>, newTarget: Callable) => Effect.Effect<unknown, unknown, R>
export type NativeOptions<R> = {
readonly name: string
@@ -114,11 +115,7 @@ export class ProgramGenerator extends ProgramObject {
constructor(
proto: ProgramObject,
readonly asynchronous: boolean,
readonly request: (
kind: GeneratorRequestKind,
value: unknown,
node: AstNode,
) => Effect.Effect<unknown, unknown, unknown>,
readonly request: (kind: GeneratorRequestKind, value: unknown) => Effect.Effect<unknown, unknown, unknown>,
) {
super(proto)
}
+38 -54
View File
@@ -1,6 +1,6 @@
import { Cause, Deferred, Effect, Exit, Fiber, Scope } from "effect"
import type { Diagnostic } from "../codemode.js"
import { type AstNode, InterpreterRuntimeError, ProgramThrow } from "./model.js"
import { CallSite, ProgramThrow, typeError } from "./model.js"
import {
Callable,
define,
@@ -13,7 +13,7 @@ import {
record,
} from "./objects.js"
import { constructor, fn, methods, native, receiver, requiresNew } from "./native.js"
import { caughtErrorValue, createAggregateErrorValue, normalizeError } from "./errors.js"
import { createAggregateErrorValue, locate, materialize, normalizeError } from "./errors.js"
import { typeofValue } from "./references.js"
import { applyCollectionCallback, isSupportedCallback, type Runner } from "./runner.js"
@@ -49,10 +49,11 @@ export class PromiseRuntime<R> {
}
create(effect: Effect.Effect<unknown, unknown, R>): Effect.Effect<ProgramPromise, never, R> {
return Effect.suspend(() => {
return Effect.flatMap(CallSite, (site) => {
// Allocate before forking so reruns get distinct IDs and diagnostics retain creation order.
const id = this.nextID++
return Effect.map(Effect.forkIn(effect, this.scope, { startImmediately: true }), (fiber) => {
const body = Effect.catchDefect(effect, (defect) => Effect.die(locate(defect, site.node)))
return Effect.map(Effect.forkIn(body, this.scope, { startImmediately: true }), (fiber) => {
const promise = new ProgramPromise(this.proto, fiber)
this.active.add(promise)
this.ids.set(promise, id)
@@ -105,16 +106,14 @@ export class PromiseRuntime<R> {
}
}
export const selfResolutionError = (node?: AstNode): InterpreterRuntimeError =>
new InterpreterRuntimeError("Chaining cycle detected: a promise cannot resolve with itself.", node)
export const resolvePromiseValue = <R>(
runner: Runner<R>,
value: unknown,
node: AstNode,
own?: { promise?: ProgramPromise },
): Effect.Effect<unknown, unknown, R> => {
if (own?.promise !== undefined && value === own.promise) return Effect.fail(selfResolutionError(node))
if (own?.promise !== undefined && value === own.promise) {
return Effect.die(typeError("Chaining cycle detected: a promise cannot resolve with itself."))
}
if (value instanceof ProgramPromise) return runner.settlePromise(value)
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
const then = get(value, "then")
@@ -128,12 +127,12 @@ export const resolvePromiseValue = <R>(
const reject = capability(runner, "reject", (reason) =>
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(reason))),
)
const executed = yield* Effect.exit(runner.invokeCallable(then, value, [resolve, reject], node))
const executed = yield* Effect.exit(runner.invokeCallable(then, value, [resolve, reject]))
if (!Exit.isSuccess(executed)) {
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
}
return yield* resolvePromiseValue(runner, yield* Deferred.await(deferred), node, own)
return yield* resolvePromiseValue(runner, yield* Deferred.await(deferred), own)
})
}
@@ -141,10 +140,9 @@ export const resolvePromise = <R>(
runner: Runner<R>,
promises: PromiseRuntime<R>,
value: unknown,
node: AstNode,
): Effect.Effect<ProgramPromise, never, R> => {
if (value instanceof ProgramPromise) return Effect.succeed(value)
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, node, self))
return promises.createWithSelf((self) => resolvePromiseValue(runner, value, self))
}
const promiseStatics = ["all", "allSettled", "race", "any", "resolve", "reject"] as const
@@ -154,10 +152,9 @@ const invokePromiseMethod = <R>(
promises: PromiseRuntime<R>,
name: (typeof promiseStatics)[number],
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (name === "resolve") {
return resolvePromise(runner, promises, args[0], node)
return resolvePromise(runner, promises, args[0])
}
if (name === "reject") {
return promises.create(Effect.fail(new ProgramThrow(args[0])))
@@ -165,15 +162,13 @@ const invokePromiseMethod = <R>(
return promises.create(
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0], node)
if (cursor === undefined) {
throw new InterpreterRuntimeError(`Promise.${name} expects an array or other synchronous iterable.`, node)
}
const cursor = yield* runner.syncIterator(args[0])
if (cursor === undefined) throw typeError(`Promise.${name} expects an array or other synchronous iterable.`)
const items: Array<ProgramPromise> = []
while (true) {
const step = yield* cursor.next
if (step.done) break
const item = yield* resolvePromise(runner, promises, step.value, node)
const item = yield* resolvePromise(runner, promises, step.value)
promises.markObserved(item)
items.push(item)
}
@@ -201,7 +196,7 @@ const invokePromiseMethod = <R>(
outcomes.push(
record(runner.prototypes.Object, {
status: "rejected",
reason: caughtErrorValue(runner, Cause.squash(exit.cause)),
reason: materialize(runner, Cause.squash(exit.cause)),
}),
)
}
@@ -210,10 +205,7 @@ const invokePromiseMethod = <R>(
}
if (name === "race") {
if (items.length === 0) {
throw new InterpreterRuntimeError(
"Promise.race([]) would never settle; provide at least one promise or value.",
node,
)
throw typeError("Promise.race([]) would never settle; provide at least one promise or value.")
}
return yield* settleAfterTurn(Effect.flatten(Effect.raceAll(items.map((item) => promises.await(item)))))
}
@@ -221,7 +213,7 @@ const invokePromiseMethod = <R>(
Effect.flatMap(promises.await(item), (exit) => {
if (Exit.isSuccess(exit)) return Effect.fail(new PromiseAnyFulfilled(exit.value))
if (Cause.hasInterruptsOnly(exit.cause)) return Effect.failCause(exit.cause)
return Effect.succeed(caughtErrorValue(runner, Cause.squash(exit.cause)))
return Effect.succeed(materialize(runner, Cause.squash(exit.cause)))
}),
)
return yield* settleAfterTurn(
@@ -244,41 +236,36 @@ const instanceMethod = <R>(
name: "then" | "catch" | "finally",
thisValue: unknown,
args: Array<unknown>,
node: AstNode,
): Effect.Effect<ProgramPromise, unknown, R> => {
const method = `Promise.prototype.${name}`
const promise = receiver(ProgramPromise, thisValue, method, node)
const promise = receiver(ProgramPromise, thisValue, method)
promises.markObserved(promise)
if (name === "finally") {
return chainFinally(runner, promises, promise, reactionHandler(args[0], method, node), method, node)
return chainFinally(runner, promises, promise, reactionHandler(args[0], method), method)
}
const onFulfilled = name === "then" ? reactionHandler(args[0], method, node) : undefined
const onRejected = reactionHandler(name === "then" ? args[1] : args[0], method, node)
return chainReaction(runner, promises, promise, onFulfilled, onRejected, method, node)
const onFulfilled = name === "then" ? reactionHandler(args[0], method) : undefined
const onRejected = reactionHandler(name === "then" ? args[1] : args[0], method)
return chainReaction(runner, promises, promise, onFulfilled, onRejected, method)
}
const constructPromise = <R>(
runner: Runner<R>,
promises: PromiseRuntime<R>,
executor: unknown,
node: AstNode,
): Effect.Effect<ProgramPromise, unknown, R> => {
if (!(executor instanceof ProgramFunction)) {
throw new InterpreterRuntimeError(
"new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).",
node,
)
throw typeError("new Promise(...) expects an executor function (e.g. new Promise((resolve, reject) => { ... })).")
}
return Effect.gen(function* () {
const deferred = Deferred.makeUnsafe<unknown, unknown>()
const promise = yield* promises.createWithSelf((self) =>
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, node, self)),
Effect.flatMap(Deferred.await(deferred), (value) => resolvePromiseValue(runner, value, self)),
)
const resolve = capability(runner, "resolve", (value) => Deferred.doneUnsafe(deferred, Exit.succeed(value)))
const reject = capability(runner, "reject", (value) =>
Deferred.doneUnsafe(deferred, Exit.fail(new ProgramThrow(value))),
)
const executed = yield* Effect.exit(runner.invokeCallable(executor, undefined, [resolve, reject], node))
const executed = yield* Effect.exit(runner.invokeCallable(executor, undefined, [resolve, reject]))
if (!Exit.isSuccess(executed)) {
if (Cause.hasInterruptsOnly(executed.cause)) return yield* Effect.failCause(executed.cause)
Deferred.doneUnsafe(deferred, Exit.fail(Cause.squash(executed.cause)))
@@ -295,12 +282,11 @@ class PromiseAnyFulfilled {
constructor(readonly value: unknown) {}
}
const reactionHandler = (value: unknown, method: string, node: AstNode): Callable | undefined => {
const reactionHandler = (value: unknown, method: string): Callable | undefined => {
if (isSupportedCallback(value)) return value
if (typeofValue(value) === "function") {
throw new InterpreterRuntimeError(
throw typeError(
`${method} cannot use this callable as a handler; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`,
node,
)
}
return undefined
@@ -325,16 +311,15 @@ const chainReaction = <R>(
onFulfilled: Callable | undefined,
onRejected: Callable | undefined,
method: string,
node: AstNode,
): Effect.Effect<ProgramPromise, never, R> => {
return promises.createWithSelf((self) =>
Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
const handler = Exit.isSuccess(exit) ? onFulfilled : onRejected
if (handler === undefined) return yield* exit
const input = Exit.isSuccess(exit) ? exit.value : caughtErrorValue(runner, Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method, node)([input])
return yield* resolvePromiseValue(runner, result, node, self)
const input = Exit.isSuccess(exit) ? exit.value : materialize(runner, Cause.squash(exit.cause))
const result = yield* applyCollectionCallback(runner, handler, method)([input])
return yield* resolvePromiseValue(runner, result, self)
}),
)
}
@@ -345,16 +330,15 @@ const chainFinally = <R>(
source: ProgramPromise,
cleanup: Callable | undefined,
method: string,
node: AstNode,
): Effect.Effect<ProgramPromise, never, R> =>
promises.create(
Effect.gen(function* () {
const exit = yield* reactionExit(promises, source)
if (cleanup !== undefined) {
const result = yield* applyCollectionCallback(runner, cleanup, method, node)([])
const result = yield* applyCollectionCallback(runner, cleanup, method)([])
const intermediate = yield* promises.create(
Effect.gen(function* () {
yield* runner.settlePromise(yield* resolvePromise(runner, promises, result, node))
yield* runner.settlePromise(yield* resolvePromise(runner, promises, result))
return yield* exit
}),
)
@@ -371,7 +355,7 @@ export const promiseGlobal = <R>(runner: Runner<R>, promises: PromiseRuntime<R>)
name: "Promise",
length: 1,
call: requiresNew("Promise"),
construct: (args, _, node) => constructPromise(runner, promises, args[0], node),
construct: (args) => constructPromise(runner, promises, args[0]),
})
// Combinators are not callbacks: `[p].map(Promise.resolve)` must ask for an arrow function.
for (const name of promiseStatics) {
@@ -381,16 +365,16 @@ export const promiseGlobal = <R>(runner: Runner<R>, promises: PromiseRuntime<R>)
native<R>(protos, {
name,
length: 1,
call: (_, args, node) => invokePromiseMethod(runner, promises, name, args, node),
call: (_, args) => invokePromiseMethod(runner, promises, name, args),
callback: false,
}),
hidden,
)
}
methods(protos, proto, [
["then", 2, (thisValue, args, node) => instanceMethod(runner, promises, "then", thisValue, args, node)],
["catch", 1, (thisValue, args, node) => instanceMethod(runner, promises, "catch", thisValue, args, node)],
["finally", 1, (thisValue, args, node) => instanceMethod(runner, promises, "finally", thisValue, args, node)],
["then", 2, (thisValue, args) => instanceMethod(runner, promises, "then", thisValue, args)],
["catch", 1, (thisValue, args) => instanceMethod(runner, promises, "catch", thisValue, args)],
["finally", 1, (thisValue, args) => instanceMethod(runner, promises, "finally", thisValue, args)],
])
return promise
}
@@ -1,5 +1,5 @@
import { ToolReference } from "../tool-runtime.js"
import { type AstNode, InterpreterRuntimeError } from "./model.js"
import { invalidData } from "./model.js"
import {
Callable,
getOwn,
@@ -66,11 +66,10 @@ export const rejectCircularInsertion = (
container: object,
value: unknown,
label: string,
node: AstNode,
seen = new Set<object>(),
): void => {
if (find(value, (current) => current === container, isRuntimeReference, seen)) {
throw new InterpreterRuntimeError(`${label} contains a circular value.`, node, "InvalidDataValue")
throw invalidData(`${label} contains a circular value.`)
}
}
+11 -15
View File
@@ -1,7 +1,7 @@
import { Effect, Exit } from "effect"
import { coerceToNumber, coerceToString } from "../stdlib/value.js"
import type { Prototypes } from "./intrinsics.js"
import { type AstNode, InterpreterRuntimeError } from "./model.js"
import { typeError } from "./model.js"
import { Callable, get, NativeFunction, ProgramDate, ProgramObject, ProgramPromise } from "./objects.js"
import { typeofValue } from "./references.js"
@@ -16,10 +16,9 @@ export type Runner<R> = {
callable: unknown,
thisValue: unknown,
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>
readonly settlePromise: (promise: ProgramPromise) => Effect.Effect<unknown, unknown, never>
readonly syncIterator: (value: unknown, node: AstNode) => Effect.Effect<IteratorCursor<R> | undefined, unknown, R>
readonly syncIterator: (value: unknown) => Effect.Effect<IteratorCursor<R> | undefined, unknown, R>
readonly prototypes: Prototypes
}
@@ -41,7 +40,6 @@ export const toPrimitive = <R>(
runner: Runner<R>,
value: unknown,
hint: "number" | "string" | "default",
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
const asString = hint === "string" || (hint === "default" && value instanceof ProgramDate)
@@ -50,18 +48,18 @@ export const toPrimitive = <R>(
for (const method of order) {
const callable = get(value, method)
if (!(callable instanceof Callable)) continue
const result = yield* runner.invokeCallable(callable, value, [], node)
const result = yield* runner.invokeCallable(callable, value, [])
if (result === null || (typeof result !== "object" && typeof result !== "function")) return result
}
throw new InterpreterRuntimeError("Cannot convert object to primitive value.", node)
throw typeError("Cannot convert object to primitive value.")
})
}
export const toPrimitiveString = <R>(runner: Runner<R>, value: unknown, node: AstNode) =>
Effect.map(toPrimitive(runner, value, "string", node), coerceToString)
export const toPrimitiveString = <R>(runner: Runner<R>, value: unknown) =>
Effect.map(toPrimitive(runner, value, "string"), coerceToString)
export const toPrimitiveNumber = <R>(runner: Runner<R>, value: unknown, node: AstNode) =>
Effect.map(toPrimitive(runner, value, "number", node), coerceToNumber)
export const toPrimitiveNumber = <R>(runner: Runner<R>, value: unknown) =>
Effect.map(toPrimitive(runner, value, "number"), coerceToNumber)
// The single acceptance list for callbacks: collections, sort, string replacers,
// Array.from mappers, and promise reactions all admit exactly these callables.
@@ -74,16 +72,14 @@ export const applyCollectionCallback = <R>(
runner: Runner<R>,
callback: unknown,
name: string,
node: AstNode,
): ((args: Array<unknown>) => Effect.Effect<unknown, unknown, R>) => {
if (!isSupportedCallback(callback)) {
if (typeofValue(callback) === "function") {
throw new InterpreterRuntimeError(
throw typeError(
`${name} cannot use this callable as a callback; wrap it in an arrow function, e.g. (value) => tools.ns.tool(value).`,
node,
)
}
throw new InterpreterRuntimeError(`${name} expects a function callback.`, node)
throw typeError(`${name} expects a function callback.`)
}
return (callbackArgs) => runner.invokeCallable(callback, undefined, callbackArgs, node)
return (callbackArgs) => runner.invokeCallable(callback, undefined, callbackArgs)
}
+161 -139
View File
@@ -48,18 +48,19 @@ import {
type AstNode,
AsyncIteratorSymbol,
type Binding,
CallSite,
type GeneratorRequestKind,
GeneratorReturn,
InterpreterRuntimeError,
IteratorSymbol,
locate,
OptionalShortCircuit,
invalidData,
ProgramThrow,
rangeError,
type StatementResult,
typeError,
unsupportedSyntax,
} from "./model.js"
import { caughtErrorValue } from "./errors.js"
import { locate, materialize } from "./errors.js"
import type { Prototypes } from "./intrinsics.js"
import { globals, type Host } from "./globals.js"
import {
@@ -124,11 +125,11 @@ const calleeDescription = (callee: Expression | Super | undefined): string => {
// OrdinaryHasInstance: walk the left operand's chain looking for the constructor's `prototype`.
const instanceofValue = (lhs: unknown, rhs: unknown, node: AstNode): boolean => {
if (!(rhs instanceof Callable)) {
throw new InterpreterRuntimeError("The right-hand side of 'instanceof' is not callable.", node)
throw typeError("The right-hand side of 'instanceof' is not callable.", node)
}
const prototype = get(rhs, "prototype")
if (!(prototype instanceof ProgramObject)) {
throw new InterpreterRuntimeError("The right-hand side of 'instanceof' has no 'prototype' object.", node)
throw typeError("The right-hand side of 'instanceof' has no 'prototype' object.", node)
}
return hasPrototype(lhs, prototype)
}
@@ -207,7 +208,7 @@ const loopDeclaration = (left: VariableDeclaration | Pattern, statement: "for...
if (left.type !== "VariableDeclaration") return undefined
const declaration = left.declarations.length === 1 ? left.declarations[0] : undefined
if (declaration === undefined) {
throw new InterpreterRuntimeError(`${statement} supports one declared binding.`, left)
throw typeError(`${statement} supports one declared binding.`, left)
}
const kind = left.kind
return {
@@ -246,8 +247,6 @@ type GeneratorState = {
available?: Deferred.Deferred<void>
}
const promiseResolutionNode: AstNode = { type: "PromiseResolution", start: 0, end: 0 }
/** One program execution: the tool bridge, promise scheduler, captured logs, and the global scope built once. */
export class Runtime<R> {
readonly runner: Runner<R>
@@ -266,9 +265,9 @@ export class Runtime<R> {
// Calling back into the program never reads frame state, so any frame serves; the root is always alive.
this.root = new Frame(this, new ScopeStack([globalScope]))
this.runner = {
invokeCallable: (callable, thisValue, args, node) => this.root.invokeCallable(callable, thisValue, args, node),
invokeCallable: (callable, thisValue, args) => this.root.invokeCallable(callable, thisValue, args),
settlePromise: (promise) => this.root.settlePromise(promise),
syncIterator: (value, node) => this.root.syncIterator(value, node),
syncIterator: (value) => this.root.syncIterator(value),
prototypes,
}
for (const [name, value] of [...globals(this), ...extraGlobals(this)]) {
@@ -281,6 +280,8 @@ export class Runtime<R> {
}
}
const MAX_CALL_DEPTH = 10_000
/** One activation: the top-level program or a single function call, evaluating against its own scope chain. */
class Frame<R> {
private generatorState?: GeneratorState
@@ -289,6 +290,8 @@ class Frame<R> {
constructor(
private readonly runtime: Runtime<R>,
private scopes: ScopeStack,
/** Nested call depth; resets when an await resumes, since the continuation runs from the job queue. */
private depth = 0,
) {}
run(program: Program): Effect.Effect<unknown, unknown, R> {
@@ -313,12 +316,12 @@ class Frame<R> {
}
if (result.kind === "break" || result.kind === "continue") {
throw new InterpreterRuntimeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
throw typeError(`Unexpected '${result.kind}' outside of a loop.`, statement)
}
}
// The implicit async body adopts returned promises before copy-out.
value = yield* resolvePromiseValue(self.runtime.runner, value, program)
value = yield* resolvePromiseValue(self.runtime.runner, value)
return value
}).pipe(Effect.ensuring(Effect.sync(() => self.scopes.pop())))
}
@@ -483,7 +486,7 @@ class Frame<R> {
return Effect.gen(function* () {
const discriminant = yield* self.evaluateExpression(node.discriminant)
if (containsOpaqueReference(discriminant)) {
throw new InterpreterRuntimeError("Switch discriminants must be data values.", node, "InvalidDataValue")
throw invalidData("Switch discriminants must be data values.", node)
}
self.scopes.push()
return yield* Effect.gen(function* () {
@@ -501,7 +504,7 @@ class Frame<R> {
}
const candidate = yield* self.evaluateExpression(test)
if (containsOpaqueReference(candidate)) {
throw new InterpreterRuntimeError("Switch case values must be data values.", test, "InvalidDataValue")
throw invalidData("Switch case values must be data values.", test)
}
if (candidate === discriminant) {
selected = index
@@ -624,7 +627,7 @@ class Frame<R> {
const iterator = yield* self.customIterator(right, node, awaiting)
const cursor = iterator === undefined ? yield* self.syncIterator(right, node) : undefined
if (iterator === undefined && cursor === undefined) {
throw new InterpreterRuntimeError(
throw invalidData(
`${awaiting ? "for await...of" : "for...of"} requires an array, string, Map, Set, or URLSearchParams, or custom iterator value.`,
node,
)
@@ -637,7 +640,7 @@ class Frame<R> {
: (cursor?.close ?? Effect.void)
if (left.type === "RestElement" || left.type === "AssignmentPattern") {
throw new InterpreterRuntimeError("Unsupported for...of binding.", left)
throw typeError("Unsupported for...of binding.", left)
}
const assignment = left.type === "VariableDeclaration" ? undefined : left
@@ -664,7 +667,7 @@ class Frame<R> {
while (true) {
const current = iterator
? yield* self.nextIteratorResult(iterator, node, awaiting)
: yield* cursor?.next ?? Effect.fail(new InterpreterRuntimeError("Iterator is unavailable.", node))
: yield* cursor?.next ?? Effect.die(typeError("Iterator is unavailable.", node))
const step = cursor && awaiting ? { done: current.done, value: yield* self.awaitValue(current.value) } : current
if (step.done) return { kind: "none" } satisfies StatementResult
const bodyExit = yield* Effect.exit(evaluateBody(step.value))
@@ -690,16 +693,19 @@ class Frame<R> {
)
}
private awaitValue(value: unknown, node: AstNode = promiseResolutionNode): Effect.Effect<unknown, unknown, R> {
return Effect.flatMap(resolvePromise(this.runtime.runner, this.runtime.promises, value, node), (promise) =>
this.settlePromise(promise),
private awaitValue(value: unknown): Effect.Effect<unknown, unknown, R> {
return Effect.flatMap(resolvePromise(this.runtime.runner, this.runtime.promises, value), (promise) =>
Effect.ensuring(
this.settlePromise(promise),
Effect.sync(() => (this.depth = 0)),
),
)
}
private awaitAsyncFromSyncValue(
iterator: CustomIterator,
value: unknown,
node: AstNode,
node: AstNode | undefined,
closeOnRejection: boolean,
): Effect.Effect<unknown, unknown, R> {
const self = this
@@ -713,7 +719,7 @@ class Frame<R> {
})
}
syncIterator(value: unknown, node: AstNode) {
syncIterator(value: unknown, node?: AstNode) {
const iterator =
value instanceof ProgramArray
? value.items[Symbol.iterator]()
@@ -750,7 +756,7 @@ class Frame<R> {
)
}
private customIterator(value: unknown, node: AstNode, allowAsync = true) {
private customIterator(value: unknown, node: AstNode | undefined, allowAsync = true) {
if (!(value instanceof ProgramObject)) return Effect.undefined
const asyncMethod = allowAsync ? get(value, AsyncIteratorSymbol) : undefined
const method = asyncMethod ?? get(value, IteratorSymbol)
@@ -769,7 +775,7 @@ class Frame<R> {
)
}
private nextIteratorResult(iterator: CustomIterator, node: AstNode, awaiting: boolean) {
private nextIteratorResult(iterator: CustomIterator, node: AstNode | undefined, awaiting: boolean) {
const self = this
return Effect.gen(function* () {
if (iterator.asynchronous) {
@@ -805,7 +811,11 @@ class Frame<R> {
})
}
private closeIterator(iterator: CustomIterator, node: AstNode, awaiting = true): Effect.Effect<void, unknown, R> {
private closeIterator(
iterator: CustomIterator,
node: AstNode | undefined,
awaiting = true,
): Effect.Effect<void, unknown, R> {
const close = get(iterator.iterator, "return")
if (close === undefined || close === null) return iterator.asynchronous || !awaiting ? Effect.void : Effect.yieldNow
const self = this
@@ -836,14 +846,14 @@ class Frame<R> {
})
}
private requireIteratorObject(value: unknown, context: string, node: AstNode): ProgramObject {
private requireIteratorObject(value: unknown, context: string, node?: AstNode): ProgramObject {
if (value instanceof ProgramObject) return value
throw new InterpreterRuntimeError(`${context} must be an object.`, node)
throw typeError(`${context} must be an object.`, node)
}
private requireIteratorMethod(value: unknown, context: string, node: AstNode): unknown {
private requireIteratorMethod(value: unknown, context: string, node?: AstNode): unknown {
if (typeofValue(value) === "function") return value
throw new InterpreterRuntimeError(`${context} must be a function.`, node)
throw typeError(`${context} must be a function.`, node)
}
// for...in over null/undefined iterates nothing, like JS.
@@ -869,7 +879,7 @@ class Frame<R> {
const keys = self.enumerableKeys(right, node.right)
if (left.type !== "Identifier" && left.type !== "VariableDeclaration") {
throw new InterpreterRuntimeError("Unsupported for...in binding.", left)
throw typeError("Unsupported for...in binding.", left)
}
const assignmentName = left.type === "Identifier" ? left.name : undefined
@@ -955,7 +965,7 @@ class Frame<R> {
return Effect.failCause(cause)
}
const caught = caughtErrorValue(self.runtime.runner, Cause.squash(cause))
const caught = materialize(self.runtime.runner, Cause.squash(cause))
const parameter = handler.param
self.scopes.push()
return Effect.gen(function* () {
@@ -991,7 +1001,7 @@ class Frame<R> {
return Effect.gen(function* () {
for (const declaration of node.declarations) {
if (declaration.type !== "VariableDeclarator") {
throw new InterpreterRuntimeError("Unsupported variable declaration shape.", declaration)
throw typeError("Unsupported variable declaration shape.", declaration)
}
const init = declaration.init
@@ -1033,10 +1043,9 @@ class Frame<R> {
if (pattern.type === "ObjectPattern") {
if (!(value instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
throw typeError(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
"InvalidDataValue",
)
}
@@ -1051,7 +1060,13 @@ class Frame<R> {
const key = yield* self.destructuringPropertyKey(property)
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.declarePattern(property.value, get(value, key), mutable, property, initialize)
yield* self.declarePattern(
property.value,
self.readProperty(value, key, property),
mutable,
property,
initialize,
)
}
return
}
@@ -1062,7 +1077,7 @@ class Frame<R> {
)
}
throw new InterpreterRuntimeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
throw typeError(`Unsupported binding pattern '${pattern.type}'.`, pattern)
})
}
@@ -1087,10 +1102,9 @@ class Frame<R> {
if (pattern.type === "ObjectPattern") {
if (!(value instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
throw invalidData(
`Object destructuring requires a data object or array value, received ${describeValue(value)}.`,
pattern,
"InvalidDataValue",
)
}
@@ -1104,7 +1118,7 @@ class Frame<R> {
}
const key = yield* self.destructuringPropertyKey(property)
consumed.add(typeof key === "symbol" ? key : String(key))
yield* self.assignPattern(property.value, get(value, key), property)
yield* self.assignPattern(property.value, self.readProperty(value, key, property), property)
}
return
}
@@ -1115,7 +1129,7 @@ class Frame<R> {
)
}
throw new InterpreterRuntimeError(`Unsupported assignment pattern '${pattern.type}'.`, node)
throw typeError(`Unsupported assignment pattern '${pattern.type}'.`, node)
})
}
@@ -1134,7 +1148,7 @@ class Frame<R> {
return Effect.gen(function* () {
const cursor = yield* self.syncIterator(value, pattern)
if (cursor === undefined) {
throw new InterpreterRuntimeError("Array destructuring requires a supported iterable value.", pattern)
throw typeError("Array destructuring requires a supported iterable value.", pattern)
}
let done = false
for (const element of pattern.elements) {
@@ -1171,7 +1185,7 @@ class Frame<R> {
private destructuringPropertyKey(property: Property | AssignmentProperty): Effect.Effect<PropertyKey, unknown, R> {
if (property.type !== "Property" || property.kind !== "init") {
throw new InterpreterRuntimeError("Unsupported object destructuring property.", property)
throw typeError("Unsupported object destructuring property.", property)
}
const keyNode = property.key
if (property.computed) {
@@ -1186,8 +1200,7 @@ class Frame<R> {
switch (node.type) {
case "Literal": {
const regex = node.regex
if (regex)
return Effect.sync(() => constructRegExp(this.runtime.prototypes, [regex.pattern, regex.flags], node))
if (regex) return Effect.sync(() => constructRegExp(this.runtime.prototypes, [regex.pattern, regex.flags]))
return Effect.sync(() => toProgram(this.runtime.prototypes, node.value, "Literal"))
}
case "Identifier":
@@ -1233,7 +1246,7 @@ class Frame<R> {
return this.evaluateUpdateExpression(node)
case "AwaitExpression": {
// Await always suspends, including for plain values.
return Effect.flatMap(this.evaluateExpression(node.argument), (value) => this.awaitValue(value, node))
return Effect.flatMap(this.evaluateExpression(node.argument), (value) => this.awaitValue(value))
}
case "YieldExpression":
return this.evaluateYieldExpression(node)
@@ -1261,10 +1274,10 @@ class Frame<R> {
: callee instanceof NativeFunction
? `new ${name}(...) is not supported; call ${name}(...) without new instead.`
: `${name} is not a constructor.`
throw new InterpreterRuntimeError(message, node)
throw typeError(message, node)
}
const args = yield* self.evaluateCallArguments(node.arguments)
return yield* construct(args, callee as NativeFunction<R>, node)
return yield* self.native(() => construct(args, callee as NativeFunction<R>), node)
})
}
@@ -1292,7 +1305,7 @@ class Frame<R> {
return has(rhs, lhs !== null && typeof lhs === "object" ? coerceToString(lhs) : (lhs as PropertyKey))
}
if (containsOpaqueReference(lhs) || containsOpaqueReference(rhs)) {
throw new InterpreterRuntimeError("Binary operators require data values.", node, "InvalidDataValue")
throw invalidData("Binary operators require data values.", node)
}
// Null-prototype data needs explicit primitive coercion; identity and `in` retain raw objects.
// Dates use their default string hint for addition and loose equality, and epoch time elsewhere.
@@ -1344,11 +1357,11 @@ class Frame<R> {
return (l as number) >>> (r as number)
case "in":
if (!(rhs instanceof ProgramObject)) {
throw new InterpreterRuntimeError("The 'in' operator requires a data object on the right-hand side.", node)
throw typeError("The 'in' operator requires a data object on the right-hand side.", node)
}
return has(rhs, coerceOperand(lhs) as PropertyKey)
default:
throw new InterpreterRuntimeError(`Unsupported binary operator '${operator}'.`, node)
throw typeError(`Unsupported binary operator '${operator}'.`, node)
}
}
@@ -1359,7 +1372,7 @@ class Frame<R> {
if (operator === "||") return left ? Effect.succeed(left) : this.evaluateExpression(node.right)
if (operator === "??")
return left !== null && left !== undefined ? Effect.succeed(left) : this.evaluateExpression(node.right)
throw new InterpreterRuntimeError(`Unsupported logical operator '${operator}'.`, node)
throw typeError(`Unsupported logical operator '${operator}'.`, node)
})
}
@@ -1376,7 +1389,7 @@ class Frame<R> {
if (operator === "!") return !value
if (operator === "void") return undefined
if (containsOpaqueReference(value)) {
throw new InterpreterRuntimeError("Unary operators require data values.", node, "InvalidDataValue")
throw invalidData("Unary operators require data values.", node)
}
const operand =
value instanceof ProgramDate
@@ -1396,7 +1409,7 @@ class Frame<R> {
result = ~(operand as number)
break
default:
throw new InterpreterRuntimeError(`Unsupported unary operator '${operator}'.`, node)
throw typeError(`Unsupported unary operator '${operator}'.`, node)
}
return toProgram(this.runtime.prototypes, result, "Unary expression result")
})
@@ -1443,7 +1456,7 @@ class Frame<R> {
}),
)
}
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
throw typeError("Assignment target must be an Identifier or MemberExpression.", left)
})
}
@@ -1475,7 +1488,7 @@ class Frame<R> {
: Effect.succeed({ write: false, next: current, result: current }),
)
}
throw new InterpreterRuntimeError("Assignment target must be an Identifier or MemberExpression.", left)
throw typeError("Assignment target must be an Identifier or MemberExpression.", left)
}
private evaluateUpdateExpression(node: UpdateExpression): Effect.Effect<unknown, unknown, R> {
@@ -1486,14 +1499,14 @@ class Frame<R> {
const increment = operator === "++" ? 1 : operator === "--" ? -1 : undefined
if (increment === undefined) {
throw new InterpreterRuntimeError(`Unsupported update operator '${operator}'.`, node)
throw typeError(`Unsupported update operator '${operator}'.`, node)
}
// CodeMode numeric coercion, not host Number(): null-prototype data objects would make
// the host throw during ToPrimitive, and opaque runtime references must reject clearly.
const operand = (current: unknown): number => {
if (containsOpaqueReference(current)) {
throw new InterpreterRuntimeError(`'${operator}' requires a data value.`, argument, "InvalidDataValue")
throw invalidData(`'${operator}' requires a data value.`, argument)
}
return coerceToNumber(current)
}
@@ -1516,7 +1529,7 @@ class Frame<R> {
})
}
throw new InterpreterRuntimeError("Update target must be an Identifier or MemberExpression.", argument)
throw typeError("Update target must be an Identifier or MemberExpression.", argument)
}
// EvaluateCall: a member callee supplies its base object as `this`; anything else calls with undefined.
@@ -1552,23 +1565,34 @@ class Frame<R> {
callable: unknown,
thisValue: unknown,
args: Array<unknown>,
node: AstNode,
node?: AstNode,
callee?: Expression,
): Effect.Effect<unknown, unknown, R> {
const self = this
return Effect.gen(function* () {
if (callable instanceof ToolReference) {
if (callable.path.length === 0) {
throw new InterpreterRuntimeError("The tools root is not callable.", callee ?? node)
throw typeError("The tools root is not callable.", callee ?? node)
}
return yield* self.createToolCallPromise(callable.path, args)
}
if (callable instanceof ProgramFunction) return yield* self.invokeFunction(callable, args)
if (callable instanceof NativeFunction) return yield* (callable as NativeFunction<R>).call(thisValue, args, node)
throw new InterpreterRuntimeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
if (callable instanceof ProgramFunction) return yield* self.invokeFunction(callable, args, node)
if (callable instanceof NativeFunction) {
return yield* self.native(() => (callable as NativeFunction<R>).call(thisValue, args), node)
}
throw typeError(`${calleeDescription(callee)} is not a function.`, callee ?? node)
})
}
// Built-ins throw without a location, synchronously or inside their Effect; the call site supplies it.
private native(body: () => Effect.Effect<unknown, unknown, R>, node?: AstNode): Effect.Effect<unknown, unknown, R> {
return Effect.provideService(
Effect.catchDefect(Effect.suspend(body), (defect) => Effect.die(locate(defect, node))),
CallSite,
{ node, depth: this.depth },
)
}
private evaluateCallArguments(
argNodes: ReadonlyArray<Expression | SpreadElement>,
): Effect.Effect<Array<unknown>, unknown, R> {
@@ -1579,8 +1603,7 @@ class Frame<R> {
if (argNode.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(argNode.argument)
const cursor = yield* self.syncIterator(spread, argNode)
if (cursor === undefined)
throw new InterpreterRuntimeError("Spread arguments require a synchronous iterable.", argNode)
if (cursor === undefined) throw typeError("Spread arguments require a synchronous iterable.", argNode)
while (true) {
const step = yield* cursor.next
if (step.done) break
@@ -1594,45 +1617,50 @@ class Frame<R> {
})
}
invokeFunction(fn: ProgramFunction, args: Array<unknown>): Effect.Effect<unknown, unknown, R> {
// A callback invoked by a built-in runs below the call that invoked the built-in, so the deeper of the two counts.
invokeFunction(fn: ProgramFunction, args: Array<unknown>, node?: AstNode): Effect.Effect<unknown, unknown, R> {
const self = this
const invocation = new Frame(this.runtime, new ScopeStack([...fn.capturedScopes, new Map()]))
const run = Effect.gen(function* () {
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
const paramScope = invocation.scopes.current()
for (const parameter of fn.parameters) {
for (const name of collectPatternNames(parameter)) {
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
return Effect.flatMap(CallSite, (site) => {
const depth = Math.max(self.depth, site.depth) + 1
if (depth > MAX_CALL_DEPTH) throw rangeError("Maximum call stack size exceeded", node)
const invocation = new Frame(this.runtime, new ScopeStack([...fn.capturedScopes, new Map()]), depth)
const run = Effect.gen(function* () {
// Seed all parameters first so defaults cannot fall through to same-named outer bindings.
const paramScope = invocation.scopes.current()
for (const parameter of fn.parameters) {
for (const name of collectPatternNames(parameter)) {
paramScope.set(name, { mutable: true, value: undefined, initialized: false })
}
}
}
for (const [index, parameter] of fn.parameters.entries()) {
if (parameter.type === "RestElement") {
yield* invocation.declarePattern(
parameter.argument,
new ProgramArray(self.runtime.prototypes.Array, args.slice(index)),
true,
parameter,
true,
)
break
for (const [index, parameter] of fn.parameters.entries()) {
if (parameter.type === "RestElement") {
yield* invocation.declarePattern(
parameter.argument,
new ProgramArray(self.runtime.prototypes.Array, args.slice(index)),
true,
parameter,
true,
)
break
}
yield* invocation.declarePattern(parameter, args[index], true, parameter, true)
}
yield* invocation.declarePattern(parameter, args[index], true, parameter, true)
}
if (fn.body.type === "BlockStatement") {
invocation.scopes.push()
invocation.hoistVars(fn.body.body, paramScope)
const result = yield* invocation.evaluateStatement(fn.body)
return result.kind === "return" ? result.value : undefined
}
if (fn.body.type === "BlockStatement") {
invocation.scopes.push()
invocation.hoistVars(fn.body.body, paramScope)
const result = yield* invocation.evaluateStatement(fn.body)
return result.kind === "return" ? result.value : undefined
}
return yield* invocation.evaluateExpression(fn.body)
return yield* invocation.evaluateExpression(fn.body)
})
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
return this.runtime.promises.createWithSelf((self) =>
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, self)),
)
})
if (fn.generator) return Effect.succeed(this.createGenerator(invocation, run, fn.async))
if (!fn.async) return run
return this.runtime.promises.createWithSelf((self) =>
Effect.flatMap(run, (value) => resolvePromiseValue(invocation.runtime.runner, value, fn.body, self)),
)
}
private createGenerator(
@@ -1645,11 +1673,9 @@ class Frame<R> {
invocation.generatorAsync = asynchronous
const protos = this.runtime.prototypes
const result = (value: unknown, done: boolean) => record(protos.Object, { value, done })
const request = (kind: GeneratorRequestKind, value: unknown, node: AstNode) => {
const request = (kind: GeneratorRequestKind, value: unknown) => {
const request = { kind, value, response: Deferred.makeUnsafe<unknown, unknown>() }
if (!asynchronous && state.active) {
return Effect.fail(new InterpreterRuntimeError("Generator is already running.", node))
}
if (!asynchronous && state.active) return Effect.die(typeError("Generator is already running."))
if (asynchronous && (state.completed || (!state.started && kind !== "next"))) {
state.started = true
state.completed = true
@@ -1769,7 +1795,7 @@ class Frame<R> {
const argument = node.argument
const self = this
return Effect.gen(function* () {
if (!self.generatorState) throw new InterpreterRuntimeError("yield is only valid inside a generator.", node)
if (!self.generatorState) throw typeError("yield is only valid inside a generator.", node)
if (node.delegate) {
const value = argument ? yield* self.evaluateExpression(argument) : undefined
return yield* self.delegateYield(value, node)
@@ -1782,7 +1808,7 @@ class Frame<R> {
private suspendGenerator(value: unknown, node: AstNode): Effect.Effect<unknown, unknown, R> {
const state = this.generatorState
if (!state?.active) throw new InterpreterRuntimeError("Generator has no active request.", node)
if (!state?.active) throw typeError("Generator has no active request.", node)
Deferred.doneUnsafe(
state.active.response,
Exit.succeed(record(this.runtime.prototypes.Object, { value, done: false })),
@@ -1809,7 +1835,7 @@ class Frame<R> {
value instanceof ProgramURLSearchParams
) {
const cursor = yield* self.syncIterator(value, node)
if (!cursor) throw new InterpreterRuntimeError("Built-in iterator is unavailable.", node)
if (!cursor) throw typeError("Built-in iterator is unavailable.", node)
while (true) {
const step = yield* cursor.next
if (step.done) return undefined
@@ -1824,14 +1850,14 @@ class Frame<R> {
}
if (error instanceof ProgramThrow) {
yield* cursor.close
throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node)
throw typeError("The delegated iterator does not provide a throw() method.", node)
}
return yield* Effect.failCause(resumed.cause)
}
}
const iterator = yield* self.customIterator(value, node, self.generatorAsync)
if (!iterator) throw new InterpreterRuntimeError("yield* requires a compatible iterable value.", node)
if (!iterator) throw typeError("yield* requires a compatible iterable value.", node)
let kind: GeneratorRequestKind = "next"
let input: unknown = undefined
while (true) {
@@ -1839,7 +1865,7 @@ class Frame<R> {
if (method === undefined || method === null) {
if (kind === "return") return yield* Effect.fail(new GeneratorReturn(input))
yield* self.closeIterator(iterator, node, self.generatorAsync)
throw new InterpreterRuntimeError("The delegated iterator does not provide a throw() method.", node)
throw typeError("The delegated iterator does not provide a throw() method.", node)
}
const called = yield* self.invokeCallable(
self.requireIteratorMethod(method, `Iterator ${kind}`, node),
@@ -1891,7 +1917,7 @@ class Frame<R> {
}
if (property.kind !== "init") {
throw new InterpreterRuntimeError("Only init object properties are supported.", property)
throw typeError("Only init object properties are supported.", property)
}
const keyNode = property.key
@@ -1905,7 +1931,7 @@ class Frame<R> {
} else if (keyNode.type === "Literal") {
key = self.toPropertyKey(keyNode.value, keyNode)
} else {
throw new InterpreterRuntimeError("Unsupported object property key shape.", keyNode)
throw typeError("Unsupported object property key shape.", keyNode)
}
const name =
@@ -1935,8 +1961,7 @@ class Frame<R> {
if (element.type === "SpreadElement") {
const spread = yield* self.evaluateExpression(element.argument)
const cursor = yield* self.syncIterator(spread, element)
if (cursor === undefined)
throw new InterpreterRuntimeError("Array spread requires a synchronous iterable.", element)
if (cursor === undefined) throw typeError("Array spread requires a synchronous iterable.", element)
while (true) {
const step = yield* cursor.next
if (step.done) break
@@ -1962,7 +1987,7 @@ class Frame<R> {
const quasi = quasis[index]!
// acorn only omits `cooked` for invalid escapes in tagged templates, which are unsupported.
if (typeof quasi.value.cooked !== "string") {
throw new InterpreterRuntimeError("Invalid template literal quasi.", quasi)
throw typeError("Invalid template literal quasi.", quasi)
}
output += quasi.value.cooked
@@ -1984,7 +2009,7 @@ class Frame<R> {
private applyCompoundAssignment(operator: string, current: unknown, incoming: unknown, node: AstNode): unknown {
if (!compoundOperators.has(operator)) {
throw new InterpreterRuntimeError(`Unsupported assignment operator '${operator}'.`, node)
throw typeError(`Unsupported assignment operator '${operator}'.`, node)
}
return this.applyBinaryOperator(operator.slice(0, -1), current, incoming, node)
}
@@ -2010,7 +2035,7 @@ class Frame<R> {
if (objectValue instanceof ToolReference) {
if (typeof key !== "string") {
throw new InterpreterRuntimeError("Tool paths must use string property names.", propertyNode)
throw typeError("Tool paths must use string property names.", propertyNode)
}
return new ToolReference([...objectValue.path, key])
}
@@ -2029,26 +2054,27 @@ class Frame<R> {
if (typeof objectValue === "boolean") return { target: protos.Boolean, key, receiver: objectValue }
if (objectValue === null || objectValue === undefined) {
throw new InterpreterRuntimeError(
`Cannot read properties of ${objectValue} (reading '${String(key)}').`,
objectNode,
)
throw typeError(`Cannot read properties of ${objectValue} (reading '${String(key)}').`, objectNode)
}
throw new InterpreterRuntimeError("Cannot access a property on a non-object value.", objectNode)
throw typeError("Cannot access a property on a non-object value.", objectNode)
})
}
private readReference(reference: MemberReference, node: MemberExpression): unknown {
// Reject unknown promise properties so a missing await cannot hide.
if (reference.target instanceof ProgramPromise && !has(reference.target, reference.key)) {
throw new InterpreterRuntimeError(
throw invalidData(
"This value is an un-awaited Promise; await it first - e.g. `const result = await tools.ns.tool(...)`.",
node.object,
"InvalidDataValue",
)
}
return this.readProperty(reference.target, reference.key, node, reference.receiver)
}
// Accessors throw without a location; the member or pattern that read them supplies it.
private readProperty(target: ProgramObject, key: PropertyKey, node: AstNode, receiver: unknown = target): unknown {
try {
return get(reference.target, reference.key, reference.receiver)
return get(target, key, receiver)
} catch (error) {
throw locate(error, node)
}
@@ -2070,15 +2096,15 @@ class Frame<R> {
private evaluateDeleteExpression(argument: Expression): Effect.Effect<boolean, unknown, R> {
const target = argument.type === "ChainExpression" ? argument.expression : argument
if (target.type !== "MemberExpression") {
throw new InterpreterRuntimeError("Only data fields may be deleted.", argument)
throw typeError("Only data fields may be deleted.", argument)
}
return Effect.map(this.getMemberReference(target), (reference) => {
if (reference === OptionalShortCircuit) return true
if (reference instanceof ToolReference || "value" in reference || reference.receiver !== reference.target) {
throw new InterpreterRuntimeError("Only data fields may be deleted.", target, "InvalidDataValue")
throw invalidData("Only data fields may be deleted.", target)
}
if (remove(reference.target, reference.key)) return true
throw new InterpreterRuntimeError(`Cannot delete property '${String(reference.key)}'.`, target)
throw typeError(`Cannot delete property '${String(reference.key)}'.`, target)
})
}
@@ -2091,10 +2117,10 @@ class Frame<R> {
return Effect.gen(function* () {
const reference = yield* self.getMemberReference(node)
if (reference === OptionalShortCircuit || reference instanceof ToolReference || "value" in reference) {
throw new InterpreterRuntimeError("Only data fields may be assigned.", node)
throw typeError("Only data fields may be assigned.", node)
}
if (reference.receiver !== reference.target) {
throw new InterpreterRuntimeError(
throw typeError(
`Cannot create property '${String(reference.key)}' on ${typeof reference.receiver} '${String(reference.receiver)}'.`,
node,
)
@@ -2107,14 +2133,13 @@ class Frame<R> {
}
private assignToReference(target: ProgramObject, key: PropertyKey, next: unknown, node: AstNode): void {
rejectCircularInsertion(
target,
next,
target instanceof ProgramArray ? "Array assignment result" : "Object assignment result",
node,
)
const written = (() => {
try {
rejectCircularInsertion(
target,
next,
target instanceof ProgramArray ? "Array assignment result" : "Object assignment result",
)
return set(target, key, next)
} catch (error) {
throw locate(error, node)
@@ -2122,7 +2147,7 @@ class Frame<R> {
})()
if (written) return
if (target instanceof ProgramArray && key === "length") throw rangeError("Invalid array length", node)
throw new InterpreterRuntimeError(`Cannot assign to read only property '${String(key)}'.`, node)
throw typeError(`Cannot assign to read only property '${String(key)}'.`, node)
}
private toPropertyKey(value: unknown, node: AstNode): PropertyKey {
@@ -2131,9 +2156,6 @@ class Frame<R> {
}
if (value === AsyncIteratorSymbol || value === IteratorSymbol) return value
throw new InterpreterRuntimeError(
"Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.",
node,
)
throw typeError("Property key must be a string or number, or Symbol.asyncIterator/Symbol.iterator.", node)
}
}
+6 -6
View File
@@ -1,4 +1,4 @@
import { type AstNode, type Binding, InterpreterRuntimeError, referenceError } from "./model.js"
import { type AstNode, type Binding, referenceError, typeError } from "./model.js"
export class ScopeStack {
private readonly scopes: Array<Map<string, Binding>>
@@ -10,7 +10,7 @@ export class ScopeStack {
reserve(name: string, mutable: boolean, node: AstNode): void {
const scope = this.current()
if (scope.has(name)) {
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
throw typeError(`Identifier '${name}' has already been declared.`, node)
}
scope.set(name, { mutable, value: undefined, initialized: false })
}
@@ -18,7 +18,7 @@ export class ScopeStack {
initialize(name: string, value: unknown, node: AstNode): void {
const binding = this.current().get(name)
if (!binding || binding.initialized !== false) {
throw new InterpreterRuntimeError(`Identifier '${name}' has not been reserved for initialization.`, node)
throw typeError(`Identifier '${name}' has not been reserved for initialization.`, node)
}
binding.value = value
binding.initialized = true
@@ -27,7 +27,7 @@ export class ScopeStack {
declare(name: string, value: unknown, mutable: boolean, node: AstNode): void {
const scope = this.current()
if (scope.has(name)) {
throw new InterpreterRuntimeError(`Identifier '${name}' has already been declared.`, node)
throw typeError(`Identifier '${name}' has already been declared.`, node)
}
scope.set(name, { mutable, value, initialized: true })
}
@@ -58,7 +58,7 @@ export class ScopeStack {
}
if (!binding.mutable) {
throw new InterpreterRuntimeError(`Cannot assign to constant '${name}'.`, node)
throw typeError(`Cannot assign to constant '${name}'.`, node)
}
binding.value = value
@@ -82,7 +82,7 @@ export class ScopeStack {
const scope = this.scopes[this.scopes.length - 1]
if (!scope) {
throw new InterpreterRuntimeError("Interpreter scope stack is empty.")
throw typeError("Interpreter scope stack is empty.")
}
return scope
+89 -101
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
import { get, ProgramArray, ProgramGenerator, ProgramObject } from "../interpreter/objects.js"
import { describeValue, rejectCircularInsertion } from "../interpreter/references.js"
import { applyCollectionCallback, preserveConsumerError, type Runner } from "../interpreter/runner.js"
@@ -9,35 +9,30 @@ import { coerceToNumber, coerceToString } from "./value.js"
const MAX_LENGTH = 4_294_967_295
const arrayLikeSource = (
source: unknown,
node: AstNode,
): { readonly length: number; readonly source: ProgramObject } => {
const arrayLikeSource = (source: unknown): { readonly length: number; readonly source: ProgramObject } => {
if (source instanceof ProgramObject && typeof get(source, "length") === "number") {
const length = get(source, "length") as number
const normalized = Number.isNaN(length) || length <= 0 ? 0 : Math.trunc(length)
if (normalized > MAX_LENGTH) throw new RangeError("Invalid array length")
return { length: normalized, source }
}
throw new InterpreterRuntimeError(
throw invalidData(
`Array.from expects an array, string, Map, Set, or array-like value, received ${describeValue(source)}.`,
node,
"InvalidDataValue",
)
}
const arrayFrom = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
const arrayFrom = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const source = args[0]
const proto = runner.prototypes.Array
const apply =
args.length < 2 || args[1] === undefined ? undefined : applyCollectionCallback(runner, args[1], "Array.from", node)
args.length < 2 || args[1] === undefined ? undefined : applyCollectionCallback(runner, args[1], "Array.from")
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
const cursor = yield* runner.syncIterator(source)
if (cursor === undefined) {
if (source instanceof ProgramGenerator) {
throw new InterpreterRuntimeError("Array.from expects a synchronous iterable or array-like value.", node)
throw typeError("Array.from expects a synchronous iterable or array-like value.")
}
const arrayLike = arrayLikeSource(source, node)
const arrayLike = arrayLikeSource(source)
const values: Array<unknown> = []
for (let index = 0; index < arrayLike.length; index += 1) {
const item = get(arrayLike.source, index)
@@ -61,12 +56,11 @@ export const sortArray = <R>(
target: Array<unknown>,
comparator: unknown,
name: string,
node: AstNode,
): Effect.Effect<Array<unknown>, unknown, R> => {
if (comparator === undefined) {
return Effect.sync(() => [...target].sort((a, b) => compareText(coerceToString(a), coerceToString(b))))
}
const apply = applyCollectionCallback(runner, comparator, name, node)
const apply = applyCollectionCallback(runner, comparator, name)
const mergeSort = (items: Array<unknown>): Effect.Effect<Array<unknown>, unknown, R> => {
if (items.length <= 1) return Effect.succeed(items)
const midpoint = Math.floor(items.length / 2)
@@ -95,32 +89,31 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
const protos = runner.prototypes
const proto = protos.Array
const wrap = (items: Array<unknown>) => new ProgramArray(proto, items)
const construct = (args: Array<unknown>, into: ProgramObject, node: AstNode): ProgramArray => {
const construct = (args: Array<unknown>, into: ProgramObject): ProgramArray => {
if (args.length !== 1) return new ProgramArray(into, [...args])
const first = args[0]
if (typeof first !== "number") return new ProgramArray(into, [first])
if (!Number.isInteger(first) || first < 0 || first > MAX_LENGTH) throw rangeError("Invalid array length.", node)
if (!Number.isInteger(first) || first < 0 || first > MAX_LENGTH) throw rangeError("Invalid array length.")
// Sparse like JS: Array(3) has holes, and combinator loops already skip them.
return new ProgramArray(into, new Array(first))
}
const array = constructor<R>(protos, proto, {
name: "Array",
length: 1,
call: (_, args, node) => Effect.sync(() => construct(args, proto, node)),
construct: (args, newTarget, node) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto), node)),
call: (_, args) => Effect.sync(() => construct(args, proto)),
construct: (args, newTarget) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto))),
})
methods(protos, array, [
["isArray", 1, (_, args) => args[0] instanceof ProgramArray],
["of", 0, (_, args) => wrap([...args])],
["from", 1, (_, args, node) => arrayFrom(runner, args, node)],
["from", 1, (_, args) => arrayFrom(runner, args)],
])
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramArray, thisValue, `Array.prototype.${name}`, node)
const optNumber = (name: string, value: unknown, label: string, node: AstNode): number | undefined => {
const self = (thisValue: unknown, name: string) => receiver(ProgramArray, thisValue, `Array.prototype.${name}`)
const optNumber = (name: string, value: unknown, label: string): number | undefined => {
if (value === undefined) return undefined
if (typeof value !== "number") {
throw new InterpreterRuntimeError(`Array.${name} expects ${label} to be a number.`, node)
throw typeError(`Array.${name} expects ${label} to be a number.`)
}
return value
}
@@ -133,14 +126,13 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
receiver: ProgramArray,
apply: (args: Array<unknown>) => Effect.Effect<unknown, unknown, R>,
args: Array<unknown>,
node: AstNode,
) => Effect.Effect<unknown, unknown, R>,
): Method => [
name,
length,
(thisValue, args, node) => {
const target = self(thisValue, name, node)
return body(target.items, target, applyCollectionCallback(runner, args[0], `Array.${name}`, node), args, node)
(thisValue, args) => {
const target = self(thisValue, name)
return body(target.items, target, applyCollectionCallback(runner, args[0], `Array.${name}`), args)
},
]
@@ -148,10 +140,10 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"join",
1,
(thisValue, args, node) => {
const target = self(thisValue, "join", node).items
(thisValue, args) => {
const target = self(thisValue, "join").items
if (args.length > 1 || (args.length === 1 && typeof args[0] !== "string")) {
throw new InterpreterRuntimeError("Array.join expects zero arguments or one string separator.", node)
throw typeError("Array.join expects zero arguments or one string separator.")
}
return target.map((item) => coerceToString(item ?? "")).join(args.length === 0 ? "," : (args[0] as string))
},
@@ -159,60 +151,56 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"toString",
0,
(thisValue, _, node) =>
self(thisValue, "toString", node)
(thisValue) =>
self(thisValue, "toString")
.items.map((item) => coerceToString(item ?? ""))
.join(","),
],
[
"includes",
1,
(thisValue, args, node) => {
const target = self(thisValue, "includes", node).items
(thisValue, args) => {
const target = self(thisValue, "includes").items
if (args.length === 0 || args.length > 2) {
throw new InterpreterRuntimeError("Array.includes expects a value and optional start index.", node)
throw typeError("Array.includes expects a value and optional start index.")
}
return target.includes(args[0], optNumber("includes", args[1], "start index", node))
return target.includes(args[0], optNumber("includes", args[1], "start index"))
},
],
[
"indexOf",
1,
(thisValue, args, node) =>
self(thisValue, "indexOf", node).items.indexOf(args[0], optNumber("indexOf", args[1], "start index", node)),
(thisValue, args) =>
self(thisValue, "indexOf").items.indexOf(args[0], optNumber("indexOf", args[1], "start index")),
],
[
"lastIndexOf",
1,
(thisValue, args, node) => {
const target = self(thisValue, "lastIndexOf", node).items
(thisValue, args) => {
const target = self(thisValue, "lastIndexOf").items
return args[1] === undefined
? target.lastIndexOf(args[0])
: target.lastIndexOf(args[0], optNumber("lastIndexOf", args[1], "start index", node))
: target.lastIndexOf(args[0], optNumber("lastIndexOf", args[1], "start index"))
},
],
[
"at",
1,
(thisValue, args, node) => self(thisValue, "at", node).items.at(optNumber("at", args[0], "index", node) ?? 0),
],
["at", 1, (thisValue, args) => self(thisValue, "at").items.at(optNumber("at", args[0], "index") ?? 0)],
[
"slice",
2,
(thisValue, args, node) =>
(thisValue, args) =>
wrap(
self(thisValue, "slice", node).items.slice(
optNumber("slice", args[0], "start", node),
optNumber("slice", args[1], "end", node),
self(thisValue, "slice").items.slice(
optNumber("slice", args[0], "start"),
optNumber("slice", args[1], "end"),
),
),
],
[
"concat",
1,
(thisValue, args, node) =>
(thisValue, args) =>
wrap(
self(thisValue, "concat", node).items.concat(
self(thisValue, "concat").items.concat(
...args.map((item) => (item instanceof ProgramArray ? item.items : item)),
),
),
@@ -220,17 +208,17 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"flat",
0,
(thisValue, args, node) => {
(thisValue, args) => {
const flatten = (items: Array<unknown>, depth: number): Array<unknown> =>
items.flatMap((item) => (item instanceof ProgramArray && depth > 0 ? flatten(item.items, depth - 1) : [item]))
return wrap(flatten(self(thisValue, "flat", node).items, optNumber("flat", args[0], "depth", node) ?? 1))
return wrap(flatten(self(thisValue, "flat").items, optNumber("flat", args[0], "depth") ?? 1))
},
],
[
"reverse",
0,
(thisValue, _, node) => {
const target = self(thisValue, "reverse", node)
(thisValue) => {
const target = self(thisValue, "reverse")
target.items.reverse()
return target
},
@@ -238,13 +226,13 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"sort",
1,
(thisValue, args, node) => {
const target = self(thisValue, "sort", node)
(thisValue, args) => {
const target = self(thisValue, "sort")
const items = target.items
const length = items.length
const holeCount = Array.from({ length }, (_, index) => Object.hasOwn(items, index)).filter((o) => !o).length
const itemCount = length - holeCount
return Effect.map(sortArray(runner, items, args[0], "Array.sort", node), (sorted) => {
return Effect.map(sortArray(runner, items, args[0], "Array.sort"), (sorted) => {
sorted.slice(0, itemCount).forEach((item, index) => {
items[index] = item
})
@@ -258,18 +246,18 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"toSorted",
1,
(thisValue, args, node) =>
Effect.map(sortArray(runner, self(thisValue, "toSorted", node).items, args[0], "Array.toSorted", node), wrap),
(thisValue, args) =>
Effect.map(sortArray(runner, self(thisValue, "toSorted").items, args[0], "Array.toSorted"), wrap),
],
["toReversed", 0, (thisValue, _, node) => wrap([...self(thisValue, "toReversed", node).items].reverse())],
["toReversed", 0, (thisValue) => wrap([...self(thisValue, "toReversed").items].reverse())],
[
"with",
2,
(thisValue, args, node) => {
const target = self(thisValue, "with", node).items
const index = optNumber("with", args[0], "index", node) ?? 0
(thisValue, args) => {
const target = self(thisValue, "with").items
const index = optNumber("with", args[0], "index") ?? 0
const resolved = index < 0 ? target.length + index : index
if (resolved < 0 || resolved >= target.length) throw rangeError("Array.with index is out of range.", node)
if (resolved < 0 || resolved >= target.length) throw rangeError("Array.with index is out of range.")
const copied = [...target]
copied[resolved] = args[1]
return wrap(copied)
@@ -278,80 +266,80 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
[
"push",
1,
(thisValue, args, node) => {
const target = self(thisValue, "push", node)
(thisValue, args) => {
const target = self(thisValue, "push")
// Validate all insertions before mutating to avoid partial cyclic updates.
for (const item of args) rejectCircularInsertion(target, item, "Array.push result", node)
for (const item of args) rejectCircularInsertion(target, item, "Array.push result")
return target.items.push(...args)
},
],
[
"unshift",
1,
(thisValue, args, node) => {
const target = self(thisValue, "unshift", node)
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result", node)
(thisValue, args) => {
const target = self(thisValue, "unshift")
for (const item of args) rejectCircularInsertion(target, item, "Array.unshift result")
return target.items.unshift(...args)
},
],
["pop", 0, (thisValue, _, node) => self(thisValue, "pop", node).items.pop()],
["shift", 0, (thisValue, _, node) => self(thisValue, "shift", node).items.shift()],
["pop", 0, (thisValue) => self(thisValue, "pop").items.pop()],
["shift", 0, (thisValue) => self(thisValue, "shift").items.shift()],
[
"splice",
2,
(thisValue, args, node) => {
const target = self(thisValue, "splice", node)
(thisValue, args) => {
const target = self(thisValue, "splice")
if (args.length === 0) return wrap(target.items.splice(0, 0))
const start = optNumber("splice", args[0], "start", node) ?? 0
const start = optNumber("splice", args[0], "start") ?? 0
if (args.length === 1) return wrap(target.items.splice(start))
const deleteCount = optNumber("splice", args[1], "delete count", node) ?? 0
const deleteCount = optNumber("splice", args[1], "delete count") ?? 0
const inserted = args.slice(2)
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result", node)
for (const item of inserted) rejectCircularInsertion(target, item, "Array.splice result")
return wrap(target.items.splice(start, deleteCount, ...inserted))
},
],
[
"toSpliced",
2,
(thisValue, args, node) => {
const copied = [...self(thisValue, "toSpliced", node).items]
(thisValue, args) => {
const copied = [...self(thisValue, "toSpliced").items]
if (args.length === 0) return wrap(copied)
const start = optNumber("toSpliced", args[0], "start", node) ?? 0
const start = optNumber("toSpliced", args[0], "start") ?? 0
if (args.length === 1) copied.splice(start)
else copied.splice(start, optNumber("toSpliced", args[1], "delete count", node) ?? 0, ...args.slice(2))
else copied.splice(start, optNumber("toSpliced", args[1], "delete count") ?? 0, ...args.slice(2))
return wrap(copied)
},
],
[
"fill",
1,
(thisValue, args, node) => {
const target = self(thisValue, "fill", node)
rejectCircularInsertion(target, args[0], "Array.fill result", node)
target.items.fill(args[0], optNumber("fill", args[1], "start", node), optNumber("fill", args[2], "end", node))
(thisValue, args) => {
const target = self(thisValue, "fill")
rejectCircularInsertion(target, args[0], "Array.fill result")
target.items.fill(args[0], optNumber("fill", args[1], "start"), optNumber("fill", args[2], "end"))
return target
},
],
[
"copyWithin",
2,
(thisValue, args, node) => {
const target = self(thisValue, "copyWithin", node)
(thisValue, args) => {
const target = self(thisValue, "copyWithin")
target.items.copyWithin(
optNumber("copyWithin", args[0], "target index", node) ?? 0,
optNumber("copyWithin", args[1], "start", node) ?? 0,
optNumber("copyWithin", args[2], "end", node),
optNumber("copyWithin", args[0], "target index") ?? 0,
optNumber("copyWithin", args[1], "start") ?? 0,
optNumber("copyWithin", args[2], "end"),
)
return target
},
],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).items.keys()))],
["values", 0, (thisValue, _, node) => wrap([...self(thisValue, "values", node).items])],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").items.keys()))],
["values", 0, (thisValue) => wrap([...self(thisValue, "values").items])],
[
"entries",
0,
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).items.entries(), ([index, item]) => wrap([index, item]))),
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").items.entries(), ([index, item]) => wrap([index, item]))),
],
iterate("map", 1, (target, receiver, apply) =>
Effect.gen(function* () {
@@ -455,7 +443,7 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
return undefined
}),
),
iterate("reduce", 1, (target, receiver, apply, args, node) =>
iterate("reduce", 1, (target, receiver, apply, args) =>
Effect.gen(function* () {
const length = target.length
let start = 0
@@ -463,7 +451,7 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
if (args.length < 2) {
while (start < length && !(start in target)) start += 1
if (start === length) {
throw new InterpreterRuntimeError("Array.reduce of an empty array with no initial value.", node)
throw typeError("Array.reduce of an empty array with no initial value.")
}
accumulator = target[start]
start += 1
@@ -475,14 +463,14 @@ export const arrayGlobal = <R>(runner: Runner<R>) => {
return accumulator
}),
),
iterate("reduceRight", 1, (target, receiver, apply, args, node) =>
iterate("reduceRight", 1, (target, receiver, apply, args) =>
Effect.gen(function* () {
let start = target.length - 1
let accumulator = args[1]
if (args.length < 2) {
while (start >= 0 && !(start in target)) start -= 1
if (start < 0) {
throw new InterpreterRuntimeError("Array.reduceRight of an empty array with no initial value.", node)
throw typeError("Array.reduceRight of an empty array with no initial value.")
}
accumulator = target[start]
start -= 1
+55 -69
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { invalidData, typeError } from "../interpreter/model.js"
import {
define,
defineAccessor,
@@ -24,35 +24,27 @@ import {
toPrimitiveString,
} from "../interpreter/runner.js"
const coerceGroupByPropertyKey = <R>(
runner: Runner<R>,
value: unknown,
node: AstNode,
): Effect.Effect<string, unknown, R> => {
const coerceGroupByPropertyKey = <R>(runner: Runner<R>, value: unknown): Effect.Effect<string, unknown, R> => {
if (value instanceof ProgramPromise) return Effect.succeed("[object Promise]")
if (!isWrapper(value) && isRuntimeReference(value)) {
throw new InterpreterRuntimeError(
`Object.groupBy callback must return a data value, received ${describeValue(value)}.`,
node,
"InvalidDataValue",
)
throw invalidData(`Object.groupBy callback must return a data value, received ${describeValue(value)}.`)
}
return toPrimitiveString(runner, value, node)
return toPrimitiveString(runner, value)
}
/** `Map.groupBy` and `Object.groupBy`: the same iteration, keyed into a Map or a data object. */
export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
fn<R>(runner.prototypes, "groupBy", 2, (_, args, node) => {
fn<R>(runner.prototypes, "groupBy", 2, (_, args) => {
const protos = runner.prototypes
const source = args[0]
if (source === null || source === undefined) {
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node)
throw typeError(`${namespace}.groupBy expects an iterable collection.`)
}
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`, node)
const apply = applyCollectionCallback(runner, args[1], `${namespace}.groupBy`)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
const cursor = yield* runner.syncIterator(source)
if (cursor === undefined) {
throw new InterpreterRuntimeError(`${namespace}.groupBy expects an iterable collection.`, node)
throw typeError(`${namespace}.groupBy expects an iterable collection.`)
}
if (namespace === "Map") {
const result = new ProgramMap(protos.Map)
@@ -78,7 +70,7 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
const item = step.value
const key = yield* preserveConsumerError(
cursor,
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value, node)),
Effect.flatMap(apply([item, index]), (value) => coerceGroupByPropertyKey(runner, value)),
)
const group = getOwn(result, key)
if (group === undefined) define(result, key, new ProgramArray(protos.Array, [item]))
@@ -88,13 +80,13 @@ export const groupBy = <R>(runner: Runner<R>, namespace: "Map" | "Object") =>
})
})
const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject, node: AstNode) => {
const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject) => {
const target = new ProgramMap(proto)
if (init === undefined || init === null) return Effect.succeed(target)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(init, node)
const cursor = yield* runner.syncIterator(init)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new Map(...) expects an iterable of [key, value] pairs or no argument.", node)
throw typeError("new Map(...) expects an iterable of [key, value] pairs or no argument.")
}
while (true) {
const step = yield* cursor.next
@@ -103,7 +95,7 @@ const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject,
cursor,
Effect.sync(() => {
if (!(step.value instanceof ProgramObject)) {
throw new InterpreterRuntimeError("new Map(...) expects [key, value] pairs as entry objects.", node)
throw typeError("new Map(...) expects [key, value] pairs as entry objects.")
}
target.map.set(getOwn(step.value, 0), getOwn(step.value, 1))
}),
@@ -112,13 +104,13 @@ const constructMap = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject,
})
}
const constructSet = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject, node: AstNode) => {
const constructSet = <R>(runner: Runner<R>, init: unknown, proto: ProgramObject) => {
const target = new ProgramSet(proto)
if (init === undefined || init === null) return Effect.succeed(target)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(init, node)
const cursor = yield* runner.syncIterator(init)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new Set(...) expects a synchronous iterable or no argument.", node)
throw typeError("new Set(...) expects a synchronous iterable or no argument.")
}
while (true) {
const step = yield* cursor.next
@@ -134,48 +126,46 @@ export const mapGlobal = <R>(runner: Runner<R>) => {
const map = constructor<R>(protos, proto, {
name: "Map",
call: requiresNew("Map"),
construct: (args, newTarget, node) => constructMap(runner, args[0], prototypeFrom(newTarget, proto), node),
construct: (args, newTarget) => constructMap(runner, args[0], prototypeFrom(newTarget, proto)),
})
define(map, "groupBy", groupBy(runner, "Map"), hidden)
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramMap, thisValue, `Map.prototype.${name}`, node)
const self = (thisValue: unknown, name: string) => receiver(ProgramMap, thisValue, `Map.prototype.${name}`)
const wrap = (items: Array<unknown>) => new ProgramArray(protos.Array, items)
defineAccessor(proto, "size", (thisValue) => receiver(ProgramMap, thisValue, "Map.prototype.size").map.size)
methods(protos, proto, [
["get", 1, (thisValue, args, node) => self(thisValue, "get", node).map.get(args[0])],
["has", 1, (thisValue, args, node) => self(thisValue, "has", node).map.has(args[0])],
["get", 1, (thisValue, args) => self(thisValue, "get").map.get(args[0])],
["has", 1, (thisValue, args) => self(thisValue, "has").map.has(args[0])],
[
"set",
2,
(thisValue, args, node) => {
const target = self(thisValue, "set", node)
(thisValue, args) => {
const target = self(thisValue, "set")
target.map.set(args[0], args[1])
return target
},
],
["delete", 1, (thisValue, args, node) => self(thisValue, "delete", node).map.delete(args[0])],
["delete", 1, (thisValue, args) => self(thisValue, "delete").map.delete(args[0])],
[
"clear",
0,
(thisValue, _, node) => {
self(thisValue, "clear", node).map.clear()
(thisValue) => {
self(thisValue, "clear").map.clear()
return undefined
},
],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).map.keys()))],
["values", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "values", node).map.values()))],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").map.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").map.values()))],
[
"entries",
0,
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).map.entries(), ([key, item]) => wrap([key, item]))),
(thisValue) => wrap(Array.from(self(thisValue, "entries").map.entries(), ([key, item]) => wrap([key, item]))),
],
[
"forEach",
1,
(thisValue, args, node) => {
const target = self(thisValue, "forEach", node)
const apply = applyCollectionCallback(runner, args[0], "Map.forEach", node)
(thisValue, args) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(runner, args[0], "Map.forEach")
return Effect.gen(function* () {
for (const [key, item] of Array.from(target.map.entries())) yield* apply([item, key, target])
return undefined
@@ -196,7 +186,6 @@ const loadSetRecord = <R>(
runner: Runner<R>,
source: unknown,
name: string,
node: AstNode,
): Effect.Effect<SetRecord<R>, unknown, R> => {
if (source instanceof ProgramSet) {
return Effect.succeed({
@@ -213,25 +202,25 @@ const loadSetRecord = <R>(
})
}
if (!(source instanceof ProgramObject)) {
throw new InterpreterRuntimeError(`Set.${name} expects a Set-like object.`, node)
throw typeError(`Set.${name} expects a Set-like object.`)
}
return Effect.gen(function* () {
const size = yield* toPrimitiveNumber(runner, get(source, "size"), node)
const size = yield* toPrimitiveNumber(runner, get(source, "size"))
if (Number.isNaN(size)) {
throw new InterpreterRuntimeError(`Set.${name} received a Set-like object with an invalid size.`, node)
throw typeError(`Set.${name} received a Set-like object with an invalid size.`)
}
const has = get(source, "has")
const keys = get(source, "keys")
if (!isSupportedCallback(has) || !isSupportedCallback(keys)) {
throw new InterpreterRuntimeError(`Set.${name} expects callable 'has' and 'keys' methods.`, node)
throw typeError(`Set.${name} expects callable 'has' and 'keys' methods.`)
}
return {
size: Math.max(Math.trunc(size), 0),
has: (item: unknown) => Effect.map(runner.invokeCallable(has, source, [item], node), Boolean),
has: (item: unknown) => Effect.map(runner.invokeCallable(has, source, [item]), Boolean),
keys: () =>
Effect.flatMap(runner.invokeCallable(keys, source, [], node), (result) => {
Effect.flatMap(runner.invokeCallable(keys, source, []), (result) => {
if (result instanceof ProgramArray) return Effect.succeed(result.items)
throw new InterpreterRuntimeError(`Set.${name} expected 'keys' to return an iterator.`, node)
throw typeError(`Set.${name} expected 'keys' to return an iterator.`)
}),
}
})
@@ -242,10 +231,9 @@ const setOperation = <R>(
target: ProgramSet,
name: string,
source: unknown,
node: AstNode,
): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const other = yield* loadSetRecord(runner, source, name, node)
const other = yield* loadSetRecord(runner, source, name)
const copy = () => {
const result = new ProgramSet(runner.prototypes.Set)
for (const item of target.set.values()) result.set.add(item)
@@ -320,51 +308,49 @@ export const setGlobal = <R>(runner: Runner<R>) => {
const set = constructor<R>(protos, proto, {
name: "Set",
call: requiresNew("Set"),
construct: (args, newTarget, node) => constructSet(runner, args[0], prototypeFrom(newTarget, proto), node),
construct: (args, newTarget) => constructSet(runner, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramSet, thisValue, `Set.prototype.${name}`, node)
const self = (thisValue: unknown, name: string) => receiver(ProgramSet, thisValue, `Set.prototype.${name}`)
const wrap = (items: Array<unknown>) => new ProgramArray(protos.Array, items)
const operation = (name: string): Method => [
name,
1,
(thisValue, args, node) => setOperation(runner, self(thisValue, name, node), name, args[0], node),
(thisValue, args) => setOperation(runner, self(thisValue, name), name, args[0]),
]
defineAccessor(proto, "size", (thisValue) => receiver(ProgramSet, thisValue, "Set.prototype.size").set.size)
methods(protos, proto, [
["has", 1, (thisValue, args, node) => self(thisValue, "has", node).set.has(args[0])],
["has", 1, (thisValue, args) => self(thisValue, "has").set.has(args[0])],
[
"add",
1,
(thisValue, args, node) => {
const target = self(thisValue, "add", node)
(thisValue, args) => {
const target = self(thisValue, "add")
target.set.add(args[0])
return target
},
],
["delete", 1, (thisValue, args, node) => self(thisValue, "delete", node).set.delete(args[0])],
["delete", 1, (thisValue, args) => self(thisValue, "delete").set.delete(args[0])],
[
"clear",
0,
(thisValue, _, node) => {
self(thisValue, "clear", node).set.clear()
(thisValue) => {
self(thisValue, "clear").set.clear()
return undefined
},
],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).set.values()))],
["values", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "values", node).set.values()))],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").set.values()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").set.values()))],
[
"entries",
0,
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).set.values(), (item) => wrap([item, item]))),
(thisValue) => wrap(Array.from(self(thisValue, "entries").set.values(), (item) => wrap([item, item]))),
],
[
"forEach",
1,
(thisValue, args, node) => {
const target = self(thisValue, "forEach", node)
const apply = applyCollectionCallback(runner, args[0], "Set.forEach", node)
(thisValue, args) => {
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(runner, args[0], "Set.forEach")
return Effect.gen(function* () {
for (const item of Array.from(target.set.values())) yield* apply([item, item, target])
return undefined
+22 -25
View File
@@ -1,16 +1,16 @@
import { Effect } from "effect"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { type AstNode, rangeError } from "../interpreter/model.js"
import { rangeError } from "../interpreter/model.js"
import { ProgramDate, ProgramObject } from "../interpreter/objects.js"
import { type Runner, toPrimitive, toPrimitiveNumber } from "../interpreter/runner.js"
import { coerceToNumber, coerceToString } from "./value.js"
const constructDate = <R>(runner: Runner<R>, args: Array<unknown>, proto: ProgramObject, node: AstNode) => {
const constructDate = <R>(runner: Runner<R>, args: Array<unknown>, proto: ProgramObject) => {
if (args.length === 0) return Effect.succeed(new ProgramDate(proto, Date.now()))
if (args.length === 1) {
const arg = args[0]
if (arg instanceof ProgramDate) return Effect.succeed(new ProgramDate(proto, arg.time))
return Effect.map(toPrimitive(runner, arg, "default", node), (value) =>
return Effect.map(toPrimitive(runner, arg, "default"), (value) =>
typeof value === "string"
? new ProgramDate(proto, Date.parse(value))
: new ProgramDate(proto, new Date(coerceToNumber(value)).getTime()),
@@ -74,7 +74,7 @@ export const dateGlobal = <R>(runner: Runner<R>) => {
length: 7,
// ISO instead of the host's locale string: date strings are deterministic and must not leak the host timezone.
call: () => Effect.sync(() => new Date().toISOString()),
construct: (args, newTarget, node) => constructDate(runner, args, prototypeFrom(newTarget, proto), node),
construct: (args, newTarget) => constructDate(runner, args, prototypeFrom(newTarget, proto)),
})
methods(protos, date, [
["now", 0, () => Date.now()],
@@ -82,42 +82,39 @@ export const dateGlobal = <R>(runner: Runner<R>) => {
["UTC", 7, (_, args) => Date.UTC(...(args.map((arg) => coerceToNumber(arg)) as Parameters<typeof Date.UTC>))],
])
const self = (thisValue: unknown, name: string, node: AstNode) =>
receiver(ProgramDate, thisValue, `Date.prototype.${name}`, node)
const iso = (value: ProgramDate, node: AstNode) => {
if (!Number.isFinite(value.time)) throw rangeError("Invalid time value.", node)
const self = (thisValue: unknown, name: string) => receiver(ProgramDate, thisValue, `Date.prototype.${name}`)
const iso = (value: ProgramDate) => {
if (!Number.isFinite(value.time)) throw rangeError("Invalid time value.")
return new Date(value.time).toISOString()
}
methods(protos, proto, [
["getTime", 0, (thisValue, _, node) => self(thisValue, "getTime", node).time],
["valueOf", 0, (thisValue, _, node) => self(thisValue, "valueOf", node).time],
["toISOString", 0, (thisValue, _, node) => iso(self(thisValue, "toISOString", node), node)],
["getTime", 0, (thisValue) => self(thisValue, "getTime").time],
["valueOf", 0, (thisValue) => self(thisValue, "valueOf").time],
["toISOString", 0, (thisValue) => iso(self(thisValue, "toISOString"))],
[
"toJSON",
1,
(thisValue, _, node) => {
const value = self(thisValue, "toJSON", node)
return Number.isFinite(value.time) ? iso(value, node) : null
(thisValue) => {
const value = self(thisValue, "toJSON")
return Number.isFinite(value.time) ? iso(value) : null
},
],
["toString", 0, (thisValue, _, node) => coerceToString(self(thisValue, "toString", node))],
["toDateString", 0, (thisValue, _, node) => new Date(self(thisValue, "toDateString", node).time).toDateString()],
["toTimeString", 0, (thisValue, _, node) => new Date(self(thisValue, "toTimeString", node).time).toTimeString()],
["toUTCString", 0, (thisValue, _, node) => new Date(self(thisValue, "toUTCString", node).time).toUTCString()],
["toGMTString", 0, (thisValue, _, node) => new Date(self(thisValue, "toGMTString", node).time).toUTCString()],
...getters.map(
(name): Method => [name, 0, (thisValue, _, node) => new Date(self(thisValue, name, node).time)[name]()],
),
["toString", 0, (thisValue) => coerceToString(self(thisValue, "toString"))],
["toDateString", 0, (thisValue) => new Date(self(thisValue, "toDateString").time).toDateString()],
["toTimeString", 0, (thisValue) => new Date(self(thisValue, "toTimeString").time).toTimeString()],
["toUTCString", 0, (thisValue) => new Date(self(thisValue, "toUTCString").time).toUTCString()],
["toGMTString", 0, (thisValue) => new Date(self(thisValue, "toGMTString").time).toUTCString()],
...getters.map((name): Method => [name, 0, (thisValue) => new Date(self(thisValue, name).time)[name]()]),
...setters.map(
([name, length]): Method => [
name,
length,
(thisValue, args, node) => {
const target = self(thisValue, name, node)
(thisValue, args) => {
const target = self(thisValue, name)
// Native setters read the current time before argument coercion, whose callbacks may mutate the Date.
const hosted = new Date(target.time)
return Effect.map(
Effect.forEach(args.slice(0, length), (arg) => toPrimitiveNumber(runner, arg, node), {
Effect.forEach(args.slice(0, length), (arg) => toPrimitiveNumber(runner, arg), {
concurrency: 1,
}),
(values) => {
+13 -16
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { methods } from "../interpreter/native.js"
import { applyCollectionCallback, type Runner } from "../interpreter/runner.js"
import { type AstNode, InterpreterRuntimeError, syntaxError } from "../interpreter/model.js"
import { syntaxError, typeError } from "../interpreter/model.js"
import { typeofValue } from "../interpreter/references.js"
import { fromData, toData, toProgram } from "../data.js"
import { Callable, get, keys, ProgramArray, ProgramObject, record, remove, set } from "../interpreter/objects.js"
@@ -9,29 +9,26 @@ import { Callable, get, keys, ProgramArray, ProgramObject, record, remove, set }
export const jsonGlobal = <R>(runner: Runner<R>) => {
const json = new ProgramObject(runner.prototypes.Object)
methods(runner.prototypes, json, [
["parse", 2, (_, args, node) => parse(runner, args, node)],
["stringify", 3, (_, args, node) => stringify(runner, args, node)],
["parse", 2, (_, args) => parse(runner, args)],
["stringify", 3, (_, args) => stringify(runner, args)],
])
return json
}
const parse = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
const parse = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const text = args[0]
if (typeof text !== "string") throw new InterpreterRuntimeError("JSON.parse expects a string.", node)
if (typeof text !== "string") throw typeError("JSON.parse expects a string.")
const parsed = (() => {
try {
return fromData(runner.prototypes, JSON.parse(text), "JSON.parse result")
} catch (error) {
throw syntaxError(
`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`,
node,
)
throw syntaxError(`JSON.parse received invalid JSON: ${error instanceof Error ? error.message : String(error)}`)
}
})()
if (typeofValue(args[1]) !== "function") return Effect.succeed(parsed)
const apply = applyCollectionCallback(runner, args[1], "JSON.parse", node)
const apply = applyCollectionCallback(runner, args[1], "JSON.parse")
const visit = (holder: ProgramObject, key: string): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const value = get(holder, key)
@@ -47,7 +44,7 @@ const parse = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effec
return visit(record(runner.prototypes.Object, { "": parsed }), "")
}
const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): Effect.Effect<unknown, unknown, R> => {
const stringify = <R>(runner: Runner<R>, args: Array<unknown>): Effect.Effect<unknown, unknown, R> => {
const space = args[2]
const indent = typeof space === "number" || typeof space === "string" ? space : undefined
const replacer = args[1]
@@ -64,17 +61,17 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): E
// Validate up front; the replacer walk below reads the original value.
toProgram(runner.prototypes, args[0], "JSON.stringify value")
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify", node)
const apply = applyCollectionCallback(runner, replacer, "JSON.stringify")
const stack = new Set<object>()
const visit = (holder: ProgramObject, key: string): Effect.Effect<unknown, unknown, R> =>
Effect.gen(function* () {
const value = yield* apply([key, yield* toJSONValue(runner, get(holder, key), key, node)])
const value = yield* apply([key, yield* toJSONValue(runner, get(holder, key), key)])
if (value === undefined || typeofValue(value) === "function") return undefined
toProgram(runner.prototypes, value, "JSON.stringify replacer result")
if (typeof value === "number") return Number.isFinite(value) ? value : null
if (value === null || typeof value === "string" || typeof value === "boolean") return value
if (!(value instanceof ProgramObject)) return {}
if (stack.has(value)) throw new InterpreterRuntimeError("Converting circular structure to JSON.", node)
if (stack.has(value)) throw typeError("Converting circular structure to JSON.")
stack.add(value)
if (value instanceof ProgramArray) {
const result: Array<unknown> = []
@@ -99,8 +96,8 @@ const stringify = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): E
}
// SerializeJSONProperty step 2: a callable `toJSON` decides the value, as Date and URL define.
const toJSONValue = <R>(runner: Runner<R>, value: unknown, key: string, node: AstNode) => {
const toJSONValue = <R>(runner: Runner<R>, value: unknown, key: string) => {
if (!(value instanceof ProgramObject)) return Effect.succeed(value)
const toJSON = get(value, "toJSON")
return toJSON instanceof Callable ? runner.invokeCallable(toJSON, value, [key], node) : Effect.succeed(value)
return toJSON instanceof Callable ? runner.invokeCallable(toJSON, value, [key]) : Effect.succeed(value)
}
+11 -15
View File
@@ -1,6 +1,6 @@
import { Effect } from "effect"
import { constants, type Method, methods } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { typeError } from "../interpreter/model.js"
import { ProgramObject } from "../interpreter/objects.js"
import { preserveConsumerError, type Runner } from "../interpreter/runner.js"
@@ -13,32 +13,28 @@ declare global {
// Validate only the arguments a method consumes; like JS, extras are ignored
// (so built-ins work as callbacks receiving (element, index, array)).
const number = (name: string, args: Array<unknown>, index: number, node: AstNode): number => {
const number = (name: string, args: Array<unknown>, index: number): number => {
if (index >= args.length) return Number.NaN
const arg = args[index]
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
if (typeof arg !== "number") throw typeError(`Math.${name} expects number arguments.`)
return arg
}
const unary = (name: string, op: (a: number) => number): Method => [
name,
1,
(_, args, node) => op(number(name, args, 0, node)),
]
const unary = (name: string, op: (a: number) => number): Method => [name, 1, (_, args) => op(number(name, args, 0))]
const binary = (name: string, op: (a: number, b: number) => number): Method => [
name,
2,
(_, args, node) => op(number(name, args, 0, node), number(name, args, 1, node)),
(_, args) => op(number(name, args, 0), number(name, args, 1)),
]
const variadic = (name: string, op: (...values: Array<number>) => number): Method => [
name,
2,
(_, args, node) =>
(_, args) =>
op(
...args.map((arg) => {
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Math.${name} expects number arguments.`, node)
if (typeof arg !== "number") throw typeError(`Math.${name} expects number arguments.`)
return arg
}),
),
@@ -97,11 +93,11 @@ export const mathGlobal = <R>(runner: Runner<R>) => {
[
"sumPrecise",
1,
(_, args, node) =>
(_, args) =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(args[0], node)
const cursor = yield* runner.syncIterator(args[0])
if (cursor === undefined) {
throw new InterpreterRuntimeError("Math.sumPrecise expects a synchronous iterable.", node)
throw typeError("Math.sumPrecise expects a synchronous iterable.")
}
const numbers: Array<number> = []
while (true) {
@@ -111,7 +107,7 @@ export const mathGlobal = <R>(runner: Runner<R>) => {
cursor,
Effect.sync(() => {
if (typeof step.value !== "number") {
throw new InterpreterRuntimeError("Math.sumPrecise expects an iterable of numbers.", node)
throw typeError("Math.sumPrecise expects an iterable of numbers.")
}
numbers.push(step.value)
}),
+21 -26
View File
@@ -1,5 +1,5 @@
import { constructor, constants, methods } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
import { rangeError, typeError } from "../interpreter/model.js"
import type { Runner } from "../interpreter/runner.js"
import { coercion, coerceToString } from "./value.js"
@@ -28,10 +28,10 @@ export const numberGlobal = <R>(runner: Runner<R>) => {
[
"parseInt",
2,
(_, args, node) => {
(_, args) => {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
throw new InterpreterRuntimeError("Number.parseInt expects a numeric radix.", node)
throw typeError("Number.parseInt expects a numeric radix.")
}
return parseInt(coerceToString(args[0]), radix)
},
@@ -39,49 +39,44 @@ export const numberGlobal = <R>(runner: Runner<R>) => {
["parseFloat", 1, (_, args) => parseFloat(coerceToString(args[0]))],
])
const self = (thisValue: unknown, name: string, node: AstNode): number => {
const self = (thisValue: unknown, name: string): number => {
if (typeof thisValue === "number") return thisValue
throw new InterpreterRuntimeError(`Number.prototype.${name} requires that 'this' be a Number.`, node)
throw typeError(`Number.prototype.${name} requires that 'this' be a Number.`)
}
const optNum = (name: string, arg: unknown, node: AstNode): number | undefined => {
const optNum = (name: string, arg: unknown): number | undefined => {
if (arg === undefined) return undefined
if (typeof arg !== "number") throw new InterpreterRuntimeError(`Number.${name} expects a number argument.`, node)
if (typeof arg !== "number") throw typeError(`Number.${name} expects a number argument.`)
return arg
}
methods(protos, protos.Number, [
[
"toFixed",
1,
(thisValue, args, node) => self(thisValue, "toFixed", node).toFixed(optNum("toFixed", args[0], node)),
],
["toFixed", 1, (thisValue, args) => self(thisValue, "toFixed").toFixed(optNum("toFixed", args[0]))],
[
"toExponential",
1,
(thisValue, args, node) =>
self(thisValue, "toExponential", node).toExponential(optNum("toExponential", args[0], node)),
(thisValue, args) => self(thisValue, "toExponential").toExponential(optNum("toExponential", args[0])),
],
[
"toPrecision",
1,
(thisValue, args, node) => {
const value = self(thisValue, "toPrecision", node)
const digits = optNum("toPrecision", args[0], node)
(thisValue, args) => {
const value = self(thisValue, "toPrecision")
const digits = optNum("toPrecision", args[0])
return digits === undefined ? value.toString() : value.toPrecision(digits)
},
],
[
"toString",
1,
(thisValue, args, node) => {
const value = self(thisValue, "toString", node)
const radix = optNum("toString", args[0], node)
(thisValue, args) => {
const value = self(thisValue, "toString")
const radix = optNum("toString", args[0])
if (radix !== undefined && (radix < 2 || radix > 36)) {
throw rangeError("Number.toString radix must be between 2 and 36.", node)
throw rangeError("Number.toString radix must be between 2 and 36.")
}
return value.toString(radix)
},
],
["valueOf", 0, (thisValue, _, node) => self(thisValue, "valueOf", node)],
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
])
return number
}
@@ -93,13 +88,13 @@ export const booleanGlobal = <R>(runner: Runner<R>) => {
length: 1,
call: coercion(runner, "Boolean").call,
})
const self = (thisValue: unknown, name: string, node: AstNode): boolean => {
const self = (thisValue: unknown, name: string): boolean => {
if (typeof thisValue === "boolean") return thisValue
throw new InterpreterRuntimeError(`Boolean.prototype.${name} requires that 'this' be a Boolean.`, node)
throw typeError(`Boolean.prototype.${name} requires that 'this' be a Boolean.`)
}
methods(protos, protos.Boolean, [
["toString", 0, (thisValue, _, node) => String(self(thisValue, "toString", node))],
["valueOf", 0, (thisValue, _, node) => self(thisValue, "valueOf", node)],
["toString", 0, (thisValue) => String(self(thisValue, "toString"))],
["valueOf", 0, (thisValue) => self(thisValue, "valueOf")],
])
return boolean
}
+43 -57
View File
@@ -4,9 +4,10 @@ import { constructor, methods, receiver } from "../interpreter/native.js"
import {
type AstNode,
AsyncIteratorSymbol,
InterpreterRuntimeError,
invalidData,
IteratorSymbol,
rangeError,
typeError,
} from "../interpreter/model.js"
import {
Callable,
@@ -34,22 +35,22 @@ import { groupBy } from "./collections.js"
import { coerceToString } from "./value.js"
// ToObject for enumeration.
export const enumerableSource = <R>(runner: Runner<R>, label: string, value: unknown, node: AstNode): ProgramObject => {
export const enumerableSource = <R>(
runner: Runner<R>,
label: string,
value: unknown,
node?: AstNode,
): ProgramObject => {
if (value === null || value === undefined) {
throw new InterpreterRuntimeError(`${label} cannot convert ${describeValue(value)} to an object.`, node)
throw typeError(`${label} cannot convert ${describeValue(value)} to an object.`, node)
}
if (value instanceof ProgramPromise) {
throw new InterpreterRuntimeError(
`${label} received an un-awaited Promise; await it before inspecting the result.`,
node,
"InvalidDataValue",
)
throw invalidData(`${label} received an un-awaited Promise; await it before inspecting the result.`, node)
}
if (value instanceof ToolReference) {
throw new InterpreterRuntimeError(
throw invalidData(
`${label} cannot read tool references: they are not plain data. Use Object.keys(tools) for names, or search({ query }) for signatures.`,
node,
"InvalidDataValue",
)
}
if (typeof value === "string") return new ProgramArray(runner.prototypes.Array, [...value])
@@ -57,40 +58,33 @@ export const enumerableSource = <R>(runner: Runner<R>, label: string, value: unk
return new ProgramObject(runner.prototypes.Object)
}
export const objectAssign = <R>(runner: Runner<R>, args: Array<unknown>, node: AstNode): unknown => {
export const objectAssign = <R>(runner: Runner<R>, args: Array<unknown>): unknown => {
const target = args[0]
// JS would box a primitive target; wrappers and primitives cannot hold fields here.
if (!(target instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
`Object.assign expects a data object or array target, received ${describeValue(target)}.`,
node,
)
throw typeError(`Object.assign expects a data object or array target, received ${describeValue(target)}.`)
}
const seen = new Set<object>()
for (const source of args.slice(1)) {
if (source === null || source === undefined) continue
const from = enumerableSource(runner, "Object.assign(...)", source, node)
const from = enumerableSource(runner, "Object.assign(...)", source)
for (const key of enumerableKeys(from)) {
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", node, seen)
rejectCircularInsertion(target, getOwn(from, key), "Object.assign result", seen)
if (!set(target, key, getOwn(from, key))) {
if (target instanceof ProgramArray && key === "length") throw rangeError("Invalid array length", node)
throw new InterpreterRuntimeError(`Cannot assign to read only property '${String(key)}'.`, node)
if (target instanceof ProgramArray && key === "length") throw rangeError("Invalid array length")
throw typeError(`Cannot assign to read only property '${String(key)}'.`)
}
}
}
return target
}
const objectFromEntries = <R>(
runner: Runner<R>,
source: unknown,
node: AstNode,
): Effect.Effect<ProgramObject, unknown, R> => {
const objectFromEntries = <R>(runner: Runner<R>, source: unknown): Effect.Effect<ProgramObject, unknown, R> => {
const out = new ProgramObject(runner.prototypes.Object)
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(source, node)
const cursor = yield* runner.syncIterator(source)
if (cursor === undefined) {
throw new InterpreterRuntimeError("Object.fromEntries expects a synchronous iterable of entries.", node)
throw typeError("Object.fromEntries expects a synchronous iterable of entries.")
}
while (true) {
const step = yield* cursor.next
@@ -99,7 +93,7 @@ const objectFromEntries = <R>(
cursor,
Effect.sync(() => {
if (!(step.value instanceof ProgramObject) || containsOpaqueReference(step.value)) {
throw new InterpreterRuntimeError("Object.fromEntries expects [key, value] entry objects.", node)
throw typeError("Object.fromEntries expects [key, value] entry objects.")
}
define(out, coerceToString(getOwn(step.value, 0)), getOwn(step.value, 1))
}),
@@ -132,91 +126,83 @@ export const objectGlobal = <R>(
toolKeys: (path: ReadonlyArray<string>) => ReadonlyArray<string>,
) => {
const protos = runner.prototypes
const construct = (args: Array<unknown>, node: AstNode): unknown => {
const construct = (args: Array<unknown>): unknown => {
const first = args[0]
if (first === null || first === undefined) return new ProgramObject(protos.Object)
if (typeof first === "object") return first
throw new InterpreterRuntimeError(
`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`,
node,
)
throw typeError(`Object(${typeof first}) wrapper objects are not supported; use the primitive value directly.`)
}
const object = constructor<R>(protos, protos.Object, {
name: "Object",
length: 1,
call: (_, args, node) => Effect.sync(() => construct(args, node)),
construct: (args, _, node) => Effect.sync(() => construct(args, node)),
call: (_, args) => Effect.sync(() => construct(args)),
construct: (args) => Effect.sync(() => construct(args)),
})
methods(protos, object, [
[
"keys",
1,
(_, args, node) =>
(_, args) =>
toProgram(
protos,
args[0] instanceof ToolReference
? [...toolKeys(args[0].path)]
: keys(enumerableSource(runner, "Object.keys(...)", args[0], node)),
: keys(enumerableSource(runner, "Object.keys(...)", args[0])),
"Object.keys result",
),
],
[
"values",
1,
(_, args, node) =>
(_, args) =>
new ProgramArray(
protos.Array,
entries(enumerableSource(runner, "Object.values(...)", args[0], node)).map((entry) => entry[1]),
entries(enumerableSource(runner, "Object.values(...)", args[0])).map((entry) => entry[1]),
),
],
[
"entries",
1,
(_, args, node) =>
(_, args) =>
new ProgramArray(
protos.Array,
entries(enumerableSource(runner, "Object.entries(...)", args[0], node)).map(
entries(enumerableSource(runner, "Object.entries(...)", args[0])).map(
(entry) => new ProgramArray(protos.Array, entry),
),
),
],
[
"hasOwn",
2,
(_, args, node) => hasOwn(enumerableSource(runner, "Object.hasOwn(...)", args[0], node), propertyKey(args[1])),
],
["hasOwn", 2, (_, args) => hasOwn(enumerableSource(runner, "Object.hasOwn(...)", args[0]), propertyKey(args[1]))],
[
"is",
2,
(_, args, node) => {
(_, args) => {
if (containsOpaqueReference(args[0]) || containsOpaqueReference(args[1])) {
throw new InterpreterRuntimeError("Object.is requires data values.", node, "InvalidDataValue")
throw invalidData("Object.is requires data values.")
}
return Object.is(args[0], args[1])
},
],
["assign", 2, (_, args, node) => objectAssign(runner, args, node)],
["fromEntries", 1, (_, args, node) => objectFromEntries(runner, args[0], node)],
["assign", 2, (_, args) => objectAssign(runner, args)],
["fromEntries", 1, (_, args) => objectFromEntries(runner, args[0])],
])
define(object, "groupBy", groupBy(runner, "Object"), hidden)
methods(protos, protos.Object, [
[
"hasOwnProperty",
1,
(thisValue, args, node) =>
hasOwn(receiver(ProgramObject, thisValue, "Object.prototype.hasOwnProperty", node), propertyKey(args[0])),
(thisValue, args) =>
hasOwn(receiver(ProgramObject, thisValue, "Object.prototype.hasOwnProperty"), propertyKey(args[0])),
],
[
"isPrototypeOf",
1,
(thisValue, args, node) =>
hasPrototype(args[0], receiver(ProgramObject, thisValue, "Object.prototype.isPrototypeOf", node)),
(thisValue, args) => hasPrototype(args[0], receiver(ProgramObject, thisValue, "Object.prototype.isPrototypeOf")),
],
[
"propertyIsEnumerable",
1,
(thisValue, args, node) =>
own(receiver(ProgramObject, thisValue, "Object.prototype.propertyIsEnumerable", node), propertyKey(args[0]))
(thisValue, args) =>
own(receiver(ProgramObject, thisValue, "Object.prototype.propertyIsEnumerable"), propertyKey(args[0]))
?.enumerable === true,
],
["toString", 0, (thisValue) => `[object ${classTag(thisValue)}]`],
@@ -224,9 +210,9 @@ export const objectGlobal = <R>(
[
"valueOf",
0,
(thisValue, _, node) => {
(thisValue) => {
if (thisValue === null || thisValue === undefined) {
throw new InterpreterRuntimeError("Object.prototype.valueOf called on null or undefined.", node)
throw typeError("Object.prototype.valueOf called on null or undefined.")
}
return thisValue
},
+11 -18
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import type { Prototypes } from "../interpreter/intrinsics.js"
import { constructor, type Method, methods, prototypeFrom, receiver } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError, syntaxError } from "../interpreter/model.js"
import { syntaxError, typeError } from "../interpreter/model.js"
import {
define,
defineAccessor,
@@ -32,7 +32,7 @@ const regexFailureReason = (error: unknown): string =>
const escapeRegexHint =
'To match special characters like ( ) [ ] { } + * ? . literally, escape them with a backslash (e.g. "\\\\(") or test for them with String.includes instead.'
export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFlags = ""): RegExp => {
export const toHostRegex = (arg: unknown, method: string, extraFlags = ""): RegExp => {
// Native parity: an undefined pattern behaves as an empty pattern.
if (arg === undefined) return new RegExp("", extraFlags)
if (arg instanceof ProgramRegExp) return arg.regex
@@ -42,13 +42,11 @@ export const toHostRegex = (arg: unknown, method: string, node: AstNode, extraFl
} catch (error) {
throw syntaxError(
`String.${method} received the string ${JSON.stringify(arg)}, which is not a valid regular expression pattern (${regexFailureReason(error)}). ${escapeRegexHint}`,
node,
)
}
}
throw new InterpreterRuntimeError(
throw typeError(
`String.${method} expects a regular expression (a /pattern/flags literal or new RegExp(...)) or a string pattern, not ${arg === null ? "null" : typeof arg}.`,
node,
)
}
@@ -67,7 +65,6 @@ export const matchToValue = (protos: Prototypes, match: RegExpMatchArray): Progr
export const constructRegExp = (
protos: Prototypes,
args: Array<unknown>,
node: AstNode,
proto: ProgramObject = protos.RegExp,
): ProgramRegExp => {
const first = args[0]
@@ -76,7 +73,6 @@ export const constructRegExp = (
if (flagsArg !== undefined && typeof flagsArg !== "string") {
throw syntaxError(
`RegExp flags must be a string of flag characters (e.g. "g", "gi"), not ${flagsArg === null ? "null" : typeof flagsArg}.`,
node,
)
}
const flags = flagsArg ?? (first instanceof ProgramRegExp ? first.regex.flags : "")
@@ -88,7 +84,6 @@ export const constructRegExp = (
/flag/i.test(reason)
? `new RegExp(...) received invalid flags ${JSON.stringify(flags)} (${reason}). Valid flags are d, g, i, m, s, u, v, and y.`
: `new RegExp(...) received ${JSON.stringify(pattern)}, which is not a valid regular expression pattern (${reason}). ${escapeRegexHint}`,
node,
)
}
}
@@ -106,23 +101,21 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
const regexp = constructor<R>(protos, proto, {
name: "RegExp",
length: 2,
call: (_, args, node) => Effect.sync(() => constructRegExp(protos, args, node)),
construct: (args, newTarget, node) =>
Effect.sync(() => constructRegExp(protos, args, node, prototypeFrom(newTarget, proto))),
call: (_, args) => Effect.sync(() => constructRegExp(protos, args)),
construct: (args, newTarget) => Effect.sync(() => constructRegExp(protos, args, prototypeFrom(newTarget, proto))),
})
methods(protos, regexp, [
[
"escape",
1,
(_, args, node) => {
if (typeof args[0] !== "string") throw new InterpreterRuntimeError("RegExp.escape expects a string.", node)
(_, args) => {
if (typeof args[0] !== "string") throw typeError("RegExp.escape expects a string.")
return RegExp.escape(args[0])
},
],
])
const self = (thisValue: unknown, name: string, node?: AstNode) =>
receiver(ProgramRegExp, thisValue, `RegExp.prototype.${name}`, node)
const self = (thisValue: unknown, name: string) => receiver(ProgramRegExp, thisValue, `RegExp.prototype.${name}`)
defineAccessor(proto, "source", (thisValue) => self(thisValue, "source").regex.source)
defineAccessor(proto, "flags", (thisValue) => self(thisValue, "flags").regex.flags)
for (const name of flagProperties) defineAccessor(proto, name, (thisValue) => self(thisValue, name).regex[name])
@@ -130,8 +123,8 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
const run = (name: "exec" | "test"): Method => [
name,
1,
(thisValue, args, node) => {
const value = self(thisValue, name, node)
(thisValue, args) => {
const value = self(thisValue, name)
const input = coerceToString(args[0])
const stateful = value.regex.global || value.regex.sticky
value.regex.lastIndex = toLength(getOwn(value, "lastIndex"))
@@ -144,7 +137,7 @@ export const regexpGlobal = <R>(runner: Runner<R>) => {
methods(protos, proto, [
run("exec"),
run("test"),
["toString", 0, (thisValue, _, node) => coerceToString(self(thisValue, "toString", node))],
["toString", 0, (thisValue) => coerceToString(self(thisValue, "toString"))],
])
return regexp
}
+73 -97
View File
@@ -1,7 +1,7 @@
import { Effect } from "effect"
import { toProgram } from "../data.js"
import { constructor, type Method, methods } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError, rangeError } from "../interpreter/model.js"
import { invalidData, rangeError, typeError } from "../interpreter/model.js"
import { ProgramArray, ProgramPromise, ProgramRegExp, record } from "../interpreter/objects.js"
import { containsOpaqueReference, typeofValue } from "../interpreter/references.js"
import { applyCollectionCallback, isSupportedCallback, type Runner } from "../interpreter/runner.js"
@@ -9,22 +9,17 @@ import { matchToValue, toHostRegex } from "./regexp.js"
import { coerceToNumber, coerceToString, coercion } from "./value.js"
// console is intercepted by the interpreter before reaching here.
const requireDataArgument = (name: string, index: number, arg: unknown, node: AstNode): unknown => {
const requireDataArgument = (name: string, index: number, arg: unknown): unknown => {
if (containsOpaqueReference(arg)) {
throw new InterpreterRuntimeError(
`String.${name} expects argument ${index + 1} to be a data value.`,
node,
"InvalidDataValue",
)
throw invalidData(`String.${name} expects argument ${index + 1} to be a data value.`)
}
return arg
}
const replaceAllNeedsGlobal = (pattern: RegExp, node: AstNode) => {
const replaceAllNeedsGlobal = (pattern: RegExp) => {
if (!pattern.global) {
throw new InterpreterRuntimeError(
throw typeError(
`String.replaceAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.replace to replace only the first match.`,
node,
)
}
}
@@ -34,10 +29,9 @@ const replaceWithCallback = <R>(
value: string,
name: "replace" | "replaceAll",
args: Array<unknown>,
node: AstNode,
): Effect.Effect<unknown, unknown, R> => {
const protos = runner.prototypes
const apply = applyCollectionCallback(runner, args[1], `String.${name}`, node)
const apply = applyCollectionCallback(runner, args[1], `String.${name}`)
const matches: Array<{ readonly match: string; readonly offset: number; readonly args: Array<unknown> }> = []
const collect = (...callbackArgs: Array<unknown>): string => {
const match = callbackArgs[0]
@@ -45,7 +39,7 @@ const replaceWithCallback = <R>(
const hasGroups = groups !== null && typeof groups === "object"
const offset = callbackArgs[callbackArgs.length - (hasGroups ? 3 : 2)]
if (typeof match !== "string" || typeof offset !== "number") {
throw new InterpreterRuntimeError(`String.${name} produced an invalid replacement match.`, node)
throw typeError(`String.${name} produced an invalid replacement match.`)
}
if (hasGroups) callbackArgs[callbackArgs.length - 1] = record(protos.Object, groups as Record<string, unknown>)
matches.push({ match, offset, args: callbackArgs })
@@ -54,11 +48,11 @@ const replaceWithCallback = <R>(
const pattern = args[0]
if (pattern instanceof ProgramRegExp) {
if (name === "replaceAll") replaceAllNeedsGlobal(pattern.regex, node)
if (name === "replaceAll") replaceAllNeedsGlobal(pattern.regex)
if (name === "replace") value.replace(pattern.regex, collect)
else value.replaceAll(pattern.regex, collect)
} else {
const search = coerceToString(requireDataArgument(name, 0, pattern, node))
const search = coerceToString(requireDataArgument(name, 0, pattern))
if (name === "replace") value.replace(search, collect)
else value.replaceAll(search, collect)
}
@@ -91,11 +85,11 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
const codeUnits = (name: string, op: (...codes: Array<number>) => string): Method => [
name,
1,
(_, args, node) =>
(_, args) =>
op(
...args.map((arg) => {
if (typeof arg !== "number") {
throw new InterpreterRuntimeError(`String.${name} expects number arguments.`, node)
throw typeError(`String.${name} expects number arguments.`)
}
return arg
}),
@@ -106,52 +100,50 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
codeUnits("fromCodePoint", String.fromCodePoint),
])
const self = (thisValue: unknown, name: string, node: AstNode): string => {
const self = (thisValue: unknown, name: string): string => {
if (typeof thisValue === "string") return thisValue
if (thisValue === null || thisValue === undefined) {
throw new InterpreterRuntimeError(`String.prototype.${name} called on null or undefined.`, node)
throw typeError(`String.prototype.${name} called on null or undefined.`)
}
return coerceToString(thisValue)
}
// Coerce arguments like native JS; opaque runtime references still reject.
const str = (name: string, args: Array<unknown>, index: number, node: AstNode): string =>
coerceToString(requireDataArgument(name, index, args[index], node))
const num = (name: string, args: Array<unknown>, index: number, node: AstNode): number =>
coerceToNumber(requireDataArgument(name, index, args[index], node))
const optNum = (name: string, args: Array<unknown>, index: number, node: AstNode): number | undefined =>
args[index] === undefined ? undefined : num(name, args, index, node)
const optStr = (name: string, args: Array<unknown>, index: number, node: AstNode): string | undefined =>
args[index] === undefined ? undefined : str(name, args, index, node)
const rejectRegex = (name: string, args: Array<unknown>, node: AstNode): void => {
const str = (name: string, args: Array<unknown>, index: number): string =>
coerceToString(requireDataArgument(name, index, args[index]))
const num = (name: string, args: Array<unknown>, index: number): number =>
coerceToNumber(requireDataArgument(name, index, args[index]))
const optNum = (name: string, args: Array<unknown>, index: number): number | undefined =>
args[index] === undefined ? undefined : num(name, args, index)
const optStr = (name: string, args: Array<unknown>, index: number): string | undefined =>
args[index] === undefined ? undefined : str(name, args, index)
const rejectRegex = (name: string, args: Array<unknown>): void => {
if (args[0] instanceof ProgramRegExp) {
throw new InterpreterRuntimeError(
throw typeError(
`String.${name} cannot take a regular expression; use regex.test(string) or String.search instead.`,
node,
)
}
}
const simple = (
name: string,
length: number,
op: (value: string, args: Array<unknown>, node: AstNode) => unknown,
): Method => [name, length, (thisValue, args, node) => op(self(thisValue, name, node), args, node)]
const simple = (name: string, length: number, op: (value: string, args: Array<unknown>) => unknown): Method => [
name,
length,
(thisValue, args) => op(self(thisValue, name), args),
]
const replace = (name: "replace" | "replaceAll") =>
simple(name, 2, (value, args, node) => {
if (isSupportedCallback(args[1])) return replaceWithCallback(runner, value, name, args, node)
simple(name, 2, (value, args) => {
if (isSupportedCallback(args[1])) return replaceWithCallback(runner, value, name, args)
if (typeofValue(args[1]) === "function") {
throw new InterpreterRuntimeError(
throw typeError(
`String.${name} cannot use this callable as a replacer; wrap it in an arrow function, e.g. (match) => tools.ns.tool(match).`,
node,
)
}
if (args[0] instanceof ProgramRegExp) {
const pattern = args[0].regex
const replacement = str(name, args, 1, node)
if (name === "replaceAll") replaceAllNeedsGlobal(pattern, node)
const replacement = str(name, args, 1)
if (name === "replaceAll") replaceAllNeedsGlobal(pattern)
return name === "replace" ? value.replace(pattern, replacement) : value.replaceAll(pattern, replacement)
}
if (name === "replace") return value.replace(str(name, args, 0, node), str(name, args, 1, node))
return value.replaceAll(str(name, args, 0, node), str(name, args, 1, node))
if (name === "replace") return value.replace(str(name, args, 0), str(name, args, 1))
return value.replaceAll(str(name, args, 0), str(name, args, 1))
})
methods(protos, protos.String, [
@@ -165,68 +157,60 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
simple("trimEnd", 0, (value) => value.trimEnd()),
simple("trimRight", 0, (value) => value.trimEnd()),
// Locale/options are deliberately unsupported; comparison uses the host default locale.
simple("localeCompare", 1, (value, args, node) => value.localeCompare(str("localeCompare", args, 0, node))),
simple("normalize", 0, (value, args, node) => {
const form = optStr("normalize", args, 0, node)
simple("localeCompare", 1, (value, args) => value.localeCompare(str("localeCompare", args, 0))),
simple("normalize", 0, (value, args) => {
const form = optStr("normalize", args, 0)
try {
return value.normalize(form)
} catch {
throw rangeError(
`String.normalize expects the form "NFC", "NFD", "NFKC", or "NFKD" (got ${JSON.stringify(form)}).`,
node,
)
}
}),
simple("split", 2, (value, args, node) => {
simple("split", 2, (value, args) => {
const wrap = (parts: Array<string>) => new ProgramArray(protos.Array, parts)
// Native: an undefined separator returns the whole string, not a split on "undefined",
// unless the limit truncates to zero.
const requestedLimit = optNum("split", args, 1, node)
const requestedLimit = optNum("split", args, 1)
if (args[0] === undefined) {
return wrap(requestedLimit !== undefined && requestedLimit >>> 0 === 0 ? [] : [value])
}
if (args[0] instanceof ProgramRegExp) return wrap(value.split(args[0].regex, requestedLimit))
return wrap(
value.split(str("split", args, 0, node), requestedLimit === undefined ? undefined : requestedLimit >>> 0),
)
return wrap(value.split(str("split", args, 0), requestedLimit === undefined ? undefined : requestedLimit >>> 0))
}),
simple("slice", 2, (value, args, node) =>
value.slice(optNum("slice", args, 0, node), optNum("slice", args, 1, node)),
),
simple("includes", 1, (value, args, node) => {
rejectRegex("includes", args, node)
return value.includes(str("includes", args, 0, node), optNum("includes", args, 1, node))
simple("slice", 2, (value, args) => value.slice(optNum("slice", args, 0), optNum("slice", args, 1))),
simple("includes", 1, (value, args) => {
rejectRegex("includes", args)
return value.includes(str("includes", args, 0), optNum("includes", args, 1))
}),
simple("startsWith", 1, (value, args, node) => {
rejectRegex("startsWith", args, node)
return value.startsWith(str("startsWith", args, 0, node), optNum("startsWith", args, 1, node))
simple("startsWith", 1, (value, args) => {
rejectRegex("startsWith", args)
return value.startsWith(str("startsWith", args, 0), optNum("startsWith", args, 1))
}),
simple("endsWith", 1, (value, args, node) => {
rejectRegex("endsWith", args, node)
return value.endsWith(str("endsWith", args, 0, node), optNum("endsWith", args, 1, node))
simple("endsWith", 1, (value, args) => {
rejectRegex("endsWith", args)
return value.endsWith(str("endsWith", args, 0), optNum("endsWith", args, 1))
}),
simple("indexOf", 1, (value, args, node) =>
value.indexOf(str("indexOf", args, 0, node), optNum("indexOf", args, 1, node)),
),
simple("lastIndexOf", 1, (value, args, node) =>
value.lastIndexOf(str("lastIndexOf", args, 0, node), optNum("lastIndexOf", args, 1, node)),
simple("indexOf", 1, (value, args) => value.indexOf(str("indexOf", args, 0), optNum("indexOf", args, 1))),
simple("lastIndexOf", 1, (value, args) =>
value.lastIndexOf(str("lastIndexOf", args, 0), optNum("lastIndexOf", args, 1)),
),
replace("replace"),
replace("replaceAll"),
simple("match", 1, (value, args, node) => {
const pattern = toHostRegex(args[0], "match", node)
simple("match", 1, (value, args) => {
const pattern = toHostRegex(args[0], "match")
const matched = value.match(pattern)
if (matched === null) return null
// Preserve the own `index` and `groups` properties on non-global matches.
if (pattern.global) return toProgram(protos, matched, "String.match result")
return matchToValue(protos, matched)
}),
simple("matchAll", 1, (value, args, node) => {
const pattern = toHostRegex(args[0], "matchAll", node, "g")
simple("matchAll", 1, (value, args) => {
const pattern = toHostRegex(args[0], "matchAll", "g")
if (!pattern.global) {
throw new InterpreterRuntimeError(
throw typeError(
`String.matchAll requires a regular expression with the global (g) flag: write /${pattern.source}/${pattern.flags}g, or use String.match for a single match.`,
node,
)
}
return new ProgramArray(
@@ -234,35 +218,27 @@ export const stringGlobal = <R>(runner: Runner<R>) => {
Array.from(value.matchAll(pattern), (match) => matchToValue(protos, match)),
)
}),
simple("search", 1, (value, args, node) => value.search(toHostRegex(args[0], "search", node))),
simple("repeat", 1, (value, args, node) => {
const count = num("repeat", args, 0, node)
simple("search", 1, (value, args) => value.search(toHostRegex(args[0], "search"))),
simple("repeat", 1, (value, args) => {
const count = num("repeat", args, 0)
if (!Number.isFinite(count) || count < 0) {
throw rangeError("String.repeat expects a finite non-negative count.", node)
throw rangeError("String.repeat expects a finite non-negative count.")
}
return value.repeat(count)
}),
simple("padStart", 1, (value, args, node) =>
value.padStart(num("padStart", args, 0, node), optStr("padStart", args, 1, node)),
),
simple("padEnd", 1, (value, args, node) =>
value.padEnd(num("padEnd", args, 0, node), optStr("padEnd", args, 1, node)),
),
simple("charAt", 1, (value, args, node) => value.charAt(optNum("charAt", args, 0, node) ?? 0)),
simple("at", 1, (value, args, node) => value.at(optNum("at", args, 0, node) ?? 0)),
simple("substring", 2, (value, args, node) =>
value.substring(optNum("substring", args, 0, node) ?? 0, optNum("substring", args, 1, node)),
),
simple("substr", 2, (value, args, node) =>
value.substr(optNum("substr", args, 0, node) ?? 0, optNum("substr", args, 1, node)),
simple("padStart", 1, (value, args) => value.padStart(num("padStart", args, 0), optStr("padStart", args, 1))),
simple("padEnd", 1, (value, args) => value.padEnd(num("padEnd", args, 0), optStr("padEnd", args, 1))),
simple("charAt", 1, (value, args) => value.charAt(optNum("charAt", args, 0) ?? 0)),
simple("at", 1, (value, args) => value.at(optNum("at", args, 0) ?? 0)),
simple("substring", 2, (value, args) =>
value.substring(optNum("substring", args, 0) ?? 0, optNum("substring", args, 1)),
),
simple("substr", 2, (value, args) => value.substr(optNum("substr", args, 0) ?? 0, optNum("substr", args, 1))),
simple("isWellFormed", 0, (value) => value.isWellFormed()),
simple("toWellFormed", 0, (value) => value.toWellFormed()),
simple("charCodeAt", 1, (value, args, node) => value.charCodeAt(optNum("charCodeAt", args, 0, node) ?? 0)),
simple("codePointAt", 1, (value, args, node) => value.codePointAt(optNum("codePointAt", args, 0, node) ?? 0)),
simple("concat", 1, (value, args, node) =>
value.concat(...args.map((_, index) => str("concat", args, index, node))),
),
simple("charCodeAt", 1, (value, args) => value.charCodeAt(optNum("charCodeAt", args, 0) ?? 0)),
simple("codePointAt", 1, (value, args) => value.codePointAt(optNum("codePointAt", args, 0) ?? 0)),
simple("concat", 1, (value, args) => value.concat(...args.map((_, index) => str("concat", args, index)))),
])
return string
}
+56 -72
View File
@@ -2,7 +2,7 @@ import { Effect } from "effect"
import { toProgram, ToolRuntimeError } from "../data.js"
import type { Prototypes } from "../interpreter/intrinsics.js"
import { constructor, fn, type Method, methods, prototypeFrom, receiver, requiresNew } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError, uriError } from "../interpreter/model.js"
import { PendingThrow, typeError, uriError } from "../interpreter/model.js"
import {
defineAccessor,
entries,
@@ -43,15 +43,12 @@ const uriFunctions: Record<UriFunction, (value: string) => string> = {
}
export const uriGlobal = <R>(runner: Runner<R>, name: UriFunction) =>
fn<R>(runner.prototypes, name, 1, (_, args, node) => {
fn<R>(runner.prototypes, name, 1, (_, args) => {
const value = uriArgument(runner.prototypes, args[0], `${name} input`)
try {
return uriFunctions[name](value)
} catch (error) {
throw uriError(
`${name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`,
node,
)
throw uriError(`${name} received malformed URI data: ${error instanceof Error ? error.message : String(error)}`)
}
})
@@ -61,32 +58,29 @@ const urlArgument = (protos: Prototypes, value: unknown, label: string): string
export const urlGlobal = <R>(runner: Runner<R>) => {
const protos = runner.prototypes
const proto = protos.URL
const construct = (args: Array<unknown>, into: ProgramObject, node: AstNode): ProgramURL => {
const construct = (args: Array<unknown>, into: ProgramObject): ProgramURL => {
if (args.length === 0) {
throw new InterpreterRuntimeError("new URL(...) requires a URL string and an optional base URL.", node)
throw typeError("new URL(...) requires a URL string and an optional base URL.")
}
const input = urlArgument(protos, args[0], "new URL input")
const base = args[1] === undefined ? undefined : urlArgument(protos, args[1], "new URL base")
try {
return new ProgramURL(into, protos.URLSearchParams, new URL(input, base))
} catch {
throw new InterpreterRuntimeError(
`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`,
node,
)
throw typeError(`new URL(...) received an invalid URL${base === undefined ? "" : " or base URL"}.`)
}
}
const url = constructor<R>(protos, proto, {
name: "URL",
length: 1,
call: requiresNew("URL"),
construct: (args, newTarget, node) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto), node)),
construct: (args, newTarget) => Effect.sync(() => construct(args, prototypeFrom(newTarget, proto))),
})
const parse = (name: "canParse" | "parse"): Method => [
name,
1,
(_, args, node) => {
if (args.length === 0) throw new InterpreterRuntimeError(`URL.${name} requires a URL argument.`, node)
(_, args) => {
if (args.length === 0) throw typeError(`URL.${name} requires a URL argument.`)
const input = urlArgument(protos, args[0], `URL.${name} input`)
const base = args[1] === undefined ? undefined : urlArgument(protos, args[1], `URL.${name} base`)
try {
@@ -99,8 +93,7 @@ export const urlGlobal = <R>(runner: Runner<R>) => {
]
methods(protos, url, [parse("canParse"), parse("parse")])
const self = (thisValue: unknown, name: string, node?: AstNode) =>
receiver(ProgramURL, thisValue, `URL.prototype.${name}`, node)
const self = (thisValue: unknown, name: string) => receiver(ProgramURL, thisValue, `URL.prototype.${name}`)
for (const name of urlProperties) {
defineAccessor(
proto,
@@ -113,25 +106,25 @@ export const urlGlobal = <R>(runner: Runner<R>) => {
try {
;(target.url as unknown as Record<string, string>)[name] = uriArgument(protos, value, `URL.${name} value`)
} catch (error) {
if (error instanceof InterpreterRuntimeError || error instanceof ToolRuntimeError) throw error
throw new InterpreterRuntimeError(`URL.${name} received an invalid value.`)
if (error instanceof PendingThrow || error instanceof ToolRuntimeError) throw error
throw typeError(`URL.${name} received an invalid value.`)
}
},
)
}
defineAccessor(proto, "searchParams", (thisValue) => self(thisValue, "searchParams").searchParams)
methods(protos, proto, [
["toString", 0, (thisValue, _, node) => self(thisValue, "toString", node).url.href],
["toJSON", 0, (thisValue, _, node) => self(thisValue, "toJSON", node).url.href],
["toString", 0, (thisValue) => self(thisValue, "toString").url.href],
["toJSON", 0, (thisValue) => self(thisValue, "toJSON").url.href],
])
return url
}
const readPair = <R>(runner: Runner<R>, value: unknown, node: AstNode): Effect.Effect<Array<string>, unknown, R> =>
const readPair = <R>(runner: Runner<R>, value: unknown): Effect.Effect<Array<string>, unknown, R> =>
Effect.gen(function* () {
const cursor = yield* runner.syncIterator(value, node)
const cursor = yield* runner.syncIterator(value)
if (cursor === undefined) {
throw new InterpreterRuntimeError("new URLSearchParams(...) expects iterable [name, value] pairs.", node)
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
const items: Array<string> = []
while (true) {
@@ -150,7 +143,6 @@ const constructURLSearchParams = <R>(
runner: Runner<R>,
init: unknown,
proto: ProgramObject,
node: AstNode,
): Effect.Effect<ProgramURLSearchParams, unknown, R> => {
const wrap = (params: URLSearchParams) => new ProgramURLSearchParams(proto, params)
if (init === undefined) return Effect.succeed(wrap(new URLSearchParams()))
@@ -160,31 +152,27 @@ const constructURLSearchParams = <R>(
return Effect.succeed(wrap(new URLSearchParams(coerceToString(init))))
}
return Effect.gen(function* () {
const cursor = yield* runner.syncIterator(init, node)
const cursor = yield* runner.syncIterator(init)
if (cursor !== undefined) {
const pairs: Array<Array<string>> = []
while (true) {
const step = yield* cursor.next
if (step.done) {
if (pairs.some((entry) => entry.length !== 2)) {
throw new InterpreterRuntimeError("new URLSearchParams(...) expects iterable [name, value] pairs.", node)
throw typeError("new URLSearchParams(...) expects iterable [name, value] pairs.")
}
return wrap(new URLSearchParams(pairs.map((entry): [string, string] => [entry[0] ?? "", entry[1] ?? ""])))
}
pairs.push(yield* preserveConsumerError(cursor, readPair(runner, step.value, node)))
pairs.push(yield* preserveConsumerError(cursor, readPair(runner, step.value)))
}
}
if (isRuntimeReference(init)) {
throw new InterpreterRuntimeError(
"new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.",
node,
)
throw typeError("new URLSearchParams(...) expects a query string, data object, or synchronous iterable pairs.")
}
if (isWrapper(init)) return wrap(new URLSearchParams())
if (!(init instanceof ProgramObject)) {
throw new InterpreterRuntimeError(
throw typeError(
"new URLSearchParams(...) expects a query string, data object, iterable pairs, or URLSearchParams.",
node,
)
}
return wrap(
@@ -199,20 +187,16 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
const searchParams = constructor<R>(protos, proto, {
name: "URLSearchParams",
call: requiresNew("URLSearchParams"),
construct: (args, newTarget, node) =>
constructURLSearchParams(runner, args[0], prototypeFrom(newTarget, proto), node),
construct: (args, newTarget) => constructURLSearchParams(runner, args[0], prototypeFrom(newTarget, proto)),
})
const self = (thisValue: unknown, name: string, node?: AstNode) =>
receiver(ProgramURLSearchParams, thisValue, `URLSearchParams.prototype.${name}`, node)
const self = (thisValue: unknown, name: string) =>
receiver(ProgramURLSearchParams, thisValue, `URLSearchParams.prototype.${name}`)
const wrap = (items: Array<unknown>) => new ProgramArray(protos.Array, items)
const arg = (name: string, args: Array<unknown>, index: number): string =>
uriArgument(protos, args[index], `URLSearchParams.${name} argument ${index + 1}`)
const requireArgs = (name: string, args: Array<unknown>, count: number, node: AstNode): void => {
const requireArgs = (name: string, args: Array<unknown>, count: number): void => {
if (args.length < count) {
throw new InterpreterRuntimeError(
`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`,
node,
)
throw typeError(`URLSearchParams.${name} requires ${count} argument${count === 1 ? "" : "s"}.`)
}
}
defineAccessor(proto, "size", (thisValue) => self(thisValue, "size").params.size)
@@ -220,18 +204,18 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
[
"append",
2,
(thisValue, args, node) => {
requireArgs("append", args, 2, node)
self(thisValue, "append", node).params.append(arg("append", args, 0), arg("append", args, 1))
(thisValue, args) => {
requireArgs("append", args, 2)
self(thisValue, "append").params.append(arg("append", args, 0), arg("append", args, 1))
return undefined
},
],
[
"delete",
1,
(thisValue, args, node) => {
requireArgs("delete", args, 1, node)
const params = self(thisValue, "delete", node).params
(thisValue, args) => {
requireArgs("delete", args, 1)
const params = self(thisValue, "delete").params
if (args[1] !== undefined) params.delete(arg("delete", args, 0), arg("delete", args, 1))
else params.delete(arg("delete", args, 0))
return undefined
@@ -240,25 +224,25 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
[
"get",
1,
(thisValue, args, node) => {
requireArgs("get", args, 1, node)
return self(thisValue, "get", node).params.get(arg("get", args, 0))
(thisValue, args) => {
requireArgs("get", args, 1)
return self(thisValue, "get").params.get(arg("get", args, 0))
},
],
[
"getAll",
1,
(thisValue, args, node) => {
requireArgs("getAll", args, 1, node)
return wrap(self(thisValue, "getAll", node).params.getAll(arg("getAll", args, 0)))
(thisValue, args) => {
requireArgs("getAll", args, 1)
return wrap(self(thisValue, "getAll").params.getAll(arg("getAll", args, 0)))
},
],
[
"has",
1,
(thisValue, args, node) => {
requireArgs("has", args, 1, node)
const params = self(thisValue, "has", node).params
(thisValue, args) => {
requireArgs("has", args, 1)
const params = self(thisValue, "has").params
return args[1] !== undefined
? params.has(arg("has", args, 0), arg("has", args, 1))
: params.has(arg("has", args, 0))
@@ -267,36 +251,36 @@ export const urlSearchParamsGlobal = <R>(runner: Runner<R>) => {
[
"set",
2,
(thisValue, args, node) => {
requireArgs("set", args, 2, node)
self(thisValue, "set", node).params.set(arg("set", args, 0), arg("set", args, 1))
(thisValue, args) => {
requireArgs("set", args, 2)
self(thisValue, "set").params.set(arg("set", args, 0), arg("set", args, 1))
return undefined
},
],
[
"sort",
0,
(thisValue, _, node) => {
self(thisValue, "sort", node).params.sort()
(thisValue) => {
self(thisValue, "sort").params.sort()
return undefined
},
],
["keys", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "keys", node).params.keys()))],
["values", 0, (thisValue, _, node) => wrap(Array.from(self(thisValue, "values", node).params.values()))],
["keys", 0, (thisValue) => wrap(Array.from(self(thisValue, "keys").params.keys()))],
["values", 0, (thisValue) => wrap(Array.from(self(thisValue, "values").params.values()))],
[
"entries",
0,
(thisValue, _, node) =>
wrap(Array.from(self(thisValue, "entries", node).params.entries(), ([key, value]) => wrap([key, value]))),
(thisValue) =>
wrap(Array.from(self(thisValue, "entries").params.entries(), ([key, value]) => wrap([key, value]))),
],
["toString", 0, (thisValue, _, node) => self(thisValue, "toString", node).params.toString()],
["toString", 0, (thisValue) => self(thisValue, "toString").params.toString()],
[
"forEach",
1,
(thisValue, args, node) => {
requireArgs("forEach", args, 1, node)
const target = self(thisValue, "forEach", node)
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach", node)
(thisValue, args) => {
requireArgs("forEach", args, 1)
const target = self(thisValue, "forEach")
const apply = applyCollectionCallback(runner, args[0], "URLSearchParams.forEach")
return Effect.gen(function* () {
for (const [key, value] of Array.from(target.params.entries())) yield* apply([value, key, target])
return undefined
+5 -5
View File
@@ -1,6 +1,6 @@
import { toProgram } from "../data.js"
import { fn } from "../interpreter/native.js"
import { type AstNode, InterpreterRuntimeError } from "../interpreter/model.js"
import { typeError } from "../interpreter/model.js"
import {
get,
isWrapper,
@@ -55,7 +55,7 @@ export const coerceToNumber = (value: unknown): number => {
export type Coercion = "Number" | "String" | "Boolean" | "parseInt" | "parseFloat" | "isFinite" | "isNaN"
const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>, node: AstNode): unknown => {
const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>): unknown => {
// Native: Number() is 0 and String() is "", unlike their undefined-argument forms; the
// other coercers match native through the undefined-argument path below.
if (args.length === 0) {
@@ -80,7 +80,7 @@ const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>, node
if (name === "parseInt") {
const radix = args[1]
if (radix !== undefined && typeof radix !== "number") {
throw new InterpreterRuntimeError("parseInt expects a numeric radix.", node)
throw typeError("parseInt expects a numeric radix.")
}
return parseInt(coerceToString(value), radix)
}
@@ -90,6 +90,6 @@ const coerce = <R>(runner: Runner<R>, name: Coercion, args: Array<unknown>, node
/** A global coercion function such as `Number` or `parseInt`. */
export const coercion = <R>(runner: Runner<R>, name: Coercion, length = 1): NativeFunction<R> =>
fn(runner.prototypes, name, length, (_, args, node) =>
toProgram(runner.prototypes, coerce(runner, name, args, node), `${name} result`),
fn(runner.prototypes, name, length, (_, args) =>
toProgram(runner.prototypes, coerce(runner, name, args), `${name} result`),
)
+4 -4
View File
@@ -1,5 +1,5 @@
import { fn, methods } from "../interpreter/native.js"
import { InterpreterRuntimeError } from "../interpreter/model.js"
import { typeError } from "../interpreter/model.js"
import { ProgramObject } from "../interpreter/objects.js"
import type { Runner } from "../interpreter/runner.js"
import { coerceToString } from "./value.js"
@@ -7,13 +7,13 @@ import { coerceToString } from "./value.js"
// WebIDL DOMString conversion: a missing argument is a TypeError, anything else stringifies. Invalid input is a
// TypeError as well; browsers throw a DOMException named InvalidCharacterError, which CodeMode does not have.
export const base64Global = <R>(runner: Runner<R>, name: "atob" | "btoa") =>
fn<R>(runner.prototypes, name, 1, (_, args, node) => {
if (args.length === 0) throw new InterpreterRuntimeError(`${name} requires 1 argument (a string)`, node)
fn<R>(runner.prototypes, name, 1, (_, args) => {
if (args.length === 0) throw typeError(`${name} requires 1 argument (a string)`)
const input = coerceToString(args[0])
try {
return name === "atob" ? atob(input) : btoa(input)
} catch {
throw new InterpreterRuntimeError("The string contains invalid characters.", node)
throw typeError("The string contains invalid characters.")
}
})
+1 -1
View File
@@ -347,7 +347,7 @@ describe("CodeMode console capture", () => {
)
expect(result.ok ? undefined : result.logs).toStrictEqual(["before failure"])
expect(result.ok ? undefined : result.error.message).toBe("Uncaught: boom")
expect(result.ok ? undefined : result.error.message).toBe("Error: boom")
})
test("prints NaN and Infinity literally instead of the JSON null", async () => {
+178
View File
@@ -0,0 +1,178 @@
import { describe, expect, test } from "bun:test"
import { Effect } from "effect"
import { CodeMode } from "../src/index.js"
// One failure is one program error object, and rethrowing it keeps the diagnostic it started with.
const run = (code: string) => Effect.runPromise(CodeMode.execute({ code, tools: {} }))
const value = async (code: string) => {
const result = await run(code)
if (!result.ok) throw new Error(`expected success, got ${result.error.kind}: ${result.error.message}`)
return result.value
}
const error = async (code: string) => {
const result = await run(code)
if (result.ok) throw new Error(`expected failure, got value ${JSON.stringify(result.value)}`)
return result.error
}
describe("error identity", () => {
test("awaiting the same rejected promise twice yields the same error object", async () => {
expect(
await value(`
const fail = async () => { null.foo }
const p = fail()
const a = await p.catch((e) => e)
try { await p } catch (b) { return a === b }
`),
).toBe(true)
})
test("allSettled reasons for the same failure are identical", async () => {
expect(
await value(`
const fail = async () => { null.foo }
const p = fail()
const [a, b] = await Promise.allSettled([p, p])
return [a.reason === b.reason, a.reason.name, a.reason.message]
`),
).toEqual([true, "TypeError", "Cannot read properties of null (reading 'foo')."])
})
test("Promise.any collects the same object a direct catch would", async () => {
expect(
await value(`
const fail = async () => { null.foo }
const p = fail()
const direct = await p.catch((e) => e)
try { await Promise.any([p]) } catch (aggregate) { return aggregate.errors[0] === direct }
`),
).toBe(true)
})
})
describe("rethrown interpreter failures", () => {
test("keep their diagnostic kind and source location", async () => {
const failure = await error(`try { switch (Symbol) {} } catch (e) { throw e }`)
expect(failure.kind).toBe("InvalidDataValue")
expect(failure.message).toStartWith("TypeError: Switch discriminants must be data values. (line ")
expect(failure.location).toBeDefined()
})
test("keep their location through a rejection handler", async () => {
const direct = await error(`
const fail = async () => { null.foo }
await fail()
`)
const rethrown = await error(`
const fail = async () => { null.foo }
await fail().catch((e) => { throw e })
`)
expect(direct.location).toBeDefined()
expect(rethrown).toEqual(direct)
})
})
describe("uncaught program throws", () => {
test("an Error reports as name: message", async () => {
const failure = await error(`throw new TypeError("bad input")`)
expect(failure).toEqual({ kind: "ExecutionFailure", message: "TypeError: bad input" })
})
test("a custom name is honored", async () => {
const failure = await error(`const e = new Error("x"); e.name = "ValidationError"; throw e`)
expect(failure.message).toBe("ValidationError: x")
})
test("non-Error values keep the Uncaught prefix", async () => {
expect((await error(`throw "boom"`)).message).toBe("Uncaught: boom")
expect((await error(`throw { code: 7 }`)).message).toBe('Uncaught: {"code":7}')
})
})
describe("host errors escaping built-ins", () => {
test("become the same-named program error", async () => {
expect(
await value(`
try { (1).toFixed(200) } catch (e) { return [e.name, e instanceof RangeError, e.message] }
`),
).toEqual(["RangeError", true, "toFixed() argument must be between 0 and 100"])
})
test("report the location of the call that raised them", async () => {
const failure = await error(`return [1].map((n) => n.toFixed(200))`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: toFixed() argument must be between 0 and 100 (line 1, col 23)")
})
test("a built-in that rejects its arguments before doing any work is located at the call", async () => {
expect((await error(`new Promise(Symbol)`)).message).toEndWith("(line 1, col 1)")
})
test("a rejection born inside a promise the built-in created is located at the creating call", async () => {
expect((await error(`return await Promise.all(1)`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.race([])`)).message).toEndWith("(line 1, col 14)")
expect((await error(`return await Promise.all({ [Symbol.iterator]: () => ({ next: 1 }) })`)).message).toEndWith(
"(line 1, col 14)",
)
expect((await error(`let p; p = Promise.resolve().then(() => p); return await p`)).message).toEndWith(
"(line 2, col 5)",
)
})
test("an un-awaited rejection born inside promise machinery keeps its location in the warning", async () => {
const result = await run(`Promise.all(1); return 1`)
expect(result.ok && result.warnings?.[0]?.message).toEndWith(
"TypeError: Promise.all expects an array or other synchronous iterable. (line 1, col 1)",
)
})
test("a failure inside a built-in called by another built-in is located at the outer call", async () => {
const failure = await error(`return Array.from({ [Symbol.iterator]: () => ({ next: 1 }) })`)
expect(failure.message).toBe("TypeError: Iterator next must be a function. (line 1, col 8)")
})
})
describe("call depth", () => {
test("runaway recursion fails fast with a catchable RangeError", async () => {
const started = Date.now()
expect(
await value(`
const f = (n) => f(n + 1)
try { f(0) } catch (e) { return [e.name, e instanceof RangeError, e.message] }
`),
).toEqual(["RangeError", true, "Maximum call stack size exceeded"])
expect(Date.now() - started).toBeLessThan(2000)
})
test("uncaught overflow reports the call that overflowed", async () => {
const failure = await error(`const f = (n) => f(n + 1); return f(0)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toBe("RangeError: Maximum call stack size exceeded (line 1, col 18)")
})
test("the limit is 10000 nested calls", async () => {
expect(await value(`let depth = 0; const f = () => { depth++; f() }; try { f() } catch { return depth }`)).toBe(
10000,
)
expect(await value(`const f = (n) => (n === 0 ? 0 : 1 + f(n - 1)); return f(9000)`)).toBe(9000)
})
test("recursion through a built-in callback counts", async () => {
const failure = await error(`const f = (n) => [n].map((x) => f(x + 1)); return f(0)`)
expect(failure.message).toStartWith("RangeError: Maximum call stack size exceeded")
})
test("an await resets the depth, so long async chains are fine", async () => {
expect(
await value(`
const page = async (n) => { await null; return n === 0 ? "done" : page(n - 1) }
return await page(3000)
`),
).toBe("done")
})
test("an async function that recurses before its first await overflows like JS", async () => {
const failure = await error(`const f = async (n) => f(n + 1); return await f(0)`)
expect(failure.message).toStartWith("RangeError: Maximum call stack size exceeded")
})
})
+13 -9
View File
@@ -30,27 +30,31 @@ describe("new on a non-constructible callee", () => {
// Number is a real constructor in JS, so the message must not claim otherwise.
const failure = await error(`return new Number(42)`)
expect(failure.kind).toBe("ExecutionFailure")
expect(failure.message).toStartWith("new Number(...) is not supported; call Number(...) without new instead.")
expect(failure.message).toStartWith(
"TypeError: new Number(...) is not supported; call Number(...) without new instead.",
)
expect(failure.suggestions).toBeUndefined()
expect((await error(`return new String("a")`)).message).toStartWith("new String(...) is not supported")
expect((await error(`return new String("a")`)).message).toStartWith("TypeError: new String(...) is not supported")
expect((await error(`return new Math.abs(1)`)).message).toStartWith(
"new Math.abs(...) is not supported; call Math.abs(...) without new instead.",
"TypeError: new Math.abs(...) is not supported; call Math.abs(...) without new instead.",
)
})
test("non-callable values are not constructors", async () => {
expect((await error(`return new tools.echo()`)).message).toStartWith("tools.echo is not a constructor.")
expect((await error(`return new (1)()`)).message).toStartWith("The called value is not a constructor.")
expect((await error(`const Date = 5; return new Date()`)).message).toStartWith("Date is not a constructor.")
expect((await error(`return new tools.echo()`)).message).toStartWith("TypeError: tools.echo is not a constructor.")
expect((await error(`return new (1)()`)).message).toStartWith("TypeError: The called value is not a constructor.")
expect((await error(`const Date = 5; return new Date()`)).message).toStartWith(
"TypeError: Date is not a constructor.",
)
})
test("user-defined functions explain the documented gap", async () => {
const failure = await error(`function Point(x) { return { x } }; return new Point(1)`)
expect(failure.message).toStartWith(
"Point cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.",
"TypeError: Point cannot be constructed: user-defined constructors and classes are not supported. Call it as a function that returns a plain object instead.",
)
expect((await error(`const make = () => ({}); return new make()`)).message).toStartWith(
"make cannot be constructed",
"TypeError: make cannot be constructed",
)
})
@@ -71,7 +75,7 @@ describe("new on a non-constructible callee", () => {
const failure = await error(`class A {}; return new A()`)
expect(failure.kind).toBe("UnsupportedSyntax")
expect(failure.message).toStartWith(
"Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
"SyntaxError: Syntax 'ClassDeclaration' is not supported. This is a restricted JavaScript-like language. Supported: ",
)
expect(failure.message).toContain(
"Unsupported: classes, this, getters/setters, tagged templates, BigInt, and custom Symbols.",
+9 -9
View File
@@ -330,7 +330,7 @@ describe("first-class promise values", () => {
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
])
})
@@ -363,7 +363,7 @@ describe("first-class promise values", () => {
expect(result.truncated).toBe(true)
expect(typeof result.value).toBe("string")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
])
})
@@ -399,9 +399,9 @@ describe("first-class promise values", () => {
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: first" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: first" },
{ kind: "ToolFailure", message: "Unhandled rejection from an un-awaited promise: Lookup refused" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: third" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: third" },
])
})
@@ -417,8 +417,8 @@ describe("first-class promise values", () => {
expect(result.ok).toBe(true)
if (!result.ok) return
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: outer" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: inner" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: outer" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: inner" },
])
})
@@ -445,7 +445,7 @@ describe("first-class promise values", () => {
)
expect(result.ok).toBe(false)
if (result.ok) return
expect(result.error.message).toBe("Uncaught: boom")
expect(result.error.message).toBe("Error: boom")
expect("warnings" in result).toBe(false)
expect(trace.completed).toBe(0)
expect(trace.interrupted).toBe(1)
@@ -911,7 +911,7 @@ describe("Promise.resolve / Promise.reject", () => {
if (!result.ok) return
expect(result.value).toBe("done")
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: abandoned" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: abandoned" },
])
})
})
@@ -1068,7 +1068,7 @@ describe("promise chaining", () => {
expect(result.value).toBe("done")
// The source rejection belongs to the chain (no warning); only the derived tail warns.
expect(result.warnings).toStrictEqual([
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Uncaught: boom" },
{ kind: "ExecutionFailure", message: "Unhandled rejection from an un-awaited promise: Error: boom" },
])
})
+2 -2
View File
@@ -737,9 +737,9 @@ describe("stdlib integration", () => {
expect(await value(`const make = (C) => new C([["a", 1]]); return make(Map).get("a")`)).toBe(1)
expect(await value(`const t = { M: Map }; return new t.M() instanceof Map`)).toBe(true)
const shadowed = await error(`const Date = 5; return new Date()`)
expect(shadowed.message).toStartWith("Date is not a constructor.")
expect(shadowed.message).toStartWith("TypeError: Date is not a constructor.")
const fn = await error(`const f = () => 1; return new f()`)
expect(fn.message).toStartWith("f cannot be constructed")
expect(fn.message).toStartWith("TypeError: f cannot be constructed")
})
test("Object.is uses SameValue semantics", async () => {
+4 -4
View File
@@ -3,7 +3,7 @@
// test262's own assert.js relies on.
import path from "node:path"
import { Cause, Effect } from "effect"
import { caughtErrorValue } from "../../src/interpreter/errors.js"
import { materialize } from "../../src/interpreter/errors.js"
import { executeProgram } from "../../src/interpreter/execute.js"
import type { Host } from "../../src/interpreter/globals.js"
import { ProgramThrow } from "../../src/interpreter/model.js"
@@ -128,13 +128,13 @@ const harness = <R>(host: Host<R>, onDone: (error: unknown) => void): ReadonlyAr
[
"throws",
3,
(_, args, node) => {
(_, args) => {
const expected = args[0] instanceof Callable ? String(get(args[0], "name")) : show(args[0])
return host.runner.invokeCallable(args[1], undefined, [], node).pipe(
return host.runner.invokeCallable(args[1], undefined, []).pipe(
Effect.matchCauseEffect({
onFailure: (cause) => {
if (cause.reasons.some(Cause.isInterruptReason)) return Effect.failCause(cause)
const thrown = caughtErrorValue(host.runner, Cause.squash(cause))
const thrown = materialize(host.runner, Cause.squash(cause))
if (!(thrown instanceof ProgramObject)) return fail(`${prefix(args[2])}Thrown value was not an object!`)
const actual = get(thrown, "constructor")
if (actual === args[0]) return Effect.void
+12 -7
View File
@@ -10,6 +10,7 @@ import { makeGlobalNode } from "@opencode/util/effect/app-node"
import { File } from "./file.js"
import { KeyedMutex } from "./effect/keyed-mutex.js"
import { VcsPatch } from "./vcs/patch.js"
import { gitExecutable } from "./util/git-executable.js"
export class Repository extends Schema.Class<Repository>("Git.Repository")({
worktree: AbsolutePath,
@@ -313,7 +314,7 @@ const layer = Layer.effect(
) {
const result = yield* proc
.run(
ChildProcess.make("git", repositoryArgs(repository, args), {
ChildProcess.make(gitExecutable, repositoryArgs(repository, args), {
cwd: repository.worktree,
env: options?.env,
extendEnv: true,
@@ -441,10 +442,14 @@ const layer = Layer.effect(
if (!input.paths.length) return new Set<RelativePath>()
const result = yield* proc
.run(
ChildProcess.make("git", repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]), {
cwd: input.repository.worktree,
extendEnv: true,
}),
ChildProcess.make(
gitExecutable,
repositoryArgs(input.repository, ["check-ignore", "--no-index", "--stdin", "-z"]),
{
cwd: input.repository.worktree,
extendEnv: true,
},
),
{ stdin: input.paths.join("\0") + "\0" },
)
.pipe(
@@ -625,7 +630,7 @@ const layer = Layer.effect(
cwd = repository.worktree,
) {
const result = yield* proc
.run(ChildProcess.make("git", args, { cwd, extendEnv: true, stdin: "ignore" }))
.run(ChildProcess.make(gitExecutable, args, { cwd, extendEnv: true, stdin: "ignore" }))
.pipe(
Effect.mapError(
(cause) => new WorktreeError({ operation, directory: worktreeDirectory, message: cause.message, cause }),
@@ -721,7 +726,7 @@ function run(cwd: string, proc: AppProcess.Interface, args: string[]) {
function execute(cwd: string, proc: AppProcess.Interface, args: string[]) {
return proc
.run(
ChildProcess.make("git", args, {
ChildProcess.make(gitExecutable, args, {
cwd,
extendEnv: true,
stdin: "ignore",
+12 -7
View File
@@ -113,7 +113,12 @@ export class AuthorizationError extends Schema.TaggedError<AuthorizationError>()
cause: Schema.Defect(),
}) {}
export type Error = CodeRequiredError | AuthorizationError
export class AttemptNotFoundError extends Schema.TaggedError<AttemptNotFoundError>()("Integration.AttemptNotFound", {
integrationID: ID,
attemptID: AttemptID,
}) {}
export type Error = CodeRequiredError | AuthorizationError | AttemptNotFoundError
export { Event } from "@opencode/schema/integration"
@@ -188,13 +193,13 @@ export interface Interface extends State.Transformable<Editor> {
readonly status: (input: {
readonly integrationID: ID
readonly attemptID: AttemptID
}) => Effect.Effect<AttemptStatus>
}) => Effect.Effect<AttemptStatus, AttemptNotFoundError>
/** Completes the attempt and stores its credential. */
readonly complete: (input: {
readonly integrationID: ID
readonly attemptID: AttemptID
readonly code?: string
}) => Effect.Effect<void, CodeRequiredError | AuthorizationError>
}) => Effect.Effect<void, CodeRequiredError | AuthorizationError | AttemptNotFoundError>
/** Cancels an attempt and releases its resources. */
readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
}
@@ -207,7 +212,7 @@ export interface Interface extends State.Transformable<Editor> {
readonly status: (input: {
readonly integrationID: ID
readonly attemptID: AttemptID
}) => Effect.Effect<CommandAttemptStatus>
}) => Effect.Effect<CommandAttemptStatus, AttemptNotFoundError>
readonly cancel: (input: { readonly integrationID: ID; readonly attemptID: AttemptID }) => Effect.Effect<void>
}
}
@@ -718,7 +723,7 @@ const layer = Layer.effect(
status: Effect.fn("Integration.oauth.status")(function* (input) {
const attempt = (yield* SynchronizedRef.get(attempts)).get(input.attemptID)
if (!attempt || attempt.integrationID !== input.integrationID)
return yield* Effect.die(new Error(`OAuth attempt not found: ${input.attemptID}`))
return yield* new AttemptNotFoundError(input)
if (attempt.status === "failed") {
return { status: attempt.status, message: attempt.message ?? "Authorization failed", time: attempt.time }
}
@@ -732,7 +737,7 @@ const layer = Layer.effect(
if (match.authorization.mode === "code" && input.code === undefined) return [match, current]
return [match, new Map(current).set(input.attemptID, { ...match, completing: true })]
})
if (!attempt) return yield* Effect.die(new Error(`OAuth attempt not found: ${input.attemptID}`))
if (!attempt) return yield* new AttemptNotFoundError(input)
if (attempt.status !== "pending") return
if (attempt.authorization.mode === "code" && input.code === undefined) {
return yield* new CodeRequiredError({ attemptID: input.attemptID })
@@ -764,7 +769,7 @@ const layer = Layer.effect(
status: Effect.fn("Integration.command.status")(function* (input) {
const attempt = (yield* SynchronizedRef.get(commandAttempts)).get(input.attemptID)
if (!attempt || attempt.integrationID !== input.integrationID)
return yield* Effect.die(new Error(`Command attempt not found: ${input.attemptID}`))
return yield* new AttemptNotFoundError(input)
if (attempt.status === "pending") {
return {
status: attempt.status,
+2 -1
View File
@@ -9,6 +9,7 @@ import { AppProcess } from "@opencode/util/process"
import { Location } from "../../location.js"
import type { Adapter, BranchOptions, DiffOptions } from "../../vcs.js"
import { DiffError } from "../../vcs.js"
import { gitExecutable } from "../../util/git-executable.js"
import {
chunksByFile,
emptyPatch,
@@ -168,7 +169,7 @@ function makeGit(proc: AppProcess.Interface) {
const run = Effect.fnUntraced(
function* (args: string[], opts: { cwd: string; maxOutputBytes?: number }) {
const result = yield* proc.run(
ChildProcess.make("git", [...cfg, ...args], {
ChildProcess.make(gitExecutable, [...cfg, ...args], {
cwd: opts.cwd,
extendEnv: true,
stdin: "ignore",
+5 -5
View File
@@ -94,7 +94,7 @@ type CompactInput = Parameters<Session.Handle["compact"]>[0] & { sessionID: Sess
type ForkInput = {
sessionID: SessionSchema.ID
boundary: SessionSchema.ForkRequestBoundary
before?: SessionMessage.ID
}
export {
@@ -308,17 +308,17 @@ const layer = Layer.effect(
.where(
and(
eq(SessionMessageTable.session_id, input.sessionID),
input.boundary.type === "before" ? eq(SessionMessageTable.id, input.boundary.messageID) : undefined,
input.before ? eq(SessionMessageTable.id, input.before) : undefined,
),
)
.orderBy(desc(SessionMessageTable.seq))
.limit(1)
.get()
.pipe(Effect.orDie)
if (!boundary && input.boundary.type === "before")
if (!boundary && input.before)
return yield* new MessageNotFoundError({
sessionID: input.sessionID,
messageID: input.boundary.messageID,
messageID: input.before,
})
if (!boundary) return yield* new ForkEmptyError({ sessionID: input.sessionID })
const sessionID = SessionSchema.ID.create()
@@ -336,7 +336,7 @@ const layer = Layer.effect(
yield* bus.publish(SessionEvent.Forked, {
sessionID,
parentID: parent.id,
boundary: { ...input.boundary, messageID: boundary.id },
boundary: { type: input.before ? "before" : "through", messageID: boundary.id },
...inherited,
})
return yield* result.get(sessionID).pipe(Effect.orDie)
+11
View File
@@ -0,0 +1,11 @@
import path from "path"
import { which } from "./which.js"
export function resolveGitExecutable(platform: NodeJS.Platform, resolved: string | null) {
if (platform !== "win32" || !resolved) return "git"
return path.resolve(resolved)
}
const resolved = process.platform === "win32" ? which("git") : null
export const gitExecutable = resolveGitExecutable(process.platform, resolved)
+2 -1
View File
@@ -11,6 +11,7 @@ import { ConfigAgentPlugin } from "@opencode/core/config/plugin/agent"
import { ConfigCommandPlugin } from "@opencode/core/config/plugin/command"
import { ConfigProviderPlugin } from "@opencode/core/config/plugin/provider"
import { ConfigReferencePlugin } from "@opencode/core/config/plugin/reference"
import { ConfigCompatibilityPlugin } from "@opencode/core/config/plugin/compatibility"
import { ConfigSkillPlugin } from "@opencode/core/config/plugin/skill"
import { Bus } from "@opencode/core/bus"
import { Integration } from "@opencode/core/integration"
@@ -67,7 +68,7 @@ describe("config plugin reloads", () => {
const plugins = yield* Plugin.Service
const skills = yield* Skill.Service
const host = yield* PluginHost.make(plugins)
yield* ConfigSkillPlugin.Plugin.effect(host)
yield* ConfigCompatibilityPlugin.Plugin.effect(host)
expect(yield* skills.list()).toEqual([])
// Finish startup by observing an ordinary config reload before creating the root.
@@ -75,11 +75,11 @@ function required<T>(value: T | undefined): T {
return value
}
function eventually<A>(
effect: Effect.Effect<A>,
function eventually<A, E>(
effect: Effect.Effect<A, E>,
predicate: (value: A) => boolean,
remaining = 1000,
): Effect.Effect<A, Error> {
): Effect.Effect<A, E | Error> {
return Effect.gen(function* () {
const value = yield* effect
if (predicate(value)) return value
+13 -13
View File
@@ -380,7 +380,7 @@ describe("Session.create", () => {
yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false })
yield* SessionInbox.promote(db, bus, created.id, "steer")
const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: created.id })
expect(forked.metadata).toEqual(metadata)
// Absent stays absent: no empty-object normalization.
@@ -402,7 +402,7 @@ describe("Session.create", () => {
yield* session.prompt({ sessionID: created.id, text: "Fork context", resume: false })
yield* SessionInbox.promote(db, bus, created.id, "steer")
const forked = yield* session.fork({ sessionID: created.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: created.id })
expect(forked.permissions).toEqual(permissions)
const replaced = [{ action: "shell", resource: "*", effect: "ask" as const }]
@@ -541,7 +541,7 @@ describe("Session.create", () => {
yield* session.synthetic({ sessionID: parent.id, text: "parent note", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: parent.id })
const parentContext = yield* session.context(parent.id)
const forkContext = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
@@ -596,7 +596,7 @@ describe("Session.create", () => {
yield* session.prompt({ sessionID: parent.id, text: "First", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: parent.id })
const row = yield* db.select().from(SessionTable).where(eq(SessionTable.id, forked.id)).get().pipe(Effect.orDie)
expect(forked.title).toBeUndefined()
@@ -614,7 +614,7 @@ describe("Session.create", () => {
yield* SessionInbox.promote(db, bus, parent.id, "steer")
yield* session.synthetic({ sessionID: parent.id, text: "Second", resume: false })
yield* SessionInbox.promote(db, bus, parent.id, "steer")
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: parent.id })
const original = (yield* session.context(forked.id)).map((message) => message.id)
const recorded = yield* db
.select()
@@ -657,7 +657,7 @@ describe("Session.create", () => {
{ discard: true },
)
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: parent.id })
const inheritedList = yield* entries.list(forked.id)
const inheritedValues = yield* entries.load(forked.id).pipe(Effect.flatMap(Instructions.read))
@@ -730,7 +730,7 @@ describe("Session.create", () => {
executed: true,
})
const forked = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const forked = yield* session.fork({ sessionID: parent.id })
expect(yield* session.context(parent.id)).toMatchObject([
Expected.user("Run both tools"),
@@ -760,7 +760,7 @@ describe("Session.create", () => {
})
yield* bus.publish(SessionEvent.Shell.Started, { sessionID: parent.id, shell })
const running = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const running = yield* session.fork({ sessionID: parent.id })
expect(yield* session.context(parent.id)).toMatchObject([
Expected.user("Run a shell"),
@@ -773,7 +773,7 @@ describe("Session.create", () => {
shell: { ...shell, status: "exited", exit: 0, time: { started: 0, completed: 1 } },
output: { output: "complete", cursor: 8, size: 8, truncated: false },
})
const completed = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const completed = yield* session.fork({ sessionID: parent.id })
expect(yield* session.context(running.id)).toMatchObject([Expected.user("Run a shell")])
expect(yield* session.context(completed.id)).toMatchObject([
@@ -789,7 +789,7 @@ describe("Session.create", () => {
const parent = yield* session.create({ location })
expect(
yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } }).pipe(Effect.flip),
yield* session.fork({ sessionID: parent.id }).pipe(Effect.flip),
).toMatchObject({ _tag: "Session.ForkEmptyError", sessionID: parent.id })
}),
)
@@ -830,13 +830,13 @@ describe("Session.create", () => {
const forked = yield* session.fork({
sessionID: parent.id,
boundary: { type: "before", messageID: second.id },
before: second.id,
})
const beforeFirst = yield* session.fork({
sessionID: parent.id,
boundary: { type: "before", messageID: first.id },
before: first.id,
})
const complete = yield* session.fork({ sessionID: parent.id, boundary: { type: "through" } })
const complete = yield* session.fork({ sessionID: parent.id })
const context = yield* session.context(forked.id)
const history = Array.from(yield* Stream.runCollect(logEvents(session, forked.id)))
+1 -1
View File
@@ -186,7 +186,7 @@ describe("Session.diff", () => {
expect(yield* diff({ from: steer }).pipe(Effect.flip)).toMatchObject({
_tag: "Session.MessageNotFoundError",
})
const forked = yield* sessions.fork({ sessionID: created.id, boundary: { type: "through" } })
const forked = yield* sessions.fork({ sessionID: created.id })
expect((yield* sessions.diff({ sessionID: forked.id, context: 0 })).map(summarize)).toEqual([
["third.txt", "added", 1, 0],
])
+5 -5
View File
@@ -1497,7 +1497,7 @@ describe("SessionRunnerLLM", () => {
expect(continued.system.map((part) => part.text)).toContain("Checkpoint instructions")
expect(systemTexts(continued)).toEqual(["Newest instructions"])
const forked = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: after.id } })
const forked = yield* s.session.fork({ sessionID, before: after.id })
yield* s.session.prompt({ sessionID: forked.id, text: "Fork prompt", resume: false })
yield* s.session.resume(forked.id)
expect(s.requests.at(-1)?.messages[0]).toEqual(replacement[0])
@@ -1552,7 +1552,7 @@ describe("SessionRunnerLLM", () => {
s.systemBaseline = "Latest context"
yield* s.runPrompt("Third")
const forked = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: second.id } })
const forked = yield* s.session.fork({ sessionID, before: second.id })
expect(
yield* s.db.select().from(InstructionStateTable).where(eq(InstructionStateTable.session_id, forked.id)).get(),
).toMatchObject({
@@ -1597,14 +1597,14 @@ describe("SessionRunnerLLM", () => {
s.systemBaseline = "Changed context"
const second = yield* s.runPrompt("Second")
const child = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: second.id } })
const child = yield* s.session.fork({ sessionID, before: second.id })
const inheritedFirst = (yield* s.session.messages({ sessionID: child.id })).find(
(message) => message.type === "user" && message.text === "First",
)
if (!inheritedFirst) return yield* Effect.die(new Error("Nested fork boundary message not found"))
const grandchild = yield* s.session.fork({
sessionID: child.id,
boundary: { type: "before", messageID: inheritedFirst.id },
before: inheritedFirst.id,
})
expect(
@@ -2036,7 +2036,7 @@ describe("SessionRunnerLLM", () => {
yield* replaySessionProjection(sessionID)
const latest = yield* s.runPrompt("Third")
expect(systemTexts(s.requests[3])).toEqual(["Replacement context"])
const fork = yield* s.session.fork({ sessionID, boundary: { type: "before", messageID: latest.id } })
const fork = yield* s.session.fork({ sessionID, before: latest.id })
expect(
(yield* s.session.context(fork.id)).flatMap((message) => (message.type === "system" ? [message.text] : [])),
).toEqual(["Replacement context"])
+1 -1
View File
@@ -129,7 +129,7 @@ describe("Session.skill", () => {
resume: false,
})
yield* SessionInbox.promote(database.db, bus, session.id, "steer")
const forked = yield* sessions.fork({ sessionID: session.id, boundary: { type: "before", messageID: selected } })
const forked = yield* sessions.fork({ sessionID: session.id, before: selected })
expect(yield* sessions.messages({ sessionID: forked.id })).toEqual([
expect.objectContaining({ type: "user", text: "Before the skill" }),
-25
View File
@@ -127,31 +127,6 @@ describe("Snapshot", () => {
),
)
testEffect(Layer.empty).live("treats fatal ignore checks as unavailable captures", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
(tmp) =>
Effect.gen(function* () {
const project = path.join(tmp.path, "project")
yield* Effect.promise(async () => {
await fs.mkdir(project)
await Bun.write(path.join(project, "tracked.txt"), "one\n")
await initGit(project)
})
yield* Effect.gen(function* () {
const snapshot = yield* Snapshot.Service
expect(yield* snapshot.capture()).toBeDefined()
yield* Effect.promise(async () => {
await Bun.write(path.join(project, "tracked.txt"), "two\n")
await Bun.write(path.join(project, ".git", "config"), "[broken\n")
})
expect(yield* snapshot.capture()).toBeUndefined()
}).pipe(Effect.provide(snapshotLayer(tmp.path, project)))
}),
(tmp) => Effect.promise(() => tmp[Symbol.asyncDispose]()),
),
)
testEffect(Layer.empty).live("applies availability transforms", () =>
Effect.acquireUseRelease(
Effect.promise(() => tmpdir()),
@@ -0,0 +1,17 @@
import { describe, expect, test } from "bun:test"
import path from "path"
import { resolveGitExecutable } from "@opencode/core/util/git-executable"
describe("Git executable", () => {
test("uses an absolute executable on Windows", () => {
expect(resolveGitExecutable("win32", "bin/git.exe")).toBe(path.resolve("bin/git.exe"))
})
test("leaves lookup to the process outside Windows", () => {
expect(resolveGitExecutable("linux", "/usr/bin/git")).toBe("git")
})
test("falls back to PATH lookup when Git is unresolved", () => {
expect(resolveGitExecutable("win32", null)).toBe("git")
})
})
+138 -133
View File
@@ -931,10 +931,10 @@
}
}
},
"/api/session/stats": {
"/api/experimental/session/stats": {
"get": {
"tags": ["session"],
"operationId": "session.stats",
"operationId": "experimental.session.stats",
"parameters": [
{
"name": "from",
@@ -1519,7 +1519,7 @@
}
}
},
"description": "Create a child session by copying projected history through or before a message boundary.",
"description": "Create a child session by copying projected history before a message. Omit before to copy the full history.",
"summary": "Fork session",
"requestBody": {
"content": {
@@ -1527,11 +1527,18 @@
"schema": {
"type": "object",
"properties": {
"boundary": {
"$ref": "#/components/schemas/Session.ForkRequestBoundary"
"before": {
"anyOf": [
{
"type": "string",
"pattern": "^msg_"
},
{
"type": "null"
}
]
}
},
"required": ["boundary"],
"additionalProperties": false
}
}
@@ -5373,6 +5380,16 @@
}
}
}
},
"404": {
"description": "IntegrationNotFoundError",
"content": {
"application/json": {
"schema": {
"$ref": "#/components/schemas/IntegrationNotFoundErrorEncoded"
}
}
}
}
},
"description": "Run a key authentication method and store the resulting credential.",
@@ -5644,6 +5661,23 @@
}
}
}
},
"404": {
"description": "IntegrationNotFoundError | IntegrationAttemptNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/IntegrationNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/IntegrationAttemptNotFoundErrorEncoded"
}
]
}
}
}
}
},
"description": "Poll the current status of an OAuth attempt.",
@@ -5813,6 +5847,23 @@
}
}
}
},
"404": {
"description": "IntegrationNotFoundError | IntegrationAttemptNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/IntegrationNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/IntegrationAttemptNotFoundErrorEncoded"
}
]
}
}
}
}
},
"description": "Complete a code-based OAuth attempt and store the resulting credential.",
@@ -5934,6 +5985,23 @@
}
}
}
},
"404": {
"description": "IntegrationNotFoundError | IntegrationMethodNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/IntegrationNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/IntegrationMethodNotFoundErrorEncoded"
}
]
}
}
}
}
},
"description": "Start a command authentication attempt.",
@@ -6060,6 +6128,23 @@
}
}
}
},
"404": {
"description": "IntegrationNotFoundError | IntegrationAttemptNotFoundError",
"content": {
"application/json": {
"schema": {
"anyOf": [
{
"$ref": "#/components/schemas/IntegrationNotFoundErrorEncoded"
},
{
"$ref": "#/components/schemas/IntegrationAttemptNotFoundErrorEncoded"
}
]
}
}
}
}
},
"description": "Poll the current status and output of a command authentication attempt.",
@@ -6232,10 +6317,10 @@
"summary": "List MCP servers"
}
},
"/api/mcp/{server}": {
"/api/experimental/mcp/{server}": {
"put": {
"tags": ["mcp"],
"operationId": "mcp.add",
"operationId": "experimental.mcp.add",
"parameters": [
{
"name": "server",
@@ -6331,7 +6416,7 @@
},
"delete": {
"tags": ["mcp"],
"operationId": "mcp.remove",
"operationId": "experimental.mcp.remove",
"parameters": [
{
"name": "server",
@@ -6412,10 +6497,10 @@
"summary": "Remove MCP server"
}
},
"/api/mcp/{server}/connect": {
"/api/experimental/mcp/{server}/connect": {
"post": {
"tags": ["mcp"],
"operationId": "mcp.connect",
"operationId": "experimental.mcp.connect",
"parameters": [
{
"name": "server",
@@ -6496,10 +6581,10 @@
"summary": "Connect MCP server"
}
},
"/api/mcp/{server}/disconnect": {
"/api/experimental/mcp/{server}/disconnect": {
"post": {
"tags": ["mcp"],
"operationId": "mcp.disconnect",
"operationId": "experimental.mcp.disconnect",
"parameters": [
{
"name": "server",
@@ -6675,36 +6760,6 @@
"type": "string"
},
"required": true
},
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
@@ -6764,36 +6819,6 @@
"type": "string"
},
"required": true
},
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
@@ -6838,36 +6863,6 @@
"type": "string"
},
"required": true
},
{
"name": "location",
"in": "query",
"schema": {
"anyOf": [
{
"type": "object",
"properties": {
"directory": {
"anyOf": [
{
"type": "string"
},
{
"type": "null"
}
]
}
},
"additionalProperties": false
},
{
"type": "null"
}
]
},
"required": false,
"style": "deepObject",
"explode": true
}
],
"security": [],
@@ -15121,6 +15116,46 @@
"required": ["id", "type", "label"],
"additionalProperties": false
},
"IntegrationAttemptNotFoundErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["IntegrationAttemptNotFoundError"]
},
"integrationID": {
"type": "string"
},
"attemptID": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "integrationID", "attemptID", "message"],
"additionalProperties": false
},
"IntegrationMethodNotFoundErrorEncoded": {
"type": "object",
"properties": {
"_tag": {
"type": "string",
"enum": ["IntegrationMethodNotFoundError"]
},
"integrationID": {
"type": "string"
},
"methodID": {
"type": "string"
},
"message": {
"type": "string"
}
},
"required": ["_tag", "integrationID", "methodID", "message"],
"additionalProperties": false
},
"IntegrationNotFoundErrorEncoded": {
"type": "object",
"properties": {
@@ -16960,36 +16995,6 @@
}
]
},
"Session.ForkRequestBoundary": {
"anyOf": [
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["before"]
},
"messageID": {
"type": "string",
"pattern": "^msg_"
}
},
"required": ["type", "messageID"],
"additionalProperties": false
},
{
"type": "object",
"properties": {
"type": {
"type": "string",
"enum": ["through"]
}
},
"required": ["type"],
"additionalProperties": false
}
]
},
"Session.Inbox.Compaction": {
"type": "object",
"properties": {
+2 -2
View File
@@ -43,7 +43,6 @@ type LocationGroups<LocationId extends HttpApiMiddleware.AnyId> =
| HttpApiGroup.AddMiddleware<typeof IntegrationGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof WebSearchGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof McpGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof CredentialGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof ProjectGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof FileSystemGroup, LocationId>
| HttpApiGroup.AddMiddleware<typeof CommandGroup, LocationId>
@@ -88,6 +87,7 @@ type ApiGroups<
| typeof MigrationGroup
| typeof GenerateGroup
| typeof PersistentPtyGroup
| typeof CredentialGroup
| LocationGroups<LocationId>
| FormGroups<LocationId, LocationService, FormLocationId, FormLocationService>
| SessionGroups<SessionLocationId, SessionLocationService>
@@ -158,7 +158,7 @@ const makeApiFromGroup = <
.add(ProviderGroup.middleware(locationMiddleware))
.add(IntegrationGroup.middleware(locationMiddleware))
.add(McpGroup.middleware(locationMiddleware))
.add(CredentialGroup.middleware(locationMiddleware))
.add(CredentialGroup)
.add(ProjectGroup.middleware(locationMiddleware))
.add(makeFormGroup(locationMiddleware, formLocationMiddleware))
.add(makePermissionGroup(locationMiddleware, sessionLocationMiddleware))
+20
View File
@@ -91,6 +91,26 @@ export class IntegrationNotFoundError extends Schema.TaggedError<IntegrationNotF
{ httpApiStatus: 404 },
) {}
export class IntegrationAttemptNotFoundError extends Schema.TaggedError<IntegrationAttemptNotFoundError>()(
"IntegrationAttemptNotFoundError",
{
integrationID: Schema.String,
attemptID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class IntegrationMethodNotFoundError extends Schema.TaggedError<IntegrationMethodNotFoundError>()(
"IntegrationMethodNotFoundError",
{
integrationID: Schema.String,
methodID: Schema.String,
message: Schema.String,
},
{ httpApiStatus: 404 },
) {}
export class ProjectNotFoundError extends Schema.TaggedError<ProjectNotFoundError>()(
"ProjectNotFoundError",
{
@@ -1,17 +1,14 @@
import { Credential } from "@opencode/schema/credential"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const CredentialGroup = HttpApiGroup.make("server.credential")
.add(
HttpApiEndpoint.patch("credential.update", "/api/credential/:credentialID", {
params: { credentialID: Credential.ID },
query: LocationQuery,
payload: Schema.Struct({ label: Schema.String }),
success: HttpApiSchema.NoContent,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "credential.update",
@@ -24,10 +21,8 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
.add(
HttpApiEndpoint.post("credential.activate", "/api/credential/:credentialID/activate", {
params: { credentialID: Credential.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "credential.activate",
@@ -39,10 +34,8 @@ export const CredentialGroup = HttpApiGroup.make("server.credential")
.add(
HttpApiEndpoint.delete("credential.remove", "/api/credential/:credentialID", {
params: { credentialID: Credential.ID },
query: LocationQuery,
success: HttpApiSchema.NoContent,
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "credential.remove",
+11 -4
View File
@@ -3,7 +3,12 @@ import { Location } from "@opencode/schema/location"
import { Form } from "@opencode/schema/form"
import { Schema } from "effect"
import { HttpApiEndpoint, HttpApiGroup, HttpApiSchema, OpenApi } from "effect/unstable/httpapi"
import { IntegrationNotFoundError, InvalidRequestError } from "../errors.js"
import {
IntegrationAttemptNotFoundError,
IntegrationMethodNotFoundError,
IntegrationNotFoundError,
InvalidRequestError,
} from "../errors.js"
import { LocationQuery, locationQueryOpenApi } from "./location.js"
export const IntegrationGroup = HttpApiGroup.make("server.integration")
@@ -63,7 +68,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
label: Schema.optional(Schema.String),
}),
success: HttpApiSchema.NoContent,
error: InvalidRequestError,
error: [IntegrationNotFoundError, InvalidRequestError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
@@ -100,6 +105,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
params: { integrationID: Integration.ID, attemptID: Integration.AttemptID },
query: LocationQuery,
success: Location.response(Integration.AttemptStatus),
error: [IntegrationNotFoundError, IntegrationAttemptNotFoundError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
@@ -119,7 +125,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
query: LocationQuery,
payload: Schema.Struct({ code: Schema.optional(Schema.String) }),
success: HttpApiSchema.NoContent,
error: InvalidRequestError,
error: [IntegrationNotFoundError, IntegrationAttemptNotFoundError, InvalidRequestError],
},
)
.annotateMerge(locationQueryOpenApi)
@@ -155,7 +161,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
label: Schema.optional(Schema.String),
}),
success: Location.response(Integration.CommandAttempt),
error: InvalidRequestError,
error: [IntegrationNotFoundError, IntegrationMethodNotFoundError, InvalidRequestError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
@@ -171,6 +177,7 @@ export const IntegrationGroup = HttpApiGroup.make("server.integration")
params: { integrationID: Integration.ID, attemptID: Integration.AttemptID },
query: LocationQuery,
success: Location.response(Integration.CommandAttemptStatus),
error: [IntegrationNotFoundError, IntegrationAttemptNotFoundError],
})
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
+8 -8
View File
@@ -21,7 +21,7 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
),
)
.add(
HttpApiEndpoint.put("mcp.add", "/api/mcp/:server", {
HttpApiEndpoint.put("mcp.add", "/api/experimental/mcp/:server", {
params: { server: Schema.String },
query: LocationQuery,
// Wrapped in a struct because the client codegen flattens payload fields and cannot
@@ -32,14 +32,14 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "mcp.add",
identifier: "experimental.mcp.add",
summary: "Add MCP server",
description: "Add an MCP server at runtime or replace an existing one, connecting it immediately.",
}),
),
)
.add(
HttpApiEndpoint.delete("mcp.remove", "/api/mcp/:server", {
HttpApiEndpoint.delete("mcp.remove", "/api/experimental/mcp/:server", {
params: { server: Schema.String },
query: LocationQuery,
success: HttpApiSchema.NoContent,
@@ -48,14 +48,14 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "mcp.remove",
identifier: "experimental.mcp.remove",
summary: "Remove MCP server",
description: "Stop an MCP server and remove it from the runtime set until restart.",
}),
),
)
.add(
HttpApiEndpoint.post("mcp.connect", "/api/mcp/:server/connect", {
HttpApiEndpoint.post("mcp.connect", "/api/experimental/mcp/:server/connect", {
params: { server: Schema.String },
query: LocationQuery,
success: HttpApiSchema.NoContent,
@@ -64,14 +64,14 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "mcp.connect",
identifier: "experimental.mcp.connect",
summary: "Connect MCP server",
description: "Connect an MCP server at runtime, overriding a disabled configuration until restart.",
}),
),
)
.add(
HttpApiEndpoint.post("mcp.disconnect", "/api/mcp/:server/disconnect", {
HttpApiEndpoint.post("mcp.disconnect", "/api/experimental/mcp/:server/disconnect", {
params: { server: Schema.String },
query: LocationQuery,
success: HttpApiSchema.NoContent,
@@ -80,7 +80,7 @@ export const McpGroup = HttpApiGroup.make("server.mcp")
.annotateMerge(locationQueryOpenApi)
.annotateMerge(
OpenApi.annotations({
identifier: "mcp.disconnect",
identifier: "experimental.mcp.disconnect",
summary: "Disconnect MCP server",
description: "Disconnect an MCP server at runtime, removing its tools until reconnected.",
}),
+5 -4
View File
@@ -175,7 +175,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
),
)
.add(
HttpApiEndpoint.get("session.stats", "/api/session/stats", {
HttpApiEndpoint.get("session.stats", "/api/experimental/session/stats", {
query: Schema.Struct({
from: Schema.NumberFromString.pipe(Schema.optional),
to: Schema.NumberFromString.pipe(Schema.optional),
@@ -187,7 +187,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
error: InvalidRequestError,
}).annotateMerge(
OpenApi.annotations({
identifier: "session.stats",
identifier: "experimental.session.stats",
summary: "Get session statistics",
description: "Aggregate local session activity, usage, and tool reliability for a time range.",
}),
@@ -285,7 +285,7 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
.add(
HttpApiEndpoint.post("session.fork", "/api/session/:sessionID/fork", {
params: { sessionID: Session.ID },
payload: Schema.Struct({ boundary: Session.ForkRequestBoundary }),
payload: Schema.Struct({ before: SessionMessage.ID.pipe(Schema.optional) }),
success: Schema.Struct({ data: PublicSessionInfo }),
error: [SessionNotFoundError, MessageNotFoundError, InvalidRequestError],
})
@@ -294,7 +294,8 @@ export const makeSessionGroup = <I extends HttpApiMiddleware.AnyId, S>(sessionLo
OpenApi.annotations({
identifier: "session.fork",
summary: "Fork session",
description: "Create a child session by copying projected history through or before a message boundary.",
description:
"Create a child session by copying projected history before a message. Omit before to copy the full history.",
}),
),
)
-6
View File
@@ -8,9 +8,3 @@ export const Boundary = Schema.Union([
Schema.Struct({ type: Schema.Literal("through"), messageID: SessionMessage.ID }),
]).annotate({ identifier: "Session.ForkBoundary" })
export type Boundary = typeof Boundary.Type
export const RequestBoundary = Schema.Union([
Schema.Struct({ type: Schema.Literal("before"), messageID: SessionMessage.ID }),
Schema.Struct({ type: Schema.Literal("through") }),
]).annotate({ identifier: "Session.ForkRequestBoundary" })
export type RequestBoundary = typeof RequestBoundary.Type
-2
View File
@@ -26,8 +26,6 @@ export const Event = SessionEvent
export { Revert }
export const ForkBoundary = SessionFork.Boundary
export type ForkBoundary = SessionFork.Boundary
export const ForkRequestBoundary = SessionFork.RequestBoundary
export type ForkRequestBoundary = SessionFork.RequestBoundary
export interface Info extends Schema.Schema.Type<typeof Info> {}
export const Info = Schema.Struct({
+3 -1
View File
@@ -26,7 +26,9 @@ export const Result = Schema.Struct({
title: Schema.String.pipe(optional),
content: Schema.String.pipe(optional),
time: Schema.Struct({
published: Schema.Finite.pipe(optional),
published: Schema.Finite.pipe(optional).annotate({
description: "Publication time in milliseconds since the Unix epoch",
}),
}),
}).annotate({ identifier: "WebSearch.Result" })
+1 -1
View File
@@ -180,7 +180,7 @@ try {
"Packed workerd health returned " + response.status + ": " + await response.text(),
)
const body = await response.json()
if (body.healthy !== true || body.version !== "packed-workerd") {
if (body.version !== "packed-workerd" || body.pid !== 1 || !Array.isArray(body.urls)) {
throw new Error("Unexpected packed workerd health: " + JSON.stringify(body))
}
} finally {
+1 -1
View File
@@ -112,7 +112,7 @@ test("Promise instances are lazy, share by key and Location, and stay isolated b
)
await first.sessions.switchAgent({ sessionID, agent: "plan" })
const fork = await first.sessions.fork({ sessionID, boundary: { type: "through" } })
const fork = await first.sessions.fork({ sessionID })
expect(fork.metadata).toEqual(original.metadata)
expect(fork.location).toEqual(original.location)
expect(fork.fork?.sessionID).toBe(sessionID)
+7 -7
View File
@@ -1,4 +1,4 @@
import { Integration } from "@opencode/core/integration"
import { Credential } from "@opencode/core/credential"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
@@ -8,24 +8,24 @@ export const CredentialHandler = HttpApiBuilder.group(Api, "server.credential",
.handle(
"credential.update",
Effect.fn(function* (ctx) {
const integration = yield* Integration.Service
yield* integration.connection.update(ctx.params.credentialID, { label: ctx.payload.label })
const credential = yield* Credential.Service
yield* credential.update(ctx.params.credentialID, { label: ctx.payload.label })
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"credential.activate",
Effect.fn(function* (ctx) {
const integration = yield* Integration.Service
yield* integration.connection.activate(ctx.params.credentialID)
const credential = yield* Credential.Service
yield* credential.activate(ctx.params.credentialID)
return HttpApiSchema.NoContent.make()
}),
)
.handle(
"credential.remove",
Effect.fn(function* (ctx) {
const integration = yield* Integration.Service
yield* integration.connection.remove(ctx.params.credentialID)
const credential = yield* Credential.Service
yield* credential.remove(ctx.params.credentialID)
return HttpApiSchema.NoContent.make()
}),
),
+86 -22
View File
@@ -2,7 +2,12 @@ import { Integration } from "@opencode/core/integration"
import { Effect } from "effect"
import { HttpApiBuilder, HttpApiSchema } from "effect/unstable/httpapi"
import { Api } from "../api"
import { IntegrationNotFoundError, InvalidRequestError } from "@opencode/protocol/errors"
import {
IntegrationAttemptNotFoundError,
IntegrationMethodNotFoundError,
IntegrationNotFoundError,
InvalidRequestError,
} from "@opencode/protocol/errors"
import { response } from "../location"
import { WellKnown } from "@opencode/core/wellknown"
@@ -61,6 +66,11 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
"integration.connect.key",
Effect.fn(function* (ctx) {
const service = yield* Integration.Service
if (!(yield* service.get(ctx.params.integrationID)))
return yield* new IntegrationNotFoundError({
integrationID: ctx.params.integrationID,
message: `Integration not found: ${ctx.params.integrationID}`,
})
yield* authorize(
service.connection.key({
integrationID: ctx.params.integrationID,
@@ -92,11 +102,27 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
"integration.oauth.status",
Effect.fn(function* (ctx) {
const service = yield* Integration.Service
return yield* response(
service.oauth.status({
if (!(yield* service.get(ctx.params.integrationID)))
return yield* new IntegrationNotFoundError({
integrationID: ctx.params.integrationID,
attemptID: ctx.params.attemptID,
}),
message: `Integration not found: ${ctx.params.integrationID}`,
})
return yield* response(
service.oauth
.status({
integrationID: ctx.params.integrationID,
attemptID: ctx.params.attemptID,
})
.pipe(
Effect.mapError(
(error) =>
new IntegrationAttemptNotFoundError({
integrationID: error.integrationID,
attemptID: error.attemptID,
message: `OAuth attempt not found: ${error.attemptID}`,
}),
),
),
)
}),
)
@@ -104,6 +130,11 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
"integration.oauth.complete",
Effect.fn(function* (ctx) {
const service = yield* Integration.Service
if (!(yield* service.get(ctx.params.integrationID)))
return yield* new IntegrationNotFoundError({
integrationID: ctx.params.integrationID,
message: `Integration not found: ${ctx.params.integrationID}`,
})
yield* service.oauth
.complete({
integrationID: ctx.params.integrationID,
@@ -111,19 +142,24 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
code: ctx.payload.code,
})
.pipe(
Effect.mapError(
(error) =>
new InvalidRequestError({
message:
error._tag === "Integration.CodeRequired"
? "Authorization code is required"
: "Authentication failed",
kind:
error._tag === "Integration.CodeRequired"
? "integration_code_required"
: "integration_authorization",
}),
),
Effect.mapError((error) => {
if (error._tag === "Integration.AttemptNotFound")
return new IntegrationAttemptNotFoundError({
integrationID: error.integrationID,
attemptID: error.attemptID,
message: `OAuth attempt not found: ${error.attemptID}`,
})
return new InvalidRequestError({
message:
error._tag === "Integration.CodeRequired"
? "Authorization code is required"
: "Authentication failed",
kind:
error._tag === "Integration.CodeRequired"
? "integration_code_required"
: "integration_authorization",
})
}),
)
return HttpApiSchema.NoContent.make()
}),
@@ -143,6 +179,18 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
"integration.command.connect",
Effect.fn(function* (ctx) {
const service = yield* Integration.Service
const integration = yield* service.get(ctx.params.integrationID)
if (!integration)
return yield* new IntegrationNotFoundError({
integrationID: ctx.params.integrationID,
message: `Integration not found: ${ctx.params.integrationID}`,
})
if (!integration.methods.some((method) => method.type === "command" && method.id === ctx.payload.methodID))
return yield* new IntegrationMethodNotFoundError({
integrationID: ctx.params.integrationID,
methodID: ctx.payload.methodID,
message: `Integration method not found: ${ctx.payload.methodID}`,
})
return yield* response(
authorize(
service.command.connect({
@@ -158,11 +206,27 @@ export const IntegrationHandler = HttpApiBuilder.group(Api, "server.integration"
"integration.command.status",
Effect.fn(function* (ctx) {
const service = yield* Integration.Service
return yield* response(
service.command.status({
if (!(yield* service.get(ctx.params.integrationID)))
return yield* new IntegrationNotFoundError({
integrationID: ctx.params.integrationID,
attemptID: ctx.params.attemptID,
}),
message: `Integration not found: ${ctx.params.integrationID}`,
})
return yield* response(
service.command
.status({
integrationID: ctx.params.integrationID,
attemptID: ctx.params.attemptID,
})
.pipe(
Effect.mapError(
(error) =>
new IntegrationAttemptNotFoundError({
integrationID: error.integrationID,
attemptID: error.attemptID,
message: `Command attempt not found: ${error.attemptID}`,
}),
),
),
)
}),
)
+1 -1
View File
@@ -209,7 +209,7 @@ export const SessionHandler = HttpApiBuilder.group(Api, "server.session", (handl
"session.fork",
Effect.fn(function* (ctx) {
return {
data: yield* session.fork({ sessionID: ctx.params.sessionID, boundary: ctx.payload.boundary }).pipe(
data: yield* session.fork({ sessionID: ctx.params.sessionID, before: ctx.payload.before }).pipe(
Effect.catchTag("Session.NotFoundError", missingSession),
Effect.catchTag("Session.MessageNotFoundError", missingMessage),
Effect.catchTag(
+1
View File
@@ -160,6 +160,7 @@ function makeRoutes<AuthError, AuthServices>(
Layer.succeedContext(
Context.pick(
Database.Service,
Credential.Service,
PermissionSaved.Service,
PluginUpdate.Service,
Project.Service,
@@ -1,13 +0,0 @@
import { Plugin } from "@opencode/plugin"
export default Plugin.define({
id: "test.worktree-delegate",
async setup(ctx) {
const directory = ctx.options.directory
if (typeof directory !== "string") throw new Error("Missing target location")
await ctx.worktree.create({
location: { directory },
name: "delegated",
})
},
})
-45
View File
@@ -178,48 +178,3 @@ it.live(
}),
30_000,
)
it.live(
"plugin calls await a different location's strategy and directory configuration",
() =>
Effect.gen(function* () {
const tmp = yield* Effect.acquireDisposable(Effect.promise(() => tmpdir("opencode-worktree-delegate-")))
const source = path.join(tmp.path, "source")
const target = path.join(tmp.path, "target")
const destination = path.join(tmp.path, "copies")
yield* Effect.promise(async () => {
for (const directory of [source, target]) {
await fs.mkdir(directory)
await initRepo(directory)
await $`git remote add origin git@github.com:example/delegate-fixture.git`.cwd(directory).quiet()
}
await Bun.write(
path.join(source, "opencode.json"),
JSON.stringify({
plugins: [
{ package: path.join(import.meta.dir, "fixture/worktree-delegate"), options: { directory: target } },
],
}),
)
await Bun.write(
path.join(target, "opencode.json"),
JSON.stringify({
worktree: { directory: destination },
plugins: [
{ package: path.join(import.meta.dir, "fixture/worktree-plugin"), options: { strategy: "target-copy" } },
],
}),
)
})
const server = yield* startServer(path.join(tmp.path, "config"))
const api = OpenCode.make({ baseUrl: server.base, headers: server.headers })
yield* Effect.promise(async () => {
await api.location.get({ location: { directory: source } })
expect(await api.worktree.list({ location: { directory: target } })).toContainEqual({
directory: path.join(destination, "delegated"),
strategy: "target-copy",
})
})
}),
30_000,
)
@@ -1264,6 +1264,8 @@
[data-component="write-tool"],
[data-component="apply-patch-tool"] {
> [data-component="collapsible"].tool-collapsible {
/* Only file lists with a tool title reserve space for its sticky row. */
--sticky-accordion-offset: calc(32px + var(--tool-content-gap));
gap: 0px;
}
@@ -1285,7 +1287,7 @@
position: sticky;
top: var(--sticky-accordion-top, 0px);
z-index: 20;
height: calc(32px + var(--tool-content-gap));
height: var(--sticky-accordion-offset);
padding-bottom: var(--tool-content-gap);
background-color: var(--v2-background-bg-base);
}
@@ -36,6 +36,33 @@ export const MixedTools = {
},
}
export const NoticesOnly = {
render: () => {
const [open, setOpen] = createStore({ tools: false, notices: false })
const tools = [storyTool("notice_read", "read", "completed", { path: "AGENTS.md" })]
return (
<section class="mx-auto flex w-full max-w-[720px] flex-col gap-4 p-6">
<CurrentSessionProviders document={storyDocument(tools)}>
<CurrentContextToolGroup
parts={tools}
busy={false}
open={open.tools}
onOpenChange={(value) => setOpen("tools", value)}
/>
<CurrentContextToolGroup
parts={[
{ type: "notice", id: "notice_instructions", render: () => <p>Instructions updated: AGENTS.md</p> },
]}
busy={false}
open={open.notices}
onOpenChange={(value) => setOpen("notices", value)}
/>
</CurrentSessionProviders>
</section>
)
},
}
export const MixedReasoning = {
args: { reasoningDefaultOpen: false },
render: (args: { reasoningDefaultOpen: boolean }) => {
@@ -536,8 +536,8 @@ export function CurrentContextToolGroup(props: {
const label = createMemo(() => {
const thoughts = props.parts.filter((part) => part.type === "reasoning").length
if (!names() && !thoughts) {
const title = i18n.t("ui.messagePart.context.details")
return { text: title, title, before: "", count: "", between: "", after: "" }
const text = i18n.t("ui.messagePart.context.updates")
return { text, title: "", before: text, count: "", between: "", after: "" }
}
const title = names() || i18n.plural("ui.messagePart.context.thought", thoughts)
const count = props.parts.filter((part) => part.type === "tool" || part.type === "shell").length || thoughts
@@ -622,7 +622,12 @@ export function CurrentContextToolGroup(props: {
<Show when={label().before || label().count || label().between}>
<span data-slot="context-tool-group-usage">
<Show when={label().before}>
{(before) => <span data-slot="context-tool-group-prefix">{before()} </span>}
{(before) => (
<span data-slot="context-tool-group-prefix">
{before()}
{label().title ? " " : ""}
</span>
)}
</Show>
<Show when={label().count}>
{(count) => <span data-slot="context-tool-group-count">{count()} </span>}
@@ -632,7 +637,7 @@ export function CurrentContextToolGroup(props: {
</Show>
</span>
</Show>
<span data-slot="basic-tool-tool-title">{label().title}</span>
<Show when={label().title}>{(title) => <span data-slot="basic-tool-tool-title">{title()}</span>}</Show>
<Show when={label().after}>
{(after) => <span data-slot="context-tool-group-prefix">{after()}</span>}
</Show>
@@ -1025,7 +1030,6 @@ function FileAccordionGroup(props: { children: JSX.Element }) {
<div
data-component="accordion"
data-scope="apply-patch"
style={{ "--sticky-accordion-offset": "calc(32px + var(--tool-content-gap))" }}
onKeyDown={(event) => {
if (event.defaultPrevented || event.altKey || event.ctrlKey || event.metaKey || event.shiftKey) return
if (!["ArrowUp", "ArrowDown", "Home", "End"].includes(event.key)) return
+2 -2
View File
@@ -680,7 +680,7 @@ function App(props: { pair?: DialogPairCredentials }) {
return
}
void client.api.session
.fork({ sessionID: match, boundary: { type: "through" } })
.fork({ sessionID: match })
.then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt }))
.catch(toast.error)
})
@@ -693,7 +693,7 @@ function App(props: { pair?: DialogPairCredentials }) {
if (forked || !args.sessionID || !args.fork) return
forked = true
void client.api.session
.fork({ sessionID: args.sessionID, boundary: { type: "through" } })
.fork({ sessionID: args.sessionID })
.then((result) => route.navigate({ type: "session", sessionID: result.id, prompt: startupPrompt }))
.catch(toast.error)
})

Some files were not shown because too many files have changed in this diff Show More