# xl 设计说明 > 面向维护者。用户向的安装与用法见 [`../README.md`](../README.md)。 ## 0. 规范文档在哪里 `docs/` 下的七份文档是**本实现的事实来源**,随本仓库一起发布: | 文件 | 内容 | | --- | --- | | [`xl-syntax.md`](./xl-syntax.md) | `*.xl.md` 语法 | | [`xl-cli.md`](./xl-cli.md) | 命令行接口、两条通道、`xl.json`、退出码 | | [`xl-check.md`](./xl-check.md) | 诊断码表(级别的唯一事实来源) | | [`xl-emit-ts.md`](./xl-emit-ts.md) | ts 直出通道的产物映射 | | [`xl-emit-cpp.md`](./xl-emit-cpp.md) | **C++ 生成指南(推荐,非规范)**:计划通道生成 C++ 时的默认约定;`xl` 不校验它,改它不影响任何代码 | | [`xl-base-case.md`](./xl-base-case.md) | 验收基准:完整输入与逐字节期望产物 | | `design.md` | 本文:面向维护者的设计说明 | **为什么以本仓库为准**:验收测试的输入与期望产物直接从 `xl-base-case.md` 抽取,链接也全部指向本目录,因此一个 checkout 就能跑完整门禁,不必同时克隆别的仓库。 **实现与文档必须同时改**:`xl-syntax.md` / `xl-cli.md` / `xl-check.md` / `xl-emit-ts.md` 描述的是本实现的真实行为(包括实现自己的选择与已知偏差),所以代码行为一变,这四份文档就要跟着变;两者不一致时以代码为准,并把文档当成 bug 修掉。可执行的核对手段: ```sh pnpm test # tests/conformance.test.js 断言 ts 打印器逐字节等于基准样例 pnpm fixtures # 从 docs/xl-base-case.md 重新抽取验收 fixture pnpm codes # 码表里每个码都要有产生它的地方 ``` **`xl-base-case.md` 例外,它是冻结的**:验收 fixture 由它生成,因此只在你确实要改字节契约时才动它;改完必须跑 `pnpm fixtures` 并提交重抽后的 `tests/fixtures/*`。`xl-emit-cpp.md` 是第二类例外:它是给计划通道生成者的**推荐指南**,没有对应的实现代码,`xl` 也不校验它,所以改它只需自洽,不必同步任何代码(生成者可以按目标仓库的规范偏离它,见该文开头)。`docs/xl-*.md` 曾经是上游 xlanguage 仓库规范的**逐字副本**(用 `pnpm docs:sync` 比对),该机制已取消:文档现在就是本仓库的规范,不再逐字跟随别处。 --- ## 1. 要解决的问题 最初的规范设计让 `xl` 自己拼一段 prompt、spawn `dsh --profile headless`、从 `--json` 里取回代码。这条路有三个结构性问题: 1. **xl 必须知道 prompt 长什么样。** prompt 由六段拼成(语法速查、源文件原文、结构摘要、语言上下文、既有实现、任务),任何一段变了都要改 xl 的代码,而它其实是「怎么读文件、怎么用工具」的知识。 2. **xl 必须自己实现重试、超时、子进程管理、`--json` 解析。** 这些是 DSH 已经有的能力。 3. **一次调用是一个黑盒。** 生成质量差的时候,xl 只能整段重试,看不到 agent 读了什么、改了哪一步。 本插件把这三件事换掉:**xl 只提供标准化零件,DSH 主动调用**。 ```text 原: xl ──拼 prompt──► dsh 子进程 ──► 代码(xl 只能整段重试) 本: agent(自己读 *.xl.md / 需求 / docs) ──► xl_plan / xl_context / xl_cache └─► xl_verify / xl_emit ──► 代码 ``` xl 保留的职责恰好是「每个 agent 都会写错、或必须与上一次运行保持一致」的部分:产物路径、产物头与指纹、历史版本、结构回读校验、写盘冲突。 --- ## 2. 分层 ```text ┌─ src/plugin/cli.js ────────── xl profile 的应用行:argv → 请求 → 终端输出 ├─ src/plugin/tools.js ──────── 七个模型工具:请求 → 服务 → 模型可读文本 ├─ src/plugin/service.js ────── 能力缝:把请求翻译成 core 调用 ├─ src/core/artifact.js ─────── 计划通道三操作:context / verify / emit(有 I/O) ├─ src/core/build.js ────────── 扫描 → 解析 → 检查 → 计划 → 产出(有 I/O) └─ src/core/{parse,check,emit-ts,plan,verify,types,header,text,diagnostics}.js 纯函数:IR、规则、ts 打印、路径、契约、指纹 ``` `scan` `config` `cache` `source` 是 core 里另外四个有 I/O 的模块(输入展开、配置查找、缓存读写、编码校验)。 ### 2.1 为什么纯核心是纯的 `parse` `check` `emit-ts` `plan` `verify` `types` `header` `text` 不碰文件系统、不读环境、不看时钟。这带来三件事: - **字节稳定性**。ts 打印器同一 IR 必得同一字节,规范要求的确定性因此可以被测试直接断言。 - **可单测**。`conformance` `parse` `emit-ts` `plan` 四个测试文件完全不需要磁盘;只有 `build` `check` `artifact` `plugin` `cli` 需要临时目录。 - **同一份代码服务两条通道**。`xl_check` 工具、`xl check` 命令、`xl_emit` 的预校验走的是同一个 `checkWorkspace`。 ### 2.2 能力缝 `xl` 服务是 Service Definition,`xl` 插件是 Service Provider,消费者有两个:七个模型工具,以及 `xl` profile 的应用行。两者都不重新实现计划、cache 或产物契约——CLI 的 `xl build` 与工具的 `xl_build` 调的是同一个 `runBuild`。 服务方法把「请求」翻译成 core 的参数,这是唯一有判断的地方:CLI 选项、环境变量、`xl.json` 的优先级在 `resolveBuildOptions` 里统一收口,工具与 CLI 都只提供自己那一层的覆盖值。 --- ## 3. 两条通道的边界 | | ts | 其它语言 | | --- | --- | --- | | 入口 | `runBuild` → `processPlan` | `runPlan` → 计划;生成由 agent 做 | | 写盘者 | xl | xl(`xl_emit`),但内容来自 agent | | 校验 | 由 `tsc` / 编辑器 | `verifyStructure` 的结构回读 | | 缓存键 | 源指纹 | 源指纹 + `xl:model` + `xl:prompt` | | 联网 | 永不 | 由调用它的会话决定 | 设计上刻意不做的事: - **不为非 ts 目标拼 prompt。** 读什么由 agent 决定;xl 给的是「必须匹配什么」。 - **不在 `xl build` 里起 agent。** `xl build -t csharp` 是纯计划命令:它打印计划与下一步(`xl_context` → `xl_emit`)就退出。自动化要的是 `xl_build` 工具或直接调 `runPlan`。 - **不把 ts 通道交给模型。** `ts` 是唯一有确定性后端的语言,任何情况下都离线直出。 --- ## 4. 契约与不变量 | 不变量 | 由谁保证 | 测试 | | --- | --- | --- | | 同一 IR 的 ts 产物逐字节等于基准样例 | `emit-ts.js` | `tests/conformance.test.js` | | 产物首行是 `@generated by xl`,第 2 行带完整字段表 | `header.js` `renderHeader` | `tests/build.test.js`、`tests/artifact.test.js` | | 同一产物路径不会被两个 源 × 目标 计划 | `plan.js` `detectConflicts` | `tests/plan.test.js` | | 没有 `--force` 时不覆盖非 xl 产物 | `build.js` / `artifact.js` | `tests/build.test.js`、`tests/artifact.test.js` | | 结构不符的产物绝不落盘 | `verify.js` + `artifact.js` | `tests/artifact.test.js` | | 覆盖前把旧产物归档,只保留最近 N 版 | `cache.js` | `tests/artifact.test.js` | | 产物与它的增量 cache 同处 `/<目标语言>/`,语言之间不共享目录,且只计划不落盘不留缓存 | `plan.js` 的语言目录、`config.js` `cacheRoot`、`cache.js` `BuildCacheSet` | `tests/plan.test.js`、`tests/build.test.js` | | 计划外的路径一律拒绝 | `artifact.js` `emitArtifacts` | `tests/artifact.test.js` | | 一个单元产出哪些文件只由目标的 `parts` 与 IR 决定,且至少产出一个 | `plan.js` `planSource` / `plannedParts` / `unitNeedsBodies` | `tests/plan.test.js` | | 定义部件(C++ `.cpp`)只校验它提到的成员,声明部件必须提到全部成员 | `verify.js` `verifyStructure` 的 `scope` | `tests/artifact.test.js` | | `# statement` 段逐字节进入 ts 产物,且不参与导出与结构校验 | `emit-ts.js` `renderStatement`、`parse.js` `exportedNamesOf`、`verify.js` `verifyStructure` | `tests/conformance.test.js`、`tests/emit-ts.test.js` | | 不挂访问器的 `property` 等价于一对空访问器(读写直通),且不触发 `W3010` | `parse.js` `collectMemberChildren`(合成并打 `synthesized` 标记)、`emit-ts.js` `renderProperty`、`check.js` `checkTargetHints` | `tests/parse.test.js`、`tests/emit-ts.test.js` | | `xl_context` 报出的目标语言生成指南存在才给路径,且不进 `promptHash` | `tools.js` `resolveGuide` / `renderContext`(纯渲染,`existsSync` 留在 plugin 层) | `tests/plugin.test.js` | | interface 的 `property` 是属性签名:有 `set` 才可变,只有 `get` 时产物是 `readonly` | `emit-ts.js` `renderInterfaceMember` | `tests/emit-ts.test.js` | | 声明之间没有顺序要求,只有 `# dependencies` 必须最前、`# namespace` 至多一次 | `parse.js` `parseXlMd` | `tests/parse.test.js` | | 码表里每个码都有产生它的地方 | `scripts/code-map.mjs` | `pnpm run codes` | `xl:prompt` 的定义值得单独说明:它是 `structureSummary + languageContext + 依赖摘要 + layout + parts + naming` 的哈希,**不含**源文件原文(源文件原文由 `xl:sha256` 表达)。这样「改了注释」不会让非 ts 产物失效,而「改了 `## csharp` 段」会。 --- ## 5. 诊断码的归属 规范把码分成 `E0`(输入)/ `E1`(语法与结构)/ `E2`(产物)/ `E4`(非 ts 通道)四段,但**级别**由 `xl-check.md` 的表决定,不看前缀(`E1105` `E1107` `E2003` 是 warning)。`src/core/diagnostics.js` 的 `DIAG_CODES` 是唯一事实来源,`severityOf` 只读它,未知码按 error 处理。 码在哪一层产生,按「需要什么才能判定」划分。`pnpm run codes` 从源码重新导出这张表,任何「定义了但没人产生」或「用了但不在表里」的码都会让它失败: | 层 | 需要的输入 | 码 | | --- | --- | --- | | `scan.js` `targets.js` | 路径展开、目标声明 | `E0001` `E0002` `E0003` `E0004` | | `source.js` | 文件字节 | `E0005`(另有读源文件失败的 `E2002`) | | `parse.js` | 单个文件的 IR | `E1001`–`E1005`(`# dependencies` 必须在最前、`# namespace` 至多一次由 `parseXlMd` 主循环判)、`E1006`(只判 `xl` 块的行形态)、`E1101`–`E1103` `E1106` `E1108`–`E1110`、`E1201`–`E1212`、`E1301`–`E1305`、`W3011` `W3012` `W3013` `W3103` | | `check.js` | 整个文档 + 依赖索引 + 目标列表 | `E1104` `E1105`(目标解析与接口成员)、`W3010` `W3101` `W3102` `W3104` | | `build.js` | 文件系统 | `E1006`(依赖目标读不到或没有该导出)、`E1107`、`E2001` `E2002` `E2003` | | `artifact.js` | 生成者提交的内容 | `E2001` `E2002` `E4002` | `E1104` 与 `E1105` 在两层各有一部分:parser 管「关键字写了但目标为空」的形状(`# class box extends`),checker 管「目标解析不到 / 不是 interface / 成员没实现」。 `E4001`(harness 失败)**故意不产生**:本插件没有子进程通道,产物不合法由 `xl_emit` 以 `E4002` 表达。`scripts/code-map.mjs` 的 `INTENTIONALLY_UNEMITTED` 记录了这个例外及其理由。 这条界线的实际意义:**同一文件内可判定的规则放进 parser**,因为它们只依赖 IR,因此 `xl_plan`、`xl_check`、`xl_build` 三条入口都能拿到同一份结果,而不必各自再跑一遍检查器。跨文件与跨目标的规则才放进 `check.js`。 --- ## 6. 为什么零依赖 插件要能 `dsh plugin --profile <任意> add <路径>` 直接装。要做到这点,包在运行时不能 import 任何东西——包括 `@deepseek-ai/*`(profile 的 `node_modules` 里只有 profile 自己装过的包,harness 包只按 peer 解析规则可见,而声明 peer 又会引入版本兼容检查与安装步骤)。 因此: - 模型工具用**裸 JSON Schema** 注册(`ctx.tools.register({ name, description, parameters, output, execute })`),不 import `defineTool`; - `apply(ctx, config)` 不 export `Config`(那需要 `@deepseek-ai/schemastery`),改为在激活时自己校验并抛 `TypeError`; - CLI 自己解析 argv(不 import commander),自己决定何时调 `ctx.get('appExit')`; - `ctx` 以结构化方式使用(`ctx.get` / `ctx.provide` / `ctx.tools.register`),不做类型 import。 代价是失去 harness 的类型检查。收益是:无构建步骤、无安装脚本、无 peer 兼容风险,改完源码新进程立刻生效(profile 里是 `link:`)。 安装后进程内持有的是安装那一刻的模块代次;改了源码要重启宿主进程才生效,`link:` 只保证下一次 import 拿到新文件。 --- ## 7. 扩展点 | 想做的事 | 改哪里 | | --- | --- | | 加一个目标语言 | `src/core/targets.js` 的 `BUILTIN_TARGETS`;或在 `xl.json` 的 `targets.` 里声明 `ext`。自定义目标走计划通道(内部名 `harness`),因此布局恒为 `type`,不需要(也不能)声明 layout | | 加一个多文件语言(如 C++ 的头/源) | `src/core/targets.js` 的 `BUILTIN_TARGETS..parts`,或在 `targets..parts` 里声明部件表:`requires: "bodies"` 让定义部件只在单元有可执行内容时出现,`scope: "definition"` 让结构回读只校验该部件提到的成员。计划、cache、产物头、工具渲染都按 `parts` 自动展开,不需要改其它模块 | | 改 ts 打印规则 | `src/core/emit-ts.js`,同时更新 `tests/emit-ts.test.js` 与基准样例的一致性测试 | | 加一条诊断码 | `src/core/diagnostics.js` 的 `DIAG_CODES`,在产生它的那一层 emit,补 `tests/parse.test.js` 或 `tests/check.test.js`,再跑 `pnpm run codes` | | 给某个目标语言写/改生成指南 | 在 `docs/` 下加 `xl-emit-.md`(例如 `xl-emit-cpp.md`):`xl_context` 会**检测该文件是否存在**并把它报给 agent,存在才报路径。指南是建议性文档,不进 `promptHash`,因此改它不会让任何产物失效;它也没有对应的实现代码,只需要自洽 | | 让生成者拿到更多上下文 | `src/core/build.js` 的 `languageContext` 与 `src/core/artifact.js` 的 `contextFor`(注意 `promptHash` 的输入要同步,否则 cache 失效判定会失真) | | 换掉写盘实现(例如走 `ctx.fs` 沙箱) | `src/core/build.js` 的 `processPlan` 与 `src/core/artifact.js` 的 `emitArtifacts` | | 让 `xl build` 自己起 agent | `src/plugin/cli.js` 的 `runBuildCommand`:在 `planned` 分支里改用 `ctx.agents` / `ctx.subagents` 驱动 | | 改规范 / 文档 | 直接改 `docs/xl-*.md`(`xl-base-case.md` 除外,它连带验收 fixture);改完跑 `pnpm test`、`pnpm codes`,动了基准样例再跑 `pnpm fixtures` | 最后一条代码扩展点是刻意留出的:当前选择是「计划通道」,如果将来要回到「xl 自动起 agent」,改动点只在应用行的 `planned` 分支——core、服务、工具都不用动。 --- ## 8. 已知行为偏差与缺口 这一节记录**代码当前的真实行为**里不明显、或与常识预期不符的地方;文档(`xl-syntax.md` / `xl-cli.md` / `xl-check.md` / `xl-emit-ts.md`)已经按这些行为撰写,这里是给维护者的「为什么 / 要不要修」清单。 | # | 现象 | 位置 | 备注 | | --- | --- | --- | --- | | 1 | `--concurrency` / `--keep-going` / `--verify` / `--no-verify` 在 `xl build` 上不产生作用 | `src/plugin/cli.js` 的 `INERT_OPTIONS` 与 build 入口 | `--verify` 只决定 `xl_emit` 的缺省;`--keep-going` 是冗余的——单个文件失败本来就不会中断整轮构建;`--concurrency` 对 ts 直出(同步)没有意义 | | 2 | `build.naming` 不做取值校验 | `src/core/config.js` | 除 `preserve` 外一律按 `idiomatic`;命令行 `--naming` 才校验(非法值退出码 2) | | 3 | interface 成员上的可见性 / `static` 修饰符不报 `E1202` | `src/core/parse.js` `parseMember` | interface 只描述签名,产物忽略这些修饰符(`docs/xl-emit-ts.md` §6.2) | | 4 | `# dependencies` / `# namespace` 标题上的修饰符被静默忽略 | `src/core/parse.js` `parseXlMd` | 这两个段落不是声明,修饰符既不报错也不生效 | | 5 | `## property ?:` 的 `?` 被静默丢弃 | `src/core/parse.js` `splitTypedName` | 可选性属于访问器语义;`## field` 的 `?` 才有意义 | | 6 | 显式给出的非 `*.xl.md` 路径不检查后缀 | `src/core/scan.js` `collectSources` | 它会被当源文件解析,通常以语法诊断收场;目录与 glob 只收 `*.xl.md` | | 7 | `XL_CONFIG` 指向不可读 / 非法 JSON 时退出 1 | `src/core/config.js` `loadConfig` | 普通 `Error` 而非 `UsageError`;其它用法错误是退出 2 | | 8 | `E1107` 在同一个文件内重复声明类型时也会报 | `src/core/build.js` `crossFileDiagnostics` 统计的是声明次数而非文件数 | 与 `E1106` 同时出现 | | 9 | `E4001` 故意不产生 | `scripts/code-map.mjs` 的 `INTENTIONALLY_UNEMITTED` | 没有子进程通道,坏产物由 `E4002` 表达 | | 10 | 被读入但无人消费的配置字段 | `src/core/targets.js`、`src/core/config.js` | `targets..namespace` / `.model` 存进描述符后没人读;`targets..layout` 根本不读;`build.verify` / `build.specHint` / `build.source` / `harness` 段不生效。`build.specHint` **不会**接上生成指南(指南路径见第 11 条):它早就是文档化的「不生效」字段,复活它要连带改两份规范 | | 11 | `xl_context` 里的生成指南是**文件存在性探测**的结果 | `src/plugin/tools.js` `resolveGuide` | 探测顺序是插件自带 `docs/` → 请求 `cwd` 的 `docs/`,都命中不到就回一句「没有指南」;因此**cwd 的目录内容会改变工具输出**,但指南不进 `promptHash`(`fingerprintPrompt` 的入参只有 summary / language / dependencies / layout / parts / naming),所以不影响复用判定。目前只有 cpp 带指南 | | 11 | 归档家族是 源 × 扩展名,不是 源 × 产物路径 | `src/core/cache.js` `latestArchives` | `layout=type` 下一个源有多个同扩展名产物时只保留其中一个的历史;这是既有粒度,见 `../README.md` §9 |