From 5d4cea9dc18ba1f2b8dde518b1e2b710f75b50da Mon Sep 17 00:00:00 2001 From: Chinesezjc Date: Fri, 31 Jul 2026 18:10:33 +0800 Subject: [PATCH] feat(code-runtime): own portable-identifier exclusions at the seam Move the reserved-word, reserved-global, reserved-error-member, and dunder exclusion sets from the worker backend up to the code-runtime seam package, and narrow the portable identifier subset to drop the JS-only `$`. Every backend now imports one contract so a binding namespace list valid on one backend is valid on all. Delivers only the seam extension and the worker's adoption; the Python backend, py-types renderer, and Code Mode language dispatch are later PRs in the stack that depend on these exports. --- ...runtime-portable-identifier-seam.i18n.yaml | 6 ++ ...1-code-runtime-portable-identifier-seam.md | 42 +++++++++++++ ...ode-runtime-portable-identifier-seam.zh.md | 42 +++++++++++++ .../code-runtime-worker/src/index.ts | 44 ++++++++----- .../code-runtime-worker/tests/runtime.spec.ts | 11 ++++ .../code-runtime/code-runtime/src/index.ts | 62 +++++++++++++++++++ .../code-runtime/code-runtime/src/types.ts | 17 ++++- .../code-runtime/tests/reserved.spec.ts | 51 +++++++++++++++ 8 files changed, 256 insertions(+), 19 deletions(-) create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md create mode 100644 .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md create mode 100644 packages/code-runtime/code-runtime/tests/reserved.spec.ts diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml new file mode 100644 index 0000000000..73bf26efee --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md +2026-07-31-code-runtime-portable-identifier-seam.md: a24a2c03c937a7d569528504b5bd5fba812009a2 +2026-07-31-code-runtime-portable-identifier-seam.zh.md: 1bef607e31a5820ba849f09eb7ae4cc781c45a84 diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md new file mode 100644 index 0000000000..a24a2c03c9 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.md @@ -0,0 +1,42 @@ +# Agent Note: the code-runtime seam owns portable-identifier exclusions + +Status: implemented + +English | [中文](2026-07-31-code-runtime-portable-identifier-seam.zh.md) + +## Problem + +The code-runtime seam promises that a binding-namespace list valid on one backend is valid on every backend, so a Code Mode consumer can hand the same bindings to any registered runtime without knowing its language. The first backend, `dsh-code-runtime-worker`, privately owned the identifier rules that enforce part of that promise: an `IDENTIFIER` regex that allowed the JS-only `$`, a `RESERVED_WORDS` set holding only ECMAScript keywords, and a `RESERVED_ERROR_PROPERTIES` set of three JS `Error` slots. Those rules described the worker's own language, not the seam's portability contract. + +A second backend written against a different language (CPython, arriving in a later PR of this stack) would either re-declare its own rules — letting `lambda` pass the worker and fail Python, or `$tools` pass the worker and fail every non-JS backend — or import the worker's, inverting the dependency so the seam's implementation reached into a sibling implementation. Neither keeps the portability promise real: it would hold only for the backend a caller happened to test against. + +## Decision + +The seam package (`@deepseek-ai/dsh-code-runtime`) exports the portable-identifier exclusion contract as four named constants, and every backend imports them rather than re-declaring: + +- `PORTABLE_RESERVED_WORDS` — the union of ECMAScript and Python reserved words. A namespace global or error-class name matching any is refused on all backends, so `lambda` is refused even though it is a legal JS parameter name. Adding a language widens this union, which is a deliberate breaking review of existing binding names. +- `RESERVED_BINDING_GLOBALS` — globals some backend owns in the program's namespace: `console` (the worker's log capture) and `__dsh_main__`/`__builtins__`/`__name__`/`__debug__` (the Python bootstrap's wrapper and seeded module globals). Refused everywhere so a namespace list cannot pick a name that works on one backend and collides on another. +- `RESERVED_ERROR_MEMBERS` — error-member names every backend refuses: the JS `Error` slots (`name`, `message`, `stack`) and Python's exception-protocol members (`args`, `with_traceback`, `add_note`). +- `DUNDER_MEMBER` — the dunder-form regex (`__*__`), refused as an error member wholesale because several are constrained CPython descriptors whose exact set is an interpreter-version detail. + +The seam also narrows the portable identifier subset to `[A-Za-z_][A-Za-z0-9_]*` (documented on `CodeBindingNamespace.global` and `CodeBindingErrorClass`), dropping the JS-only `$`. The worker consumes the shared constants: `RESERVED_WORDS = PORTABLE_RESERVED_WORDS`, `RESERVED_ERROR_PROPERTIES = RESERVED_ERROR_MEMBERS`, its `IDENTIFIER` regex loses `$`, and its error-member check adds `DUNDER_MEMBER`. + +The constants live at the seam even though only one backend ships in this PR: the whole point is that the contract is language-agnostic and owned above any single language. A backend that violated it would be the bug, and the shared set is where a reviewer looks to see what "portable" means. + +## Scope + +This PR delivers only the seam extension and the worker's adoption of it. No Python backend, `py-types` renderer, or Code Mode language dispatch ships here — they are later PRs in the stack that depend on these exports. The seam README's worker-only wording is left unchanged for the same reason: linking to a `dsh-code-runtime-python` README that does not yet exist would break the dead-link gate. + +## Alternatives considered + +**Each backend declares its own exclusions.** Rejected: it makes the portability promise per-backend. A binding list the caller tested on the worker could be refused by Python, which is exactly the split the seam exists to prevent. + +**The Python backend imports the worker's constants.** Rejected: it inverts the dependency — the seam's implementations would reach into a sibling implementation for a contract neither owns. The contract belongs above both, at the seam. + +**Keep `$` in the portable identifier subset.** Rejected: `$` is JS-only spelling. Allowing it would let `$tools` pass the worker and fail every non-JS backend, breaking portability for a purely cosmetic gain. + +## Consequences + +Bought: one place — the seam package — defines what a portable binding name is, and every backend enforces the same contract by import. A namespace list valid on one backend is valid on all, verifiably, not by coincidence of which backend the caller tested. + +Cost: existing worker callers using a `$`-containing global now fail identifier validation. Under the pre-release stance this is a corrected foundation, not a compatibility break to shim. The worker's seam-misuse tests gain cases for `$tools`, Python exception members (`args`), dunders (`__dict__`), and a Python-owned global (`__dsh_main__`), proving the shared set is enforced from the worker side. diff --git a/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md new file mode 100644 index 0000000000..1bef607e31 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-07-31-code-runtime-portable-identifier-seam.zh.md @@ -0,0 +1,42 @@ +# Agent Note:code-runtime seam 拥有可移植标识符排除集 + +Status: implemented + +[English](2026-07-31-code-runtime-portable-identifier-seam.md) | 中文 + +## Problem + +code-runtime seam 承诺:在一个后端上有效的绑定命名空间列表,在每个后端上都有效,因此 Code Mode 消费方可以把同一组绑定交给任何已注册的运行时,而不必知道它的语言。首个后端 `dsh-code-runtime-worker` 私自拥有了执行这项承诺一部分的标识符规则:一个允许 JS 专有 `$` 的 `IDENTIFIER` 正则、一个只含 ECMAScript 关键字的 `RESERVED_WORDS` 集合,以及一个含三个 JS `Error` 槽位的 `RESERVED_ERROR_PROPERTIES` 集合。这些规则描述的是 worker 自身的语言,而非 seam 的可移植性契约。 + +一个针对不同语言(CPython,将在本 stack 后续 PR 中到来)编写的第二后端,要么重新声明自己的规则——让 `lambda` 通过 worker 却在 Python 上失败,或让 `$tools` 通过 worker 却在每个非 JS 后端上失败——要么导入 worker 的规则,从而反转依赖,使 seam 的一个实现伸手进入另一个兄弟实现。二者都无法让可移植承诺成真:它只对调用方恰好测试过的那个后端成立。 + +## Decision + +seam 包(`@deepseek-ai/dsh-code-runtime`)以四个具名常量导出可移植标识符排除契约,每个后端导入它们而非重新声明: + +- `PORTABLE_RESERVED_WORDS`——ECMAScript 与 Python 保留字的联集。任何命名空间 global 或 error-class 名称匹配其中之一,都在所有后端上被拒绝,因此 `lambda` 即便是合法的 JS 参数名也被拒绝。新增一门语言即扩宽此联集,这是对现有绑定名称的一次有意的破坏性复审。 +- `RESERVED_BINDING_GLOBALS`——某个后端在程序命名空间中拥有的 global:`console`(worker 的日志捕获)与 `__dsh_main__`/`__builtins__`/`__name__`/`__debug__`(Python bootstrap 的包装器与预置模块 global)。在所有后端上被拒绝,使命名空间列表无法选到一个在某后端能用、在另一后端冲突的名称。 +- `RESERVED_ERROR_MEMBERS`——每个后端都拒绝的 error-member 名称:JS `Error` 槽位(`name`、`message`、`stack`)与 Python 异常协议成员(`args`、`with_traceback`、`add_note`)。 +- `DUNDER_MEMBER`——dunder 形式正则(`__*__`),作为 error member 被整体拒绝,因为其中若干是受约束的 CPython 描述符,其确切集合是解释器版本细节。 + +seam 同时把可移植标识符子集收窄为 `[A-Za-z_][A-Za-z0-9_]*`(记录在 `CodeBindingNamespace.global` 与 `CodeBindingErrorClass` 上),去掉 JS 专有的 `$`。worker 消费这些共享常量:`RESERVED_WORDS = PORTABLE_RESERVED_WORDS`、`RESERVED_ERROR_PROPERTIES = RESERVED_ERROR_MEMBERS`,其 `IDENTIFIER` 正则去掉 `$`,其 error-member 检查加上 `DUNDER_MEMBER`。 + +尽管本 PR 只交付一个后端,这些常量仍置于 seam:要点正是该契约与语言无关,且拥有权在任何单一语言之上。违反它的后端才是 bug,而共享集合正是复审者查看"可移植"含义的地方。 + +## Scope + +本 PR 只交付 seam 扩展与 worker 对它的采用。这里不交付任何 Python 后端、`py-types` 渲染器或 Code Mode 的语言分发——它们是本 stack 中依赖这些导出的后续 PR。seam README 中仅描述 worker 的措辞保持不变,理由相同:链接到一个尚不存在的 `dsh-code-runtime-python` README 会破坏死链 gate。 + +## Alternatives considered + +**每个后端声明自己的排除集。** 拒绝:这让可移植承诺变成逐后端成立。调用方在 worker 上测过的绑定列表可能被 Python 拒绝,而这正是 seam 存在要防止的分裂。 + +**Python 后端导入 worker 的常量。** 拒绝:这反转依赖——seam 的实现会为一个二者都不拥有的契约伸手进入兄弟实现。契约属于二者之上,即 seam。 + +**在可移植标识符子集中保留 `$`。** 拒绝:`$` 是 JS 专有拼写。允许它会让 `$tools` 通过 worker 却在每个非 JS 后端上失败,为纯粹表面的好处破坏可移植性。 + +## Consequences + +获得:一个地方——seam 包——定义什么是可移植绑定名称,每个后端通过导入执行同一契约。在一个后端上有效的命名空间列表在所有后端上都有效,这是可验证的,而非取决于调用方测试了哪个后端的巧合。 + +代价:现有使用含 `$` global 的 worker 调用方现在会在标识符校验时失败。在预发布立场下这是一次被纠正的地基,而非需要 shim 的兼容性破坏。worker 的 seam-misuse 测试新增了 `$tools`、Python 异常成员(`args`)、dunder(`__dict__`)与一个 Python 拥有的 global(`__dsh_main__`)等用例,从 worker 侧证明共享集合被执行。 diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index be156c85ba..3c198ca94e 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -13,7 +13,7 @@ import { fileURLToPath } from 'node:url' import { Context } from 'cordis' import z from 'schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime' import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { ReplyMessage, WorkerBootData, WorkerToHost } from './protocol.ts' @@ -65,20 +65,27 @@ const ELU_POLL_INTERVAL_MS = 25 /** Smallest cap that can represent the counted payloads: an empty logs array plus an empty JSON failure message. */ const MIN_OUTPUT_BYTES = 4 -/** ECMAScript reserved words that cannot be async-function parameter names — rejected as binding globals. */ -const RESERVED_WORDS = new Set([ - 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', - 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', - 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', - 'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package', - 'private', 'protected', 'public', 'arguments', 'eval', -]) +/** + * The seam's cross-language reserved-word union: the portable-identifier + * contract promises a namespace list valid here is valid on every backend, so + * a Python keyword like `lambda` is refused even though it is a legal JS + * parameter name. + */ +const RESERVED_WORDS = PORTABLE_RESERVED_WORDS -/** Valid async-function parameter name (the binding global becomes one). */ -const IDENTIFIER = /^[A-Za-z_$][A-Za-z0-9_$]*$/ +/** + * The seam's language-portable identifier subset (see + * `CodeBindingNamespace.global`): no `$`, which is JS-only spelling — the same + * namespace list must be usable against every backend regardless of language. + */ +const IDENTIFIER = /^[A-Za-z_][A-Za-z0-9_]*$/ -/** Error properties whose binding-member replacement would destroy the promised Error contract. */ -const RESERVED_ERROR_PROPERTIES = new Set(['name', 'message', 'stack']) +/** + * The seam's shared error-member exclusions (plus the dunder rule below): + * enforced identically here and in the Python backend so an errorClass valid + * on one backend is valid on all. + */ +const RESERVED_ERROR_PROPERTIES = RESERVED_ERROR_MEMBERS /** * The shell a program is wrapped in for the type-strip, matching the @@ -331,7 +338,11 @@ export class WorkerCodeRuntime extends CodeRuntime { if (!IDENTIFIER.test(namespace.global) || RESERVED_WORDS.has(namespace.global)) { throw new Error(`dsh-code-runtime-worker: binding global ${JSON.stringify(namespace.global)} is not a usable identifier`) } - if (namespace.global === 'console' || bindings.has(namespace.global)) { + // RESERVED_BINDING_GLOBALS is the seam's shared backend-owned set: + // `console` is THIS backend's log-capture slot; the dunder entries are + // the Python bootstrap's — refused here too so the namespace list stays + // portable across backends. + if (RESERVED_BINDING_GLOBALS.has(namespace.global) || bindings.has(namespace.global)) { throw new Error(`dsh-code-runtime-worker: duplicate binding global ${JSON.stringify(namespace.global)}`) } bindings.set(namespace.global, namespace) @@ -344,10 +355,11 @@ export class WorkerCodeRuntime extends CodeRuntime { if (!IDENTIFIER.test(descriptor.name) || RESERVED_WORDS.has(descriptor.name)) { throw new Error(`dsh-code-runtime-worker: binding error class ${JSON.stringify(descriptor.name)} is not a usable identifier`) } - if (descriptor.name === 'console' || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) { + if (RESERVED_BINDING_GLOBALS.has(descriptor.name) || bindings.has(descriptor.name) || errorClassNames.has(descriptor.name)) { throw new Error(`dsh-code-runtime-worker: duplicate injected global ${JSON.stringify(descriptor.name)}`) } - if (descriptor.memberNameProperty.length === 0 || RESERVED_ERROR_PROPERTIES.has(descriptor.memberNameProperty)) { + const member = descriptor.memberNameProperty + if (member.length === 0 || RESERVED_ERROR_PROPERTIES.has(member) || DUNDER_MEMBER.test(member)) { throw new Error(`dsh-code-runtime-worker: binding error member property ${JSON.stringify(descriptor.memberNameProperty)} is not usable`) } errorClassNames.add(descriptor.name) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index b16e2b5671..ce97faadb3 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -787,6 +787,9 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => { const cases: [string, RegExp][] = [ ['not valid!', /not a usable identifier/], ['await', /not a usable identifier/], + // `$tools` is legal JS but outside the seam's language-portable subset: + // the same namespace list must work against every backend's language. + ['$tools', /not a usable identifier/], ['console', /duplicate binding global/], ] for (const [global, message] of cases) { @@ -822,6 +825,14 @@ describe('WorkerCodeRuntime — seam misuse and lifecycle', () => { ])).rejects.toThrow(/duplicate injected global/) await expect(run([namespace('tools', 'CallError', '')])).rejects.toThrow(/member property.*not usable/) await expect(run([namespace('tools', 'CallError', 'message')])).rejects.toThrow(/member property.*not usable/) + // The shared exclusion set covers Python's exception-protocol members and + // dunders too, so the same errorClass is valid (or not) on every backend. + await expect(run([namespace('tools', 'CallError', 'args')])).rejects.toThrow(/member property.*not usable/) + await expect(run([namespace('tools', 'CallError', '__dict__')])).rejects.toThrow(/member property.*not usable/) + // The Python bootstrap's owned globals are refused here too (shared + // RESERVED_BINDING_GLOBALS), keeping namespace lists backend-portable. + await expect(runtime.run({ program: 'return 1', bindings: [{ global: '__dsh_main__', functions: {} }] })) + .rejects.toThrow(/duplicate binding global/) }) it('rejects config values that are not positive numbers', async () => { diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index bd52b9ed29..681c0465a7 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -17,6 +17,68 @@ export type { CodeRunResult, } from './types.ts' +/** + * Binding globals EVERY backend refuses because SOME backend owns the slot in + * the program's namespace: `console` (the worker's log capture), and + * `__dsh_main__`/`__builtins__`/`__name__` (the Python bootstrap's wrapper + * and seeded module globals), and `__debug__`. One shared set — rather than each backend + * refusing only its own slots — keeps the portability promise real: a + * namespace list valid on one backend is valid on all, so a caller cannot + * pick a name that works on the worker and collides on Python (or vice + * versa). Dunder-form names are additionally covered by the identifier rule + * on `CodeBindingNamespace.global` only when they fail it; `__name__` et al. + * ARE valid identifiers, hence this explicit set. `__debug__` is listed for a + * different reason than a collision: CPython compiles a bare `__debug__` + * reference to the constant `True` and rejects any assignment to the name at + * COMPILE time, so an injected global under that name is unreachable from the + * program — accepted by validation, unusable on the Python backend, which is + * exactly the split the shared set exists to prevent. + */ +export const RESERVED_BINDING_GLOBALS: ReadonlySet = new Set([ + 'console', + '__dsh_main__', '__builtins__', '__name__', '__debug__', +]) + +/** + * `CodeBindingErrorClass.memberNameProperty` names EVERY backend refuses, as + * one shared contract so a request valid on one backend is valid on all. The + * JS `Error` exclusions (`name`, `message`, `stack`) and Python's + * exception-protocol members (`args`, `with_traceback`, `add_note`) are + * listed by name; dunder-form names (`__*__`) are refused wholesale — several + * are constrained CPython descriptors whose `setattr` raises while + * constructing the rejection, and the exact set is an interpreter version + * detail. Any other non-empty own property name is accepted everywhere. + */ +export const RESERVED_ERROR_MEMBERS: ReadonlySet = new Set([ + 'name', 'message', 'stack', + 'args', 'with_traceback', 'add_note', +]) + +/** Dunder form (`__*__`): object-protocol slots in Python, refused as {@link RESERVED_ERROR_MEMBERS | error members} on every backend. */ +export const DUNDER_MEMBER = /^__.*__$/ + +/** + * Reserved words of EVERY shipped backend language (ECMAScript ∪ Python), + * refused as {@link CodeBindingNamespace.global} / error-class names by all + * backends. The portable-identifier contract promises a namespace list valid + * on one backend is valid on every backend; a per-language check would let + * `lambda` pass the TypeScript backend and fail the Python one. Extending the + * seam with a new language means widening this union (a breaking review of + * existing binding names, by design). + */ +export const PORTABLE_RESERVED_WORDS: ReadonlySet = new Set([ + // ECMAScript reserved words and reserved-in-strict-mode names. + 'await', 'break', 'case', 'catch', 'class', 'const', 'continue', 'debugger', 'default', 'delete', 'do', + 'else', 'enum', 'export', 'extends', 'false', 'finally', 'for', 'function', 'if', 'import', 'in', + 'instanceof', 'new', 'null', 'return', 'super', 'switch', 'this', 'throw', 'true', 'try', 'typeof', + 'var', 'void', 'while', 'with', 'yield', 'let', 'static', 'implements', 'interface', 'package', + 'private', 'protected', 'public', 'arguments', 'eval', + // Python 3.x keywords and soft keywords not already above ('type' and '_' + // are soft keywords: legal names in practice, reserved here for safety). + 'False', 'None', 'True', 'and', 'as', 'assert', 'async', 'def', 'del', 'elif', 'except', 'from', + 'global', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'match', 'type', '_', +]) + declare module 'cordis' { interface Context { codeRuntime: CodeRuntime diff --git a/packages/code-runtime/code-runtime/src/types.ts b/packages/code-runtime/code-runtime/src/types.ts index a53353799b..e96d8bc959 100644 --- a/packages/code-runtime/code-runtime/src/types.ts +++ b/packages/code-runtime/code-runtime/src/types.ts @@ -28,9 +28,14 @@ export type CodeJsonValue = null | boolean | number | string | CodeJsonValue[] | * of a particular consumer such as Code Mode. */ export interface CodeBindingErrorClass { - /** Constructor global and resulting `Error.name` (must be a usable JS identifier). */ + /** Constructor global and resulting `Error.name`; same portable identifier rule as {@link CodeBindingNamespace.global}. */ name: string - /** Non-empty own property for the member name; cannot replace `name`, `message`, or `stack`. */ + /** + * Non-empty own property for the member name. The portable exclusion set is + * `RESERVED_ERROR_MEMBERS` plus dunder-form names (`__*__`), enforced + * identically by every backend; any other name — identifiers or not — is + * accepted everywhere. + */ memberNameProperty: string } @@ -42,7 +47,13 @@ export interface CodeBindingErrorClass { * collisions. */ export interface CodeBindingNamespace { - /** The global identifier the program sees (must be a valid JS identifier). */ + /** + * The global identifier the program sees. Must match the LANGUAGE-PORTABLE + * identifier subset `[A-Za-z_][A-Za-z0-9_]*` and no language's reserved + * words, so the same namespace list works against every backend regardless + * of `language` — a JS-only spelling like `$tools` is rejected by design, + * not just by the Python backend. + */ global: string /** The callable members, keyed by the exact name the program calls. */ functions: Record diff --git a/packages/code-runtime/code-runtime/tests/reserved.spec.ts b/packages/code-runtime/code-runtime/tests/reserved.spec.ts new file mode 100644 index 0000000000..31868252a1 --- /dev/null +++ b/packages/code-runtime/code-runtime/tests/reserved.spec.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + DUNDER_MEMBER, + PORTABLE_RESERVED_WORDS, + RESERVED_BINDING_GLOBALS, + RESERVED_ERROR_MEMBERS, +} from '@deepseek-ai/dsh-code-runtime' + +/** + * The seam owns the portable-identifier exclusion sets so every backend + * enforces one contract: a namespace list valid on one backend is valid on + * all. These assertions pin the shared membership backends import rather than + * re-declare. + */ +describe('seam-owned portable identifier exclusions', () => { + it('RESERVED_BINDING_GLOBALS covers each backend-owned slot', () => { + expect(RESERVED_BINDING_GLOBALS.has('console')).toBe(true) + expect(RESERVED_BINDING_GLOBALS.has('__dsh_main__')).toBe(true) + expect(RESERVED_BINDING_GLOBALS.has('__builtins__')).toBe(true) + expect(RESERVED_BINDING_GLOBALS.has('__name__')).toBe(true) + expect(RESERVED_BINDING_GLOBALS.has('__debug__')).toBe(true) + expect(RESERVED_BINDING_GLOBALS.has('tools')).toBe(false) + }) + + it('RESERVED_ERROR_MEMBERS covers the JS Error and Python exception-protocol members', () => { + for (const name of ['name', 'message', 'stack', 'args', 'with_traceback', 'add_note']) { + expect(RESERVED_ERROR_MEMBERS.has(name)).toBe(true) + } + expect(RESERVED_ERROR_MEMBERS.has('code')).toBe(false) + }) + + it('DUNDER_MEMBER matches dunder-form names only', () => { + expect(DUNDER_MEMBER.test('__dict__')).toBe(true) + expect(DUNDER_MEMBER.test('__init__')).toBe(true) + expect(DUNDER_MEMBER.test('_private')).toBe(false) + expect(DUNDER_MEMBER.test('name')).toBe(false) + expect(DUNDER_MEMBER.test('__mid')).toBe(false) + }) + + it('PORTABLE_RESERVED_WORDS is the union of ECMAScript and Python reserved words', () => { + // ECMAScript-only keyword. + expect(PORTABLE_RESERVED_WORDS.has('function')).toBe(true) + // Python-only keyword — refused here so the list stays portable. + expect(PORTABLE_RESERVED_WORDS.has('lambda')).toBe(true) + expect(PORTABLE_RESERVED_WORDS.has('nonlocal')).toBe(true) + // Shared keyword. + expect(PORTABLE_RESERVED_WORDS.has('class')).toBe(true) + // Ordinary identifier is not reserved. + expect(PORTABLE_RESERVED_WORDS.has('tools')).toBe(false) + }) +})