mirror of
https://github.com/anomalyco/opencode.git
synced 2026-09-14 04:46:23 +00:00
Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1d4d9b5fa5 | ||
|
|
752d980770 | ||
|
|
07225172df | ||
|
|
223b0271e6 | ||
|
|
93a37958a7 | ||
|
|
40f4778296 | ||
|
|
e9bb6d490b | ||
|
|
1e697962bd | ||
|
|
4418df5d62 | ||
|
|
052be04466 | ||
|
|
cd3a64b225 | ||
|
|
48c9a0a8de | ||
|
|
48875190ef | ||
|
|
476432de1e | ||
|
|
a71bb4d38c | ||
|
|
5d3019e5a1 |
@@ -9,6 +9,7 @@ on:
|
||||
|
||||
jobs:
|
||||
check:
|
||||
name: typecheck
|
||||
runs-on: blacksmith-4vcpu-ubuntu-2404
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
|
||||
@@ -32,6 +32,7 @@ target
|
||||
# Local dev files
|
||||
opencode-dev
|
||||
UPCOMING_CHANGELOG.md
|
||||
RELEASE_REVIEW.md
|
||||
logs/
|
||||
*.bun-build
|
||||
tsconfig.tsbuildinfo
|
||||
|
||||
+18
-18
@@ -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` | | |
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:",
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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) =>
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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: {},
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -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" } },
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
})
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]) => ({
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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>`.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
},
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
|
||||
@@ -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> =>
|
||||
|
||||
@@ -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)}.`)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`),
|
||||
)
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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")
|
||||
})
|
||||
})
|
||||
@@ -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.",
|
||||
|
||||
@@ -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" },
|
||||
])
|
||||
})
|
||||
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
|
||||
@@ -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],
|
||||
])
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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" }),
|
||||
|
||||
@@ -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
@@ -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": {
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
|
||||
@@ -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.",
|
||||
}),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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" })
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
}),
|
||||
),
|
||||
|
||||
@@ -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}`,
|
||||
}),
|
||||
),
|
||||
),
|
||||
)
|
||||
}),
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
})
|
||||
},
|
||||
})
|
||||
@@ -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
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user